Compare commits

...

6 Commits
18.7 ... 18.12

Author SHA1 Message Date
1531b77b5c [tests] Fix metadata test 2025-07-30 13:59:11 -04:00
9437fdba60 [scrobbles] Fix log data parsing for tasks and boardgames
Add pagination to task and board game detail pages
2025-07-30 11:37:57 -04:00
a7551ef162 [music] Weird hack to get timezone for LFM scrobbles
Last.fm seems to send timestamps for scrobbles with a timezone of UTC
but the actual timezone is already localized. But that means we can't
extract the timezone we want, even though the timestamp is already in
the right timezone for storage.
2025-07-28 10:52:02 -04:00
c20204a6ea [music] Turns out lastfm already has our timeszone 2025-07-28 09:14:25 -04:00
685de842ea [views] Fix showing only a users scrobbles 2025-07-26 21:31:44 -04:00
7d13967708 [scrobbles] Fix admin filtering 2025-07-26 20:57:23 -04:00
19 changed files with 164 additions and 68 deletions

View File

@ -7,23 +7,24 @@ from rest_framework.authtoken.models import Token
from boardgames.models import BoardGame
from music.models import Track, Artist
from scrobbles.models import Scrobble
from people.models import Person
User = get_user_model()
@pytest.fixture
def boardgame_scrobble():
user = User.objects.create(
email="test@exmaple.com", first_name="Test", last_name="User"
)
first = Person.objects.create(name="First Player")
second = Person.objects.create(name="Second Player")
return Scrobble.objects.create(
board_game=BoardGame.objects.create(title="Test Board Game"),
media_type="BoardGame",
played_to_completion=True,
log={
"players": [
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
]
{"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
{"person_id": second.id, "win": False, "score": 28, "color": "Red"}
],
},
)

View File

@ -3,14 +3,13 @@ import pytest
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again")
@pytest.mark.django_db
def test_boardgame_log_data(boardgame_scrobble):
assert not boardgame_scrobble.geo_location
assert boardgame_scrobble.logdata == BoardGameLogData(
players=[
BoardGameScoreLogData(
user_id=1,
name_str="",
person_id=1,
bgg_username="",
color="Blue",
character=None,
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
score=30,
win=True,
new=None,
)
rank=None,
seat_order=None,
role=None
),
BoardGameScoreLogData(
person_id=2,
bgg_username="",
color="Red",
character=None,
team=None,
score=28,
win=False,
new=None,
rank=None,
seat_order=None,
role=None
),
],
location=None,
geo_location_id=None,
difficulty=None,
solo=None,
two_handed=None,

View File

@ -102,7 +102,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin):
# date_hierarchy = "timestamp"
date_hierarchy = "timestamp"
list_display = (
"timestamp",
"media_name",
@ -112,6 +112,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"in_progress",
"is_paused",
"played_to_completion",
"user",
)
raw_id_fields = (
"video",
@ -140,6 +141,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"long_play_complete",
"source",
"timezone",
"user",
)
ordering = ("-timestamp",)
@ -148,3 +150,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
def playback_percent(self, obj):
return obj.percent_played
def get_queryset(self, request):
qs = super().get_queryset(request).exclude(timestamp__year=None)
return qs

View File

@ -7,6 +7,7 @@ from typing import Optional
from dataclass_wizard import JSONWizard
from django.contrib.auth import get_user_model
from locations.models import GeoLocation
from people.models import Person
User = get_user_model()
@ -66,8 +67,7 @@ class WithOthersLogData(JSONDataclass):
@dataclass
class BoardGameScoreLogData(JSONDataclass):
user_id: Optional[int] = None
name_str: str = ""
person_id: Optional[int] = None
bgg_username: str = ""
color: Optional[str] = None
character: Optional[str] = None
@ -75,19 +75,19 @@ class BoardGameScoreLogData(JSONDataclass):
score: Optional[int] = None
win: Optional[bool] = None
new: Optional[bool] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
@property
def user(self) -> Optional[User]:
user = None
if self.user_id:
user = User.objects.filter(id=self.user_id).first()
return user
def person(self) -> Optional[Person]:
return Person.objects.filter(id=self.person_id).first()
@property
def name(self) -> str:
name = self.name_str
if self.user_id:
name = self.user.first_name
name = ""
if self.person:
name = self.person.name
return name
def __str__(self) -> str:
@ -106,17 +106,20 @@ class BoardGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int] = None
long_play_complete: Optional[bool] = None
players: Optional[list[BoardGameScoreLogData]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None
@cached_property
def geo_location(self) -> Optional[GeoLocation]:
if self.geo_location_id:
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@cached_property
def player_log(self) -> str:
return ", ".join([BoardGameScoreLogData(**player).__str__() for player in self.players])
@dataclass
class BookPageLogData(JSONDataclass):

View File

@ -50,9 +50,10 @@ class LastFM:
enrich=True,
)
timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
lfm_scrobble.get("timestamp")
)
timestamp = lfm_scrobble.get("timestamp")
stop_timestamp = timestamp + timedelta(
seconds=track.run_time_seconds
)
@ -65,7 +66,7 @@ class LastFM:
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name,
timezone=tz_timestamp.tzinfo.name,
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)

View File

@ -736,7 +736,7 @@ class Scrobble(TimeStampedModel):
if not log_dict:
log_dict = {}
return logdata_cls.from_dict(log_dict)
return logdata_cls(**log_dict)
def redirect_url(self, user_id) -> str:
user = User.objects.filter(id=user_id).first()

View File

@ -19,6 +19,8 @@ from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from rest_framework import status
from rest_framework.decorators import (
api_view,
@ -64,29 +66,46 @@ class ScrobbleableListView(ListView):
def get_queryset(self):
queryset = super().get_queryset()
user_filter = Q()
if not self.request.user.is_anonymous:
queryset = queryset.annotate(
user_filter = Q(scrobble__user=self.request.user)
queryset = (
queryset.annotate(
scrobble_count=Count("scrobble"),
filter=Q(scrobble__user=self.request.user),
).order_by("-scrobble_count")
else:
queryset = queryset.annotate(
scrobble_count=Count("scrobble")
).order_by("-scrobble_count")
)
.filter(user_filter, scrobble_count__gt=0)
.order_by("-scrobble_count")
)
return queryset
class ScrobbleableDetailView(DetailView):
model = None
slug_field = "uuid"
paginate_by = 200 # You can set this to whatever page size you want
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["scrobbles"] = list()
scrobbles = []
if not self.request.user.is_anonymous:
context_data["scrobbles"] = self.object.scrobble_set.filter(
scrobbles = self.object.scrobble_set.filter(
user=self.request.user
)
).order_by("-timestamp")
paginator = Paginator(scrobbles, self.paginate_by)
page_number = self.request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
context_data["page_obj"] = page_obj
context_data["scrobbles"] = page_obj.object_list
context_data["is_paginated"] = paginator.num_pages > 1
return context_data
@ -201,7 +220,7 @@ class RecentScrobbleList(ListView):
processed_finished__isnull=True,
user=self.request.user,
)
data["counts"] = [] #scrobble_counts(user)
data["counts"] = [] # scrobble_counts(user)
else:
data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts()

View File

@ -23,6 +23,16 @@ class TaskLogData(JSONDataclass):
todoist_type: Optional[str] = None
notes: Optional[dict] = None
def notes_as_str(self) -> str:
"""Return formatted notes with line breaks and no keys"""
note_block = ""
if not self.notes:
return note_block
for id, content in self.notes.items():
note_block += content + "</br>"
return note_block
class Task(LongPlayScrobblableMixin):
"""Basically a holder for Todoist Tasks
@ -42,9 +52,9 @@ class Task(LongPlayScrobblableMixin):
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Doing", tags="memo")
# @property
# def logdata_cls(self):
# return TaskLogData
@property
def logdata_cls(self):
return TaskLogData
def source_url_for_user(self, user_id) -> str:
url = ""

View File

@ -39,7 +39,7 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>{{scrobbles.count}} scrobbles</p>
<p>
<a href="{{object.start_url}}">Drink again</a>
</p>
@ -55,7 +55,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
</tr>

View File

@ -42,7 +42,7 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>{{scrobbles.count}} scrobbles</p>
<p>
<a href="{{object.start_url}}">Play again</a>
</p>
@ -56,20 +56,40 @@
<tr>
<th scope="col">Date</th>
<th scope="col">Publisher</th>
<th scope="col">Screenshot</th>
<th scope="col">Players</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td>{{scrobble.media_obj.publisher}}</td>
<td>{% if scrobble.screenshot%}<img src="{{scrobble.screenshot.url}}" width=250 />{% endif %}</td>
<td>{% if scrobble.logdata.player_log %}{{scrobble.logdata.player_log}}{% else %}No data{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if is_paginated %}
<div class="pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">&laquo; Previous</a>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<strong>{{ num }}</strong>
{% else %}
<a href="?page={{ num }}">{{ num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -26,10 +26,10 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>Read {{object.scrobble_set.last.book_pages_read}} pages{% if object.scrobble_set.last.long_play_complete %} and completed{% else %}{% endif %}</p>
<p>{{scrobbles.count}} scrobbles</p>
<p>Read {{scrobbles.last.book_pages_read}} pages{% if scrobbles.last.long_play_complete %} and completed{% else %}{% endif %}</p>
<p>
{% if object.scrobble_set.last.long_play_complete == True %}
{% if scrobbles.last.long_play_complete == True %}
<a href="">Read again</a>
{% else %}
<a href="">Resume reading</a>
@ -50,7 +50,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td>

View File

@ -48,7 +48,7 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>{{scrobbles.count}} scrobbles</p>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
@ -60,7 +60,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp|naturaltime}}</td>
</tr>

View File

@ -26,7 +26,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
</tr>

View File

@ -9,7 +9,7 @@
{% endif %}
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>{{scrobbles.count}} scrobbles</p>
{% if charts %}
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
{% endif %}
@ -26,7 +26,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>

View File

@ -18,7 +18,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td>{{scrobble.media_obj.round.season.name}}</td>

View File

@ -22,6 +22,7 @@
width: 600px;
margin-left: 10px;
}
.pagination a { padding: 0 5px 0 5px; }
</style>
{% endblock %}
@ -39,7 +40,7 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>{{scrobbles.count}} scrobbles</p>
<p>
<a href="{{object.start_url}}">Play again</a>
</p>
@ -47,27 +48,49 @@
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Description</th>
<th scope="col">Notes</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td>
<td>{{scrobble.logdata.notes_as_str|safe}}</td>
<td>{{scrobble.source}}</td>
<td>{{scrobble.log.notes}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if is_paginated %}
<div class="pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">&laquo; Previous</a>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<strong>{{ num }}</strong>
{% else %}
<a href="?page={{ num }}">{{ num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -59,12 +59,12 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
{% if object.scrobble_set.last.long_play_seconds %}
<p>{{object.scrobble_set.last.long_play_seconds|natural_duration}}{% if object.scrobble_set.last.long_play_complete %} and completed{% else %} spent playing{% endif %}</p>
<p>{{scrobbles.count}} scrobbles</p>
{% if scrobbles.last.long_play_seconds %}
<p>{{scrobbles.last.long_play_seconds|natural_duration}}{% if scrobbles.last.long_play_complete %} and completed{% else %} spent playing{% endif %}</p>
{% endif %}
<p>
{% if object.scrobble_set.last.long_play_complete == True %}
{% if scrobbles.last.long_play_complete == True %}
<a href="">Play again</a>
{% else %}
<a href="{{object.start_url}}">Resume playing</a>
@ -86,7 +86,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local-timestamp}}</td>
<td>{% if scrobble.long_play_complete == True %}Yes{% else %}Not yet{% endif %}</td>

View File

@ -85,7 +85,7 @@ dd {
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
</tr>

View File

@ -51,7 +51,7 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
</tr>