diff --git a/PROJECT.org b/PROJECT.org index 6b9c021..4e0c3f0 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [2/22] :vrobbler:project:personal: +* Backlog [3/23] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb @@ -590,6 +590,11 @@ We should rename `email_scrobble_board_game` to reflect the fact that it's just a helper method to create board game scrobbles given a json blob. It's independent of the email flow it was originally creatdd for +** DONE [#B] Add peak hour, weekly rhythm and activity dist trends :trends:scrobbles: +:PROPERTIES: +:ID: 5fa52fac-d5f0-4369-bcaa-589c886b07d3 +:END: + ** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub: :PROPERTIES: :ID: 1d3beafd-62cb-4735-a465-edb37bf885db diff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.py index 76f605e..51f7f67 100644 --- a/vrobbler/apps/trends/management/commands/compute_trends.py +++ b/vrobbler/apps/trends/management/commands/compute_trends.py @@ -1,8 +1,13 @@ +import logging + from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand +from django.utils import timezone -from trends.tasks import compute_user_trends +from trends.tasks import _compute_and_save_trend +from trends.trends import TREND_REGISTRY +logger = logging.getLogger(__name__) User = get_user_model() @@ -25,16 +30,57 @@ class Command(BaseCommand): ) return users = [user] - self.stdout.write(f"Computing trends for user: {user}") else: users = User.objects.filter(is_active=True) - self.stdout.write(f"Computing trends for {users.count()} users...") + + total_users = len(users) + self.stdout.write(f"Computing trends for {total_users} user(s)...") + + overall_start = timezone.now() + ok_count = 0 + fail_count = 0 for user in users: - try: - compute_user_trends(user.id) - self.stdout.write(self.style.SUCCESS(f" OK {user}")) - except Exception as e: - self.stderr.write(self.style.ERROR(f" FAILED {user}: {e}")) + total_trends = len(TREND_REGISTRY) + self.stdout.write(f" {user} ({user.id}): {total_trends} trends...") + user_start = timezone.now() + user_ok = 0 + user_fail = 0 - self.stdout.write(self.style.SUCCESS("Done!")) + for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): + trend_start = timezone.now() + self.stdout.write( + f" [{idx}/{total_trends}] {slug}... ", ending="" + ) + try: + elapsed = _compute_and_save_trend(user, slug) + self.stdout.write( + self.style.SUCCESS(f"OK ({elapsed:.1f}s)") + ) + user_ok += 1 + except Exception as e: + elapsed = (timezone.now() - trend_start).total_seconds() + self.stdout.write( + self.style.ERROR( + f"FAILED after {elapsed:.1f}s: {e}" + ) + ) + user_fail += 1 + + user_elapsed = (timezone.now() - user_start).total_seconds() + self.stdout.write( + self.style.SUCCESS( + f" {user}: {user_ok} OK, {user_fail} failed " + f"({user_elapsed:.1f}s total)" + ) + ) + ok_count += user_ok + fail_count += user_fail + + overall_elapsed = (timezone.now() - overall_start).total_seconds() + self.stdout.write( + self.style.SUCCESS( + f"Done! {ok_count} OK, {fail_count} failed " + f"({overall_elapsed:.1f}s across {total_users} user(s))" + ) + ) diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.py index 85e6a7e..39e9500 100644 --- a/vrobbler/apps/trends/tasks.py +++ b/vrobbler/apps/trends/tasks.py @@ -11,10 +11,30 @@ logger = logging.getLogger(__name__) User = get_user_model() +def _compute_and_save_trend(user, slug): + """Compute a single trend and persist the result. + + Returns elapsed seconds on success, raises on failure. + """ + fn = TREND_REGISTRY[slug] + start = timezone.now() + data = fn(user) + TrendResult.objects.update_or_create( + user=user, + trend_slug=slug, + defaults={"data": data, "computed_at": timezone.now()}, + ) + return (timezone.now() - start).total_seconds() + + @shared_task def compute_all_trends(): - for user in User.objects.filter(is_active=True): - compute_user_trends.delay(user.id) + user_ids = list( + User.objects.filter(is_active=True).values_list("id", flat=True) + ) + logger.info("Dispatching trend computation for %d users", len(user_ids)) + for uid in user_ids: + compute_user_trends.delay(uid) @shared_task @@ -25,15 +45,38 @@ def compute_user_trends(user_id): logger.warning("User %s not found, skipping trends", user_id) return - for slug, fn in TREND_REGISTRY.items(): - try: - data = fn(user) - TrendResult.objects.update_or_create( - user=user, - trend_slug=slug, - defaults={"data": data, "computed_at": timezone.now()}, - ) - except Exception: - logger.exception( - "Failed to compute trend '%s' for user %s", slug, user_id - ) + total = len(TREND_REGISTRY) + logger.info( + "Computing %d trends for user %s (%d)", + total, user, user_id, + ) + + for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): + compute_single_trend.delay(user_id, slug) + + logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id) + + +@shared_task +def compute_single_trend(user_id, slug): + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + logger.warning( + "User %d not found for trend '%s', skipping", user_id, slug + ) + return + + if slug not in TREND_REGISTRY: + logger.warning("Unknown trend slug '%s' for user %d", slug, user_id) + return + + logger.info("[%s] Computing for user %d...", slug, user_id) + try: + elapsed = _compute_and_save_trend(user, slug) + logger.info( + "[%s] Completed for user %d in %.1fs", + slug, user_id, elapsed, + ) + except Exception: + logger.exception("[%s] Failed for user %d", slug, user_id) diff --git a/vrobbler/apps/trends/templates/trends/_activity_distribution.html b/vrobbler/apps/trends/templates/trends/_activity_distribution.html new file mode 100644 index 0000000..e48545c --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_activity_distribution.html @@ -0,0 +1,47 @@ +
+ Total scrobbles: {{ data.total_count }} +
+| Media Type | +Total | +Completed | +% | +Distribution | +
|---|---|---|---|---|
| {{ entry.media_type }} | +{{ entry.count }} | +{{ entry.completed }} | +{{ entry.pct }}% | +
+ {% if max > 0 %}
+
+
+
+ {% endif %}
+ |
+
No activity data found.
+ {% endif %} +| Hour | +Scrobbles | +Distribution | +
|---|---|---|
| + {% if entry.hour == 0 %} + 12 AM + {% elif entry.hour < 12 %} + {{ entry.hour }} AM + {% elif entry.hour == 12 %} + 12 PM + {% else %} + {{ entry.hour|add:"-12" }} PM + {% endif %} + | +{{ entry.count }} | +
+ {% if max_count > 0 %}
+
+
+
+ {% endif %}
+ |
+
No activity data found.
+ {% endif %} +| Day | +Scrobbles | +Distribution | +
|---|---|---|
| {{ entry.day_name }} | +{{ entry.count }} | +
+ {% if max_count > 0 %}
+
+
+
+ {% endif %}
+ |
+
No weekly data found.
+ {% endif %} +