[longplay] Fix how we store long plays
All checks were successful
build / test (push) Successful in 2m3s
All checks were successful
build / test (push) Successful in 2m3s
This commit is contained in:
@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user