sessions / 947713d44adbe87214ae688c0e24ee9db613ccd2

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit 947713d44adbe87214ae688c0e24ee9db613ccd2Author: Colin Powell <colin@unbl.ink>Date:   Mon Jun 15 14:12:21 2026 -0400    [longplay] Fix how we store long playsdiff --git a/PROJECT.org b/PROJECT.orgindex 636c411..c7ea815 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [3/19] :vrobbler:project:personal:+* Backlog [4/24] :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@@ -594,7 +594,8 @@ named constants for maintainability.   - ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=   - ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status -** TODO [#A] Fix how long play scrobbles are tracked :scrobbles:longplay:serial:+** TODO [#B] Allow marking media as long play complete from detail page :templates:scrobbles:longplay:+** DONE [#A] Fix how long play scrobbles are tracked :scrobbles:longplay:serial: :PROPERTIES: :ID:       908b0493-cabf-40c1-825f-cd59a8ad0f7a :END:diff --git a/vrobbler/apps/books/koreader.py b/vrobbler/apps/books/koreader.pyindex c955105..8747a45 100644--- a/vrobbler/apps/books/koreader.py+++ b/vrobbler/apps/books/koreader.py@@ -344,13 +344,16 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl  def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:     """Given a list of scrobbles, update pages read, long play seconds and check-    for media completion"""+    for media completion.++    Uses the long_play_last_scrobble FK chain to accumulate time.+    Consider using the recompute_long_play_seconds management command instead.+    """      for scrobble in scrobbles:-        # But if there's a next scrobble, set pages read to their starting page-        if scrobble.previous and not scrobble.previous.long_play_complete:+        if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:             scrobble.long_play_seconds = scrobble.playback_position_seconds + (-                scrobble.previous.long_play_seconds or 0+                scrobble.long_play_last_scrobble.long_play_seconds or 0             )         else:             scrobble.long_play_seconds = scrobble.playback_position_secondsdiff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.pyindex 020ebfe..2132c8d 100644--- a/vrobbler/apps/scrobbles/admin.py+++ b/vrobbler/apps/scrobbles/admin.py@@ -118,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin):         "web_page",         "life_event",         "birding_location",+        "long_play_last_scrobble",     )     list_filter = (         "is_paused",diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.pyindex cbcf585..c606a37 100644--- a/vrobbler/apps/scrobbles/dataclasses.py+++ b/vrobbler/apps/scrobbles/dataclasses.py@@ -207,7 +207,7 @@ class BaseLogData(JSONDataclass):  @dataclass class LongPlayLogData(JSONDataclass):-    long_play_complete: bool = False+    pass   @dataclassdiff --git a/vrobbler/apps/scrobbles/management/commands/recompute_long_play_seconds.py b/vrobbler/apps/scrobbles/management/commands/recompute_long_play_seconds.pynew file mode 100644index 0000000..7430f60--- /dev/null+++ b/vrobbler/apps/scrobbles/management/commands/recompute_long_play_seconds.py@@ -0,0 +1,146 @@+from django.core.management.base import BaseCommand+from django.db import connection++from scrobbles.constants import LONG_PLAY_MEDIA+from scrobbles.models import Scrobble+++class Command(BaseCommand):+    help = (+        "Backfill long_play_last_scrobble FK chains, then recompute "+        "long_play_seconds by walking backward through the chain."+    )++    def add_arguments(self, parser):+        parser.add_argument(+            "--dry-run",+            action="store_true",+            help="Show what would be changed without making changes",+        )+        parser.add_argument(+            "--media-type",+            type=str,+            help="Only process a specific media type (e.g., Book, VideoGame)",+        )++    def handle(self, *args, **options):+        dry_run = options["dry_run"]+        media_type = options.get("media_type")++        media_types = list(LONG_PLAY_MEDIA.values())+        if media_type:+            if media_type not in media_types:+                self.stdout.write(+                    self.style.ERROR(+                        f"Invalid media type '{media_type}'. "+                        f"Valid: {', '.join(media_types)}"+                    )+                )+                return+            media_types = [media_type]++        # Step 1: backfill long_play_last_scrobble+        self.stdout.write("Step 1: Backfilling long_play_last_scrobble chains...")+        total_backfilled = 0+        for mt in media_types:+            n = self._backfill_chain(mt, dry_run)+            total_backfilled += n+            self.stdout.write(f"  {mt}: {n} scrobbles linked")++        if dry_run:+            self.stdout.write(+                self.style.WARNING(+                    f"Would backfill {total_backfilled} scrobbles total. "+                    "Run without --dry-run to apply."+                )+            )+        else:+            self.stdout.write(+                self.style.SUCCESS(f"Backfilled {total_backfilled} scrobbles")+            )++        # Step 2: recompute long_play_seconds+        self.stdout.write(+            "\nStep 2: Recomputing long_play_seconds via FK chain..."+        )++        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"])++        if dry_run:+            self.stdout.write(+                self.style.WARNING(+                    f"Dry run: would update {len(to_update)} scrobbles. "+                    "Use without --dry-run to apply."+                )+            )+        else:+            self.stdout.write(+                self.style.SUCCESS(f"Updated {len(to_update)} scrobbles")+            )++    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+        correlated subquery."""+        fk = _media_type_to_fk(media_type)+        table = Scrobble._meta.db_table++        if dry_run:+            with connection.cursor() as cursor:+                cursor.execute(+                    f"SELECT COUNT(*) FROM {table} "+                    f"WHERE long_play_last_scrobble_id IS NULL "+                    f"AND {fk}_id IS NOT NULL"+                )+                return cursor.fetchone()[0]++        with connection.cursor() as cursor:+            cursor.execute(+                f"UPDATE {table} "+                f"SET long_play_last_scrobble_id = ("+                f"  SELECT id FROM {table} AS prev "+                f"  WHERE prev.{fk}_id = {table}.{fk}_id "+                f"    AND prev.user_id = {table}.user_id "+                f"    AND prev.timestamp < {table}.timestamp "+                f"  ORDER BY prev.timestamp DESC LIMIT 1"+                f") "+                f"WHERE long_play_last_scrobble_id IS NULL "+                f"AND {fk}_id IS NOT NULL"+            )+            return cursor.rowcount+++def _media_type_to_fk(media_type):+    mapping = {+        "VideoGame": "video_game",+        "Book": "book",+        "BrickSet": "brick_set",+        "Task": "task",+    }+    return mapping.get(media_type)diff --git a/vrobbler/apps/scrobbles/migrations/0098_scrobble_long_play_last_scrobble.py b/vrobbler/apps/scrobbles/migrations/0098_scrobble_long_play_last_scrobble.pynew file mode 100644index 0000000..9351e61--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0098_scrobble_long_play_last_scrobble.py@@ -0,0 +1,25 @@+# Generated by Django 4.2.29 on 2026-06-15 17:48++from django.db import migrations, models+import django.db.models.deletion+++class Migration(migrations.Migration):++    dependencies = [+        ("scrobbles", "0097_scrobble_scrobbles_s_user_id_d367a7_idx"),+    ]++    operations = [+        migrations.AddField(+            model_name="scrobble",+            name="long_play_last_scrobble",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.SET_NULL,+                related_name="next_long_play_scrobbles",+                to="scrobbles.scrobble",+            ),+        ),+    ]diff --git a/vrobbler/apps/scrobbles/mixins.py b/vrobbler/apps/scrobbles/mixins.pyindex 136ac9d..fbb909d 100644--- a/vrobbler/apps/scrobbles/mixins.py+++ b/vrobbler/apps/scrobbles/mixins.py@@ -165,23 +165,18 @@ class LongPlayScrobblableMixin(ScrobblableMixin):         return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})      def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:-        return (-            get_scrobbles_for_media(self, user)-            .filter(-                log__long_play_complete=False,-                log__serial_scrobble_id__isnull=True,-            )-            .order_by("timestamp")-            .first()-        )+        last = self.last_long_play_scrobble_for_user(user)+        if not last:+            return None+        current = last+        while current.long_play_last_scrobble and not current.long_play_last_scrobble.long_play_complete:+            current = current.long_play_last_scrobble+        return current      def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:         return (             get_scrobbles_for_media(self, user)-            .filter(-                log__long_play_complete=False,-                log__serial_scrobble_id__isnull=False,-            )-            .order_by("timestamp")-            .last()+            .filter(long_play_complete=False)+            .order_by("-timestamp")+            .first()         )diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 4390e5d..d9ab2c3 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -795,6 +795,12 @@ class Scrobble(TimeStampedModel):     )     long_play_seconds = models.BigIntegerField(**BNULL)     long_play_complete = models.BooleanField(**BNULL)+    long_play_last_scrobble = models.ForeignKey(+        "self",+        **BNULL,+        on_delete=models.SET_NULL,+        related_name="next_long_play_scrobbles",+    )      class Meta:         indexes = [@@ -1182,8 +1188,8 @@ class Scrobble(TimeStampedModel):          if self.is_long_play:             long_play_secs = 0-            if self.previous and not self.previous.long_play_complete:-                long_play_secs = self.previous.long_play_seconds or 0+            if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:+                long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0             percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)          return percent@@ -1499,6 +1505,21 @@ class Scrobble(TimeStampedModel):                 scrobble_data["log"] = FoodLogData(calories=media.calories).asdict          scrobble = cls.create(scrobble_data)++        if mtype in LONG_PLAY_MEDIA.values():+            last_finished = (+                cls.objects.filter(+                    models.Q(**{key: media}),+                    user_id=user_id,+                    timestamp__lt=scrobble.timestamp,+                )+                .order_by("-timestamp")+                .first()+            )+            if last_finished:+                scrobble.long_play_last_scrobble = last_finished+                scrobble.save(update_fields=["long_play_last_scrobble"])+         return scrobble      @classmethod@@ -1757,8 +1778,8 @@ class Scrobble(TimeStampedModel):          # Set our playback seconds, and calc long play seconds         self.playback_position_seconds = seconds_elapsed-        if self.previous:-            past_seconds = self.previous.long_play_seconds or 0+        if self.long_play_last_scrobble:+            past_seconds = self.long_play_last_scrobble.long_play_seconds or 0          self.long_play_seconds = past_seconds + seconds_elapsed diff --git a/vrobbler/templates/_longplay_scrobblable_list.html b/vrobbler/templates/_longplay_scrobblable_list.htmlindex 0f3a01e..b5cdf7b 100644--- a/vrobbler/templates/_longplay_scrobblable_list.html+++ b/vrobbler/templates/_longplay_scrobblable_list.html@@ -19,7 +19,7 @@             <td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>             {% if request.user.is_authenticated %}             <td>{{obj.scrobble_count}}</td>-            <td>{% if obj.scrobble_set.last.logdata.long_play_complete == True %}Yes{% endif %}</td>+            <td>{% if obj.scrobble_set.last.long_play_complete == True %}Yes{% endif %}</td>             <td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>             {% endif %}         </tr>diff --git a/vrobbler/templates/books/book_detail.html b/vrobbler/templates/books/book_detail.htmlindex 1ed11fa..101ce14 100644--- a/vrobbler/templates/books/book_detail.html+++ b/vrobbler/templates/books/book_detail.html@@ -61,7 +61,7 @@                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}                     <tr>                         <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>-                        <td>{% if scrobble.logdata.long_play_complete == True %}Yes{% endif %}</td>+                        <td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td>                         <td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>                         <td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>                     </tr>diff --git a/vrobbler/templates/bricksets/brickset_detail.html b/vrobbler/templates/bricksets/brickset_detail.htmlindex 5cdf599..592ea90 100644--- a/vrobbler/templates/bricksets/brickset_detail.html+++ b/vrobbler/templates/bricksets/brickset_detail.html@@ -44,7 +44,7 @@                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}                     <tr>                         <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>-                        <td>{% if scrobble.logdata.long_play_complete == True %}Yes{% endif %}</td>+                        <td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td>                         <td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>                     </tr>                     {% endfor %}