[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
|
*** Metadata sources
|
||||||
**** Scraper
|
**** 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:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||||
@ -594,7 +594,8 @@ named constants for maintainability.
|
|||||||
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
|
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
|
||||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
- ~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:
|
:PROPERTIES:
|
||||||
:ID: 908b0493-cabf-40c1-825f-cd59a8ad0f7a
|
:ID: 908b0493-cabf-40c1-825f-cd59a8ad0f7a
|
||||||
:END:
|
: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:
|
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
"""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:
|
for scrobble in scrobbles:
|
||||||
# But if there's a next scrobble, set pages read to their starting page
|
if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:
|
||||||
if scrobble.previous and not scrobble.previous.long_play_complete:
|
|
||||||
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
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:
|
else:
|
||||||
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||||
|
|||||||
@ -118,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"long_play_last_scrobble",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_paused",
|
"is_paused",
|
||||||
|
|||||||
@ -207,7 +207,7 @@ class BaseLogData(JSONDataclass):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LongPlayLogData(JSONDataclass):
|
class LongPlayLogData(JSONDataclass):
|
||||||
long_play_complete: bool = False
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@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})
|
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
|
||||||
|
|
||||||
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
||||||
return (
|
last = self.last_long_play_scrobble_for_user(user)
|
||||||
get_scrobbles_for_media(self, user)
|
if not last:
|
||||||
.filter(
|
return None
|
||||||
log__long_play_complete=False,
|
current = last
|
||||||
log__serial_scrobble_id__isnull=True,
|
while current.long_play_last_scrobble and not current.long_play_last_scrobble.long_play_complete:
|
||||||
)
|
current = current.long_play_last_scrobble
|
||||||
.order_by("timestamp")
|
return current
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
||||||
return (
|
return (
|
||||||
get_scrobbles_for_media(self, user)
|
get_scrobbles_for_media(self, user)
|
||||||
.filter(
|
.filter(long_play_complete=False)
|
||||||
log__long_play_complete=False,
|
.order_by("-timestamp")
|
||||||
log__serial_scrobble_id__isnull=False,
|
.first()
|
||||||
)
|
|
||||||
.order_by("timestamp")
|
|
||||||
.last()
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -795,6 +795,12 @@ class Scrobble(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
long_play_seconds = models.BigIntegerField(**BNULL)
|
long_play_seconds = models.BigIntegerField(**BNULL)
|
||||||
long_play_complete = models.BooleanField(**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:
|
class Meta:
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -1182,8 +1188,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
if self.is_long_play:
|
if self.is_long_play:
|
||||||
long_play_secs = 0
|
long_play_secs = 0
|
||||||
if self.previous and not self.previous.long_play_complete:
|
if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:
|
||||||
long_play_secs = self.previous.long_play_seconds or 0
|
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
|
||||||
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
||||||
|
|
||||||
return percent
|
return percent
|
||||||
@ -1499,6 +1505,21 @@ class Scrobble(TimeStampedModel):
|
|||||||
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
||||||
|
|
||||||
scrobble = cls.create(scrobble_data)
|
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
|
return scrobble
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1757,8 +1778,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
# Set our playback seconds, and calc long play seconds
|
# Set our playback seconds, and calc long play seconds
|
||||||
self.playback_position_seconds = seconds_elapsed
|
self.playback_position_seconds = seconds_elapsed
|
||||||
if self.previous:
|
if self.long_play_last_scrobble:
|
||||||
past_seconds = self.previous.long_play_seconds or 0
|
past_seconds = self.long_play_last_scrobble.long_play_seconds or 0
|
||||||
|
|
||||||
self.long_play_seconds = past_seconds + seconds_elapsed
|
self.long_play_seconds = past_seconds + seconds_elapsed
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
|
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<td>{{obj.scrobble_count}}</td>
|
<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>
|
<td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -61,7 +61,7 @@
|
|||||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
<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>{% 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>
|
<td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -44,7 +44,7 @@
|
|||||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
<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>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user