Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab10758f40 | |||
| 88f16f0aaa | |||
| c1744fab37 | |||
| 042a3eb737 | |||
| 01d25e1b55 | |||
| c0be131e3d | |||
| 7d3f615ed7 | |||
| c2138b3ac6 | |||
| 947713d44a | |||
| 12b76837a3 | |||
| 102494ede7 | |||
| 96bda8d4ad | |||
| 46956d06d8 | |||
| 8a28d0675b | |||
| 5f6e75b14e | |||
| a96a42cdbf | |||
| c7f5d7d384 | |||
| d5830f5cd1 | |||
| c71b51fdb8 | |||
| 935d059a20 | |||
| 25776eb495 | |||
| 5ac4625af9 |
248
PROJECT.org
248
PROJECT.org
@ -88,8 +88,8 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/14] :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,17 +439,8 @@ 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] 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
|
||||
:END:
|
||||
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
:PROPERTIES:
|
||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||
@ -474,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
|
||||
@ -486,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
|
||||
@ -518,6 +506,228 @@ 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] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#C] Implement loguru into project :feature:loguru:logging:
|
||||
:PROPERTIES:
|
||||
:ID: efcd0c0a-db81-4518-9c23-5505d59e8ef5
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Would be great to formalize how we log so we can search for errors and such more
|
||||
easily. And our exposure to PII is really low at this point in the project,
|
||||
so we can probably use backtrace=True and diagnose=True to help us root cause
|
||||
bugs faster.
|
||||
|
||||
** TODO [#B] Add a /trends/ page that shows trends based on scrobble data :feature:trends:scrobbles:
|
||||
|
||||
*** Description
|
||||
|
||||
This project is a bit invovled. But we should add a top level URL `trends` that shows
|
||||
various trends as defined either in a static settings file, or dynamically via a database table.
|
||||
|
||||
Examples of trends:
|
||||
|
||||
- How often does the user:
|
||||
+ watch sports while doing a task?
|
||||
+ do a task while watching a video?
|
||||
* how often do I do
|
||||
|
||||
- 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:
|
||||
:ID: cd875450-7117-78ca-8be4-9c8b73037dba
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Still getting wonky results with some comicbooks. Would be nice to be able to
|
||||
tag a Book as a comicbook, and also gather volume information. I also noticed
|
||||
that some books that are found in OL never get their comicvine_id populated. We
|
||||
should make sure we always have comicvine_ids if available.
|
||||
|
||||
|
||||
* Version 51.3 [1/1]
|
||||
** DONE [#A] Improve speed of index and chart pages :bug:scrobbles:perf:
|
||||
:PROPERTIES:
|
||||
:ID: 031a23f8-7c02-4926-9884-6654ceca16c2
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Over the last few releases, the home page and charts pages have gotten really
|
||||
slow.
|
||||
|
||||
We should look into what's causing the slowness and maybe do more agressive
|
||||
query optimization or caching.
|
||||
|
||||
|
||||
* Version 51.2 [2/2]
|
||||
** DONE [#A] Fix bug where last page of book gets separate scrobble :bug:books:importers:koreader:
|
||||
:PROPERTIES:
|
||||
:ID: e13e0b4c-461e-e5a9-c685-b972f4e262e5
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
The new KoReader code is working great to import books with the correct timezone
|
||||
and what-not. But it has a weird artifact of creating one extra scrobble for the
|
||||
last page read. Need to button that up.
|
||||
|
||||
** DONE [#B] Fix metadata scraping for books :books:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: ea416a69-a8a8-4d05-b7d4-0a3470820e34
|
||||
:END:
|
||||
|
||||
|
||||
* Version 51.1 [1/1]
|
||||
** DONE [#A] Fix scrobbling comic books :books:scrobbles:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 8dfbff19-3fa4-f3b8-21c7-7a416498000c
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
At some point logdata and log got confused, and now when you try
|
||||
to scrobble a comic book, it just throws errors. We should look
|
||||
into where the confusion happened and fix it.
|
||||
|
||||
|
||||
* Version 51.0 [3/3]
|
||||
** DONE [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||
: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.0"
|
||||
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 = [
|
||||
|
||||
@ -217,7 +217,6 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
pages_not_found.append(book_id)
|
||||
continue
|
||||
|
||||
should_create_scrobble = False
|
||||
scrobble_page_data = {}
|
||||
playback_position_seconds = 0
|
||||
prev_page_stats = {}
|
||||
@ -247,32 +246,39 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
end_of_reading = pages_processed == total_pages_read
|
||||
big_jump_to_this_page = (page_number - last_page_number) > 10
|
||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
should_create_scrobble = (
|
||||
is_session_gap and not big_jump_to_this_page
|
||||
) or end_of_reading
|
||||
|
||||
# Always accumulate the current page first
|
||||
scrobble_page_data[page_number] = stats
|
||||
|
||||
if should_create_scrobble:
|
||||
if not scrobble_page_data:
|
||||
scrobble_page_data[page_number] = stats
|
||||
# For end-of-reading, the current page is already accumulated
|
||||
# and belongs in this scrobble. For a session gap, remove the
|
||||
# current page from this scrobble — it starts a new session.
|
||||
if is_session_gap and not end_of_reading:
|
||||
del scrobble_page_data[page_number]
|
||||
|
||||
scrobble_page_data = dict(
|
||||
sorted(
|
||||
scrobble_page_data.items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
)
|
||||
)
|
||||
try:
|
||||
first_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[0]
|
||||
)
|
||||
last_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
)
|
||||
except IndexError:
|
||||
|
||||
if not scrobble_page_data:
|
||||
logger.error(
|
||||
"Could not process book, no page data found",
|
||||
extra={"scrobble_page_data": scrobble_page_data},
|
||||
)
|
||||
continue
|
||||
|
||||
first_page = next(iter(scrobble_page_data.values()))
|
||||
last_page = scrobble_page_data[
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
]
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
@ -319,90 +325,18 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
timezone=tz,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
should_create_scrobble = False
|
||||
# Then start over for the next session
|
||||
playback_position_seconds = 0
|
||||
scrobble_page_data = {}
|
||||
|
||||
# We accumulate pages for the scrobble until we should create a new one
|
||||
scrobble_page_data[page_number] = stats
|
||||
# For session gaps, re-add the current page as the
|
||||
# beginning of the next session's accumulation
|
||||
if is_session_gap and not end_of_reading:
|
||||
scrobble_page_data[page_number] = stats
|
||||
|
||||
last_page_number = page_number
|
||||
prev_page_stats = stats
|
||||
|
||||
# Handle leftover pages that never triggered a session gap
|
||||
if scrobble_page_data:
|
||||
scrobble_page_data = dict(
|
||||
sorted(
|
||||
scrobble_page_data.items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
)
|
||||
)
|
||||
try:
|
||||
first_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[0]
|
||||
)
|
||||
last_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
)
|
||||
except IndexError:
|
||||
logger.error(
|
||||
"Could not process book, no page data found",
|
||||
extra={"scrobble_page_data": scrobble_page_data},
|
||||
)
|
||||
continue
|
||||
|
||||
playback_position_seconds = sum(
|
||||
p["duration"] for p in scrobble_page_data.values()
|
||||
)
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
)
|
||||
)
|
||||
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(
|
||||
int(last_page.get("end_ts"))
|
||||
)
|
||||
)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
book_id=book_id,
|
||||
user_id=user.id,
|
||||
).first()
|
||||
|
||||
if not scrobble:
|
||||
logger.info(
|
||||
f"Queueing scrobble for {book_id}, page {list(scrobble_page_data.keys())[0]}"
|
||||
)
|
||||
log_data = {
|
||||
"koreader_hash": book_dict.get("hash"),
|
||||
"page_data": scrobble_page_data,
|
||||
"pages_read": len(scrobble_page_data.keys()),
|
||||
}
|
||||
if hasattr(timestamp.tzinfo, "tzname"):
|
||||
tz = timestamp.tzinfo.tzname
|
||||
if hasattr(timestamp.tzinfo, "name"):
|
||||
tz = timestamp.tzinfo.name
|
||||
scrobbles_to_create.append(
|
||||
Scrobble(
|
||||
book_id=book_id,
|
||||
user_id=user.id,
|
||||
source="KOReader",
|
||||
media_type=Scrobble.MediaType.BOOK,
|
||||
timestamp=timestamp,
|
||||
log=log_data,
|
||||
stop_timestamp=stop_timestamp,
|
||||
playback_position_seconds=playback_position_seconds,
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
long_play_complete=False,
|
||||
timezone=tz,
|
||||
)
|
||||
)
|
||||
|
||||
if pages_not_found:
|
||||
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||
return scrobbles_to_create
|
||||
@ -410,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
|
||||
|
||||
301
vrobbler/apps/books/management/commands/cleanup_book_metadata.py
Normal file
301
vrobbler/apps/books/management/commands/cleanup_book_metadata.py
Normal file
@ -0,0 +1,301 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MISSING_ALL = [
|
||||
"cover",
|
||||
"summary",
|
||||
"isbn",
|
||||
"pages",
|
||||
"language",
|
||||
"publisher",
|
||||
"publish_year",
|
||||
]
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda b: not bool(b.cover),
|
||||
"summary": lambda b: not b.summary,
|
||||
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
||||
"pages": lambda b: b.pages is None,
|
||||
"language": lambda b: not b.language,
|
||||
"publisher": lambda b: not b.publisher,
|
||||
"publish_year": lambda b: b.first_publish_year is None,
|
||||
}
|
||||
|
||||
|
||||
def _book_matches(book, flags):
|
||||
if not flags:
|
||||
return False
|
||||
for flag in flags:
|
||||
fn = MISSING_GROUPS.get(flag)
|
||||
if fn and fn(book):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill missing metadata on books from Google Books, OpenLibrary, and ComicVine"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of books to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to sleep between API calls (default: 0.5)",
|
||||
)
|
||||
for flag in MISSING_ALL:
|
||||
parser.add_argument(
|
||||
f"--missing-{flag}",
|
||||
dest="missing_flags",
|
||||
action="append_const",
|
||||
const=flag,
|
||||
help=f"Process books missing {flag}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--comics-only",
|
||||
action="store_true",
|
||||
help="Only process books with a readcomicsonline.ru URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
dest="all_missing",
|
||||
help="Process books missing any metadata field",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from books.models import Book
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
flags = options.get("missing_flags") or []
|
||||
comics_only = options["comics_only"]
|
||||
all_missing = options["all_missing"]
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
|
||||
if not flags and not comics_only:
|
||||
self.stdout.write(
|
||||
"No filters specified. Use --all, --missing-*, or --comics-only."
|
||||
)
|
||||
return
|
||||
|
||||
qs = Book.objects.all()
|
||||
if comics_only:
|
||||
qs = qs.filter(readcomics_url__isnull=False)
|
||||
|
||||
if flags:
|
||||
qs = [b for b in qs.iterator() if _book_matches(b, flags)]
|
||||
else:
|
||||
qs = list(qs)
|
||||
|
||||
total = len(qs)
|
||||
self.stdout.write(f"Found {total} books to process")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
stats = {
|
||||
"cover_fixed": 0,
|
||||
"summary_fixed": 0,
|
||||
"isbn_fixed": 0,
|
||||
"pages_fixed": 0,
|
||||
"language_fixed": 0,
|
||||
"publisher_fixed": 0,
|
||||
"publish_year_fixed": 0,
|
||||
}
|
||||
|
||||
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for book in batch:
|
||||
result = self._enrich_book(book, sleep_secs)
|
||||
if result:
|
||||
enriched += 1
|
||||
for key in stats:
|
||||
if result.get(key):
|
||||
stats[key] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Books enriched: {enriched}\n"
|
||||
f" Books skipped: {skipped}\n"
|
||||
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||
f" Summaries fixed:{stats['summary_fixed']}\n"
|
||||
f" ISBNs fixed: {stats['isbn_fixed']}\n"
|
||||
f" Pages fixed: {stats['pages_fixed']}\n"
|
||||
f" Languages fixed:{stats['language_fixed']}\n"
|
||||
f" Publishers fixed:{stats['publisher_fixed']}\n"
|
||||
f" Publish yrs fixed: {stats['publish_year_fixed']}"
|
||||
)
|
||||
|
||||
def _enrich_book(self, book, sleep_secs):
|
||||
from books.sources.comicvine import (
|
||||
lookup_comic_from_comicvine,
|
||||
lookup_issue_by_comicvine_id,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import lookup_book_from_openlibrary as lookup_book_from_ol
|
||||
|
||||
title = book.original_title or book.title
|
||||
author_name = book.author.name if book.author else None
|
||||
book_dict = {}
|
||||
|
||||
cv_data = None
|
||||
if book.comicvine_id:
|
||||
cv_data = lookup_issue_by_comicvine_id(str(book.comicvine_id))
|
||||
if not cv_data:
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
book_dict.update(cv_data)
|
||||
|
||||
ol_data = lookup_book_from_ol(title, author=author_name)
|
||||
time.sleep(sleep_secs)
|
||||
google_data = lookup_book_from_google(title)
|
||||
|
||||
if ol_data:
|
||||
for k, v in ol_data.items():
|
||||
book_dict.setdefault(k, v)
|
||||
|
||||
if google_data:
|
||||
for k, v in google_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
|
||||
if not book_dict:
|
||||
return None
|
||||
|
||||
changed = self._apply(book, book_dict, title)
|
||||
return changed
|
||||
|
||||
def _apply(self, book, data, title):
|
||||
changed = {
|
||||
"cover_fixed": False,
|
||||
"summary_fixed": False,
|
||||
"isbn_fixed": False,
|
||||
"pages_fixed": False,
|
||||
"language_fixed": False,
|
||||
"publisher_fixed": False,
|
||||
"publish_year_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
if data.get("summary") and not book.summary:
|
||||
book.summary = data["summary"]
|
||||
update_fields.append("summary")
|
||||
changed["summary_fixed"] = True
|
||||
|
||||
if data.get("isbn_13") and not book.isbn_13:
|
||||
book.isbn_13 = data["isbn_13"]
|
||||
update_fields.append("isbn_13")
|
||||
changed["isbn_fixed"] = True
|
||||
|
||||
if data.get("isbn_10") and not book.isbn_10:
|
||||
book.isbn_10 = data["isbn_10"]
|
||||
update_fields.append("isbn_10")
|
||||
changed["isbn_fixed"] = True
|
||||
|
||||
if data.get("pages") and book.pages is None:
|
||||
book.pages = data["pages"]
|
||||
update_fields.append("pages")
|
||||
changed["pages_fixed"] = True
|
||||
|
||||
if data.get("language") and not book.language:
|
||||
book.language = data["language"]
|
||||
update_fields.append("language")
|
||||
changed["language_fixed"] = True
|
||||
|
||||
if data.get("publisher") and not book.publisher:
|
||||
book.publisher = data["publisher"]
|
||||
update_fields.append("publisher")
|
||||
changed["publisher_fixed"] = True
|
||||
|
||||
if data.get("first_publish_year") and book.first_publish_year is None:
|
||||
book.first_publish_year = data["first_publish_year"]
|
||||
update_fields.append("first_publish_year")
|
||||
changed["publish_year_fixed"] = True
|
||||
|
||||
if data.get("openlibrary_id") and not book.openlibrary_id:
|
||||
book.openlibrary_id = data["openlibrary_id"]
|
||||
update_fields.append("openlibrary_id")
|
||||
|
||||
if data.get("comicvine_id") and not book.comicvine_id:
|
||||
book.comicvine_id = data["comicvine_id"]
|
||||
update_fields.append("comicvine_id")
|
||||
|
||||
if data.get("issue_number") and book.issue_number is None:
|
||||
book.issue_number = data["issue_number"]
|
||||
update_fields.append("issue_number")
|
||||
|
||||
if data.get("volume_number") and book.volume_number is None:
|
||||
book.volume_number = data["volume_number"]
|
||||
update_fields.append("volume_number")
|
||||
|
||||
if data.get("volume") and not book.volume:
|
||||
book.volume = data["volume"]
|
||||
update_fields.append("volume")
|
||||
|
||||
if data.get("volume_comicvine_id") and not book.volume_comicvine_id:
|
||||
book.volume_comicvine_id = data["volume_comicvine_id"]
|
||||
update_fields.append("volume_comicvine_id")
|
||||
|
||||
if update_fields:
|
||||
book.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [ENRICHED] {book} — {', '.join(update_fields)}")
|
||||
|
||||
if cover_url and not book.cover:
|
||||
book.save_image_from_url(cover_url)
|
||||
if book.cover:
|
||||
changed["cover_fixed"] = True
|
||||
self.stdout.write(f" [COVER] {book} — cover saved from source")
|
||||
|
||||
genres = data.pop("genres", data.pop("generes", []))
|
||||
if genres:
|
||||
existing = set(book.genre.names())
|
||||
new_genres = [g for g in genres if g not in existing]
|
||||
if new_genres:
|
||||
book.genre.add(*new_genres)
|
||||
self.stdout.write(f" [GENRES] {book} — added {len(new_genres)} genres")
|
||||
|
||||
tags = data.pop("tags", [])
|
||||
if tags:
|
||||
existing_tags = set(book.tags.names())
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
if new_tags:
|
||||
book.tags.add(*new_tags)
|
||||
self.stdout.write(f" [TAGS] {book} — added {', '.join(new_tags)}")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
@ -0,0 +1,130 @@
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from books.koreader import SESSION_GAP_SECONDS, fix_long_play_stats_for_scrobbles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SESSION_GAP = timedelta(seconds=SESSION_GAP_SECONDS)
|
||||
|
||||
|
||||
def _page_data_keys(pages):
|
||||
return sorted(int(k) for k in (pages or {}))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Merge orphaned 1-page KOReader scrobbles into the preceding scrobble"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
commit = options["commit"]
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
media_type="Book", source="KOReader"
|
||||
).order_by("book_id", "timestamp")
|
||||
|
||||
if not qs.exists():
|
||||
self.stdout.write("No KOReader book scrobbles found.")
|
||||
return
|
||||
|
||||
merged = 0
|
||||
affected_books = set()
|
||||
|
||||
# Group by book_id manually since we're iterating in order
|
||||
book_scrobbles = {}
|
||||
for s in qs:
|
||||
book_scrobbles.setdefault(s.book_id, []).append(s)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
|
||||
|
||||
for book_id, scrobbles in book_scrobbles.items():
|
||||
batch_merged = 0
|
||||
i = 0
|
||||
while i < len(scrobbles) - 1:
|
||||
current = scrobbles[i]
|
||||
orphan = scrobbles[i + 1]
|
||||
|
||||
orphan_pages = orphan.logdata.page_data if orphan.logdata else {}
|
||||
orphan_keys = _page_data_keys(orphan_pages)
|
||||
if len(orphan_keys) != 1:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
current_pages = current.logdata.page_data if current.logdata else {}
|
||||
current_keys = _page_data_keys(current_pages)
|
||||
if not current_keys:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
orphan_page_num = orphan_keys[0]
|
||||
current_last_page = current_keys[-1]
|
||||
|
||||
if orphan_page_num != current_last_page + 1:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check that the orphan is close enough in time
|
||||
gap = orphan.timestamp - current.stop_timestamp
|
||||
if gap > SESSION_GAP:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Merge orphan into current
|
||||
current_pages[str(orphan_page_num)] = orphan_pages[str(orphan_page_num)]
|
||||
current.log["page_data"] = current_pages
|
||||
current.log["pages_read"] = len(current_pages)
|
||||
current.stop_timestamp = orphan.stop_timestamp
|
||||
current.playback_position_seconds += orphan.playback_position_seconds
|
||||
|
||||
affected_books.add(book_id)
|
||||
|
||||
if commit:
|
||||
with transaction.atomic():
|
||||
current.save(
|
||||
update_fields=[
|
||||
"log",
|
||||
"stop_timestamp",
|
||||
"playback_position_seconds",
|
||||
]
|
||||
)
|
||||
orphan.delete()
|
||||
|
||||
merged += 1
|
||||
batch_merged += 1
|
||||
scrobbles.pop(i + 1)
|
||||
|
||||
if batch_merged:
|
||||
self.stdout.write(
|
||||
f" Book {book_id}: merged {batch_merged} orphan scrobble(s)"
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal orphans merged: {merged}")
|
||||
|
||||
if commit and affected_books:
|
||||
self.stdout.write("Recalculating long_play_stats for affected books...")
|
||||
for book_id in affected_books:
|
||||
scrobbles_to_fix = (
|
||||
Scrobble.objects.filter(book_id=book_id, source="KOReader")
|
||||
.order_by("timestamp")
|
||||
)
|
||||
fix_long_play_stats_for_scrobbles(list(scrobbles_to_fix))
|
||||
|
||||
self.stdout.write(f"Fixed stats for {len(affected_books)} books.")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
f"\nWould merge {merged} orphan scrobble(s) across "
|
||||
f"{len(affected_books)} book(s)."
|
||||
)
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-15 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="volume",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="volume_comicvine_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -20,6 +20,7 @@ from books.openlibrary import (
|
||||
from books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
lookup_issue_by_comicvine_id,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import (
|
||||
@ -150,6 +151,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
first_sentence = models.TextField(**BNULL)
|
||||
# ComicVine
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
volume = models.CharField(max_length=255, **BNULL)
|
||||
volume_comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
@ -229,36 +232,24 @@ class Book(LongPlayScrobblableMixin):
|
||||
) -> "Book":
|
||||
book, created = cls.objects.get_or_create(title=title)
|
||||
|
||||
if not created:
|
||||
if not created and not overwrite:
|
||||
return book
|
||||
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
if not book_dict:
|
||||
return book
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
author_dicts = book_dict.pop("author_dicts")
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
semantic_id=author_dict.get("authorId")
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
tags = book_dict.pop("tags", [])
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
if author_list:
|
||||
book.authors.add(*author_list)
|
||||
genres = book_dict.pop("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
@ -292,24 +283,43 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
book_dict = None
|
||||
source_tag = None
|
||||
tried_comicvine = False
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
tried_comicvine = True
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_ol(title, author=author)
|
||||
if book_dict:
|
||||
book_dict = {}
|
||||
ol_data = lookup_book_from_ol(title, author=author)
|
||||
google_data = lookup_book_from_google(title)
|
||||
|
||||
if ol_data:
|
||||
book_dict.update(ol_data)
|
||||
source_tag = MediaSourceTag.OPENLIBRARY
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
if book_dict:
|
||||
if google_data:
|
||||
for k, v in google_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
source_tag = MediaSourceTag.GOOGLE_BOOKS
|
||||
if ol_data and ol_data.get("cover_url"):
|
||||
book_dict["cover_url"] = ol_data["cover_url"]
|
||||
|
||||
# Always try ComicVine as a fallback — it may recognize books that
|
||||
# OL/Google don't flag as comics
|
||||
if not tried_comicvine:
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
for k, v in cv_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
@ -321,6 +331,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
tags = book_dict.pop("tags", [])
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
@ -340,6 +351,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
book.save_image_from_url(cover_url)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
book.authors.add(*author_list)
|
||||
if source_tag:
|
||||
book.tags.add(source_tag.value)
|
||||
@ -377,9 +390,14 @@ class Book(LongPlayScrobblableMixin):
|
||||
data = lookup_comic_from_locg(str(self.title))
|
||||
|
||||
if not data and COMICVINE_API_KEY:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
if self.comicvine_id:
|
||||
logger.warn(
|
||||
f"Checking ComicVine by ID for {self.title}"
|
||||
)
|
||||
data = lookup_issue_by_comicvine_id(str(self.comicvine_id))
|
||||
if not data:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
|
||||
if not data:
|
||||
logger.warn(f"Book not found in any sources: {self.title}")
|
||||
@ -417,10 +435,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
data.pop("pages")
|
||||
|
||||
# Pop this, so we can look it up later
|
||||
# Pop these so they don't get passed to update()
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
subject_key_list = data.pop("subject_key_list", [])
|
||||
tags = data.pop("tags", [])
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
Book.objects.filter(pk=self.id).update(**data)
|
||||
@ -428,6 +446,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
if subject_key_list:
|
||||
self.genre.add(*subject_key_list)
|
||||
if tags:
|
||||
self.tags.add(*tags)
|
||||
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
|
||||
@ -17,8 +17,10 @@ class ComicVineClient(object):
|
||||
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
||||
"""
|
||||
|
||||
# All API requests made by this client will be made to this URL.
|
||||
API_URL = "https://www.comicvine.com/api/search/"
|
||||
# All API requests made by this client will be made to these URLs.
|
||||
API_URL = "https://comicvine.gamespot.com/api/search/"
|
||||
ISSUE_API_URL = "https://comicvine.gamespot.com/api/issue/4000-{issue_id}/"
|
||||
VOLUME_API_URL = "https://comicvine.gamespot.com/api/volume/4050-{volume_id}/"
|
||||
|
||||
# A valid User-Agent header must be set in order for our API requests to
|
||||
# be accepted, otherwise our request will be rejected with a
|
||||
@ -41,15 +43,12 @@ class ComicVineClient(object):
|
||||
"volume",
|
||||
}
|
||||
|
||||
def __init__(self, api_key, expire_after=300):
|
||||
def __init__(self, api_key):
|
||||
"""
|
||||
Store the API key in a class variable, and install the requests cache,
|
||||
configuring it using the ``expire_after`` parameter.
|
||||
Store the API key in a class variable.
|
||||
|
||||
:param api_key: Your personal ComicVine API key.
|
||||
:type api_key: str
|
||||
:param expire_after: The number of seconds to retain an entry in cache.
|
||||
:type expire_after: int or None
|
||||
"""
|
||||
|
||||
self.api_key = api_key
|
||||
@ -109,14 +108,17 @@ class ComicVineClient(object):
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
return {
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
"limit": min(10, limit), # hard limit of 10
|
||||
"offset": max(0, offset), # cannot provide negative offset
|
||||
"query": query,
|
||||
"resources": self._validate_resources(resources),
|
||||
}
|
||||
validated = self._validate_resources(resources)
|
||||
if validated:
|
||||
params["resources"] = validated
|
||||
return params
|
||||
|
||||
def _validate_resources(self, resources):
|
||||
"""
|
||||
@ -141,33 +143,35 @@ class ComicVineClient(object):
|
||||
def _query_api(self, params):
|
||||
"""
|
||||
Query the ComicVine API's ``search`` resource, providing the required
|
||||
headers and parameters with the request. Optionally allow the caller
|
||||
of the function to disable the request cache.
|
||||
headers and parameters with the request.
|
||||
|
||||
If an error occurs during the request, handle it accordingly. Upon
|
||||
success, return the JSON from the response.
|
||||
|
||||
:param params: Parameters to include with the request.
|
||||
:type params: dict
|
||||
:param use_cache: Toggle the use of requests_cache.
|
||||
:type use_cache: bool
|
||||
|
||||
:return: The JSON contained in the response.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
# Since we're performing the identical action regardless of whether
|
||||
# or not the request cache is to be used, store the procedure in a
|
||||
# local function to avoid repetition.
|
||||
def __httpget():
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
return response.json()
|
||||
json_data = response.json()
|
||||
|
||||
return __httpget()
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data
|
||||
|
||||
def _handle_http_error(self, response):
|
||||
"""
|
||||
@ -195,15 +199,81 @@ class ComicVineClient(object):
|
||||
|
||||
raise exception(message)
|
||||
|
||||
def get_issue(self, issue_id: str) -> dict:
|
||||
"""
|
||||
Fetch a single issue by its ComicVine ID directly from the issue detail
|
||||
endpoint, which returns richer data than the search endpoint.
|
||||
|
||||
:param issue_id: The ComicVine numeric ID for the issue (e.g. "538480")
|
||||
:type issue_id: str
|
||||
|
||||
:return: The full JSON response for the issue, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
url = self.ISSUE_API_URL.format(issue_id=issue_id)
|
||||
response = requests.get(url, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
json_data = response.json()
|
||||
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data.get("results", {})
|
||||
|
||||
def get_volume(self, volume_id: str) -> dict:
|
||||
"""
|
||||
Fetch a single volume by its ComicVine ID from the volume detail
|
||||
endpoint. Used to get publisher info and other volume-level metadata.
|
||||
|
||||
:param volume_id: The ComicVine numeric ID for the volume (e.g. "91273")
|
||||
:type volume_id: str
|
||||
|
||||
:return: The full JSON response for the volume, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
url = self.VOLUME_API_URL.format(volume_id=volume_id)
|
||||
response = requests.get(url, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
json_data = response.json()
|
||||
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data.get("results", {})
|
||||
|
||||
|
||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
original_title = title
|
||||
|
||||
issue_number = None
|
||||
volume_nubmer = None
|
||||
resource_type = "issue"
|
||||
if "Issue " in title:
|
||||
resource_type = "issue"
|
||||
issue_number = title.split("Issue ")[1]
|
||||
volume_number = None
|
||||
if "Volume " in title:
|
||||
@ -215,48 +285,136 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(api_key=getattr(settings, "COMICVINE_API_KEY", None))
|
||||
client = ComicVineClient(api_key=api_key)
|
||||
|
||||
raw_results = client.search(title).get("results")
|
||||
results = [r for r in raw_results if r.get("resource_type") == resource_type]
|
||||
raw_results = client.search(title)
|
||||
if not raw_results:
|
||||
return {}
|
||||
results = raw_results.get("results", [])
|
||||
results = [r for r in results if r.get("resource_type") == resource_type]
|
||||
if not results:
|
||||
logger.warning("No comic found on ComicVine")
|
||||
return {}
|
||||
|
||||
found_result = None
|
||||
for result in results:
|
||||
if result.get("issue_number") == str(issue_number):
|
||||
if issue_number is not None and result.get("issue_number") == str(issue_number):
|
||||
found_result = result
|
||||
break
|
||||
if result.get("volume_number") == str(volume_number):
|
||||
if volume_number is not None and result.get("volume_number") == str(volume_number):
|
||||
found_result = result
|
||||
break
|
||||
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
logger.info("ComicVine results", extra={"results": results})
|
||||
data_dict = _build_data_dict_from_issue(found_result, original_title)
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
if not found_result:
|
||||
logger.warning("No matches found on ComicVine")
|
||||
|
||||
def lookup_issue_by_comicvine_id(comicvine_id: str) -> dict:
|
||||
"""
|
||||
Look up an issue directly by its ComicVine ID using the issue detail
|
||||
endpoint. Returns richer data than the search-based lookup.
|
||||
|
||||
:param comicvine_id: The ComicVine numeric ID for the issue (e.g. "538480")
|
||||
:type comicvine_id: str
|
||||
|
||||
:return: A dict of extracted book metadata, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not comicvine_id:
|
||||
return {}
|
||||
|
||||
title = found_result.get("name")
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
client = ComicVineClient(api_key=api_key)
|
||||
issue_data = client.get_issue(comicvine_id)
|
||||
if not issue_data:
|
||||
logger.warning("No issue found on ComicVine for ID %s", comicvine_id)
|
||||
return {}
|
||||
|
||||
data_dict = _build_data_dict_from_issue(issue_data, issue_data.get("name", ""))
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
|
||||
def _build_data_dict_from_issue(issue_data: dict, original_title: str = "") -> dict:
|
||||
"""
|
||||
Build a book metadata dict from a ComicVine issue resource (either from
|
||||
search results or issue detail endpoint). Both return the same shape of
|
||||
issue data.
|
||||
|
||||
:param issue_data: The issue resource dict from ComicVine.
|
||||
:param original_title: The original search term, if any.
|
||||
|
||||
:return: A dict of extracted book metadata.
|
||||
:rtype: dict
|
||||
"""
|
||||
title = issue_data.get("name")
|
||||
if issue_data.get("volume"):
|
||||
title = issue_data.get("volume").get("name")
|
||||
|
||||
cover_url = None
|
||||
if issue_data.get("image"):
|
||||
cover_url = issue_data["image"].get("original_url")
|
||||
|
||||
volume_name = None
|
||||
volume_cv_id = None
|
||||
publisher_name = None
|
||||
volume_data = issue_data.get("volume")
|
||||
if volume_data:
|
||||
volume_name = volume_data.get("name")
|
||||
volume_cv_id = volume_data.get("id")
|
||||
publisher_data = volume_data.get("publisher")
|
||||
if publisher_data:
|
||||
publisher_name = publisher_data.get("name")
|
||||
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"original_title": original_title,
|
||||
"issue_number": found_result.get("issue_number"),
|
||||
"volume_number": found_result.get("volume_number"),
|
||||
"cover_url": found_result.get("image").get("original_url"),
|
||||
"comicvine_id": found_result.get("id"),
|
||||
"comicvine_data": found_result,
|
||||
"summary": found_result.get("description"),
|
||||
"publish_date": found_result.get("cover_date"),
|
||||
"first_publish_year": found_result.get("cover_date", "")[:4],
|
||||
"issue_number": issue_data.get("issue_number"),
|
||||
"volume_number": issue_data.get("volume_number"),
|
||||
"volume": volume_name,
|
||||
"volume_comicvine_id": volume_cv_id,
|
||||
"publisher": publisher_name,
|
||||
"cover_url": cover_url,
|
||||
"comicvine_id": issue_data.get("id"),
|
||||
"summary": issue_data.get("description"),
|
||||
"publish_date": issue_data.get("cover_date"),
|
||||
"first_publish_year": (issue_data.get("cover_date") or "")[:4],
|
||||
"tags": ["comicbook"],
|
||||
}
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def _enrich_with_volume_data(client: ComicVineClient, data_dict: dict) -> None:
|
||||
"""
|
||||
Follow-up a successful issue lookup by fetching the volume detail and
|
||||
filling in publisher and other volume-level metadata that the issue
|
||||
endpoint doesn't provide.
|
||||
|
||||
:param client: An initialised ComicVineClient instance.
|
||||
:param data_dict: The data dict from an issue lookup (mutated in place).
|
||||
"""
|
||||
volume_cv_id = data_dict.get("volume_comicvine_id")
|
||||
if not volume_cv_id:
|
||||
return
|
||||
|
||||
volume_data = client.get_volume(str(volume_cv_id))
|
||||
if not volume_data:
|
||||
return
|
||||
|
||||
publisher_data = volume_data.get("publisher")
|
||||
if publisher_data:
|
||||
publisher_name = publisher_data.get("name")
|
||||
if publisher_name and not data_dict.get("publisher"):
|
||||
data_dict["publisher"] = publisher_name
|
||||
|
||||
if not data_dict.get("volume"):
|
||||
data_dict["volume"] = volume_data.get("name")
|
||||
|
||||
@ -26,8 +26,6 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
if not google_result:
|
||||
return {}
|
||||
|
||||
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||
|
||||
isbn_13 = ""
|
||||
isbn_10 = ""
|
||||
for ident in google_result.get("industryIdentifiers", []):
|
||||
@ -35,25 +33,25 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
isbn_13 = ident.get("identifier")
|
||||
if ident.get("type") == "ISBN_10":
|
||||
isbn_10 = ident.get("identifier")
|
||||
# TODO this may lead to issues with the first get if Google changes our title
|
||||
# book_metadata.title = google_result.get("title")
|
||||
# if google_result.get("subtitle"):
|
||||
# book_metadata["title"] = ": ".join(
|
||||
# [google_result.get("title"), google_result.get("subtitle")]
|
||||
# )
|
||||
# book_dict["subtitle"] = google_result.get("subtitle")
|
||||
book_dict["authors"] = google_result.get("authors")
|
||||
book_dict["publisher"] = google_result.get("publisher")
|
||||
book_dict["first_publish_year"] = publish_date.year
|
||||
book_dict["pages"] = google_result.get("pageCount")
|
||||
book_dict["isbn_13"] = isbn_13
|
||||
book_dict["isbn_10"] = isbn_10
|
||||
book_dict["publish_date"] = google_result.get("publishedDate")
|
||||
if len(book_dict["publish_date"]) == 4:
|
||||
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
|
||||
book_dict["language"] = google_result.get("language")
|
||||
book_dict["summary"] = google_result.get("description")
|
||||
book_dict["genres"] = google_result.get("categories")
|
||||
|
||||
raw_date = google_result.get("publishedDate")
|
||||
if raw_date:
|
||||
try:
|
||||
publish_date = pendulum.parse(raw_date)
|
||||
book_dict["first_publish_year"] = publish_date.year
|
||||
except Exception:
|
||||
pass
|
||||
book_dict["publish_date"] = raw_date
|
||||
if len(raw_date) == 4:
|
||||
book_dict["publish_date"] = f"{raw_date}-1-1"
|
||||
book_dict["cover_url"] = (
|
||||
google_result.get("imageLinks", {})
|
||||
.get("thumbnail", "")
|
||||
|
||||
@ -46,18 +46,16 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
|
||||
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
|
||||
# The test data generator adds the session-gap 3600s AFTER the trigger page
|
||||
# (not before), so the first session includes 21 pages (1-21), and each
|
||||
# subsequent session has 20 until the last. The last trigger page (120) was
|
||||
# previously orphaned by the loop structure; the post-loop fix now creates a
|
||||
# scrobble for it.
|
||||
expected_scrobbles = 7 * len(book_map.keys())
|
||||
# subsequent session has 20 until the last. The last page is now included
|
||||
# in the final scrobble instead of being orphaned.
|
||||
expected_scrobbles = 6 * len(book_map.keys())
|
||||
assert len(scrobbles) == expected_scrobbles
|
||||
assert len(scrobbles[0].logdata.page_data.keys()) == 21
|
||||
assert len(scrobbles[1].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[2].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[3].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[4].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 18
|
||||
assert len(scrobbles[6].logdata.page_data.keys()) == 1
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 19
|
||||
|
||||
|
||||
def test_get_author_str_from_row():
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-12 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("charts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "album", "rank"],
|
||||
name="charts_char_user_id_1adcde_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "track", "rank"],
|
||||
name="charts_char_user_id_d18aab_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "video", "rank"],
|
||||
name="charts_char_user_id_de9f0a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "board_game", "rank"],
|
||||
name="charts_char_user_id_d5d58f_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "book", "rank"],
|
||||
name="charts_char_user_id_e877cf_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "food", "rank"],
|
||||
name="charts_char_user_id_a0ad71_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "podcast", "rank"],
|
||||
name="charts_char_user_id_846b80_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "trail", "rank"],
|
||||
name="charts_char_user_id_54feba_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "album", "rank"],
|
||||
name="charts_char_user_id_a3dc49_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "track", "rank"],
|
||||
name="charts_char_user_id_4b01ab_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "video", "rank"],
|
||||
name="charts_char_user_id_2ac9d2_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "board_game", "rank"],
|
||||
name="charts_char_user_id_ba968a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "book", "rank"],
|
||||
name="charts_char_user_id_e66751_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "food", "rank"],
|
||||
name="charts_char_user_id_d23f06_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "podcast", "rank"],
|
||||
name="charts_char_user_id_be8122_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "trail", "rank"],
|
||||
name="charts_char_user_id_b94ea9_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "artist", "rank"],
|
||||
name="charts_char_user_id_406e0e_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "album", "rank"],
|
||||
name="charts_char_user_id_322b0d_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "tv_series", "rank"],
|
||||
name="charts_char_user_id_aa44b7_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -60,10 +60,29 @@ class ChartRecord(TimeStampedModel):
|
||||
models.Index(fields=["user", "year", "geo_location", "rank"]),
|
||||
models.Index(fields=["user", "year", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
||||
]
|
||||
|
||||
@property
|
||||
|
||||
@ -438,12 +438,14 @@ class ChartRecordView(TemplateView):
|
||||
return context
|
||||
|
||||
def get_available_years(self, user):
|
||||
return list(
|
||||
ChartRecord.objects.filter(user=user)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
if not hasattr(self, "_available_years"):
|
||||
self._available_years = list(
|
||||
ChartRecord.objects.filter(user=user)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
return self._available_years
|
||||
|
||||
def get_period_type(self):
|
||||
date_param = self.request.GET.get("date")
|
||||
|
||||
@ -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,19 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-12 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0096_convert_book_page_data_to_dict"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="scrobble",
|
||||
index=models.Index(
|
||||
fields=["user", "-timestamp"], name="scrobbles_s_user_id_d367a7_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -225,7 +225,9 @@ class KoReaderImport(BaseFileImportMixin):
|
||||
|
||||
self.mark_started()
|
||||
try:
|
||||
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
|
||||
scrobbles = process_koreader_sqlite_file(
|
||||
self.upload_file_path, self.user.id
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
@ -272,7 +274,9 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
||||
|
||||
self.mark_started()
|
||||
try:
|
||||
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
|
||||
scrobbles = import_audioscrobbler_tsv_file(
|
||||
self.upload_file_path, self.user.id
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
@ -608,6 +612,30 @@ class EBirdCSVImport(BaseFileImportMixin):
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||
"Video": ("video",),
|
||||
"Track": ("track", "track__artist_fk"),
|
||||
"PodcastEpisode": ("podcast_episode", "podcast_episode__podcast"),
|
||||
"SportEvent": ("sport_event",),
|
||||
"Book": ("book",),
|
||||
"Paper": ("paper",),
|
||||
"VideoGame": ("video_game",),
|
||||
"BoardGame": ("board_game",),
|
||||
"GeoLocation": ("geo_location",),
|
||||
"Trail": ("trail",),
|
||||
"Beer": ("beer",),
|
||||
"Puzzle": ("puzzle",),
|
||||
"Food": ("food",),
|
||||
"Task": ("task",),
|
||||
"WebPage": ("web_page",),
|
||||
"LifeEvent": ("life_event",),
|
||||
"Mood": ("mood",),
|
||||
"BrickSet": ("brick_set",),
|
||||
"Channel": ("channel",),
|
||||
"BirdingLocation": ("birding_location",),
|
||||
}
|
||||
|
||||
|
||||
class ScrobbleQuerySet(models.QuerySet):
|
||||
def with_related(self):
|
||||
return self.select_related("user").prefetch_related(
|
||||
@ -634,6 +662,13 @@ class ScrobbleQuerySet(models.QuerySet):
|
||||
"birding_location",
|
||||
)
|
||||
|
||||
def with_related_for_types(self, media_types: list[str]):
|
||||
prefetches = []
|
||||
for t in media_types:
|
||||
if t in TYPE_FK_PREFETCHES:
|
||||
prefetches.extend(TYPE_FK_PREFETCHES[t])
|
||||
return self.select_related("user").prefetch_related(*prefetches)
|
||||
|
||||
|
||||
class ShareViewLog(TimeStampedModel):
|
||||
scrobble = models.ForeignKey(
|
||||
@ -760,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 = [
|
||||
@ -774,6 +815,7 @@ class Scrobble(TimeStampedModel):
|
||||
"is_paused",
|
||||
]
|
||||
),
|
||||
models.Index(fields=["user", "-timestamp"]),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@ -806,11 +848,14 @@ class Scrobble(TimeStampedModel):
|
||||
@classmethod
|
||||
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
|
||||
scrobbles_by_type = defaultdict(list)
|
||||
scrobbles = (
|
||||
scrobble_qs.with_related()
|
||||
if hasattr(scrobble_qs, "with_related")
|
||||
else scrobble_qs
|
||||
)
|
||||
|
||||
if hasattr(scrobble_qs, "with_related"):
|
||||
media_types_present = list(
|
||||
scrobble_qs.values_list("media_type", flat=True).distinct()
|
||||
)
|
||||
scrobbles = scrobble_qs.with_related_for_types(media_types_present)
|
||||
else:
|
||||
scrobbles = scrobble_qs
|
||||
|
||||
for scrobble in scrobbles:
|
||||
scrobbles_by_type[scrobble.media_type].append(scrobble)
|
||||
@ -825,7 +870,7 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
# Remove any locations without titles
|
||||
if "GeoLocation" in scrobbles_by_type.keys():
|
||||
for loc_scrobble in scrobbles_by_type["GeoLocation"]:
|
||||
for loc_scrobble in list(scrobbles_by_type["GeoLocation"]):
|
||||
if not loc_scrobble.media_obj.title:
|
||||
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
|
||||
scrobbles_by_type["GeoLocation_count"] -= 1
|
||||
@ -936,6 +981,8 @@ class Scrobble(TimeStampedModel):
|
||||
logdata_cls = logdata.BaseLogData
|
||||
|
||||
log_dict = self.log
|
||||
if isinstance(log_dict, logdata.BaseLogData):
|
||||
log_dict = log_dict.asdict
|
||||
if isinstance(self.log, str):
|
||||
# There's nothing stopping django from saving a string in a JSONField :(
|
||||
logger.warning(
|
||||
@ -1141,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
|
||||
@ -1440,7 +1487,7 @@ class Scrobble(TimeStampedModel):
|
||||
start_ts=int(timezone.now().timestamp()),
|
||||
)
|
||||
}
|
||||
)
|
||||
).asdict
|
||||
|
||||
logger.info(
|
||||
f"[scrobbling] creating new scrobble",
|
||||
@ -1455,9 +1502,27 @@ class Scrobble(TimeStampedModel):
|
||||
"calories", None
|
||||
):
|
||||
if media.calories:
|
||||
scrobble_data["log"] = FoodLogData(calories=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
|
||||
@ -1716,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
|
||||
|
||||
@ -1778,7 +1843,7 @@ class Scrobble(TimeStampedModel):
|
||||
return False
|
||||
|
||||
def calculate_reading_stats(self, commit=True):
|
||||
page_data = self.log.get("page_data")
|
||||
page_data = self.logdata.page_data
|
||||
|
||||
if page_data:
|
||||
if isinstance(page_data, dict):
|
||||
@ -1838,9 +1903,7 @@ class FavoriteMedia(TimeStampedModel):
|
||||
birding_location = models.ForeignKey(
|
||||
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
media_type = models.CharField(
|
||||
max_length=20, choices=Scrobble.MediaType.choices
|
||||
)
|
||||
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
||||
sent_to_mopidy = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -357,10 +357,7 @@ def manual_scrobble_book(
|
||||
|
||||
if action == "stop":
|
||||
if url:
|
||||
if isinstance(scrobble.log, "BookLogData"):
|
||||
scrobble.log.resume_url = next_url_if_exists(url)
|
||||
else:
|
||||
scrobble.log["resume_url"] = next_url_if_exists(url)
|
||||
scrobble.log["resume_url"] = next_url_if_exists(url)
|
||||
scrobble.save(update_fields=["log"])
|
||||
scrobble.stop(force_finish=True)
|
||||
|
||||
|
||||
@ -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