diff --git a/PROJECT.org b/PROJECT.org index 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.py index 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_seconds diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 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.py index 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 @dataclass diff --git a/vrobbler/apps/scrobbles/management/commands/recompute_long_play_seconds.py b/vrobbler/apps/scrobbles/management/commands/recompute_long_play_seconds.py new file mode 100644 index 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.py new file mode 100644 index 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.py index 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.py index 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.html index 0f3a01e..b5cdf7b 100644 --- a/vrobbler/templates/_longplay_scrobblable_list.html +++ b/vrobbler/templates/_longplay_scrobblable_list.html @@ -19,7 +19,7 @@ {{obj}} {% if request.user.is_authenticated %} {{obj.scrobble_count}} - {% if obj.scrobble_set.last.logdata.long_play_complete == True %}Yes{% endif %} + {% if obj.scrobble_set.last.long_play_complete == True %}Yes{% endif %} Scrobble {% endif %} diff --git a/vrobbler/templates/books/book_detail.html b/vrobbler/templates/books/book_detail.html index 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" %} {{scrobble.local_timestamp}} - {% if scrobble.logdata.long_play_complete == True %}Yes{% endif %} + {% if scrobble.long_play_complete == True %}Yes{% endif %} {% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %} {% for author in scrobble.book.authors.all %}{{author}}{% if not forloop.last %}, {% endif %}{% endfor %} diff --git a/vrobbler/templates/bricksets/brickset_detail.html b/vrobbler/templates/bricksets/brickset_detail.html index 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" %} {{scrobble.local_timestamp}} - {% if scrobble.logdata.long_play_complete == True %}Yes{% endif %} + {% if scrobble.long_play_complete == True %}Yes{% endif %} {% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %} {% endfor %}