Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab10758f40 | |||
| 88f16f0aaa | |||
| c1744fab37 | |||
| 042a3eb737 |
21
PROJECT.org
21
PROJECT.org
@ -594,7 +594,26 @@ 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 [#C] Show time per scrobble in long play lists and total time playing :templates:longplay:scrobbles:
|
* Version 52.2 [1/1]
|
||||||
|
** DONE [#A] Fix bug in recomputing long play seconds taking forever :bug:longplay:commands:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0a813cf9-17fb-dbd7-b5a7-7410d9bd4d8c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 52.1 [1/1]
|
||||||
|
** DONE [#C] Show time per scrobble in long play lists and total time playing :templates:longplay:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: b3d16230-8ec5-46db-b166-59e98d0ee06c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Long play time should be show in the table of scrobbles on a media detail page.
|
||||||
|
The total time spent in a long play that's either no completed yet or completed
|
||||||
|
should be displayed as well. If completed, the date finished should be shown as
|
||||||
|
well.
|
||||||
|
|
||||||
|
|
||||||
* Version 52.0 [5/5]
|
* Version 52.0 [5/5]
|
||||||
** DONE [#B] Allow marking media as long play complete from detail page :templates:scrobbles:longplay:
|
** DONE [#B] Allow marking media as long play complete from detail page :templates:scrobbles:longplay:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "52.0"
|
version = "52.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,14 @@ from django.db import connection
|
|||||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"Backfill long_play_last_scrobble FK chains, then recompute "
|
"Backfill long_play_last_scrobble FK chains, then recompute "
|
||||||
"long_play_seconds by walking backward through the chain."
|
"long_play_seconds by walking forward through scrobbles in "
|
||||||
|
"timestamp order with a running accumulator."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
@ -61,49 +64,74 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Step 2: recompute long_play_seconds
|
# Step 2: recompute long_play_seconds
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"\nStep 2: Recomputing long_play_seconds via FK chain..."
|
"\nStep 2: Recomputing long_play_seconds in timestamp order..."
|
||||||
)
|
)
|
||||||
|
|
||||||
scrobbles = Scrobble.objects.filter(
|
total_updated = 0
|
||||||
media_type__in=media_types,
|
for mt in media_types:
|
||||||
playback_position_seconds__isnull=False,
|
n = self._recompute_for_media_type(mt, dry_run)
|
||||||
).order_by("-timestamp")
|
total_updated += n
|
||||||
|
self.stdout.write(f" {mt}: {n} scrobbles updated")
|
||||||
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:
|
if dry_run:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.WARNING(
|
self.style.WARNING(
|
||||||
f"Dry run: would update {len(to_update)} scrobbles. "
|
f"Dry run: would update {total_updated} scrobbles total. "
|
||||||
"Use without --dry-run to apply."
|
"Use without --dry-run to apply."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(f"Updated {len(to_update)} scrobbles")
|
self.style.SUCCESS(f"Updated {total_updated} scrobbles")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _recompute_for_media_type(self, media_type: str, dry_run: bool) -> int:
|
||||||
|
"""Process scrobbles for a single media type in timestamp order with a
|
||||||
|
running accumulator, avoiding O(n2) FK chain walks."""
|
||||||
|
fk = _media_type_to_fk(media_type)
|
||||||
|
fk_id = f"{fk}_id"
|
||||||
|
|
||||||
|
scrobbles = Scrobble.objects.filter(
|
||||||
|
media_type=media_type,
|
||||||
|
**{f"{fk}__isnull": False},
|
||||||
|
playback_position_seconds__isnull=False,
|
||||||
|
).order_by(fk_id, "user_id", "timestamp")
|
||||||
|
|
||||||
|
total = scrobbles.count()
|
||||||
|
if not total:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
batch = []
|
||||||
|
last_key = None
|
||||||
|
running_total = 0
|
||||||
|
|
||||||
|
for scrobble in scrobbles.iterator():
|
||||||
|
key = (getattr(scrobble, fk_id), scrobble.user_id)
|
||||||
|
|
||||||
|
if key != last_key:
|
||||||
|
running_total = 0
|
||||||
|
last_key = key
|
||||||
|
|
||||||
|
running_total += scrobble.playback_position_seconds or 0
|
||||||
|
|
||||||
|
if scrobble.long_play_seconds != running_total:
|
||||||
|
updated += 1
|
||||||
|
if not dry_run:
|
||||||
|
scrobble.long_play_seconds = running_total
|
||||||
|
batch.append(scrobble)
|
||||||
|
if len(batch) >= BATCH_SIZE:
|
||||||
|
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
if scrobble.long_play_complete:
|
||||||
|
running_total = 0
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
def _backfill_chain(self, media_type: str, dry_run: bool) -> int:
|
def _backfill_chain(self, media_type: str, dry_run: bool) -> int:
|
||||||
"""Set long_play_last_scrobble on each scrobble to the previous
|
"""Set long_play_last_scrobble on each scrobble to the previous
|
||||||
scrobble for the same media+user using a single UPDATE with a
|
scrobble for the same media+user using a single UPDATE with a
|
||||||
|
|||||||
@ -206,6 +206,19 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
|
|||||||
context_data["scrobbles"] = page_obj.object_list
|
context_data["scrobbles"] = page_obj.object_list
|
||||||
context_data["is_paginated"] = paginator.num_pages > 1
|
context_data["is_paginated"] = paginator.num_pages > 1
|
||||||
|
|
||||||
|
media = self.object
|
||||||
|
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
|
||||||
|
qs = media.scrobble_set.filter(user=self.request.user)
|
||||||
|
completed = qs.filter(long_play_complete=True).order_by("-timestamp").first()
|
||||||
|
if completed and completed.long_play_seconds:
|
||||||
|
context_data["long_play_total_seconds"] = completed.long_play_seconds
|
||||||
|
context_data["long_play_finished_date"] = completed.timestamp
|
||||||
|
else:
|
||||||
|
latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first()
|
||||||
|
if latest_finished and latest_finished.long_play_seconds:
|
||||||
|
context_data["long_play_total_seconds"] = latest_finished.long_play_seconds
|
||||||
|
context_data["long_play_finished_date"] = None
|
||||||
|
|
||||||
return context_data
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
@ -1297,6 +1310,24 @@ class ScrobbleDetailView(DetailView):
|
|||||||
else:
|
else:
|
||||||
context["has_mopidy_uri"] = False
|
context["has_mopidy_uri"] = False
|
||||||
|
|
||||||
|
if self.object.is_long_play and fk_field:
|
||||||
|
all_scrobbles = Scrobble.objects.filter(
|
||||||
|
user=user, **{fk_field: media_obj}
|
||||||
|
)
|
||||||
|
completed = all_scrobbles.filter(
|
||||||
|
long_play_complete=True
|
||||||
|
).order_by("-timestamp").first()
|
||||||
|
if completed and completed.long_play_seconds:
|
||||||
|
context["long_play_total_seconds"] = completed.long_play_seconds
|
||||||
|
context["long_play_finished_date"] = completed.timestamp
|
||||||
|
else:
|
||||||
|
latest_finished = all_scrobbles.filter(
|
||||||
|
played_to_completion=True
|
||||||
|
).order_by("-timestamp").first()
|
||||||
|
if latest_finished and latest_finished.long_play_seconds:
|
||||||
|
context["long_play_total_seconds"] = latest_finished.long_play_seconds
|
||||||
|
context["long_play_finished_date"] = None
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Latest</th>
|
<th scope="col">Latest</th>
|
||||||
<th scope="col">Title</th>
|
<th scope="col">Title</th>
|
||||||
|
<th scope="col">Time</th>
|
||||||
<th scope="col">Scrobbles</th>
|
<th scope="col">Scrobbles</th>
|
||||||
<th scope="col">Complete</th>
|
<th scope="col">Complete</th>
|
||||||
<th scope="col">Start</th>
|
<th scope="col">Start</th>
|
||||||
@ -15,16 +16,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for obj in object_list %}
|
{% for obj in object_list %}
|
||||||
{% if obj.title %}
|
{% if obj.title %}
|
||||||
|
{% with last=obj.scrobble_set.last %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{obj.scrobble_set.last.get_absolute_url}}">{{obj.scrobble_set.last.local_timestamp}}
|
<td><a href="{{last.get_absolute_url}}">{{last.local_timestamp}}
|
||||||
<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>{% if last.long_play_seconds %}{{ last.long_play_seconds|natural_duration }}{% elif last.elapsed_time %}{{ last.elapsed_time|natural_duration }}{% endif %}</td>
|
||||||
<td>{{obj.scrobble_count}}</td>
|
<td>{{obj.scrobble_count}}</td>
|
||||||
<td>{% if obj.scrobble_set.last.long_play_complete == True %}Yes{% else %}No{% endif %}</td>
|
<td>{% if obj.scrobble_set.last.long_play_complete == True %}Yes{% else %}No{% 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>
|
||||||
<td><a type="button" class="btn btn-sm btn-warning" href="{{obj.get_longplay_finish_url}}">Finish</a></td>
|
<td><a type="button" class="btn btn-sm btn-warning" href="{{obj.get_longplay_finish_url}}">Finish</a></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -33,7 +33,11 @@
|
|||||||
<p><a href="{{object.next_readcomics_url}}">Read next issue</a></p>
|
<p><a href="{{object.next_readcomics_url}}">Read next issue</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>{{scrobbles.count}} scrobbles</p>
|
<p>
|
||||||
|
{{scrobbles.count}} scrobbles
|
||||||
|
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
|
||||||
|
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
<p><a href="{{object.resume_start_url}}">Resume reading</a></p>
|
<p><a href="{{object.resume_start_url}}">Resume reading</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,7 +26,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p>{{scrobbles.count}} scrobbles</p>
|
<p>
|
||||||
|
{{scrobbles.count}} scrobbles
|
||||||
|
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
|
||||||
|
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
|
|||||||
@ -264,7 +264,13 @@
|
|||||||
|
|
||||||
<h2 class="mt-4">All scrobbles of this {{ object.media_type|lower }}</h2>
|
<h2 class="mt-4">All scrobbles of this {{ object.media_type|lower }}</h2>
|
||||||
{% if related_scrobbles %}
|
{% if related_scrobbles %}
|
||||||
<p class="text-muted">{{ related_scrobbles.paginator.count }} scrobble{{ related_scrobbles.paginator.count|pluralize }}</p>
|
<p class="text-muted">
|
||||||
|
{{ related_scrobbles.paginator.count }} scrobble{{ related_scrobbles.paginator.count|pluralize }}
|
||||||
|
{% if object.is_long_play and long_play_total_seconds %}
|
||||||
|
| Total time: {{ long_play_total_seconds|natural_duration }}
|
||||||
|
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -281,7 +287,11 @@
|
|||||||
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if scrobble.playback_position_seconds %}{{ scrobble.playback_position_seconds|natural_duration }}{% endif %}
|
{% if scrobble.is_long_play and scrobble.long_play_seconds %}
|
||||||
|
{{ scrobble.playback_position_seconds|natural_duration }} ({{ scrobble.long_play_seconds|natural_duration }} total)
|
||||||
|
{% elif scrobble.playback_position_seconds %}
|
||||||
|
{{ scrobble.playback_position_seconds|natural_duration }}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -50,7 +50,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p>{{scrobbles.count}} scrobbles</p>
|
<p>
|
||||||
|
{{scrobbles.count}} scrobbles
|
||||||
|
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
|
||||||
|
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{object.start_url}}">Play again</a>
|
<a href="{{object.start_url}}">Play again</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
{% block lists %}
|
{% block lists %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
|
<div class="table-responsive">{% include "_longplay_scrobblable_list.html" %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -59,7 +59,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p>{{scrobbles.count}} scrobbles</p>
|
<p>
|
||||||
|
{{scrobbles.count}} scrobbles
|
||||||
|
{% if long_play_total_seconds %} | Total time: {{ long_play_total_seconds|natural_duration }}{% endif %}
|
||||||
|
{% if long_play_finished_date %} | Finished: {{ long_play_finished_date|date:"M d, Y" }}{% endif %}
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="">Play again</a>
|
<a href="">Play again</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
{% include "_scrobblable_list.html" %}
|
{% include "_longplay_scrobblable_list.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user