Compare commits

..

13 Commits
52.0 ... 53.1

Author SHA1 Message Date
ea7b0946bb [release] Bump to version 53.1
All checks were successful
deploy / test (push) Successful in 1m58s
build / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 34s
- Error with loading logdict
2026-06-16 11:31:14 -04:00
b8384166de [music] Fix logdata lookup for music
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:30:54 -04:00
d2705758c6 [release] Bump to version 53.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Successful in 52s
- Add a /trends/ page that shows trends based on scrobble data
- Notify users when Last.fm import completes
- Cleaner =GeoLocationLogData= deserialization
- Webpage scrobbles should diff existing webpages content
- Make ArchiveBox push asynchronous
2026-06-16 11:13:07 -04:00
f4368c31f3 [project] Update todos
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:12:50 -04:00
57f273b0cc [trends] Initial trends app
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:12:13 -04:00
ac82292200 [importers] Add last.fm import notifications 2026-06-16 10:26:08 -04:00
6a8432c08f [locations] Handle log data cleaner
All checks were successful
build / test (push) Successful in 1m54s
2026-06-16 10:12:58 -04:00
5a2c41155c [webpages] Add historical extract stashing
All checks were successful
build / test (push) Successful in 1m50s
2026-06-16 09:52:56 -04:00
83a046111b [webpages] Async pushing to archivebox
All checks were successful
build / test (push) Successful in 1m52s
2026-06-16 09:27:40 -04:00
ab10758f40 [release] Bump to version 52.2
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 50s
- Fix bug in recomputing long play seconds taking forever
2026-06-15 17:27:38 -04:00
88f16f0aaa [longplay] Fix recompute script
All checks were successful
build / test (push) Successful in 2m0s
2026-06-15 17:20:34 -04:00
c1744fab37 [release] Bump to version 52.1
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 29s
- Show time per scrobble in long play lists and total time playing
2026-06-15 15:27:10 -04:00
042a3eb737 [templates] Add aggregate data
Some checks failed
build / test (push) Has been cancelled
2026-06-15 15:26:46 -04:00
49 changed files with 1413 additions and 108 deletions

View File

@ -15,6 +15,8 @@ ro class method should call the utility function.
Be sure to check pyproject.toml for project defaults. Specifically for black and
isort expectations.
Imports in python files should always be top level if possible.
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
In local development, environment variables for various sensitive values live in a .envrc file

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/20] :vrobbler:project:personal:
* Backlog [0/19] :vrobbler:project:personal:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -522,23 +522,6 @@ easily. And our exposure to PII is really low at this point in the project,
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:
*** 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:
- 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
- ...
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :books:feature:comicbook:
:PROPERTIES:
:ID: b3cc57ca-3d2c-468d-ab7c-c47f1120309b
@ -564,6 +547,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()=).
@ -594,7 +578,102 @@ named constants for maintainability.
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
** TODO [#C] Show time per scrobble in long play lists and total time playing :templates:longplay:scrobbles:
* Version 53.1 [1/1]
** DONE [#A] Error with loading logdict :scrobbles:bug:logdata:
:PROPERTIES:
:ID: 92d4fa16-4b90-47e0-95ae-472bdca582ce
:END:
* Version 53.0 [5/5]
** DONE [#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.
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?
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.
** DONE [#B] Notify users when Last.fm import completes :importers:notifications:
:PROPERTIES:
:ID: 92846b36-54c5-4b78-9c57-bdc401045fbe
:END:
*** Description
After a bulk import from Last.fm, users receive no confirmation. Should add a notification (in-app, email, or similar).
File: ~vrobbler/apps/scrobbles/importers/lastfm.py~ (line 96)
** DONE [#C] Cleaner =GeoLocationLogData= deserialization :models:refactoring:
:PROPERTIES:
:ID: 85465dbf-69b3-48cb-9df0-cd076c4470ab
:END:
*** Description
Currently special-cases =GeoLocationLogData= by reaching into a nested ="movement_detection"= key. Should be handled at the LogData dataclass level.
File: ~vrobbler/apps/scrobbles/models.py~ (line 977)
** DONE [#B] Webpage scrobbles should diff existing webpages content :webpages:metadata:
:PROPERTIES:
:ID: 25576197-258f-48d6-bfe9-e4172a0a1898
:END:
*** Description
Webpages change content between scrobbles. The current model stores the webpage content once, the
first time it's scrobbled. When a page has been seen before, we should move the existing content
to a new model HistoricalWebPage with the following fields:
webpage_id -> FK to WebPage
date -> date from existing WebPage content
domain -> same as existing WebPage content
extract -> copy of existing WebPage content
Once the HistoricalWebPage instance is successfully created, the new extract data
should be saved into the WebPage instance.
** DONE [#B] Make ArchiveBox push asynchronous :archivebox:async:
:PROPERTIES:
:ID: 17c116a7-5952-db37-e56c-2987c2fc456b
:END:
*** Description
=push_to_archivebox()= runs synchronously during the request. Should be moved to a
Celery task or similar background worker.
File: ~vrobbler/apps/webpages/models.py~ (line 133)
* Version 52.2 [1/1]
** DONE [#A] Fix bug in recomputing long play seconds taking forever :bug:longplay:commands:
:PROPERTIES:
:ID: 0a813cf9-17fb-dbd7-b5a7-7410d9bd4d8c
:END:
* Version 52.1 [1/1]
** DONE [#C] Show time per scrobble in long play lists and total time playing :templates:longplay:scrobbles:
:PROPERTIES:
:ID: b3d16230-8ec5-46db-b166-59e98d0ee06c
:END:
*** Description
Long play time should be show in the table of scrobbles on a media detail page.
The total time spent in a long play that's either no completed yet or completed
should be displayed as well. If completed, the date finished should be shown as
well.
* Version 52.0 [5/5]
** DONE [#B] Allow marking media as long play complete from detail page :templates:scrobbles:longplay:
:PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "52.0"
version = "53.1"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -28,6 +28,14 @@ class GeoLocationLogData(BaseLogData, WithPeopleLogData):
activity: str = ""
detected_at: str = ""
@classmethod
def from_log_dict(cls, log_dict: dict) -> dict:
instance_data = log_dict.get("movement_detection", {}).copy()
for field_name in ["description", "notes", "with_people_ids"]:
if field_name in log_dict:
instance_data[field_name] = log_dict[field_name]
return instance_data
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)

View File

@ -600,8 +600,9 @@ class Track(ScrobblableMixin):
def __str__(self):
return f"{self.title} by {self.artist}"
@property
def logdata_cls(self):
return TrackLogData()
return TrackLogData
@property
def primary_album(self):

View File

@ -50,6 +50,18 @@ class BaseLogData(JSONDataclass):
def override_fields(cls) -> dict:
return {}
@classmethod
def from_log_dict(cls, log_dict: dict) -> dict:
"""Extract LogData keyword arguments from a stored log dict.
Override in subclasses to handle custom nesting/structure.
"""
return {
k: v
for k, v in log_dict.items()
if k in cls.__dataclass_fields__
}
def notes_as_str(self, separator: str = " | ") -> str:
import html
import re

View File

@ -93,7 +93,6 @@ class LastFM:
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={

View File

@ -4,11 +4,14 @@ from django.db import connection
from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.models import Scrobble
BATCH_SIZE = 1000
class Command(BaseCommand):
help = (
"Backfill long_play_last_scrobble FK chains, then recompute "
"long_play_seconds by walking backward through the chain."
"long_play_seconds by walking forward through scrobbles in "
"timestamp order with a running accumulator."
)
def add_arguments(self, parser):
@ -61,49 +64,74 @@ class Command(BaseCommand):
# Step 2: recompute long_play_seconds
self.stdout.write(
"\nStep 2: Recomputing long_play_seconds via FK chain..."
"\nStep 2: Recomputing long_play_seconds in timestamp order..."
)
scrobbles = Scrobble.objects.filter(
media_type__in=media_types,
playback_position_seconds__isnull=False,
).order_by("-timestamp")
total = scrobbles.count()
self.stdout.write(f" Found {total} long play scrobbles to process")
to_update = []
for scrobble in scrobbles.iterator():
accumulated = scrobble.playback_position_seconds or 0
current = scrobble.long_play_last_scrobble
while current and not current.long_play_complete:
accumulated += current.playback_position_seconds or 0
current = current.long_play_last_scrobble
if scrobble.long_play_seconds != accumulated:
self.stdout.write(
f" Scrobble {scrobble.id} ({scrobble.media_type}): "
f"{scrobble.long_play_seconds or 0} -> {accumulated}"
)
if not dry_run:
scrobble.long_play_seconds = accumulated
to_update.append(scrobble)
if to_update:
Scrobble.objects.bulk_update(to_update, ["long_play_seconds"])
total_updated = 0
for mt in media_types:
n = self._recompute_for_media_type(mt, dry_run)
total_updated += n
self.stdout.write(f" {mt}: {n} scrobbles updated")
if dry_run:
self.stdout.write(
self.style.WARNING(
f"Dry run: would update {len(to_update)} scrobbles. "
f"Dry run: would update {total_updated} scrobbles total. "
"Use without --dry-run to apply."
)
)
else:
self.stdout.write(
self.style.SUCCESS(f"Updated {len(to_update)} scrobbles")
self.style.SUCCESS(f"Updated {total_updated} scrobbles")
)
def _recompute_for_media_type(self, media_type: str, dry_run: bool) -> int:
"""Process scrobbles for a single media type in timestamp order with a
running accumulator, avoiding O(n2) FK chain walks."""
fk = _media_type_to_fk(media_type)
fk_id = f"{fk}_id"
scrobbles = Scrobble.objects.filter(
media_type=media_type,
**{f"{fk}__isnull": False},
playback_position_seconds__isnull=False,
).order_by(fk_id, "user_id", "timestamp")
total = scrobbles.count()
if not total:
return 0
updated = 0
batch = []
last_key = None
running_total = 0
for scrobble in scrobbles.iterator():
key = (getattr(scrobble, fk_id), scrobble.user_id)
if key != last_key:
running_total = 0
last_key = key
running_total += scrobble.playback_position_seconds or 0
if scrobble.long_play_seconds != running_total:
updated += 1
if not dry_run:
scrobble.long_play_seconds = running_total
batch.append(scrobble)
if len(batch) >= BATCH_SIZE:
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
batch = []
if scrobble.long_play_complete:
running_total = 0
if batch:
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
return updated
def _backfill_chain(self, media_type: str, dry_run: bool) -> int:
"""Set long_play_last_scrobble on each scrobble to the previous
scrobble for the same media+user using a single UPDATE with a

View File

@ -51,7 +51,10 @@ from scrobbles.constants import (
MEDIA_END_PADDING_SECONDS,
)
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.notifications import (
LastFmImportNtfyNotification,
ScrobbleNtfyNotification,
)
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent
from taggit.managers import TaggableManager
@ -428,6 +431,8 @@ class LastFmImport(BaseFileImportMixin):
try:
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
self.record_log(scrobbles)
if scrobbles:
LastFmImportNtfyNotification(self, len(scrobbles)).send()
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
@ -952,27 +957,6 @@ class Scrobble(TimeStampedModel):
self.share_token_version += 1
self.save(update_fields=["share_token_version"])
def push_to_archivebox(self):
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
self.media_obj.push_to_archivebox
)
if pushable_media and self.user.profile.archivebox_url:
try:
self.media_obj.push_to_archivebox(
url=self.user.profile.archivebox_url,
username=self.user.profile.archivebox_username,
password=self.user.profile.archivebox_password,
)
except Exception:
logger.info(
"Failed to push URL to archivebox",
extra={
"archivebox_url": self.user.profile.archivebox_url,
"archivebox_username": self.user.profile.archivebox_username,
},
)
@property
def logdata(self) -> Optional[logdata.BaseLogData]:
if self.media_obj:
@ -994,24 +978,8 @@ class Scrobble(TimeStampedModel):
if not log_dict:
log_dict = {}
# Special handling for GeoLocationLogData - data is nested under 'movement_detection'
# TODO there's a better way to fix this this at the LogData level
if logdata_cls.__name__ == "GeoLocationLogData":
instance_data = log_dict.get("movement_detection", {}).copy()
# Add top-level fields that GeoLocationLogData expects from BaseLogData/WithPeopleLogData
for field_name in ["description", "notes", "with_people_ids"]:
if field_name in log_dict:
instance_data[field_name] = log_dict[field_name]
try:
return logdata_cls(**instance_data)
except Exception as e:
logger.warning("Log data could not be loaded", e)
return logdata_cls()
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
logdata_kwargs = {
k: v for k, v in log_dict.items() if k in logdata_cls().__dataclass_fields__
}
# Use LogData's from_log_dict to handle any custom nesting/structure
logdata_kwargs = logdata_cls.from_log_dict(log_dict)
try:
return logdata_cls(**logdata_kwargs)

View File

@ -79,6 +79,27 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
)
class LastFmImportNtfyNotification(BasicNtfyNotification):
def __init__(self, lfm_import, scrobble_count):
super().__init__(lfm_import.user.profile)
self.ntfy_str = f"Imported {scrobble_count} scrobble(s) from Last.fm"
self.click_url = lfm_import.get_absolute_url()
self.title = "Last.fm Import Complete"
def send(self):
if self.profile and self.profile.ntfy_enabled and self.profile.ntfy_url:
requests.post(
self.profile.ntfy_url,
data=self.ntfy_str.encode(encoding="utf-8"),
headers={
"Title": self.title,
"Priority": "default",
"Tags": "musical_note",
"Click": self.click_url,
},
)
class MoodNtfyNotification(BasicNtfyNotification):
def __init__(self, profile, **kwargs):
super().__init__(profile)

View File

@ -32,6 +32,7 @@ from scrobbles.constants import (
)
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.tasks import push_scrobble_to_archivebox
from scrobbles.utils import (
convert_to_seconds,
extract_domain,
@ -1028,8 +1029,7 @@ def manual_scrobble_webpage(
if action == "stop":
scrobble.stop(force_finish=True)
else:
# possibly async this?
scrobble.push_to_archivebox()
push_scrobble_to_archivebox.delay(scrobble.id)
return scrobble

View File

@ -252,6 +252,25 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
logger.error(f"[charts] Failed to update charts: {e}")
@shared_task
def push_scrobble_to_archivebox(scrobble_id):
from scrobbles.models import Scrobble
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble:
logger.warning(
"Scrobble %s not found for archivebox push", scrobble_id
)
return
webpage = scrobble.web_page
if not webpage:
logger.warning(
"Scrobble %s has no web_page for archivebox push", scrobble_id
)
return
webpage.push_to_archivebox(scrobble.user)
# ── Crontab replacements ──────────────────────────────────────────────────────

View File

@ -206,6 +206,19 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
context_data["scrobbles"] = page_obj.object_list
context_data["is_paginated"] = paginator.num_pages > 1
media = self.object
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
qs = media.scrobble_set.filter(user=self.request.user)
completed = qs.filter(long_play_complete=True).order_by("-timestamp").first()
if completed and completed.long_play_seconds:
context_data["long_play_total_seconds"] = completed.long_play_seconds
context_data["long_play_finished_date"] = completed.timestamp
else:
latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first()
if latest_finished and latest_finished.long_play_seconds:
context_data["long_play_total_seconds"] = latest_finished.long_play_seconds
context_data["long_play_finished_date"] = None
return context_data
@ -1297,6 +1310,24 @@ class ScrobbleDetailView(DetailView):
else:
context["has_mopidy_uri"] = False
if self.object.is_long_play and fk_field:
all_scrobbles = Scrobble.objects.filter(
user=user, **{fk_field: media_obj}
)
completed = all_scrobbles.filter(
long_play_complete=True
).order_by("-timestamp").first()
if completed and completed.long_play_seconds:
context["long_play_total_seconds"] = completed.long_play_seconds
context["long_play_finished_date"] = completed.timestamp
else:
latest_finished = all_scrobbles.filter(
played_to_completion=True
).order_by("-timestamp").first()
if latest_finished and latest_finished.long_play_seconds:
context["long_play_total_seconds"] = latest_finished.long_play_seconds
context["long_play_finished_date"] = None
return context

View File

View File

@ -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",)

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TrendsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "trends"

View File

@ -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!"))

View File

@ -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")},
},
),
]

View File

@ -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})"

View File

@ -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
)

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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">&larr; 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 %}

View File

@ -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 %}

View File

@ -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
)

View File

@ -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],
}

View File

@ -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),
}

View File

@ -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

View File

@ -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"),
]

View File

@ -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

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from webpages.models import Domain, WebPage
from webpages.models import Domain, HistoricalWebPage, WebPage
from scrobbles.admin import ScrobbleInline
@ -20,6 +20,12 @@ class DomainAdmin(admin.ModelAdmin):
inlines = [WebPageInline]
class HistoricalWebPageInline(admin.TabularInline):
model = HistoricalWebPage
extra = 0
readonly_fields = ("date", "domain", "extract", "created")
@admin.register(WebPage)
class WebPageAdmin(admin.ModelAdmin):
date_hierarchy = "created"
@ -33,4 +39,20 @@ class WebPageAdmin(admin.ModelAdmin):
search_fields = ("title",)
inlines = [
ScrobbleInline,
HistoricalWebPageInline,
]
@admin.register(HistoricalWebPage)
class HistoricalWebPageAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"webpage",
"date",
"domain",
"created",
)
raw_id_fields = ("webpage", "domain")
ordering = ("-created",)
search_fields = ("webpage__title",)

View File

@ -0,0 +1,71 @@
# Generated by Django 4.2.29 on 2026-06-16 13:39
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("webpages", "0009_alter_webpage_genre"),
]
operations = [
migrations.CreateModel(
name="HistoricalWebPage",
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"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("date", models.DateField(blank=True, null=True)),
("extract", models.TextField(blank=True, null=True)),
(
"domain",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="webpages.domain",
),
),
(
"webpage",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="historical_webpages",
to="webpages.webpage",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -17,6 +17,7 @@ from htmldate import find_date
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from scrobbles.tasks import push_scrobble_to_archivebox
from taggit.managers import TaggableManager
logger = logging.getLogger(__name__)
@ -130,8 +131,7 @@ class WebPage(ScrobblableMixin):
},
)
scrobble = Scrobble.create_or_update(self, user_id, scrobble_data)
# TODO Possibly make this async?
scrobble.push_to_archivebox()
push_scrobble_to_archivebox.delay(scrobble.id)
return scrobble
def scrobbles(self, user):
@ -183,7 +183,13 @@ class WebPage(ScrobblableMixin):
if save:
self.save(update_fields=["date"])
def push_to_archivebox(self, url: str, username: str, password: str):
def push_to_archivebox(self, user):
profile = user.profile
url = profile.archivebox_url
if not url:
return
username = profile.archivebox_username
password = profile.archivebox_password
login_url = requests.compat.urljoin(url, "admin/login/")
session = requests.Session()
response = session.get(login_url)
@ -297,4 +303,41 @@ class WebPage(ScrobblableMixin):
if not webpage:
webpage = cls(url=data_dict.get("url"))
webpage.fetch_data_from_web(save=True)
else:
webpage._archive_and_refetch()
return webpage
def _archive_and_refetch(self):
"""Archive current content to HistoricalWebPage and re-fetch from web."""
if self.extract or self.date or self.domain:
HistoricalWebPage.objects.create(
webpage=self,
date=self.date,
domain=self.domain,
extract=self.extract,
)
self.extract = None
self.date = None
self.domain = None
self.title = None
self.base_run_time_seconds = None
self.image = None
self.fetch_data_from_web(save=True, force=True)
class HistoricalWebPage(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
webpage = models.ForeignKey(
WebPage, on_delete=models.CASCADE, related_name="historical_webpages"
)
date = models.DateField(**BNULL)
domain = models.ForeignKey(Domain, on_delete=models.DO_NOTHING, **BNULL)
extract = models.TextField(**BNULL)
def __str__(self) -> str:
if self.webpage.title:
return "{} ({}) - {}".format(
self.webpage.title, self.webpage.domain, self.created
)
return "{} - {}".format(self.webpage.url, self.created)

View File

@ -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",

View File

@ -6,6 +6,7 @@
<tr>
<th scope="col">Latest</th>
<th scope="col">Title</th>
<th scope="col">Time</th>
<th scope="col">Scrobbles</th>
<th scope="col">Complete</th>
<th scope="col">Start</th>
@ -15,16 +16,19 @@
<tbody>
{% for obj in object_list %}
{% if obj.title %}
{% with last=obj.scrobble_set.last %}
<tr>
<td><a href="{{obj.scrobble_set.last.get_absolute_url}}">{{obj.scrobble_set.last.local_timestamp}}
<td><a href="{{last.get_absolute_url}}">{{last.local_timestamp}}
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
{% if request.user.is_authenticated %}
<td>{% if last.long_play_seconds %}{{ last.long_play_seconds|natural_duration }}{% elif last.elapsed_time %}{{ last.elapsed_time|natural_duration }}{% endif %}</td>
<td>{{obj.scrobble_count}}</td>
<td>{% if obj.scrobble_set.last.long_play_complete == True %}Yes{% else %}No{% endif %}</td>
<td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>
<td><a type="button" class="btn btn-sm btn-warning" href="{{obj.get_longplay_finish_url}}">Finish</a></td>
{% endif %}
</tr>
{% endwith %}
{% endif %}
{% endfor %}
</tbody>

View File

@ -33,7 +33,11 @@
<p><a href="{{object.next_readcomics_url}}">Read next issue</a></p>
{% endif %}
<p>{{scrobbles.count}} scrobbles</p>
<p>
{{scrobbles.count}} scrobbles
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
</p>
<p><a href="{{object.resume_start_url}}">Resume reading</a></p>
</div>

View File

@ -26,7 +26,11 @@
</div>
</div>
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
<p>
{{scrobbles.count}} scrobbles
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
</p>
</div>
<div class="row">
<div class="col-md">

View File

@ -264,7 +264,13 @@
<h2 class="mt-4">All scrobbles of this {{ object.media_type|lower }}</h2>
{% if related_scrobbles %}
<p class="text-muted">{{ related_scrobbles.paginator.count }} scrobble{{ related_scrobbles.paginator.count|pluralize }}</p>
<p class="text-muted">
{{ related_scrobbles.paginator.count }} scrobble{{ related_scrobbles.paginator.count|pluralize }}
{% if object.is_long_play and long_play_total_seconds %}
| Total time: {{ long_play_total_seconds|natural_duration }}
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
{% endif %}
</p>
<table class="table table-striped table-sm">
<thead>
<tr>
@ -281,7 +287,11 @@
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
</td>
<td>
{% if scrobble.playback_position_seconds %}{{ scrobble.playback_position_seconds|natural_duration }}{% endif %}
{% if scrobble.is_long_play and scrobble.long_play_seconds %}
{{ scrobble.playback_position_seconds|natural_duration }} ({{ scrobble.long_play_seconds|natural_duration }} total)
{% elif scrobble.playback_position_seconds %}
{{ scrobble.playback_position_seconds|natural_duration }}
{% endif %}
</td>
</tr>
{% endfor %}

View File

@ -50,7 +50,11 @@
{% endif %}
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
<p>
{{scrobbles.count}} scrobbles
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
</p>
<p>
<a href="{{object.start_url}}">Play again</a>
</p>

View File

@ -26,7 +26,7 @@
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
<div class="table-responsive">{% include "_longplay_scrobblable_list.html" %}</div>
</div>
</div>
{% endblock %}

View File

@ -59,7 +59,11 @@
</div>
</div>
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
<p>
{{scrobbles.count}} scrobbles
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
</p>
<p>
<a href="">Play again</a>
</p>

View File

@ -16,7 +16,7 @@
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
{% include "_longplay_scrobblable_list.html" %}
</div>
</div>
</div>

View File

@ -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: