Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37112babbb | |||
| fb775f2f58 | |||
| b26470c279 | |||
| d3b9ec815b | |||
| 19f2b5e801 | |||
| 9e3288a5ff | |||
| 06465919dd |
32
PROJECT.org
32
PROJECT.org
@ -590,6 +590,38 @@ We should rename `email_scrobble_board_game` to reflect the fact that it's just
|
||||
a helper method to create board game scrobbles given a json blob. It's
|
||||
independent of the email flow it was originally creatdd for
|
||||
|
||||
* Version 54.2 [4/4]
|
||||
** DONE [#B] Add script to clean up TV series metadata :videos:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: a468b328-59d9-f84b-9ddb-087216783453
|
||||
:END:
|
||||
** DONE [#A] Update youtube video detail pages with links to channel :videos:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 8b87cb42-09e5-a3f5-136f-182f967fa81f
|
||||
:END:
|
||||
** DONE [#A] Concurrent reading trend does not consolidate on single book :trends:reading:
|
||||
:PROPERTIES:
|
||||
:ID: fe220f55-7e0d-2a17-2477-a5aa7c4a1f2c
|
||||
:END:
|
||||
** 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]
|
||||
** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 4aa3b719-6b22-cae9-85f0-fac67b4fc753
|
||||
:END:
|
||||
|
||||
* Version 54.0 [3/3]
|
||||
** DONE [#B] Add peak hour, weekly rhythm and activity dist trends :trends:scrobbles:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "54.0"
|
||||
version = "54.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from trends.models import TrendResult
|
||||
|
||||
|
||||
|
||||
@ -3,9 +3,8 @@ import logging
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from trends.tasks import _compute_and_save_trend
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -48,24 +47,21 @@ class Command(BaseCommand):
|
||||
user_fail = 0
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
trend_start = timezone.now()
|
||||
self.stdout.write(
|
||||
f" [{idx}/{total_trends}] {slug}... ", ending=""
|
||||
)
|
||||
try:
|
||||
elapsed = _compute_and_save_trend(user, slug)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"OK ({elapsed:.1f}s)")
|
||||
)
|
||||
user_ok += 1
|
||||
except Exception as e:
|
||||
elapsed = (timezone.now() - trend_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"FAILED after {elapsed:.1f}s: {e}"
|
||||
periods = get_supported_periods(slug)
|
||||
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||
for period in periods:
|
||||
trend_start = timezone.now()
|
||||
self.stdout.write(f" {period}... ", ending="")
|
||||
try:
|
||||
elapsed = compute_and_save_trend(user, slug, period)
|
||||
self.stdout.write(self.style.SUCCESS(f"OK ({elapsed:.1f}s)"))
|
||||
user_ok += 1
|
||||
except Exception as e:
|
||||
elapsed = (timezone.now() - trend_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"FAILED after {elapsed:.1f}s: {e}")
|
||||
)
|
||||
)
|
||||
user_fail += 1
|
||||
user_fail += 1
|
||||
|
||||
user_elapsed = (timezone.now() - user_start).total_seconds()
|
||||
self.stdout.write(
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# 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
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
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()
|
||||
|
||||
PERIOD_CHOICES = [
|
||||
("last_30", "Last 30 days"),
|
||||
("last_90", "Last 90 days"),
|
||||
("last_year", "Last year"),
|
||||
("all_time", "All time"),
|
||||
]
|
||||
|
||||
|
||||
class TrendResult(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
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)
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["user", "trend_slug"]
|
||||
unique_together = ["user", "trend_slug", "period"]
|
||||
|
||||
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 django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _compute_and_save_trend(user, slug):
|
||||
"""Compute a single trend and persist the result.
|
||||
|
||||
Returns elapsed seconds on success, raises on failure.
|
||||
"""
|
||||
fn = TREND_REGISTRY[slug]
|
||||
start = timezone.now()
|
||||
data = fn(user)
|
||||
TrendResult.objects.update_or_create(
|
||||
user=user,
|
||||
trend_slug=slug,
|
||||
defaults={"data": data, "computed_at": timezone.now()},
|
||||
)
|
||||
return (timezone.now() - start).total_seconds()
|
||||
|
||||
|
||||
@shared_task
|
||||
def compute_all_trends():
|
||||
user_ids = list(
|
||||
User.objects.filter(is_active=True).values_list("id", flat=True)
|
||||
)
|
||||
user_ids = list(User.objects.filter(is_active=True).values_list("id", flat=True))
|
||||
logger.info("Dispatching trend computation for %d users", len(user_ids))
|
||||
for uid in user_ids:
|
||||
compute_user_trends.delay(uid)
|
||||
@ -48,7 +29,9 @@ def compute_user_trends(user_id):
|
||||
total = len(TREND_REGISTRY)
|
||||
logger.info(
|
||||
"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):
|
||||
@ -62,21 +45,25 @@ def compute_single_trend(user_id, slug):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
logger.warning(
|
||||
"User %d not found for trend '%s', skipping", user_id, slug
|
||||
)
|
||||
logger.warning("User %d not found for trend '%s', skipping", user_id, slug)
|
||||
return
|
||||
|
||||
if slug not in TREND_REGISTRY:
|
||||
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
||||
return
|
||||
|
||||
logger.info("[%s] Computing for user %d...", slug, user_id)
|
||||
try:
|
||||
elapsed = _compute_and_save_trend(user, slug)
|
||||
logger.info(
|
||||
"[%s] Completed for user %d in %.1fs",
|
||||
slug, user_id, elapsed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[%s] Failed for user %d", slug, user_id)
|
||||
periods = get_supported_periods(slug)
|
||||
|
||||
for period in periods:
|
||||
logger.info("[%s/%s] Computing for user %d...", slug, period, user_id)
|
||||
try:
|
||||
elapsed = compute_and_save_trend(user, slug, period)
|
||||
logger.info(
|
||||
"[%s/%s] Completed for user %d in %.1fs",
|
||||
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">
|
||||
{% if data.distribution %}
|
||||
<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>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
<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="card">
|
||||
<div class="card-body">
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
<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">Recent ({{ current_period_label }})</th>
|
||||
<th class="text-end">Previous ({{ current_period_label }})</th>
|
||||
<th class="text-end">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -8,6 +8,30 @@
|
||||
<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 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 %}
|
||||
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||
{% endif %}
|
||||
@ -19,7 +43,7 @@
|
||||
|
||||
{% elif data is None %}
|
||||
<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>
|
||||
|
||||
{% elif trend.slug == "concurrent-listening" %}
|
||||
|
||||
@ -3,37 +3,34 @@ from trends.trends.activity import (
|
||||
compute_peak_hours,
|
||||
compute_weekly_rhythm,
|
||||
)
|
||||
from trends.trends.concurrent import compute_concurrent_listening, compute_concurrent_reading
|
||||
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_activity_distribution = register("activity-distribution")(
|
||||
compute_activity_distribution
|
||||
)
|
||||
compute_concurrent_listening = register("concurrent-listening")(
|
||||
compute_concurrent_listening
|
||||
)
|
||||
compute_concurrent_reading = register("concurrent-reading")(
|
||||
compute_concurrent_reading
|
||||
)
|
||||
compute_peak_hours = register("peak-hours")(
|
||||
compute_peak_hours
|
||||
)
|
||||
# compute_concurrent_listening = register("concurrent-listening")(
|
||||
# compute_concurrent_listening
|
||||
# )
|
||||
compute_concurrent_reading = register("concurrent-reading")(compute_concurrent_reading)
|
||||
compute_peak_hours = register("peak-hours")(compute_peak_hours)
|
||||
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||
compute_reading_pace_vs_activity
|
||||
)
|
||||
compute_trending_up = register("trending-up")(
|
||||
compute_trending_up
|
||||
)
|
||||
compute_weekly_rhythm = register("weekly-rhythm")(
|
||||
compute_weekly_rhythm
|
||||
)
|
||||
compute_trending_up = register("trending-up")(compute_trending_up)
|
||||
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)
|
||||
|
||||
@ -3,11 +3,10 @@ from collections import OrderedDict, defaultdict
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
|
||||
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.
|
||||
|
||||
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
|
||||
@ -28,21 +27,23 @@ def compute_peak_hours(user):
|
||||
return {"hours": hours}
|
||||
|
||||
|
||||
def compute_weekly_rhythm(user):
|
||||
def compute_weekly_rhythm(user, period="all_time"):
|
||||
"""Group scrobble counts by day of the week.
|
||||
|
||||
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
|
||||
with human-readable day names.
|
||||
"""
|
||||
DAY_NAMES = OrderedDict([
|
||||
(1, "Monday"),
|
||||
(2, "Tuesday"),
|
||||
(3, "Wednesday"),
|
||||
(4, "Thursday"),
|
||||
(5, "Friday"),
|
||||
(6, "Saturday"),
|
||||
(7, "Sunday"),
|
||||
])
|
||||
DAY_NAMES = OrderedDict(
|
||||
[
|
||||
(1, "Monday"),
|
||||
(2, "Tuesday"),
|
||||
(3, "Wednesday"),
|
||||
(4, "Thursday"),
|
||||
(5, "Friday"),
|
||||
(6, "Saturday"),
|
||||
(7, "Sunday"),
|
||||
]
|
||||
)
|
||||
|
||||
days_qs = (
|
||||
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}
|
||||
days = []
|
||||
for idx, name in DAY_NAMES.items():
|
||||
days.append({
|
||||
"day_index": idx,
|
||||
"day_name": name,
|
||||
"count": raw.get(idx, 0),
|
||||
})
|
||||
days.append(
|
||||
{
|
||||
"day_index": idx,
|
||||
"day_name": name,
|
||||
"count": raw.get(idx, 0),
|
||||
}
|
||||
)
|
||||
|
||||
return {"days": days}
|
||||
|
||||
|
||||
def compute_activity_distribution(user):
|
||||
def compute_activity_distribution(user, period="all_time"):
|
||||
"""Proportion of total scrobbles per media type.
|
||||
|
||||
Returns dict: {"distribution": [{"media_type": "...", "count": N,
|
||||
"completed": N, "pct": float}, ...]} sorted by count desc, plus
|
||||
"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 = (
|
||||
Scrobble.objects.filter(user=user)
|
||||
Scrobble.objects.filter(filters)
|
||||
.values("media_type")
|
||||
.annotate(
|
||||
count=Count("id"),
|
||||
@ -86,12 +98,14 @@ def compute_activity_distribution(user):
|
||||
|
||||
distribution = []
|
||||
for row in rows:
|
||||
distribution.append({
|
||||
"media_type": row["media_type"],
|
||||
"count": row["count"],
|
||||
"completed": row["completed"],
|
||||
"pct": round((row["count"] / total) * 100, 1),
|
||||
})
|
||||
distribution.append(
|
||||
{
|
||||
"media_type": row["media_type"],
|
||||
"count": row["count"],
|
||||
"completed": row["completed"],
|
||||
"pct": round((row["count"] / total) * 100, 1),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"distribution": distribution,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
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
|
||||
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_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)
|
||||
|
||||
@ -41,7 +38,10 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
||||
def _get_media_name(scrobble):
|
||||
"""Return the name of the media object associated with a scrobble."""
|
||||
for attr in [
|
||||
"trail", "geo_location", "book", "track",
|
||||
"trail",
|
||||
"geo_location",
|
||||
"book",
|
||||
"track",
|
||||
]:
|
||||
obj = getattr(scrobble, attr, None)
|
||||
if obj is not None:
|
||||
@ -49,22 +49,45 @@ def _get_media_name(scrobble):
|
||||
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.
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
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,
|
||||
base_filters,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.exclude(media_type__in=media_types_to_exclude_from_anchor)
|
||||
@ -74,9 +97,8 @@ def compute_concurrent_listening(user):
|
||||
|
||||
paired_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
timestamp__isnull=False,
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -131,29 +153,45 @@ def compute_concurrent_listening(user):
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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],
|
||||
"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.
|
||||
|
||||
Returns a dict with key 'books' containing a list of entries with the
|
||||
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(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Book",
|
||||
timestamp__isnull=False,
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -163,9 +201,8 @@ def compute_concurrent_reading(user):
|
||||
|
||||
paired_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
timestamp__isnull=False,
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -179,43 +216,59 @@ def compute_concurrent_reading(user):
|
||||
anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)
|
||||
paired_by_pk = {s.pk: s for s in paired_scrobbles}
|
||||
|
||||
books = []
|
||||
books_by_uuid = {}
|
||||
|
||||
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 = {}
|
||||
book = anchor.book
|
||||
book_uuid = str(book.uuid) if book and book.uuid else ""
|
||||
book_key = book_uuid or str(book) if book else "Unknown"
|
||||
|
||||
if book_key not in books_by_uuid:
|
||||
books_by_uuid[book_key] = {
|
||||
"book_title": str(book) if book else "Unknown",
|
||||
"book_uuid": book_uuid,
|
||||
"total_sessions": 0,
|
||||
"tracks_by_name": defaultdict(int),
|
||||
"track_details": {},
|
||||
}
|
||||
|
||||
books_by_uuid[book_key]["total_sessions"] += len(paired_pks)
|
||||
|
||||
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] = {
|
||||
books_by_uuid[book_key]["tracks_by_name"][name] += 1
|
||||
if name not in books_by_uuid[book_key]["track_details"]:
|
||||
books_by_uuid[book_key]["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],
|
||||
})
|
||||
books = []
|
||||
for bd in books_by_uuid.values():
|
||||
books.append(
|
||||
{
|
||||
"book_title": bd["book_title"],
|
||||
"book_uuid": bd["book_uuid"],
|
||||
"total_sessions": bd["total_sessions"],
|
||||
"tracks": sorted(
|
||||
[
|
||||
{**bd["track_details"][name], "count": count}
|
||||
for name, count in bd["tracks_by_name"].items()
|
||||
],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:5],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||
|
||||
@ -1,21 +1,30 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Book",
|
||||
timestamp__isnull=False,
|
||||
playback_position_seconds__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -28,12 +37,10 @@ def compute_reading_pace_vs_activity(user):
|
||||
|
||||
track_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
).order_by("-timestamp")
|
||||
)
|
||||
|
||||
track_ranges = []
|
||||
|
||||
@ -2,18 +2,21 @@ 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):
|
||||
def compute_trending_up(user, period="last_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.
|
||||
The period controls the window size (e.g. 30, 90, 365 days).
|
||||
|
||||
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()
|
||||
recent_start = now - timezone.timedelta(days=days)
|
||||
previous_start = recent_start - timezone.timedelta(days=days)
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from django.urls import path
|
||||
|
||||
from trends.views import TrendDetailView, TrendListView
|
||||
|
||||
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.views.generic import TemplateView
|
||||
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import get_period_nav, get_supported_periods
|
||||
|
||||
TREND_METADATA = {
|
||||
"activity-distribution": {
|
||||
@ -48,24 +48,29 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
results = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("trend_slug", "-computed_at")
|
||||
|
||||
latest_by_slug = {}
|
||||
for r in results:
|
||||
if r.trend_slug not in latest_by_slug:
|
||||
latest_by_slug[r.trend_slug] = r
|
||||
|
||||
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,
|
||||
})
|
||||
result = latest_by_slug.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
|
||||
|
||||
@ -81,6 +86,8 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
ctx["trend_not_found"] = True
|
||||
return ctx
|
||||
|
||||
period = self.request.GET.get("period", "all_time")
|
||||
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
ctx["trend"] = {
|
||||
"slug": slug,
|
||||
@ -89,9 +96,19 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
"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(
|
||||
user=self.request.user,
|
||||
trend_slug=slug,
|
||||
period=period,
|
||||
).first()
|
||||
|
||||
if result:
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Enrich TV series metadata from TMDB/OMDB APIs"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite existing cover image",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--imdb-id",
|
||||
type=str,
|
||||
help="Only process series with this imdb_id",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from videos.models import Series
|
||||
|
||||
force = options["force"]
|
||||
dry_run = options["dry_run"]
|
||||
imdb_id = options["imdb_id"]
|
||||
|
||||
qs = Series.objects.all()
|
||||
if imdb_id:
|
||||
qs = qs.filter(imdb_id=imdb_id)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Processing {total} series")
|
||||
|
||||
if dry_run:
|
||||
for series in qs.iterator():
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would fix {series.name} (imdb_id={series.imdb_id})"
|
||||
)
|
||||
return
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for series in qs.iterator():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
series.fix_metadata(force_update=force)
|
||||
updated += 1
|
||||
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" Error updating series {series.name} (imdb_id={series.imdb_id}): {e}"
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nDone! {updated} series updated, {errors} errors")
|
||||
)
|
||||
@ -80,6 +80,28 @@ class Channel(ScrobblableMixin):
|
||||
def title(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def safe_cover_image_url(self) -> str:
|
||||
if self.cover_image:
|
||||
try:
|
||||
if self.cover_image.storage.exists(self.cover_image.name):
|
||||
return self.cover_medium.url
|
||||
except Exception:
|
||||
pass
|
||||
return "/static/images/not-found.jpg"
|
||||
|
||||
@property
|
||||
def youtube_url(self) -> str:
|
||||
if self.youtube_id:
|
||||
return YOUTUBE_CHANNEL_URL + self.youtube_id
|
||||
return ""
|
||||
|
||||
@property
|
||||
def twitch_url(self) -> str:
|
||||
if self.twitch_id:
|
||||
return f"https://www.twitch.tv/{self.twitch_id}"
|
||||
return ""
|
||||
|
||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||
if not self.cover_image or (force_update and url):
|
||||
r = requests.get(url)
|
||||
@ -95,7 +117,7 @@ class Channel(ScrobblableMixin):
|
||||
played_query = models.Q()
|
||||
return Scrobble.objects.filter(
|
||||
played_query,
|
||||
channel=self,
|
||||
models.Q(channel=self) | models.Q(video__channel=self),
|
||||
user=user_id,
|
||||
).order_by("-timestamp")
|
||||
|
||||
@ -308,16 +330,18 @@ class Series(TimeStampedModel):
|
||||
logger.warning(f"No imdb data for {self}")
|
||||
return
|
||||
|
||||
cover_url = imdb_dict.get("cover_url")
|
||||
|
||||
if (not self.cover_image or force_update) and cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if video_metadata.cover_url and (not self.cover_image or force_update):
|
||||
r = requests.get(video_metadata.cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.name}_{self.uuid}.jpg"
|
||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if genres := imdb_dict.get("genres"):
|
||||
self.genre.add(*genres)
|
||||
self.plot = video_metadata.plot
|
||||
self.imdb_rating = video_metadata.imdb_rating
|
||||
self.save()
|
||||
|
||||
if video_metadata.genres:
|
||||
self.genre.add(*video_metadata.genres)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, imdb_id: str, overwrite: bool = True):
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from scrobbles.models import Scrobble
|
||||
@ -44,15 +46,31 @@ class SeriesDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView
|
||||
return context_data
|
||||
|
||||
|
||||
class ChannelDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
class ChannelDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView):
|
||||
model = Channel
|
||||
slug_field = "uuid"
|
||||
template_name = "videos/channel_detail.html"
|
||||
paginate_by = 50
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user_id = self.request.user.id
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
|
||||
|
||||
scrobbles = self.object.scrobbles_for_user(user_id)
|
||||
paginator = Paginator(scrobbles, self.paginate_by)
|
||||
page_number = self.request.GET.get("page")
|
||||
|
||||
try:
|
||||
page_obj = paginator.page(page_number)
|
||||
except PageNotAnInteger:
|
||||
page_obj = paginator.page(1)
|
||||
except EmptyPage:
|
||||
page_obj = paginator.page(paginator.num_pages)
|
||||
|
||||
context_data["page_obj"] = page_obj
|
||||
context_data["scrobbles"] = page_obj.object_list
|
||||
context_data["is_paginated"] = paginator.num_pages > 1
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
|
||||
@ -19,6 +19,26 @@
|
||||
color:white;
|
||||
background:rgba(0,0,0,0.4);
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
padding-right:20px;
|
||||
border:none;
|
||||
}
|
||||
dt {
|
||||
flex-basis: 20%;
|
||||
padding: 5px;
|
||||
background: #3cf;
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
}
|
||||
dd {
|
||||
flex-basis: 70%;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
border:none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -29,9 +49,30 @@
|
||||
<img src="{{ object.safe_cover_image_url }}" width="400px" />
|
||||
</div>
|
||||
<div class="summary">
|
||||
{% if object.youtube_id %}<p><a href="{{object.youtube_url}}" target="_blank">View on YouTube</a></p>{% endif %}
|
||||
{% if object.description %}<p><em>{{object.description}}</em></p>{% endif %}
|
||||
{% if object.genre.all %}
|
||||
<p>Genres: {% for tag in object.genre.all %}<span class="badge bg-secondary">{{tag.name}}</span> {% endfor %}</p>
|
||||
{% endif %}
|
||||
<hr />
|
||||
{% if object.youtube_id %}
|
||||
<p style="float:right;">
|
||||
<a href="{{object.youtube_url}}" target="_blank"><img src="{% static "images/youtube_logo.png" %}" width=35></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if object.twitch_id %}
|
||||
<p style="float:right;">
|
||||
<a href="{{object.twitch_url}}" target="_blank">View on Twitch</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if charts %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
{% include "scrobbles/_chart_links.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
@ -41,6 +82,8 @@
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">With</th>
|
||||
<th scope="col">Rated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -48,11 +91,26 @@
|
||||
<tr>
|
||||
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
|
||||
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
|
||||
<td>{% firstof scrobble.logdata.with_people|join:", " "Solo" %}</td>
|
||||
<td>{% firstof scrobble.logdata.rating "Unrated" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if is_paginated %}
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -63,6 +63,7 @@ dd {
|
||||
</div>
|
||||
<div class="summary">
|
||||
{% if object.tv_series %}<h4><a href="{{object.tv_series.get_absolute_url}}">{{object.tv_series}}</a> - S{{object.season_number}}E{{object.episode_number}}</h4>{% endif %}
|
||||
{% if object.channel %}<h5><a href="{{object.channel.get_absolute_url}}">{{object.channel.name}}</a></h5>{% endif %}
|
||||
{% if object.overview %}<p><em>{{object.overview}}</em></p>{% endif %}
|
||||
{% if object.plot%}<p>{{object.plot|safe|linebreaks|truncatewords:160}}</p>{% endif %}
|
||||
<hr />
|
||||
|
||||
Reference in New Issue
Block a user