[trends] Add time periods
This commit is contained in:
14
PROJECT.org
14
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/20] :vrobbler:project:personal:
|
* Backlog [1/21] :vrobbler:project:personal:
|
||||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||||
@ -590,6 +590,18 @@ 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
|
a helper method to create board game scrobbles given a json blob. It's
|
||||||
independent of the email flow it was originally creatdd for
|
independent of the email flow it was originally creatdd for
|
||||||
|
|
||||||
|
** DONE [#B] Trends dont seem to look very far back :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: ffcfba3f-5a93-9ee0-9680-666e6eccd684
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Specificially, looking at reading-pace when run on prod, it claims that I've
|
||||||
|
only had one reading session without music. Which may be true, but perhaps we
|
||||||
|
need to indicate what the time frame we're looking at is (month, week, year)
|
||||||
|
and provide a way to jump back and forward through time, same as charts.
|
||||||
|
|
||||||
* Version 54.1 [1/1]
|
* Version 54.1 [1/1]
|
||||||
** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles:
|
** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from trends.models import TrendResult
|
from trends.models import TrendResult
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,8 @@ import logging
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from trends.tasks import _compute_and_save_trend
|
|
||||||
from trends.trends import TREND_REGISTRY
|
from trends.trends import TREND_REGISTRY
|
||||||
|
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -48,24 +47,21 @@ class Command(BaseCommand):
|
|||||||
user_fail = 0
|
user_fail = 0
|
||||||
|
|
||||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||||
trend_start = timezone.now()
|
periods = get_supported_periods(slug)
|
||||||
self.stdout.write(
|
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||||
f" [{idx}/{total_trends}] {slug}... ", ending=""
|
for period in periods:
|
||||||
)
|
trend_start = timezone.now()
|
||||||
try:
|
self.stdout.write(f" {period}... ", ending="")
|
||||||
elapsed = _compute_and_save_trend(user, slug)
|
try:
|
||||||
self.stdout.write(
|
elapsed = compute_and_save_trend(user, slug, period)
|
||||||
self.style.SUCCESS(f"OK ({elapsed:.1f}s)")
|
self.stdout.write(self.style.SUCCESS(f"OK ({elapsed:.1f}s)"))
|
||||||
)
|
user_ok += 1
|
||||||
user_ok += 1
|
except Exception as e:
|
||||||
except Exception as e:
|
elapsed = (timezone.now() - trend_start).total_seconds()
|
||||||
elapsed = (timezone.now() - trend_start).total_seconds()
|
self.stdout.write(
|
||||||
self.stdout.write(
|
self.style.ERROR(f"FAILED after {elapsed:.1f}s: {e}")
|
||||||
self.style.ERROR(
|
|
||||||
f"FAILED after {elapsed:.1f}s: {e}"
|
|
||||||
)
|
)
|
||||||
)
|
user_fail += 1
|
||||||
user_fail += 1
|
|
||||||
|
|
||||||
user_elapsed = (timezone.now() - user_start).total_seconds()
|
user_elapsed = (timezone.now() - user_start).total_seconds()
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
# Generated by Django 4.2.29 on 2026-06-16 14:52
|
# 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.db.models.deletion
|
||||||
import django_extensions.db.fields
|
import django_extensions.db.fields
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-17 14:32
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("trends", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="trendresult",
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="trendresult",
|
||||||
|
name="period",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("last_30", "Last 30 days"),
|
||||||
|
("last_90", "Last 90 days"),
|
||||||
|
("last_year", "Last year"),
|
||||||
|
("all_time", "All time"),
|
||||||
|
],
|
||||||
|
default="all_time",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="trendresult",
|
||||||
|
unique_together={("user", "trend_slug", "period")},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -4,15 +4,27 @@ from django_extensions.db.models import TimeStampedModel
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
PERIOD_CHOICES = [
|
||||||
|
("last_30", "Last 30 days"),
|
||||||
|
("last_90", "Last 90 days"),
|
||||||
|
("last_year", "Last year"),
|
||||||
|
("all_time", "All time"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TrendResult(TimeStampedModel):
|
class TrendResult(TimeStampedModel):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
trend_slug = models.CharField(max_length=100, db_index=True)
|
trend_slug = models.CharField(max_length=100, db_index=True)
|
||||||
|
period = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=PERIOD_CHOICES,
|
||||||
|
default="all_time",
|
||||||
|
)
|
||||||
computed_at = models.DateTimeField(auto_now_add=True)
|
computed_at = models.DateTimeField(auto_now_add=True)
|
||||||
data = models.JSONField(default=dict)
|
data = models.JSONField(default=dict)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ["user", "trend_slug"]
|
unique_together = ["user", "trend_slug", "period"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} - {self.trend_slug} ({self.computed_at})"
|
return f"{self.user} - {self.trend_slug} ({self.period})"
|
||||||
|
|||||||
@ -3,35 +3,16 @@ import logging
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from trends.models import TrendResult
|
|
||||||
from trends.trends import TREND_REGISTRY
|
from trends.trends import TREND_REGISTRY
|
||||||
|
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
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
|
@shared_task
|
||||||
def compute_all_trends():
|
def compute_all_trends():
|
||||||
user_ids = list(
|
user_ids = list(User.objects.filter(is_active=True).values_list("id", flat=True))
|
||||||
User.objects.filter(is_active=True).values_list("id", flat=True)
|
|
||||||
)
|
|
||||||
logger.info("Dispatching trend computation for %d users", len(user_ids))
|
logger.info("Dispatching trend computation for %d users", len(user_ids))
|
||||||
for uid in user_ids:
|
for uid in user_ids:
|
||||||
compute_user_trends.delay(uid)
|
compute_user_trends.delay(uid)
|
||||||
@ -48,7 +29,9 @@ def compute_user_trends(user_id):
|
|||||||
total = len(TREND_REGISTRY)
|
total = len(TREND_REGISTRY)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Computing %d trends for user %s (%d)",
|
"Computing %d trends for user %s (%d)",
|
||||||
total, user, user_id,
|
total,
|
||||||
|
user,
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||||
@ -62,21 +45,25 @@ def compute_single_trend(user_id, slug):
|
|||||||
try:
|
try:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
logger.warning(
|
logger.warning("User %d not found for trend '%s', skipping", user_id, slug)
|
||||||
"User %d not found for trend '%s', skipping", user_id, slug
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if slug not in TREND_REGISTRY:
|
if slug not in TREND_REGISTRY:
|
||||||
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("[%s] Computing for user %d...", slug, user_id)
|
periods = get_supported_periods(slug)
|
||||||
try:
|
|
||||||
elapsed = _compute_and_save_trend(user, slug)
|
for period in periods:
|
||||||
logger.info(
|
logger.info("[%s/%s] Computing for user %d...", slug, period, user_id)
|
||||||
"[%s] Completed for user %d in %.1fs",
|
try:
|
||||||
slug, user_id, elapsed,
|
elapsed = compute_and_save_trend(user, slug, period)
|
||||||
)
|
logger.info(
|
||||||
except Exception:
|
"[%s/%s] Completed for user %d in %.1fs",
|
||||||
logger.exception("[%s] Failed for user %d", slug, user_id)
|
slug,
|
||||||
|
period,
|
||||||
|
user_id,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[%s/%s] Failed for user %d", slug, period, user_id)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.distribution %}
|
{% if data.distribution %}
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Total scrobbles: <strong>{{ data.total_count }}</strong>
|
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
|
{% if current_period_label %}
|
||||||
|
<div class="col-12 mb-2">
|
||||||
|
<small class="text-muted">Period: {{ current_period_label }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Media Type</th>
|
<th>Media Type</th>
|
||||||
<th class="text-end">Recent (30 days)</th>
|
<th class="text-end">Recent ({{ current_period_label }})</th>
|
||||||
<th class="text-end">Previous (30 days)</th>
|
<th class="text-end">Previous ({{ current_period_label }})</th>
|
||||||
<th class="text-end">Change</th>
|
<th class="text-end">Change</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@ -8,6 +8,30 @@
|
|||||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
||||||
<h2>{{ trend.icon }} {{ trend.title }}</h2>
|
<h2>{{ trend.icon }} {{ trend.title }}</h2>
|
||||||
<p class="text-muted">{{ trend.description }}</p>
|
<p class="text-muted">{{ trend.description }}</p>
|
||||||
|
|
||||||
|
{% if supported_periods|length > 1 %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<nav class="btn-group btn-group-sm" role="group">
|
||||||
|
{% for period_slug, period_label in supported_periods.items %}
|
||||||
|
<a href="?period={{ period_slug }}"
|
||||||
|
class="btn btn-sm {% if period_slug == current_period %}btn-primary{% else %}btn-outline-secondary{% endif %}">
|
||||||
|
{{ period_label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% if prev_period or next_period %}
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% if prev_period %}
|
||||||
|
<a href="?period={{ prev_period }}" class="btn btn-outline-secondary">« Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_period %}
|
||||||
|
<a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next »</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if computed_at %}
|
{% if computed_at %}
|
||||||
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -19,7 +43,7 @@
|
|||||||
|
|
||||||
{% elif data is None %}
|
{% elif data is None %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
No data computed yet. Trends are updated once daily, check back later.
|
No data computed yet for this period. Trends are updated once daily, check back later.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif trend.slug == "concurrent-listening" %}
|
{% elif trend.slug == "concurrent-listening" %}
|
||||||
|
|||||||
@ -3,11 +3,10 @@ from collections import OrderedDict, defaultdict
|
|||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.db.models.functions import Extract
|
from django.db.models.functions import Extract
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
def compute_peak_hours(user):
|
def compute_peak_hours(user, period="all_time"):
|
||||||
"""Group scrobbles by hour of day (0-23) and count them.
|
"""Group scrobbles by hour of day (0-23) and count them.
|
||||||
|
|
||||||
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
|
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
|
||||||
@ -28,21 +27,23 @@ def compute_peak_hours(user):
|
|||||||
return {"hours": hours}
|
return {"hours": hours}
|
||||||
|
|
||||||
|
|
||||||
def compute_weekly_rhythm(user):
|
def compute_weekly_rhythm(user, period="all_time"):
|
||||||
"""Group scrobble counts by day of the week.
|
"""Group scrobble counts by day of the week.
|
||||||
|
|
||||||
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
|
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
|
||||||
with human-readable day names.
|
with human-readable day names.
|
||||||
"""
|
"""
|
||||||
DAY_NAMES = OrderedDict([
|
DAY_NAMES = OrderedDict(
|
||||||
(1, "Monday"),
|
[
|
||||||
(2, "Tuesday"),
|
(1, "Monday"),
|
||||||
(3, "Wednesday"),
|
(2, "Tuesday"),
|
||||||
(4, "Thursday"),
|
(3, "Wednesday"),
|
||||||
(5, "Friday"),
|
(4, "Thursday"),
|
||||||
(6, "Saturday"),
|
(5, "Friday"),
|
||||||
(7, "Sunday"),
|
(6, "Saturday"),
|
||||||
])
|
(7, "Sunday"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
days_qs = (
|
days_qs = (
|
||||||
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
||||||
@ -55,24 +56,35 @@ def compute_weekly_rhythm(user):
|
|||||||
raw = {row["day"]: row["count"] for row in days_qs}
|
raw = {row["day"]: row["count"] for row in days_qs}
|
||||||
days = []
|
days = []
|
||||||
for idx, name in DAY_NAMES.items():
|
for idx, name in DAY_NAMES.items():
|
||||||
days.append({
|
days.append(
|
||||||
"day_index": idx,
|
{
|
||||||
"day_name": name,
|
"day_index": idx,
|
||||||
"count": raw.get(idx, 0),
|
"day_name": name,
|
||||||
})
|
"count": raw.get(idx, 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"days": days}
|
return {"days": days}
|
||||||
|
|
||||||
|
|
||||||
def compute_activity_distribution(user):
|
def compute_activity_distribution(user, period="all_time"):
|
||||||
"""Proportion of total scrobbles per media type.
|
"""Proportion of total scrobbles per media type.
|
||||||
|
|
||||||
Returns dict: {"distribution": [{"media_type": "...", "count": N,
|
Returns dict: {"distribution": [{"media_type": "...", "count": N,
|
||||||
"completed": N, "pct": float}, ...]} sorted by count desc, plus
|
"completed": N, "pct": float}, ...]} sorted by count desc, plus
|
||||||
"total_count".
|
"total_count".
|
||||||
"""
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
filters = Q(user=user)
|
||||||
|
if start:
|
||||||
|
filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
dist_qs = (
|
dist_qs = (
|
||||||
Scrobble.objects.filter(user=user)
|
Scrobble.objects.filter(filters)
|
||||||
.values("media_type")
|
.values("media_type")
|
||||||
.annotate(
|
.annotate(
|
||||||
count=Count("id"),
|
count=Count("id"),
|
||||||
@ -86,12 +98,14 @@ def compute_activity_distribution(user):
|
|||||||
|
|
||||||
distribution = []
|
distribution = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
distribution.append({
|
distribution.append(
|
||||||
"media_type": row["media_type"],
|
{
|
||||||
"count": row["count"],
|
"media_type": row["media_type"],
|
||||||
"completed": row["completed"],
|
"count": row["count"],
|
||||||
"pct": round((row["count"] / total) * 100, 1),
|
"completed": row["completed"],
|
||||||
})
|
"pct": round((row["count"] / total) * 100, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"distribution": distribution,
|
"distribution": distribution,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
@ -21,12 +22,8 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
|||||||
Returns a dict mapping each anchor scrobble PK to a list of
|
Returns a dict mapping each anchor scrobble PK to a list of
|
||||||
paired scrobble PKs that overlap with it.
|
paired scrobble PKs that overlap with it.
|
||||||
"""
|
"""
|
||||||
anchor_ranges = {
|
anchor_ranges = {s.pk: _range_for(s) for s in anchor_scrobbles}
|
||||||
s.pk: _range_for(s) for s in anchor_scrobbles
|
paired_ranges = {s.pk: _range_for(s) for s in paired_scrobbles}
|
||||||
}
|
|
||||||
paired_ranges = {
|
|
||||||
s.pk: _range_for(s) for s in paired_scrobbles
|
|
||||||
}
|
|
||||||
|
|
||||||
anchor_to_paired = defaultdict(list)
|
anchor_to_paired = defaultdict(list)
|
||||||
|
|
||||||
@ -41,7 +38,10 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
|||||||
def _get_media_name(scrobble):
|
def _get_media_name(scrobble):
|
||||||
"""Return the name of the media object associated with a scrobble."""
|
"""Return the name of the media object associated with a scrobble."""
|
||||||
for attr in [
|
for attr in [
|
||||||
"trail", "geo_location", "book", "track",
|
"trail",
|
||||||
|
"geo_location",
|
||||||
|
"book",
|
||||||
|
"track",
|
||||||
]:
|
]:
|
||||||
obj = getattr(scrobble, attr, None)
|
obj = getattr(scrobble, attr, None)
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
@ -49,22 +49,45 @@ def _get_media_name(scrobble):
|
|||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
def compute_concurrent_listening(user):
|
def compute_concurrent_listening(user, period="all_time"):
|
||||||
"""Find what music was listened to while on trails or at locations.
|
"""Find what music was listened to while on trails or at locations.
|
||||||
|
|
||||||
Returns a dict with two keys: 'trails' and 'locations', each containing
|
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.
|
a list of entries with the trail/location name and the tracks listened to.
|
||||||
"""
|
"""
|
||||||
media_types_to_exclude_from_anchor = ("Track", "Book", "Video", "PodcastEpisode",
|
from trends.utils import get_date_range
|
||||||
"VideoGame", "BoardGame", "Puzzle", "Food",
|
|
||||||
"Beer", "Task", "WebPage", "LifeEvent",
|
start, end = get_date_range(period)
|
||||||
"Mood", "BrickSet", "Channel", "BirdingLocation",
|
base_filters = Q(user=user, timestamp__isnull=False)
|
||||||
"Paper", "SportEvent")
|
if start:
|
||||||
|
base_filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
base_filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
|
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(
|
anchor_scrobbles = list(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=user,
|
base_filters,
|
||||||
timestamp__isnull=False,
|
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
)
|
)
|
||||||
.exclude(media_type__in=media_types_to_exclude_from_anchor)
|
.exclude(media_type__in=media_types_to_exclude_from_anchor)
|
||||||
@ -74,9 +97,8 @@ def compute_concurrent_listening(user):
|
|||||||
|
|
||||||
paired_scrobbles = list(
|
paired_scrobbles = list(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=user,
|
base_filters,
|
||||||
media_type="Track",
|
media_type="Track",
|
||||||
timestamp__isnull=False,
|
|
||||||
stop_timestamp__isnull=False,
|
stop_timestamp__isnull=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
)
|
)
|
||||||
@ -131,29 +153,45 @@ def compute_concurrent_listening(user):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if anchor.media_type == "Trail":
|
if anchor.media_type == "Trail":
|
||||||
entry["uuid"] = str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""
|
entry["uuid"] = (
|
||||||
|
str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""
|
||||||
|
)
|
||||||
trails.append(entry)
|
trails.append(entry)
|
||||||
else:
|
else:
|
||||||
entry["uuid"] = str(anchor.geo_location.uuid) if anchor.geo_location and anchor.geo_location.uuid else ""
|
entry["uuid"] = (
|
||||||
|
str(anchor.geo_location.uuid)
|
||||||
|
if anchor.geo_location and anchor.geo_location.uuid
|
||||||
|
else ""
|
||||||
|
)
|
||||||
locations.append(entry)
|
locations.append(entry)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
"trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||||
"locations": sorted(locations, 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):
|
def compute_concurrent_reading(user, period="all_time"):
|
||||||
"""Find what music was listened to while reading books.
|
"""Find what music was listened to while reading books.
|
||||||
|
|
||||||
Returns a dict with key 'books' containing a list of entries with the
|
Returns a dict with key 'books' containing a list of entries with the
|
||||||
book title and the tracks listened to while reading.
|
book title and the tracks listened to while reading.
|
||||||
"""
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
base_filters = Q(user=user, timestamp__isnull=False)
|
||||||
|
if start:
|
||||||
|
base_filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
base_filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
anchor_scrobbles = list(
|
anchor_scrobbles = list(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=user,
|
base_filters,
|
||||||
media_type="Book",
|
media_type="Book",
|
||||||
timestamp__isnull=False,
|
|
||||||
stop_timestamp__isnull=False,
|
stop_timestamp__isnull=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
)
|
)
|
||||||
@ -163,9 +201,8 @@ def compute_concurrent_reading(user):
|
|||||||
|
|
||||||
paired_scrobbles = list(
|
paired_scrobbles = list(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=user,
|
base_filters,
|
||||||
media_type="Track",
|
media_type="Track",
|
||||||
timestamp__isnull=False,
|
|
||||||
stop_timestamp__isnull=False,
|
stop_timestamp__isnull=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
)
|
)
|
||||||
@ -203,19 +240,21 @@ def compute_concurrent_reading(user):
|
|||||||
}
|
}
|
||||||
|
|
||||||
book = anchor.book
|
book = anchor.book
|
||||||
books.append({
|
books.append(
|
||||||
"book_title": str(book) if book else "Unknown",
|
{
|
||||||
"book_uuid": str(book.uuid) if book and book.uuid else "",
|
"book_title": str(book) if book else "Unknown",
|
||||||
"total_sessions": len(paired_pks),
|
"book_uuid": str(book.uuid) if book and book.uuid else "",
|
||||||
"tracks": sorted(
|
"total_sessions": len(paired_pks),
|
||||||
[
|
"tracks": sorted(
|
||||||
{**track_details[name], "count": count}
|
[
|
||||||
for name, count in tracks_by_name.items()
|
{**track_details[name], "count": count}
|
||||||
],
|
for name, count in tracks_by_name.items()
|
||||||
key=lambda x: x["count"],
|
],
|
||||||
reverse=True,
|
key=lambda x: x["count"],
|
||||||
)[:20],
|
reverse=True,
|
||||||
})
|
)[:20],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||||
|
|||||||
@ -1,21 +1,30 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
def compute_reading_pace_vs_activity(user):
|
def compute_reading_pace_vs_activity(user, period="all_time"):
|
||||||
"""Compare reading pace (seconds per session) when music is playing vs. not.
|
"""Compare reading pace (seconds per session) when music is playing vs. not.
|
||||||
|
|
||||||
For each Book scrobble with a playback_position_seconds value, checks
|
For each Book scrobble with a playback_position_seconds value, checks
|
||||||
whether there is an overlapping Track scrobble and groups the data.
|
whether there is an overlapping Track scrobble and groups the data.
|
||||||
Returns average session duration for both groups.
|
Returns average session duration for both groups.
|
||||||
"""
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
base_filters = Q(user=user, timestamp__isnull=False)
|
||||||
|
if start:
|
||||||
|
base_filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
base_filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
book_scrobbles = list(
|
book_scrobbles = list(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=user,
|
base_filters,
|
||||||
media_type="Book",
|
media_type="Book",
|
||||||
timestamp__isnull=False,
|
|
||||||
playback_position_seconds__isnull=False,
|
playback_position_seconds__isnull=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
)
|
)
|
||||||
@ -28,12 +37,10 @@ def compute_reading_pace_vs_activity(user):
|
|||||||
|
|
||||||
track_scrobbles = list(
|
track_scrobbles = list(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=user,
|
base_filters,
|
||||||
media_type="Track",
|
media_type="Track",
|
||||||
timestamp__isnull=False,
|
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
)
|
).order_by("-timestamp")
|
||||||
.order_by("-timestamp")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
track_ranges = []
|
track_ranges = []
|
||||||
|
|||||||
@ -2,18 +2,21 @@ from collections import defaultdict
|
|||||||
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
def compute_trending_up(user, days=30):
|
def compute_trending_up(user, period="last_30"):
|
||||||
"""Compare scrobble counts per media type between two periods.
|
"""Compare scrobble counts per media type between two periods.
|
||||||
|
|
||||||
Compares the most recent N days against the N days before that,
|
Compares the most recent N days against the N days before that,
|
||||||
returning the count for each period and the percentage change.
|
returning the count for each period and the percentage change.
|
||||||
|
The period controls the window size (e.g. 30, 90, 365 days).
|
||||||
|
|
||||||
Returns a dict keyed by media_type with count and change info.
|
Returns a dict keyed by media_type with count and change info.
|
||||||
"""
|
"""
|
||||||
|
from trends.utils import get_period_days
|
||||||
|
|
||||||
|
days = get_period_days(period) or 30
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
recent_start = now - timezone.timedelta(days=days)
|
recent_start = now - timezone.timedelta(days=days)
|
||||||
previous_start = recent_start - timezone.timedelta(days=days)
|
previous_start = recent_start - timezone.timedelta(days=days)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from trends.views import TrendDetailView, TrendListView
|
from trends.views import TrendDetailView, TrendListView
|
||||||
|
|
||||||
app_name = "trends"
|
app_name = "trends"
|
||||||
|
|||||||
80
vrobbler/apps/trends/utils.py
Normal file
80
vrobbler/apps/trends/utils.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from trends.models import PERIOD_CHOICES, TrendResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PERIOD_DAYS = {
|
||||||
|
"last_30": 30,
|
||||||
|
"last_90": 90,
|
||||||
|
"last_year": 365,
|
||||||
|
"all_time": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
||||||
|
|
||||||
|
TIME_BOUND_TRENDS = {
|
||||||
|
"activity-distribution",
|
||||||
|
"concurrent-reading",
|
||||||
|
"concurrent-listening",
|
||||||
|
"reading-pace-vs-activity",
|
||||||
|
"trending-up",
|
||||||
|
}
|
||||||
|
|
||||||
|
TREND_PERIOD_OVERRIDES = {
|
||||||
|
"trending-up": ["last_30", "last_90", "last_year"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_supported_periods(trend_slug):
|
||||||
|
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||||
|
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||||
|
return {s: PERIOD_LABELS[s] for s in slugs}
|
||||||
|
if trend_slug in TIME_BOUND_TRENDS:
|
||||||
|
return dict(PERIOD_LABELS)
|
||||||
|
return {"all_time": PERIOD_LABELS["all_time"]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_days(period):
|
||||||
|
return PERIOD_DAYS.get(period)
|
||||||
|
|
||||||
|
|
||||||
|
def get_date_range(period):
|
||||||
|
days = get_period_days(period)
|
||||||
|
if days is None:
|
||||||
|
return None, None
|
||||||
|
now = timezone.now()
|
||||||
|
return now - timedelta(days=days), now
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_nav(current_period, trend_slug):
|
||||||
|
supported = get_supported_periods(trend_slug)
|
||||||
|
keys = list(supported.keys())
|
||||||
|
try:
|
||||||
|
idx = keys.index(current_period)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
prev_period = keys[idx - 1] if idx > 0 else None
|
||||||
|
next_period = keys[idx + 1] if idx < len(keys) - 1 else None
|
||||||
|
return prev_period, next_period
|
||||||
|
|
||||||
|
|
||||||
|
def compute_and_save_trend(user, slug, period="all_time"):
|
||||||
|
"""Compute a single trend for a given period and persist the result.
|
||||||
|
|
||||||
|
Returns elapsed seconds on success, raises on failure.
|
||||||
|
"""
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
|
||||||
|
fn = TREND_REGISTRY[slug]
|
||||||
|
start = timezone.now()
|
||||||
|
data = fn(user, period=period)
|
||||||
|
TrendResult.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
trend_slug=slug,
|
||||||
|
period=period,
|
||||||
|
defaults={"data": data, "computed_at": timezone.now()},
|
||||||
|
)
|
||||||
|
return (timezone.now() - start).total_seconds()
|
||||||
@ -1,8 +1,8 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from trends.models import TrendResult
|
from trends.models import TrendResult
|
||||||
from trends.trends import TREND_REGISTRY
|
from trends.trends import TREND_REGISTRY
|
||||||
|
from trends.utils import get_period_nav, get_supported_periods
|
||||||
|
|
||||||
TREND_METADATA = {
|
TREND_METADATA = {
|
||||||
"activity-distribution": {
|
"activity-distribution": {
|
||||||
@ -48,24 +48,29 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
results = {
|
results = TrendResult.objects.filter(
|
||||||
r.trend_slug: r
|
user=self.request.user,
|
||||||
for r in TrendResult.objects.filter(
|
).order_by("trend_slug", "-computed_at")
|
||||||
user=self.request.user
|
|
||||||
)
|
latest_by_slug = {}
|
||||||
}
|
for r in results:
|
||||||
|
if r.trend_slug not in latest_by_slug:
|
||||||
|
latest_by_slug[r.trend_slug] = r
|
||||||
|
|
||||||
trends = []
|
trends = []
|
||||||
for slug in TREND_REGISTRY:
|
for slug in TREND_REGISTRY:
|
||||||
meta = TREND_METADATA.get(slug, {})
|
meta = TREND_METADATA.get(slug, {})
|
||||||
result = results.get(slug)
|
result = latest_by_slug.get(slug)
|
||||||
trends.append({
|
trends.append(
|
||||||
"slug": slug,
|
{
|
||||||
"title": meta.get("title", slug),
|
"slug": slug,
|
||||||
"description": meta.get("description", ""),
|
"title": meta.get("title", slug),
|
||||||
"icon": meta.get("icon", ""),
|
"description": meta.get("description", ""),
|
||||||
"computed_at": result.computed_at if result else None,
|
"icon": meta.get("icon", ""),
|
||||||
"has_data": result is not None,
|
"computed_at": result.computed_at if result else None,
|
||||||
})
|
"has_data": result is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
ctx["trends"] = trends
|
ctx["trends"] = trends
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@ -81,6 +86,8 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
|||||||
ctx["trend_not_found"] = True
|
ctx["trend_not_found"] = True
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
period = self.request.GET.get("period", "all_time")
|
||||||
|
|
||||||
meta = TREND_METADATA.get(slug, {})
|
meta = TREND_METADATA.get(slug, {})
|
||||||
ctx["trend"] = {
|
ctx["trend"] = {
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
@ -89,9 +96,19 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
|||||||
"icon": meta.get("icon", ""),
|
"icon": meta.get("icon", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supported = get_supported_periods(slug)
|
||||||
|
ctx["supported_periods"] = supported
|
||||||
|
ctx["current_period"] = period
|
||||||
|
ctx["current_period_label"] = supported.get(period, "")
|
||||||
|
|
||||||
|
prev_period, next_period = get_period_nav(period, slug)
|
||||||
|
ctx["prev_period"] = prev_period
|
||||||
|
ctx["next_period"] = next_period
|
||||||
|
|
||||||
result = TrendResult.objects.filter(
|
result = TrendResult.objects.filter(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
trend_slug=slug,
|
trend_slug=slug,
|
||||||
|
period=period,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
|
|||||||
Reference in New Issue
Block a user