This commit has no recorded session.
diff
commit 57f273b0cc424598fd97458b749f2185dacd1eafAuthor: Colin Powell <colin@unbl.ink>Date: Tue Jun 16 11:12:13 2026 -0400 [trends] Initial trends app@@ -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()=). @@ -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",)@@ -0,0 +1,6 @@+from django.apps import AppConfig+++class TrendsConfig(AppConfig):+ default_auto_field = "django.db.models.BigAutoField"+ name = "trends"@@ -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!"))@@ -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")},+ },+ ),+ ]@@ -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})"@@ -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+ )@@ -0,0 +1,89 @@+<div class="row">+ {% if data.trails %}+ <div class="col-md-6 mb-3">+ <h4>๐ฅพ While on Trails</h4>+ {% for trail in data.trails %}+ <div class="card mb-2">+ <div class="card-body py-2">+ <h6 class="card-title mb-1">+ {% if trail.uuid %}+ <a href="{% url 'trails:trail_detail' trail.uuid %}">{{ trail.name }}</a>+ {% else %}+ {{ trail.name }}+ {% endif %}+ <small class="text-muted">({{ trail.total_sessions }} sessions)</small>+ </h6>+ {% if trail.tracks %}+ <table class="table table-sm table-borderless mb-0">+ <thead>+ <tr>+ <th>Track</th>+ <th>Artist</th>+ <th class="text-end">Plays</th>+ </tr>+ </thead>+ <tbody>+ {% for t in trail.tracks %}+ <tr>+ <td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>+ <td>{{ t.artist_name }}</td>+ <td class="text-end">{{ t.count }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ {% endif %}+ </div>+ </div>+ {% empty %}+ <p class="text-muted">No concurrent listening data for trails.</p>+ {% endfor %}+ </div>+ {% endif %}++ {% if data.locations %}+ <div class="col-md-6 mb-3">+ <h4>๐ While at Locations</h4>+ {% for loc in data.locations %}+ <div class="card mb-2">+ <div class="card-body py-2">+ <h6 class="card-title mb-1">+ {% if loc.uuid %}+ <a href="{% url 'locations:geolocation_detail' loc.uuid %}">{{ loc.name }}</a>+ {% else %}+ {{ loc.name }}+ {% endif %}+ <small class="text-muted">({{ loc.total_sessions }} sessions)</small>+ </h6>+ {% if loc.tracks %}+ <table class="table table-sm table-borderless mb-0">+ <thead>+ <tr>+ <th>Track</th>+ <th>Artist</th>+ <th class="text-end">Plays</th>+ </tr>+ </thead>+ <tbody>+ {% for t in loc.tracks %}+ <tr>+ <td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>+ <td>{{ t.artist_name }}</td>+ <td class="text-end">{{ t.count }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ {% endif %}+ </div>+ </div>+ {% empty %}+ <p class="text-muted">No concurrent listening data for locations.</p>+ {% endfor %}+ </div>+ {% endif %}+</div>++{% if not data.trails and not data.locations %}+<p class="text-muted">No concurrent listening data found.</p>+{% endif %}@@ -0,0 +1,42 @@+<div class="row">+ {% if data.books %}+ {% for book in data.books %}+ <div class="col-md-6 mb-3">+ <div class="card">+ <div class="card-body py-2">+ <h6 class="card-title mb-1">+ {% if book.book_uuid %}+ <a href="{% url 'books:book_detail' book.book_uuid %}">{{ book.book_title }}</a>+ {% else %}+ {{ book.book_title }}+ {% endif %}+ <small class="text-muted">({{ book.total_sessions }} listening sessions)</small>+ </h6>+ {% if book.tracks %}+ <table class="table table-sm table-borderless mb-0">+ <thead>+ <tr>+ <th>Track</th>+ <th>Artist</th>+ <th class="text-end">Plays</th>+ </tr>+ </thead>+ <tbody>+ {% for t in book.tracks %}+ <tr>+ <td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>+ <td>{{ t.artist_name }}</td>+ <td class="text-end">{{ t.count }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ {% endif %}+ </div>+ </div>+ </div>+ {% endfor %}+ {% else %}+ <p class="text-muted">No concurrent reading data found.</p>+ {% endif %}+</div>@@ -0,0 +1,52 @@+<div class="row">+ <div class="col-md-6 mb-3">+ <div class="card">+ <div class="card-body">+ <h5 class="card-title">๐ต Reading with Music</h5>+ {% if data.with_music %}+ <table class="table table-sm mb-0">+ <tr>+ <th>Avg session duration</th>+ <td>{{ data.with_music.avg_seconds }} seconds</td>+ </tr>+ <tr>+ <th>Total reading time</th>+ <td>{{ data.with_music.total_seconds }} seconds</td>+ </tr>+ <tr>+ <th>Reading sessions</th>+ <td>{{ data.with_music.sessions_count }}</td>+ </tr>+ </table>+ {% else %}+ <p class="text-muted mb-0">No data.</p>+ {% endif %}+ </div>+ </div>+ </div>+ <div class="col-md-6 mb-3">+ <div class="card">+ <div class="card-body">+ <h5 class="card-title">๐ Reading without Music</h5>+ {% if data.without_music %}+ <table class="table table-sm mb-0">+ <tr>+ <th>Avg session duration</th>+ <td>{{ data.without_music.avg_seconds }} seconds</td>+ </tr>+ <tr>+ <th>Total reading time</th>+ <td>{{ data.without_music.total_seconds }} seconds</td>+ </tr>+ <tr>+ <th>Reading sessions</th>+ <td>{{ data.without_music.sessions_count }}</td>+ </tr>+ </table>+ {% else %}+ <p class="text-muted mb-0">No data.</p>+ {% endif %}+ </div>+ </div>+ </div>+</div>@@ -0,0 +1,38 @@+<div class="row">+ <div class="col-12">+ {% if data %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Media Type</th>+ <th class="text-end">Recent (30 days)</th>+ <th class="text-end">Previous (30 days)</th>+ <th class="text-end">Change</th>+ </tr>+ </thead>+ <tbody>+ {% for mt, info in data.items %}+ <tr>+ <td>{{ mt }}</td>+ <td class="text-end">{{ info.recent }}</td>+ <td class="text-end">{{ info.previous }}</td>+ <td class="text-end">+ {% if info.change_pct > 0 %}+ <span class="text-success">+{{ info.change_pct }}%</span>+ {% elif info.change_pct < 0 %}+ <span class="text-danger">{{ info.change_pct }}%</span>+ {% else %}+ <span class="text-muted">0%</span>+ {% endif %}+ </td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No trending data found.</p>+ {% endif %}+ </div>+</div>@@ -0,0 +1,38 @@+{% extends "base_list.html" %}++{% block title %}{{ trend.title }}{% endblock %}++{% block lists %}+<div class="row mb-3">+ <div class="col-12">+ <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>+ <h2>{{ trend.icon }} {{ trend.title }}</h2>+ <p class="text-muted">{{ trend.description }}</p>+ {% if computed_at %}+ <small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>+ {% endif %}+ </div>+</div>++{% if trend_not_found %}+<div class="alert alert-warning">Trend not found.</div>++{% elif data is None %}+<div class="alert alert-info">+ No data computed yet. Trends are updated once daily, check back later.+</div>++{% 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 %}@@ -0,0 +1,39 @@+{% extends "base_list.html" %}++{% block title %}Trends{% endblock %}++{% block lists %}+<div class="row">+ {% if not user.is_authenticated %}+ <div class="col-12">+ <div class="alert alert-info">Log in to see your trends.</div>+ </div>+ {% elif not trends %}+ <div class="col-12">+ <div class="alert alert-info">+ No trends computed yet. Trends are computed once daily, check back later.+ </div>+ </div>+ {% else %}+ {% for trend in trends %}+ <div class="col-md-6 mb-3">+ <div class="card h-100">+ <div class="card-body">+ <h5 class="card-title">+ <a href="{% url 'trends:trend-detail' trend.slug %}" class="stretched-link text-decoration-none">+ {{ trend.icon }} {{ trend.title }}+ </a>+ </h5>+ <p class="card-text text-muted">{{ trend.description }}</p>+ {% if trend.computed_at %}+ <small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>+ {% else %}+ <span class="badge bg-warning text-dark">Pending</span>+ {% endif %}+ </div>+ </div>+ </div>+ {% endfor %}+ {% endif %}+</div>+{% endblock %}@@ -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+)@@ -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],+ }@@ -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),+ }@@ -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@@ -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/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),+]@@ -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@@ -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",@@ -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: