diff --git a/PROJECT.org b/PROJECT.org index 392b4ee..96c1fdb 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -523,21 +523,19 @@ so we can probably use backtrace=True and diagnose=True to help us root cause bugs faster. ** TODO [#B] Add a /trends/ page that shows trends based on scrobble data :feature:trends:scrobbles: +:PROPERTIES: +:ID: 03e9fe30-2bc6-4062-bb24-e95b98daf05b +:END: *** Description This project is a bit invovled. But we should add a top level URL `trends` that shows various trends as defined either in a static settings file, or dynamically via a database table. -Examples of trends: +Trends could be things like doing multiple things at the same time, like while driving, what +did we listen to this week, or while running, what were listening to this week? -- How often does the user: - + watch sports while doing a task? - + do a task while watching a video? - * how often do I do - -- trail_scrobble__average_heartrate per trail -- ... +Or more complicated trends like, how time per page changes based on the book I was reading, or if I was doing something else (music or sport event) while reading. ** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :books:feature:comicbook: :PROPERTIES: @@ -564,6 +562,7 @@ File: ~vrobbler/apps/podcasts/utils.py~ (line 13) :END: *** Description + The zombie scrobble cleanup query lives in a utility function. Should be a custom model manager method (e.g. =Scrobble.objects.zombies()=). diff --git a/vrobbler/apps/trends/__init__.py b/vrobbler/apps/trends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/trends/admin.py b/vrobbler/apps/trends/admin.py new file mode 100644 index 0000000..50a65ab --- /dev/null +++ b/vrobbler/apps/trends/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from trends.models import TrendResult + + +@admin.register(TrendResult) +class TrendResultAdmin(admin.ModelAdmin): + list_display = ("user", "trend_slug", "computed_at", "created") + list_filter = ("user", "trend_slug") + ordering = ("-computed_at",) diff --git a/vrobbler/apps/trends/apps.py b/vrobbler/apps/trends/apps.py new file mode 100644 index 0000000..77005c0 --- /dev/null +++ b/vrobbler/apps/trends/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TrendsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "trends" diff --git a/vrobbler/apps/trends/management/__init__.py b/vrobbler/apps/trends/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/trends/management/commands/__init__.py b/vrobbler/apps/trends/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.py new file mode 100644 index 0000000..76f605e --- /dev/null +++ b/vrobbler/apps/trends/management/commands/compute_trends.py @@ -0,0 +1,40 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from trends.tasks import compute_user_trends + +User = get_user_model() + + +class Command(BaseCommand): + help = "Compute trends for all users (or a specific user)" + + def add_arguments(self, parser): + parser.add_argument( + "--user-id", + type=int, + help="Compute trends for a specific user only", + ) + + def handle(self, *args, **options): + if options["user_id"]: + user = User.objects.filter(id=options["user_id"]).first() + if not user: + self.stderr.write( + self.style.ERROR(f"User with id {options['user_id']} not found") + ) + 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...") + + 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}")) + + self.stdout.write(self.style.SUCCESS("Done!")) diff --git a/vrobbler/apps/trends/migrations/0001_initial.py b/vrobbler/apps/trends/migrations/0001_initial.py new file mode 100644 index 0000000..b30032a --- /dev/null +++ b/vrobbler/apps/trends/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.29 on 2026-06-16 14:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TrendResult", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ("trend_slug", models.CharField(db_index=True, max_length=100)), + ("computed_at", models.DateTimeField(auto_now_add=True)), + ("data", models.JSONField(default=dict)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "trend_slug")}, + }, + ), + ] diff --git a/vrobbler/apps/trends/migrations/__init__.py b/vrobbler/apps/trends/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/trends/models.py b/vrobbler/apps/trends/models.py new file mode 100644 index 0000000..0998562 --- /dev/null +++ b/vrobbler/apps/trends/models.py @@ -0,0 +1,18 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django_extensions.db.models import TimeStampedModel + +User = get_user_model() + + +class TrendResult(TimeStampedModel): + user = models.ForeignKey(User, on_delete=models.CASCADE) + trend_slug = models.CharField(max_length=100, db_index=True) + computed_at = models.DateTimeField(auto_now_add=True) + data = models.JSONField(default=dict) + + class Meta: + unique_together = ["user", "trend_slug"] + + def __str__(self): + return f"{self.user} - {self.trend_slug} ({self.computed_at})" diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.py new file mode 100644 index 0000000..85e6a7e --- /dev/null +++ b/vrobbler/apps/trends/tasks.py @@ -0,0 +1,39 @@ +import logging + +from celery import shared_task +from django.contrib.auth import get_user_model +from django.utils import timezone + +from trends.models import TrendResult +from trends.trends import TREND_REGISTRY + +logger = logging.getLogger(__name__) +User = get_user_model() + + +@shared_task +def compute_all_trends(): + for user in User.objects.filter(is_active=True): + compute_user_trends.delay(user.id) + + +@shared_task +def compute_user_trends(user_id): + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + 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 + ) diff --git a/vrobbler/apps/trends/templates/trends/_concurrent_listening.html b/vrobbler/apps/trends/templates/trends/_concurrent_listening.html new file mode 100644 index 0000000..f7f83d5 --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_concurrent_listening.html @@ -0,0 +1,89 @@ +
+ {% if data.trails %} +
+

🥾 While on Trails

+ {% for trail in data.trails %} +
+
+
+ {% if trail.uuid %} + {{ trail.name }} + {% else %} + {{ trail.name }} + {% endif %} + ({{ trail.total_sessions }} sessions) +
+ {% if trail.tracks %} + + + + + + + + + + {% for t in trail.tracks %} + + + + + + {% endfor %} + +
TrackArtistPlays
{% if t.track_uuid %}{{ t.track_name }}{% else %}{{ t.track_name }}{% endif %}{{ t.artist_name }}{{ t.count }}
+ {% endif %} +
+
+ {% empty %} +

No concurrent listening data for trails.

+ {% endfor %} +
+ {% endif %} + + {% if data.locations %} +
+

📍 While at Locations

+ {% for loc in data.locations %} +
+
+
+ {% if loc.uuid %} + {{ loc.name }} + {% else %} + {{ loc.name }} + {% endif %} + ({{ loc.total_sessions }} sessions) +
+ {% if loc.tracks %} + + + + + + + + + + {% for t in loc.tracks %} + + + + + + {% endfor %} + +
TrackArtistPlays
{% if t.track_uuid %}{{ t.track_name }}{% else %}{{ t.track_name }}{% endif %}{{ t.artist_name }}{{ t.count }}
+ {% endif %} +
+
+ {% empty %} +

No concurrent listening data for locations.

+ {% endfor %} +
+ {% endif %} +
+ +{% if not data.trails and not data.locations %} +

No concurrent listening data found.

+{% endif %} diff --git a/vrobbler/apps/trends/templates/trends/_concurrent_reading.html b/vrobbler/apps/trends/templates/trends/_concurrent_reading.html new file mode 100644 index 0000000..0adc7a6 --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_concurrent_reading.html @@ -0,0 +1,42 @@ +
+ {% if data.books %} + {% for book in data.books %} +
+
+
+
+ {% if book.book_uuid %} + {{ book.book_title }} + {% else %} + {{ book.book_title }} + {% endif %} + ({{ book.total_sessions }} listening sessions) +
+ {% if book.tracks %} + + + + + + + + + + {% for t in book.tracks %} + + + + + + {% endfor %} + +
TrackArtistPlays
{% if t.track_uuid %}{{ t.track_name }}{% else %}{{ t.track_name }}{% endif %}{{ t.artist_name }}{{ t.count }}
+ {% endif %} +
+
+
+ {% endfor %} + {% else %} +

No concurrent reading data found.

+ {% endif %} +
diff --git a/vrobbler/apps/trends/templates/trends/_reading_pace.html b/vrobbler/apps/trends/templates/trends/_reading_pace.html new file mode 100644 index 0000000..0a9196d --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_reading_pace.html @@ -0,0 +1,52 @@ +
+
+
+
+
🎵 Reading with Music
+ {% if data.with_music %} + + + + + + + + + + + + + +
Avg session duration{{ data.with_music.avg_seconds }} seconds
Total reading time{{ data.with_music.total_seconds }} seconds
Reading sessions{{ data.with_music.sessions_count }}
+ {% else %} +

No data.

+ {% endif %} +
+
+
+
+
+
+
🔇 Reading without Music
+ {% if data.without_music %} + + + + + + + + + + + + + +
Avg session duration{{ data.without_music.avg_seconds }} seconds
Total reading time{{ data.without_music.total_seconds }} seconds
Reading sessions{{ data.without_music.sessions_count }}
+ {% else %} +

No data.

+ {% endif %} +
+
+
+
diff --git a/vrobbler/apps/trends/templates/trends/_trending_up.html b/vrobbler/apps/trends/templates/trends/_trending_up.html new file mode 100644 index 0000000..a6c6a3c --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_trending_up.html @@ -0,0 +1,38 @@ +
+
+ {% if data %} +
+ + + + + + + + + + + {% for mt, info in data.items %} + + + + + + + {% endfor %} + +
Media TypeRecent (30 days)Previous (30 days)Change
{{ mt }}{{ info.recent }}{{ info.previous }} + {% if info.change_pct > 0 %} + +{{ info.change_pct }}% + {% elif info.change_pct < 0 %} + {{ info.change_pct }}% + {% else %} + 0% + {% endif %} +
+
+ {% else %} +

No trending data found.

+ {% endif %} +
+
diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.html new file mode 100644 index 0000000..cf6b569 --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/trend_detail.html @@ -0,0 +1,38 @@ +{% extends "base_list.html" %} + +{% block title %}{{ trend.title }}{% endblock %} + +{% block lists %} +
+
+ ← All Trends +

{{ trend.icon }} {{ trend.title }}

+

{{ trend.description }}

+ {% if computed_at %} + Last computed: {{ computed_at|date:"F j, Y H:i" }} + {% endif %} +
+
+ +{% if trend_not_found %} +
Trend not found.
+ +{% elif data is None %} +
+ No data computed yet. Trends are updated once daily, check back later. +
+ +{% elif trend.slug == "concurrent-listening" %} + {% include "trends/_concurrent_listening.html" %} + +{% elif trend.slug == "concurrent-reading" %} + {% include "trends/_concurrent_reading.html" %} + +{% elif trend.slug == "reading-pace-vs-activity" %} + {% include "trends/_reading_pace.html" %} + +{% elif trend.slug == "trending-up" %} + {% include "trends/_trending_up.html" %} + +{% endif %} +{% endblock %} diff --git a/vrobbler/apps/trends/templates/trends/trend_list.html b/vrobbler/apps/trends/templates/trends/trend_list.html new file mode 100644 index 0000000..a204b3d --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/trend_list.html @@ -0,0 +1,39 @@ +{% extends "base_list.html" %} + +{% block title %}Trends{% endblock %} + +{% block lists %} +
+ {% if not user.is_authenticated %} +
+
Log in to see your trends.
+
+ {% elif not trends %} +
+
+ No trends computed yet. Trends are computed once daily, check back later. +
+
+ {% else %} + {% for trend in trends %} +
+
+
+
+ + {{ trend.icon }} {{ trend.title }} + +
+

{{ trend.description }}

+ {% if trend.computed_at %} + Last computed: {{ trend.computed_at|date:"M j, Y H:i" }} + {% else %} + Pending + {% endif %} +
+
+
+ {% endfor %} + {% endif %} +
+{% endblock %} diff --git a/vrobbler/apps/trends/templatetags/__init__.py b/vrobbler/apps/trends/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/trends/trends/__init__.py b/vrobbler/apps/trends/trends/__init__.py new file mode 100644 index 0000000..369f46c --- /dev/null +++ b/vrobbler/apps/trends/trends/__init__.py @@ -0,0 +1,25 @@ +from trends.trends.concurrent import compute_concurrent_listening, compute_concurrent_reading +from trends.trends.reading import compute_reading_pace_vs_activity +from trends.trends.trending import compute_trending_up + +TREND_REGISTRY = {} + +def register(slug): + def decorator(fn): + TREND_REGISTRY[slug] = fn + return fn + return decorator + + +compute_concurrent_listening = register("concurrent-listening")( + compute_concurrent_listening +) +compute_concurrent_reading = register("concurrent-reading")( + compute_concurrent_reading +) +compute_reading_pace_vs_activity = register("reading-pace-vs-activity")( + compute_reading_pace_vs_activity +) +compute_trending_up = register("trending-up")( + compute_trending_up +) diff --git a/vrobbler/apps/trends/trends/concurrent.py b/vrobbler/apps/trends/trends/concurrent.py new file mode 100644 index 0000000..9f6d406 --- /dev/null +++ b/vrobbler/apps/trends/trends/concurrent.py @@ -0,0 +1,222 @@ +import datetime +from collections import defaultdict + +from scrobbles.models import Scrobble + + +def _range_for(scrobble): + start = scrobble.timestamp + end = scrobble.stop_timestamp + if end is None: + try: + end = start + datetime.timedelta(hours=12) + except AttributeError: + end = start + return start, end + + +def _find_concurrent(anchor_scrobbles, paired_scrobbles): + """Find paired scrobbles that overlap in time with anchor scrobbles. + + Returns a dict mapping each anchor scrobble PK to a list of + paired scrobble PKs that overlap with it. + """ + anchor_ranges = { + s.pk: _range_for(s) for s in anchor_scrobbles + } + paired_ranges = { + s.pk: _range_for(s) for s in paired_scrobbles + } + + anchor_to_paired = defaultdict(list) + + for a_pk, (a_start, a_end) in anchor_ranges.items(): + for p_pk, (p_start, p_end) in paired_ranges.items(): + if a_start <= p_end and p_start <= a_end: + anchor_to_paired[a_pk].append(p_pk) + + return anchor_to_paired + + +def _get_media_name(scrobble): + """Return the name of the media object associated with a scrobble.""" + for attr in [ + "trail", "geo_location", "book", "track", + ]: + obj = getattr(scrobble, attr, None) + if obj is not None: + return str(obj) + return "Unknown" + + +def compute_concurrent_listening(user): + """Find what music was listened to while on trails or at locations. + + Returns a dict with two keys: 'trails' and 'locations', each containing + a list of entries with the trail/location name and the tracks listened to. + """ + media_types_to_exclude_from_anchor = ("Track", "Book", "Video", "PodcastEpisode", + "VideoGame", "BoardGame", "Puzzle", "Food", + "Beer", "Task", "WebPage", "LifeEvent", + "Mood", "BrickSet", "Channel", "BirdingLocation", + "Paper", "SportEvent") + + anchor_scrobbles = list( + Scrobble.objects.filter( + user=user, + timestamp__isnull=False, + played_to_completion=True, + ) + .exclude(media_type__in=media_types_to_exclude_from_anchor) + .select_related("trail", "geo_location") + .order_by("-timestamp") + ) + + paired_scrobbles = list( + Scrobble.objects.filter( + user=user, + media_type="Track", + timestamp__isnull=False, + stop_timestamp__isnull=False, + played_to_completion=True, + ) + .select_related("track") + .order_by("-timestamp") + ) + + if not anchor_scrobbles or not paired_scrobbles: + return {"trails": [], "locations": []} + + anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles) + + paired_by_pk = {s.pk: s for s in paired_scrobbles} + + trails = [] + locations = [] + + for anchor in anchor_scrobbles: + paired_pks = anchor_to_paired.get(anchor.pk, []) + if not paired_pks: + continue + + tracks_by_name = defaultdict(int) + track_details = {} + for p_pk in paired_pks: + ps = paired_by_pk[p_pk] + track = ps.track + if track is None: + continue + name = str(track) + tracks_by_name[name] += 1 + if name not in track_details: + track_details[name] = { + "track_name": name, + "track_uuid": str(track.uuid) if track.uuid else "", + "artist_name": str(track.artist) if track.artist else "", + } + + anchor_name = _get_media_name(anchor) + entry = { + "name": anchor_name, + "uuid": "", + "total_sessions": len(paired_pks), + "tracks": sorted( + [ + {**track_details[name], "count": count} + for name, count in tracks_by_name.items() + ], + key=lambda x: x["count"], + reverse=True, + )[:20], + } + + if anchor.media_type == "Trail": + entry["uuid"] = str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else "" + trails.append(entry) + else: + entry["uuid"] = str(anchor.geo_location.uuid) if anchor.geo_location and anchor.geo_location.uuid else "" + locations.append(entry) + + return { + "trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20], + "locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[:20], + } + + +def compute_concurrent_reading(user): + """Find what music was listened to while reading books. + + Returns a dict with key 'books' containing a list of entries with the + book title and the tracks listened to while reading. + """ + anchor_scrobbles = list( + Scrobble.objects.filter( + user=user, + media_type="Book", + timestamp__isnull=False, + stop_timestamp__isnull=False, + played_to_completion=True, + ) + .select_related("book") + .order_by("-timestamp") + ) + + paired_scrobbles = list( + Scrobble.objects.filter( + user=user, + media_type="Track", + timestamp__isnull=False, + stop_timestamp__isnull=False, + played_to_completion=True, + ) + .select_related("track") + .order_by("-timestamp") + ) + + if not anchor_scrobbles or not paired_scrobbles: + return {"books": []} + + anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles) + paired_by_pk = {s.pk: s for s in paired_scrobbles} + + books = [] + + for anchor in anchor_scrobbles: + paired_pks = anchor_to_paired.get(anchor.pk, []) + if not paired_pks: + continue + + tracks_by_name = defaultdict(int) + track_details = {} + for p_pk in paired_pks: + ps = paired_by_pk[p_pk] + track = ps.track + if track is None: + continue + name = str(track) + tracks_by_name[name] += 1 + if name not in track_details: + track_details[name] = { + "track_name": name, + "track_uuid": str(track.uuid) if track.uuid else "", + "artist_name": str(track.artist) if track.artist else "", + } + + book = anchor.book + books.append({ + "book_title": str(book) if book else "Unknown", + "book_uuid": str(book.uuid) if book and book.uuid else "", + "total_sessions": len(paired_pks), + "tracks": sorted( + [ + {**track_details[name], "count": count} + for name, count in tracks_by_name.items() + ], + key=lambda x: x["count"], + reverse=True, + )[:20], + }) + + return { + "books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20], + } diff --git a/vrobbler/apps/trends/trends/reading.py b/vrobbler/apps/trends/trends/reading.py new file mode 100644 index 0000000..b27fc4c --- /dev/null +++ b/vrobbler/apps/trends/trends/reading.py @@ -0,0 +1,86 @@ +import datetime +from collections import defaultdict + +from scrobbles.models import Scrobble + + +def compute_reading_pace_vs_activity(user): + """Compare reading pace (seconds per session) when music is playing vs. not. + + For each Book scrobble with a playback_position_seconds value, checks + whether there is an overlapping Track scrobble and groups the data. + Returns average session duration for both groups. + """ + book_scrobbles = list( + Scrobble.objects.filter( + user=user, + media_type="Book", + timestamp__isnull=False, + playback_position_seconds__isnull=False, + played_to_completion=True, + ) + .select_related("book") + .order_by("-timestamp") + ) + + if not book_scrobbles: + return {"with_music": None, "without_music": None} + + track_scrobbles = list( + Scrobble.objects.filter( + user=user, + media_type="Track", + timestamp__isnull=False, + played_to_completion=True, + ) + .order_by("-timestamp") + ) + + track_ranges = [] + for ts in track_scrobbles: + p_start = ts.timestamp + p_end = ts.stop_timestamp + if p_end is None: + try: + p_end = p_start + datetime.timedelta(hours=12) + except AttributeError: + p_end = p_start + track_ranges.append((p_start, p_end)) + + with_music_durations = [] + without_music_durations = [] + + for bs in book_scrobbles: + b_start = bs.timestamp + b_end = bs.stop_timestamp + if b_end is None: + try: + b_end = b_start + datetime.timedelta(hours=12) + except AttributeError: + b_end = b_start + + has_overlap = False + for p_start, p_end in track_ranges: + if b_start <= p_end and p_start <= b_end: + has_overlap = True + break + + duration = bs.playback_position_seconds + if has_overlap: + with_music_durations.append(duration) + else: + without_music_durations.append(duration) + + def _stats(durations): + if not durations: + return None + return { + "avg_seconds": int(sum(durations) / len(durations)), + "sessions_count": len(durations), + "total_seconds": sum(durations), + } + + return { + "with_music": _stats(with_music_durations), + "without_music": _stats(without_music_durations), + } diff --git a/vrobbler/apps/trends/trends/trending.py b/vrobbler/apps/trends/trends/trending.py new file mode 100644 index 0000000..188bade --- /dev/null +++ b/vrobbler/apps/trends/trends/trending.py @@ -0,0 +1,64 @@ +from collections import defaultdict + +from django.db.models import Count +from django.utils import timezone + +from scrobbles.models import Scrobble + + +def compute_trending_up(user, days=30): + """Compare scrobble counts per media type between two periods. + + Compares the most recent N days against the N days before that, + returning the count for each period and the percentage change. + + Returns a dict keyed by media_type with count and change info. + """ + now = timezone.now() + recent_start = now - timezone.timedelta(days=days) + previous_start = recent_start - timezone.timedelta(days=days) + + recent_counts = defaultdict(int) + for row in ( + Scrobble.objects.filter( + user=user, + timestamp__gte=recent_start, + timestamp__lte=now, + played_to_completion=True, + ) + .values("media_type") + .annotate(count=Count("id")) + ): + recent_counts[row["media_type"]] = row["count"] + + previous_counts = defaultdict(int) + for row in ( + Scrobble.objects.filter( + user=user, + timestamp__gte=previous_start, + timestamp__lt=recent_start, + played_to_completion=True, + ) + .values("media_type") + .annotate(count=Count("id")) + ): + previous_counts[row["media_type"]] = row["count"] + + all_types = set(list(recent_counts.keys()) + list(previous_counts.keys())) + changes = {} + for mt in sorted(all_types): + rc = recent_counts.get(mt, 0) + pc = previous_counts.get(mt, 0) + if pc > 0: + change_pct = round(((rc - pc) / pc) * 100, 1) + elif rc > 0: + change_pct = 100.0 + else: + change_pct = 0.0 + changes[mt] = { + "recent": rc, + "previous": pc, + "change_pct": change_pct, + } + + return changes diff --git a/vrobbler/apps/trends/urls.py b/vrobbler/apps/trends/urls.py new file mode 100644 index 0000000..cbab5da --- /dev/null +++ b/vrobbler/apps/trends/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from trends.views import TrendDetailView, TrendListView + +app_name = "trends" + +urlpatterns = [ + path("trends/", TrendListView.as_view(), name="trends-home"), + path("trends//", TrendDetailView.as_view(), name="trend-detail"), +] diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.py new file mode 100644 index 0000000..2600554 --- /dev/null +++ b/vrobbler/apps/trends/views.py @@ -0,0 +1,89 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView + +from trends.models import TrendResult +from trends.trends import TREND_REGISTRY + +TREND_METADATA = { + "concurrent-listening": { + "title": "Concurrent Listening", + "description": "What music were you listening to while on trails or at locations?", + "icon": "🎧", + }, + "concurrent-reading": { + "title": "Concurrent Reading", + "description": "What music did you listen to while reading books?", + "icon": "📖", + }, + "reading-pace-vs-activity": { + "title": "Reading Pace vs Music", + "description": "Compare how long you read per session with and without concurrent music.", + "icon": "📊", + }, + "trending-up": { + "title": "Trending Media Types", + "description": "Which media types have you been consuming more or less of recently?", + "icon": "📈", + }, +} + + +class TrendListView(LoginRequiredMixin, TemplateView): + template_name = "trends/trend_list.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + results = { + r.trend_slug: r + for r in TrendResult.objects.filter( + user=self.request.user + ) + } + trends = [] + for slug in TREND_REGISTRY: + meta = TREND_METADATA.get(slug, {}) + result = results.get(slug) + trends.append({ + "slug": slug, + "title": meta.get("title", slug), + "description": meta.get("description", ""), + "icon": meta.get("icon", ""), + "computed_at": result.computed_at if result else None, + "has_data": result is not None, + }) + ctx["trends"] = trends + return ctx + + +class TrendDetailView(LoginRequiredMixin, TemplateView): + template_name = "trends/trend_detail.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + slug = kwargs["trend_slug"] + + if slug not in TREND_REGISTRY: + ctx["trend_not_found"] = True + return ctx + + meta = TREND_METADATA.get(slug, {}) + ctx["trend"] = { + "slug": slug, + "title": meta.get("title", slug), + "description": meta.get("description", ""), + "icon": meta.get("icon", ""), + } + + result = TrendResult.objects.filter( + user=self.request.user, + trend_slug=slug, + ).first() + + if result: + ctx["computed_at"] = result.computed_at + ctx["data"] = result.data + else: + ctx["computed_at"] = None + ctx["data"] = None + + return ctx diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 813e533..d37f04e 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -134,6 +134,10 @@ CELERY_BEAT_SCHEDULE = { "task": "scrobbles.tasks.rebuild_yearly_charts", "schedule": crontab(hour=0, minute=30, day_of_month=1, month_of_year=1), }, + "compute-daily-trends": { + "task": "trends.tasks.compute_all_trends", + "schedule": crontab(hour=0, minute=10), + }, # ── Crontab replacements ───────────────────────────────────────────── "database-backup": { "task": "scrobbles.tasks.backup_database", @@ -192,6 +196,7 @@ INSTALLED_APPS = [ "scrobbles", "people", "charts", + "trends", "videos", "music", "podcasts", diff --git a/vrobbler/urls.py b/vrobbler/urls.py index f0ae218..6e0418d 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -105,6 +105,7 @@ from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet from vrobbler.apps.people import urls as people_urls from vrobbler.apps.charts import urls as charts_urls +from vrobbler.apps.trends import urls as trends_urls # from vrobbler.apps.modern_ui import urls as modern_ui_urls @@ -182,6 +183,7 @@ urlpatterns = [ path("", include(profiles_urls, namespace="profiles")), path("", include(people_urls, namespace="people")), path("", include(charts_urls, namespace="charts")), + path("", include(trends_urls, namespace="trends")), path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"), ] if settings.DEBUG: