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 %}
+
+
+
+ | Track |
+ Artist |
+ Plays |
+
+
+
+ {% for t in trail.tracks %}
+
+ | {% if t.track_uuid %}{{ t.track_name }}{% else %}{{ t.track_name }}{% endif %} |
+ {{ t.artist_name }} |
+ {{ t.count }} |
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+
+
+ | Track |
+ Artist |
+ Plays |
+
+
+
+ {% for t in loc.tracks %}
+
+ | {% if t.track_uuid %}{{ t.track_name }}{% else %}{{ t.track_name }}{% endif %} |
+ {{ t.artist_name }} |
+ {{ t.count }} |
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+
+
+ | Track |
+ Artist |
+ Plays |
+
+
+
+ {% for t in book.tracks %}
+
+ | {% if t.track_uuid %}{{ t.track_name }}{% else %}{{ t.track_name }}{% endif %} |
+ {{ t.artist_name }} |
+ {{ t.count }} |
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+
+
+
+ | Media Type |
+ Recent (30 days) |
+ Previous (30 days) |
+ Change |
+
+
+
+ {% for mt, info in data.items %}
+
+ | {{ mt }} |
+ {{ info.recent }} |
+ {{ info.previous }} |
+
+ {% if info.change_pct > 0 %}
+ +{{ info.change_pct }}%
+ {% elif info.change_pct < 0 %}
+ {{ info.change_pct }}%
+ {% else %}
+ 0%
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% 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.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: