This commit has no recorded session.
diff
commit 947713d44adbe87214ae688c0e24ee9db613ccd2Author: Colin Powell <colin@unbl.ink>Date: Mon Jun 15 14:12:21 2026 -0400 [longplay] Fix how we store long plays@@ -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:@@ -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_seconds@@ -118,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin): "web_page", "life_event", "birding_location",+ "long_play_last_scrobble", ) list_filter = ( "is_paused",@@ -207,7 +207,7 @@ class BaseLogData(JSONDataclass): @dataclass class LongPlayLogData(JSONDataclass):- long_play_complete: bool = False+ pass @dataclass@@ -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)@@ -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",+ ),+ ),+ ]@@ -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() )@@ -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 @@ -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>@@ -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>@@ -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 %}