Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab10758f40 | |||
| 88f16f0aaa | |||
| c1744fab37 | |||
| 042a3eb737 | |||
| 01d25e1b55 | |||
| c0be131e3d | |||
| 7d3f615ed7 | |||
| c2138b3ac6 | |||
| 947713d44a | |||
| 12b76837a3 | |||
| 102494ede7 | |||
| 96bda8d4ad | |||
| 46956d06d8 |
151
PROJECT.org
151
PROJECT.org
@ -88,8 +88,8 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/16] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
* Backlog [0/20] :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
|
||||
:END:
|
||||
@ -387,11 +387,11 @@ fetching and simple saving.
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
|
||||
** TODO [#C] Allow auto trail tracking via email with Garmin LiveTrack URLs :trails:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 133bcf71-078f-4efa-a029-1eae4b4d146d
|
||||
:END:
|
||||
** TODO [#C] Fix exporting so it works reliably :exporting:project:feature:
|
||||
** TODO [#C] Fix exporting so it works reliably :exporting:feature:
|
||||
|
||||
*** Description
|
||||
|
||||
@ -405,8 +405,7 @@ placed in the media directory:
|
||||
And this should all be done in a celery task that is just kicked off by the
|
||||
"Export" button on the frontend
|
||||
|
||||
|
||||
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
||||
** TODO [#B] Add AllTrails as a source for Trail data :trails:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
||||
:END:
|
||||
@ -416,7 +415,7 @@ Pretty clear, I would love to make trails more useful. Historically I wasn't
|
||||
hiking a lot, which made the source for this a bit silly. But it's clear that
|
||||
AllTrails is the best source, though having TrailForks is nice to.
|
||||
|
||||
** TODO [#B] Add `garmin_activity_id` to the TrailLogData class :trails:feature:personal:project:
|
||||
** TODO [#B] Add `garmin_activity_id` to the TrailLogData class :trails:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||
:END:
|
||||
@ -440,7 +439,7 @@ added.
|
||||
They should also probably support markdown formatting and that should be
|
||||
displayed in the template.
|
||||
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :books:feature:export:
|
||||
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
|
||||
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
:PROPERTIES:
|
||||
@ -465,7 +464,6 @@ needed import celery task. This is how the WebDAV celery task currently works.
|
||||
This would also be an opporunity to clean up the code around WebDAV imports
|
||||
and make them more re-usable for other import services.
|
||||
|
||||
|
||||
** TODO [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8
|
||||
@ -477,7 +475,6 @@ Certain artists like "Simon & Garfunkel" are actually one artist. While we don't
|
||||
tracks into featured artists, we should have a "LITERAL_ARTIST_TITLES" constant that can have exceptions like
|
||||
this put into it and then we stop trying to pull the artist apart when we run into it.
|
||||
|
||||
|
||||
** TODO [#A] Before enriching anything, trust the POST data :feature:scrobbles:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0
|
||||
@ -509,11 +506,6 @@ log a warning and move on.
|
||||
We should have a global view `/favorites/` that shows the logged in users's
|
||||
favorited media objects.
|
||||
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
@ -547,6 +539,135 @@ Examples of trends:
|
||||
- trail_scrobble__average_heartrate per trail
|
||||
- ...
|
||||
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :books:feature:comicbook:
|
||||
:PROPERTIES:
|
||||
:ID: b3cc57ca-3d2c-468d-ab7c-c47f1120309b
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
|
||||
** TODO [#C] Make podcast date format configurable in settings :podcasts:configuration:
|
||||
:PROPERTIES:
|
||||
:ID: b01a94f8-328f-41ed-a62e-8b99c755b82d
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
=PODCAST_DATE_FORMAT= is hardcoded to ="YYYY-MM-DD"=. Should be in Django settings or environment variables for deploy-specific configuration.
|
||||
|
||||
File: ~vrobbler/apps/podcasts/utils.py~ (line 13)
|
||||
|
||||
** TODO [#C] Extract zombie scrobble query into custom manager :refactoring:manager:
|
||||
:PROPERTIES:
|
||||
:ID: 79c874e1-ca6f-4bce-9259-e3eebdca8a41
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
The zombie scrobble cleanup query lives in a utility function. Should be a
|
||||
custom model manager method (e.g. =Scrobble.objects.zombies()=).
|
||||
|
||||
File: ~vrobbler/apps/scrobbles/utils.py~ (line 204)
|
||||
|
||||
** TODO [#C] Allow profile to set start of week :profiles:configuration:
|
||||
:PROPERTIES:
|
||||
:ID: 0449279a-9550-430e-be0c-816df7273080
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
=start_of_week()= and =end_of_week()= use Monday as default. Should be a user
|
||||
profile setting for different cultural week start conventions.
|
||||
|
||||
File: ~vrobbler/apps/profiles/utils.py~ (lines 39, 44)
|
||||
|
||||
** TODO [#C] Add constants for data dictionary keys (multiple files) :refactoring:constants:
|
||||
:PROPERTIES:
|
||||
:ID: d4415f9b-620a-4be7-925d-fa71c02ba1d1
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
Multiple files use magic string literals for dict keys. Should be extracted to
|
||||
named constants for maintainability.
|
||||
|
||||
- Files:
|
||||
- ~vrobbler/apps/locations/models.py~ (line 63) -- ="lat"=, ="lon"= etc.
|
||||
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
|
||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
||||
|
||||
* 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]
|
||||
** DONE [#B] Allow marking media as long play complete from detail page :templates:scrobbles:longplay:
|
||||
:PROPERTIES:
|
||||
:ID: 2c314768-be97-9b10-d13c-9cfd0f38a64e
|
||||
:END:
|
||||
** DONE [#A] Fix how long play scrobbles are tracked :scrobbles:longplay:serial:
|
||||
:PROPERTIES:
|
||||
:ID: 908b0493-cabf-40c1-825f-cd59a8ad0f7a
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently we have this idea of "long_play" scrobbles but there's a lot missing
|
||||
to tie it together.
|
||||
|
||||
What we'd prefer is that when a new scrobble is added for a media_type that
|
||||
`is_long_play` the most recent scrobble finished is added as the
|
||||
`last_serial_scrobble` to the log data. But all the other long play stuff exsits
|
||||
as data model fields. We should add `long_play_last_scrobble` as a FK to this
|
||||
scrobble when creating a new longplay scrobble.
|
||||
|
||||
Additionally, `long_play_seconds` we should have a recompute management command
|
||||
to walk backward from `long_play_last_scrobble` until a `long_play_complete`
|
||||
scrobble is found (exclusive) and save the time.
|
||||
|
||||
We should also ony use `long_play_complete` field on the scrobble ... some
|
||||
logdatas have a similar field, but we should make sure that we always use the
|
||||
model field to determine if a long play is finished.
|
||||
|
||||
This should include a command to clean up long play data to consolidate around
|
||||
the `long_play_complete` field.
|
||||
|
||||
|
||||
** DONE [#B] Paginate or limite scrobbles on media admin pages :admin:scrobbles:media:
|
||||
:PROPERTIES:
|
||||
:ID: f02e487b-d7ed-4834-838a-303560f2ad3b
|
||||
:END:
|
||||
|
||||
** DONE [#B] Clean up books admin :admin:books:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 7539bee6-0a52-26f6-ebc6-5554ac49a716
|
||||
:END:
|
||||
** DONE [#B] Clean up favorites admin :admin:favorites:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: f2be0c69-1bf8-b5a3-5269-9c8ea873361d
|
||||
:END:
|
||||
|
||||
|
||||
*** Description
|
||||
|
||||
Some FK lookups in admin should be raw_id_fields.
|
||||
|
||||
|
||||
|
||||
* Version 51.4 [1/1]
|
||||
** DONE [#A] Clean up metadata comicbook enrichment :bug:comics:books:metadata:
|
||||
:PROPERTIES:
|
||||
|
||||
96
data/play-example.json
Normal file
96
data/play-example.json
Normal file
@ -0,0 +1,96 @@
|
||||
{
|
||||
"about": "This is a Play file that can be read by Board Game Stats. If you see this text, try to use a share, export or open-in function to open it with Board Game Stats.",
|
||||
"players": [
|
||||
{
|
||||
"uuid": "31f8b92e-11d8-4162-88b1-fd9c79eea249",
|
||||
"id": 2,
|
||||
"name": "Colin Powell",
|
||||
"isAnonymous": false,
|
||||
"modificationDate": "2025-10-18 08:32:40",
|
||||
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"AnnikaHeller_Capybara.webp\",\"shape\":[1,1,1,1],\"color\":[0.6,0.20000000298023224,0.9803921580314636]}}"
|
||||
},
|
||||
{
|
||||
"uuid": "dd2d1881-84ab-474c-a6b2-3045d034dc40",
|
||||
"id": 3,
|
||||
"name": "Silas Sewell",
|
||||
"isAnonymous": false,
|
||||
"modificationDate": "2026-01-18 12:27:12",
|
||||
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"RoryMuldoon_07.webp\",\"shape\":[1,1,1,1],\"color\":[0,0,0.1835034190722739]}}"
|
||||
}
|
||||
],
|
||||
"locations": [
|
||||
{
|
||||
"uuid": "14f7389c-767f-4725-9b35-906c407b293c",
|
||||
"id": 3,
|
||||
"name": "Timberwyck Farm",
|
||||
"modificationDate": "2025-07-01 18:03:38"
|
||||
}
|
||||
],
|
||||
"games": [
|
||||
{
|
||||
"uuid": "9e431cdd-b325-4061-a875-d415d46342c0",
|
||||
"id": 1046,
|
||||
"name": "Sweet Takes",
|
||||
"modificationDate": "2026-04-11 16:25:35",
|
||||
"cooperative": false,
|
||||
"highestWins": true,
|
||||
"noPoints": false,
|
||||
"usesTeams": false,
|
||||
"urlThumb": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__small/img/gmGqlmwe9fniqYpniGwhg5RUgVQ=/fit-in/200x150/filters:strip_icc()/pic8784202.jpg",
|
||||
"urlImage": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__original/img/b4IU8WIEWRpacbXp0FHG9HfFRpw=/0x0/filters:format(jpeg)/pic8784202.jpg",
|
||||
"bggName": "Sweet Takes",
|
||||
"bggYear": 2023,
|
||||
"bggId": 407581,
|
||||
"designers": "Hisashi Hayashi",
|
||||
"isBaseGame": 1,
|
||||
"isExpansion": 0,
|
||||
"rating": 67,
|
||||
"minPlayerCount": 2,
|
||||
"maxPlayerCount": 5,
|
||||
"minPlayTime": 15,
|
||||
"maxPlayTime": 15,
|
||||
"minAge": 8
|
||||
}
|
||||
],
|
||||
"plays": [
|
||||
{
|
||||
"uuid": "7b2fd583-e8f2-40fe-9565-90178390b87e",
|
||||
"modificationDate": "2026-04-16 20:18:03",
|
||||
"entryDate": "2026-04-16 20:13:33",
|
||||
"playDate": "2026-04-16 20:13:33",
|
||||
"usesTeams": false,
|
||||
"durationMin": 4,
|
||||
"ignored": false,
|
||||
"manualWinner": false,
|
||||
"rounds": 0,
|
||||
"locationRefId": 3,
|
||||
"gameRefId": 1046,
|
||||
"board": "",
|
||||
"scoringSetting": 1,
|
||||
"metaData": "{\"playerRefId\":2,\"playGameBggVersion\":\"{\\\"versionId\\\":0,\\\"versionName\\\":\\\"\\\",\\\"imageUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"thumbUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"yearPublished\\\":0}\",\"playUsedGameCopy\":2}",
|
||||
"playerScores": [
|
||||
{
|
||||
"score": "27",
|
||||
"winner": false,
|
||||
"newPlayer": false,
|
||||
"startPlayer": false,
|
||||
"playerRefId": 2,
|
||||
"role": "",
|
||||
"rank": 0,
|
||||
"seatOrder": 0
|
||||
},
|
||||
{
|
||||
"score": "36",
|
||||
"winner": true,
|
||||
"newPlayer": true,
|
||||
"startPlayer": false,
|
||||
"playerRefId": 3,
|
||||
"rank": 0,
|
||||
"seatOrder": 0
|
||||
}
|
||||
],
|
||||
"expansionPlays": []
|
||||
}
|
||||
],
|
||||
"userInfo": { "meRefId": 2 }
|
||||
}
|
||||
BIN
data/statistics.sqlite3
Normal file
BIN
data/statistics.sqlite3
Normal file
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "51.4"
|
||||
version = "52.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ class BeerAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
raw_id_fields = ("styles", "producer")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
|
||||
@ -27,4 +27,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
||||
raw_id_fields = ("user",)
|
||||
ordering = ("-created",)
|
||||
|
||||
@ -38,6 +38,7 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"geo_location",
|
||||
)
|
||||
raw_id_fields = ("geo_location",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@ -49,6 +50,7 @@ class BoardGameAdmin(admin.ModelAdmin):
|
||||
"title",
|
||||
"published_year",
|
||||
)
|
||||
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -27,6 +27,7 @@ class BookAdmin(admin.ModelAdmin):
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
)
|
||||
raw_id_fields = ("authors",)
|
||||
search_fields = ("name",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
@ -34,11 +35,11 @@ class BookAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
def issue_or_volume(self, obj):
|
||||
return obj.issue_number or obj.volume_number
|
||||
return obj.subtitle
|
||||
|
||||
|
||||
@admin.register(Paper)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
class PaperAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
@ -47,6 +48,7 @@ class BookAdmin(admin.ModelAdmin):
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
)
|
||||
raw_id_fields = ("authors",)
|
||||
search_fields = ("name",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -21,6 +21,7 @@ class FoodAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
raw_id_fields = ("category",)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
|
||||
@ -6,6 +6,7 @@ from people.models import Person, PersonScrobble
|
||||
class PersonAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "bgg_username", "bgstats_id")
|
||||
raw_id_fields = ("user", "created_by")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ class PodcastAdmin(admin.ModelAdmin):
|
||||
"producer",
|
||||
"active",
|
||||
)
|
||||
raw_id_fields = ("producer",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@ -28,6 +29,7 @@ class PodcastEpisodeAdmin(admin.ModelAdmin):
|
||||
"title",
|
||||
"podcast",
|
||||
)
|
||||
raw_id_fields = ("podcast",)
|
||||
list_filter = ("podcast",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -6,6 +6,7 @@ from profiles.models import UserProfile
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
raw_id_fields = ("user",)
|
||||
ordering = ("-created",)
|
||||
readonly_fields = ("timezone_change_log",)
|
||||
exclude = (
|
||||
|
||||
@ -21,6 +21,7 @@ class PuzzleAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
raw_id_fields = ("manufacturer",)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
|
||||
@ -19,35 +19,11 @@ from scrobbles.mixins import Genre
|
||||
class ScrobbleInline(admin.TabularInline):
|
||||
model = Scrobble
|
||||
extra = 0
|
||||
raw_id_fields = (
|
||||
"video",
|
||||
"channel",
|
||||
"podcast_episode",
|
||||
"track",
|
||||
"video_game",
|
||||
"book",
|
||||
"paper",
|
||||
"sport_event",
|
||||
"food",
|
||||
"board_game",
|
||||
"geo_location",
|
||||
"task",
|
||||
"puzzle",
|
||||
"mood",
|
||||
"brick_set",
|
||||
"trail",
|
||||
"beer",
|
||||
"web_page",
|
||||
"life_event",
|
||||
"birding_location",
|
||||
"user",
|
||||
)
|
||||
exclude = (
|
||||
"scrobble_log",
|
||||
"timezone",
|
||||
"videogame_save_data",
|
||||
"screenshot",
|
||||
)
|
||||
per_page = 15
|
||||
ordering = ("-timestamp",)
|
||||
show_change_link = True
|
||||
fields = ("timestamp", "media_type", "source", "in_progress")
|
||||
readonly_fields = fields
|
||||
|
||||
|
||||
class ImportBaseAdmin(admin.ModelAdmin):
|
||||
@ -121,7 +97,9 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"user",
|
||||
)
|
||||
raw_id_fields = (
|
||||
"user",
|
||||
"video",
|
||||
"channel",
|
||||
"podcast_episode",
|
||||
"track",
|
||||
"sport_event",
|
||||
@ -140,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"web_page",
|
||||
"life_event",
|
||||
"birding_location",
|
||||
"long_play_last_scrobble",
|
||||
)
|
||||
list_filter = (
|
||||
"is_paused",
|
||||
@ -152,6 +131,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"user",
|
||||
)
|
||||
ordering = ("-timestamp",)
|
||||
readonly_fields = ("share_token_version", "share_view_count")
|
||||
|
||||
def media_name(self, obj):
|
||||
return obj.media_obj
|
||||
@ -178,14 +158,19 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||
list_filter = ("media_type", "sent_to_mopidy", "user")
|
||||
date_hierarchy = "created"
|
||||
raw_id_fields = (
|
||||
"user",
|
||||
"video",
|
||||
"channel",
|
||||
"track",
|
||||
"podcast_episode",
|
||||
"sport_event",
|
||||
"book",
|
||||
"paper",
|
||||
"video_game",
|
||||
"board_game",
|
||||
"geo_location",
|
||||
"puzzle",
|
||||
"food",
|
||||
"task",
|
||||
"mood",
|
||||
"brick_set",
|
||||
|
||||
@ -207,7 +207,7 @@ class BaseLogData(JSONDataclass):
|
||||
|
||||
@dataclass
|
||||
class LongPlayLogData(JSONDataclass):
|
||||
long_play_complete: bool = False
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Backfill long_play_last_scrobble FK chains, then recompute "
|
||||
"long_play_seconds by walking forward through scrobbles in "
|
||||
"timestamp order with a running accumulator."
|
||||
)
|
||||
|
||||
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 in timestamp order..."
|
||||
)
|
||||
|
||||
total_updated = 0
|
||||
for mt in media_types:
|
||||
n = self._recompute_for_media_type(mt, dry_run)
|
||||
total_updated += n
|
||||
self.stdout.write(f" {mt}: {n} scrobbles updated")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Dry run: would update {total_updated} scrobbles total. "
|
||||
"Use without --dry-run to apply."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
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:
|
||||
"""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
|
||||
@ -1498,7 +1504,25 @@ class Scrobble(TimeStampedModel):
|
||||
if media.calories:
|
||||
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
||||
|
||||
if mtype not in LONG_PLAY_MEDIA.values():
|
||||
scrobble_data.pop("long_play_complete", None)
|
||||
|
||||
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 +1781,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
|
||||
|
||||
|
||||
@ -206,6 +206,19 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
|
||||
context_data["scrobbles"] = page_obj.object_list
|
||||
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
|
||||
|
||||
|
||||
@ -909,6 +922,28 @@ def scrobble_longplay_finish(request, uuid):
|
||||
if not user.is_authenticated:
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
# Try scrobble UUID first
|
||||
scrobble = Scrobble.objects.filter(uuid=uuid, user=user).first()
|
||||
if scrobble:
|
||||
if scrobble.long_play_complete == True:
|
||||
scrobble.long_play_complete = None
|
||||
scrobble.save(update_fields=["long_play_complete"])
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.INFO,
|
||||
f"Long play of {scrobble.media_obj} marked as not complete.",
|
||||
)
|
||||
else:
|
||||
scrobble.long_play_complete = True
|
||||
scrobble.save(update_fields=["long_play_complete"])
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.SUCCESS,
|
||||
f"Long play of {scrobble.media_obj} finished.",
|
||||
)
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
# Fall back to media UUID (existing behavior)
|
||||
media_obj = None
|
||||
for app, model in LONG_PLAY_MEDIA.items():
|
||||
media_model = apps.get_model(app_label=app, model_name=model)
|
||||
@ -917,10 +952,21 @@ def scrobble_longplay_finish(request, uuid):
|
||||
break
|
||||
|
||||
if not media_obj:
|
||||
return
|
||||
messages.add_message(
|
||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
||||
)
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
last_scrobble = media_obj.last_long_play_scrobble_for_user(user)
|
||||
if last_scrobble and last_scrobble.long_play_complete == False:
|
||||
if last_scrobble and last_scrobble.long_play_complete == True:
|
||||
last_scrobble.long_play_complete = None
|
||||
last_scrobble.save(update_fields=["long_play_complete"])
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.INFO,
|
||||
f"Long play of {media_obj} marked as not complete.",
|
||||
)
|
||||
elif last_scrobble:
|
||||
last_scrobble.long_play_complete = True
|
||||
last_scrobble.save(update_fields=["long_play_complete"])
|
||||
messages.add_message(
|
||||
@ -1264,6 +1310,24 @@ class ScrobbleDetailView(DetailView):
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ class SportAdmin(admin.ModelAdmin):
|
||||
class LeagueAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "abbreviation_str")
|
||||
raw_id_fields = ("sport",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@ -30,6 +31,7 @@ class LeagueAdmin(admin.ModelAdmin):
|
||||
class PlayerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "league", "team")
|
||||
raw_id_fields = ("league", "team")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@ -37,6 +39,7 @@ class PlayerAdmin(admin.ModelAdmin):
|
||||
class SeasonAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "league")
|
||||
raw_id_fields = ("league",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@ -44,6 +47,7 @@ class SeasonAdmin(admin.ModelAdmin):
|
||||
class RoundAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "season")
|
||||
raw_id_fields = ("season",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@ -51,6 +55,7 @@ class RoundAdmin(admin.ModelAdmin):
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "league")
|
||||
raw_id_fields = ("league",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@ -63,6 +68,7 @@ class SportEventAdmin(admin.ModelAdmin):
|
||||
"event_type",
|
||||
"comp_str",
|
||||
)
|
||||
raw_id_fields = ("league", "teams", "players", "round")
|
||||
list_filter = ("league", "event_type")
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -26,6 +26,7 @@ class GameAdmin(admin.ModelAdmin):
|
||||
"main_story_time",
|
||||
"release_year",
|
||||
)
|
||||
raw_id_fields = ("platforms",)
|
||||
search_fields = (
|
||||
"title",
|
||||
"alternative_name",
|
||||
|
||||
@ -6,23 +6,29 @@
|
||||
<tr>
|
||||
<th scope="col">Latest</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
<th scope="col">Complete</th>
|
||||
<th scope="col">Start</th>
|
||||
<th scope="col">Finish</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for obj in object_list %}
|
||||
{% if obj.title %}
|
||||
{% with last=obj.scrobble_set.last %}
|
||||
<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>
|
||||
{% 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>{% if obj.scrobble_set.last.logdata.long_play_complete == True %}Yes{% 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-warning" href="{{obj.get_longplay_finish_url}}">Finish</a></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -33,7 +33,11 @@
|
||||
<p><a href="{{object.next_readcomics_url}}">Read next issue</a></p>
|
||||
{% 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>
|
||||
</div>
|
||||
@ -61,7 +65,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 <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% 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>
|
||||
|
||||
@ -26,7 +26,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="row">
|
||||
<div class="col-md">
|
||||
@ -44,7 +48,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 <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -264,7 +264,13 @@
|
||||
|
||||
<h2 class="mt-4">All scrobbles of this {{ object.media_type|lower }}</h2>
|
||||
{% 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">
|
||||
<thead>
|
||||
<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 %}
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -50,7 +50,11 @@
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
<a href="{{object.start_url}}">Play again</a>
|
||||
</p>
|
||||
@ -67,6 +71,7 @@
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -76,6 +81,7 @@
|
||||
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.title}}</a></td>
|
||||
<td>{{scrobble.logdata.notes_as_str}}</td>
|
||||
<td>{{scrobble.source}}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@ -59,7 +59,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<a href="">Play again</a>
|
||||
</p>
|
||||
@ -83,7 +87,7 @@
|
||||
<tr>
|
||||
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes{% else %}Not yet{% endif %}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">Not yet</a>{% endif %}</td>
|
||||
<td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td>
|
||||
<td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
||||
<td>{% if scrobble.videogame_save_data %}<a href="{{scrobble.videogame_save_data.url}}">Save data</a>{% else %}Not yet{% endif %}</td>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
{% include "_longplay_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user