Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01d25e1b55 | |||
| c0be131e3d | |||
| 7d3f615ed7 | |||
| c2138b3ac6 | |||
| 947713d44a | |||
| 12b76837a3 | |||
| 102494ede7 | |||
| 96bda8d4ad | |||
| 46956d06d8 | |||
| 8a28d0675b | |||
| 5f6e75b14e | |||
| a96a42cdbf | |||
| c7f5d7d384 |
190
PROJECT.org
190
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,7 +439,7 @@ added.
|
||||
They should also probably support markdown formatting and that should be
|
||||
displayed in the template.
|
||||
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :books:feature:export:
|
||||
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
|
||||
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
:PROPERTIES:
|
||||
@ -465,7 +464,6 @@ needed import celery task. This is how the WebDAV celery task currently works.
|
||||
This would also be an opporunity to clean up the code around WebDAV imports
|
||||
and make them more re-usable for other import services.
|
||||
|
||||
|
||||
** TODO [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8
|
||||
@ -477,7 +475,6 @@ Certain artists like "Simon & Garfunkel" are actually one artist. While we don't
|
||||
tracks into featured artists, we should have a "LITERAL_ARTIST_TITLES" constant that can have exceptions like
|
||||
this put into it and then we stop trying to pull the artist apart when we run into it.
|
||||
|
||||
|
||||
** TODO [#A] Before enriching anything, trust the POST data :feature:scrobbles:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0
|
||||
@ -509,15 +506,178 @@ log a warning and move on.
|
||||
We should have a global view `/favorites/` that shows the logged in users's
|
||||
favorited media objects.
|
||||
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
: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
|
||||
|
||||
** TODO [#C] Show time per scrobble in long play lists and total time playing :templates:longplay:scrobbles:
|
||||
* 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:
|
||||
|
||||
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.2"
|
||||
version = "52.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ class BeerAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
raw_id_fields = ("styles", "producer")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
|
||||
@ -27,4 +27,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
||||
raw_id_fields = ("user",)
|
||||
ordering = ("-created",)
|
||||
|
||||
@ -38,6 +38,7 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"geo_location",
|
||||
)
|
||||
raw_id_fields = ("geo_location",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@ -49,6 +50,7 @@ class BoardGameAdmin(admin.ModelAdmin):
|
||||
"title",
|
||||
"published_year",
|
||||
)
|
||||
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -27,6 +27,7 @@ class BookAdmin(admin.ModelAdmin):
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
)
|
||||
raw_id_fields = ("authors",)
|
||||
search_fields = ("name",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
@ -34,11 +35,11 @@ class BookAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
def issue_or_volume(self, obj):
|
||||
return obj.issue_number or obj.volume_number
|
||||
return obj.subtitle
|
||||
|
||||
|
||||
@admin.register(Paper)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
class PaperAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
@ -47,6 +48,7 @@ class BookAdmin(admin.ModelAdmin):
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
)
|
||||
raw_id_fields = ("authors",)
|
||||
search_fields = ("name",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -344,13 +344,16 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
|
||||
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
||||
for media completion"""
|
||||
for media completion.
|
||||
|
||||
Uses the long_play_last_scrobble FK chain to accumulate time.
|
||||
Consider using the recompute_long_play_seconds management command instead.
|
||||
"""
|
||||
|
||||
for scrobble in scrobbles:
|
||||
# But if there's a next scrobble, set pages read to their starting page
|
||||
if scrobble.previous and not scrobble.previous.long_play_complete:
|
||||
if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:
|
||||
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
||||
scrobble.previous.long_play_seconds or 0
|
||||
scrobble.long_play_last_scrobble.long_play_seconds or 0
|
||||
)
|
||||
else:
|
||||
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||
|
||||
@ -160,7 +160,10 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def _enrich_book(self, book, sleep_secs):
|
||||
from books.sources.comicvine import lookup_comic_from_comicvine
|
||||
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
|
||||
|
||||
@ -168,13 +171,13 @@ class Command(BaseCommand):
|
||||
author_name = book.author.name if book.author else None
|
||||
book_dict = {}
|
||||
|
||||
is_comic = bool(book.readcomics_url) or (
|
||||
book.issue_number is not None or book.volume_number is not None
|
||||
)
|
||||
if is_comic and READCOMICSONLINE_URL in (book.readcomics_url or ""):
|
||||
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)
|
||||
if cv_data:
|
||||
book_dict.update(cv_data)
|
||||
|
||||
ol_data = lookup_book_from_ol(title, author=author_name)
|
||||
time.sleep(sleep_secs)
|
||||
@ -261,6 +264,14 @@ class Command(BaseCommand):
|
||||
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)}")
|
||||
@ -279,4 +290,12 @@ class Command(BaseCommand):
|
||||
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,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)
|
||||
@ -236,13 +239,17 @@ class Book(LongPlayScrobblableMixin):
|
||||
if not book_dict:
|
||||
return book
|
||||
|
||||
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()
|
||||
|
||||
genres = book_dict.get("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
@ -276,8 +283,10 @@ 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)
|
||||
@ -302,6 +311,16 @@ class Book(LongPlayScrobblableMixin):
|
||||
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(
|
||||
"No book found in any source, using data as is",
|
||||
@ -312,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:
|
||||
@ -331,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)
|
||||
@ -368,8 +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}")
|
||||
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}")
|
||||
@ -407,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)
|
||||
@ -418,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.
|
||||
# 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
|
||||
@ -197,6 +199,74 @@ 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
|
||||
@ -238,26 +308,113 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
title = found_result.get("name")
|
||||
data_dict = _build_data_dict_from_issue(found_result, original_title)
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
|
||||
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 {}
|
||||
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
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 found_result.get("image"):
|
||||
cover_url = found_result["image"].get("original_url")
|
||||
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"),
|
||||
"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": 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") or "")[:4],
|
||||
"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")
|
||||
|
||||
@ -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,146 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Backfill long_play_last_scrobble FK chains, then recompute "
|
||||
"long_play_seconds by walking backward through the chain."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--media-type",
|
||||
type=str,
|
||||
help="Only process a specific media type (e.g., Book, VideoGame)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options["dry_run"]
|
||||
media_type = options.get("media_type")
|
||||
|
||||
media_types = list(LONG_PLAY_MEDIA.values())
|
||||
if media_type:
|
||||
if media_type not in media_types:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Invalid media type '{media_type}'. "
|
||||
f"Valid: {', '.join(media_types)}"
|
||||
)
|
||||
)
|
||||
return
|
||||
media_types = [media_type]
|
||||
|
||||
# Step 1: backfill long_play_last_scrobble
|
||||
self.stdout.write("Step 1: Backfilling long_play_last_scrobble chains...")
|
||||
total_backfilled = 0
|
||||
for mt in media_types:
|
||||
n = self._backfill_chain(mt, dry_run)
|
||||
total_backfilled += n
|
||||
self.stdout.write(f" {mt}: {n} scrobbles linked")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Would backfill {total_backfilled} scrobbles total. "
|
||||
"Run without --dry-run to apply."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Backfilled {total_backfilled} scrobbles")
|
||||
)
|
||||
|
||||
# Step 2: recompute long_play_seconds
|
||||
self.stdout.write(
|
||||
"\nStep 2: Recomputing long_play_seconds via FK chain..."
|
||||
)
|
||||
|
||||
scrobbles = Scrobble.objects.filter(
|
||||
media_type__in=media_types,
|
||||
playback_position_seconds__isnull=False,
|
||||
).order_by("-timestamp")
|
||||
|
||||
total = scrobbles.count()
|
||||
self.stdout.write(f" Found {total} long play scrobbles to process")
|
||||
|
||||
to_update = []
|
||||
for scrobble in scrobbles.iterator():
|
||||
accumulated = scrobble.playback_position_seconds or 0
|
||||
current = scrobble.long_play_last_scrobble
|
||||
while current and not current.long_play_complete:
|
||||
accumulated += current.playback_position_seconds or 0
|
||||
current = current.long_play_last_scrobble
|
||||
|
||||
if scrobble.long_play_seconds != accumulated:
|
||||
self.stdout.write(
|
||||
f" Scrobble {scrobble.id} ({scrobble.media_type}): "
|
||||
f"{scrobble.long_play_seconds or 0} -> {accumulated}"
|
||||
)
|
||||
if not dry_run:
|
||||
scrobble.long_play_seconds = accumulated
|
||||
to_update.append(scrobble)
|
||||
|
||||
if to_update:
|
||||
Scrobble.objects.bulk_update(to_update, ["long_play_seconds"])
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Dry run: would update {len(to_update)} scrobbles. "
|
||||
"Use without --dry-run to apply."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {len(to_update)} scrobbles")
|
||||
)
|
||||
|
||||
def _backfill_chain(self, media_type: str, dry_run: bool) -> int:
|
||||
"""Set long_play_last_scrobble on each scrobble to the previous
|
||||
scrobble for the same media+user using a single UPDATE with a
|
||||
correlated subquery."""
|
||||
fk = _media_type_to_fk(media_type)
|
||||
table = Scrobble._meta.db_table
|
||||
|
||||
if dry_run:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) FROM {table} "
|
||||
f"WHERE long_play_last_scrobble_id IS NULL "
|
||||
f"AND {fk}_id IS NOT NULL"
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"UPDATE {table} "
|
||||
f"SET long_play_last_scrobble_id = ("
|
||||
f" SELECT id FROM {table} AS prev "
|
||||
f" WHERE prev.{fk}_id = {table}.{fk}_id "
|
||||
f" AND prev.user_id = {table}.user_id "
|
||||
f" AND prev.timestamp < {table}.timestamp "
|
||||
f" ORDER BY prev.timestamp DESC LIMIT 1"
|
||||
f") "
|
||||
f"WHERE long_play_last_scrobble_id IS NULL "
|
||||
f"AND {fk}_id IS NOT NULL"
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def _media_type_to_fk(media_type):
|
||||
mapping = {
|
||||
"VideoGame": "video_game",
|
||||
"Book": "book",
|
||||
"BrickSet": "brick_set",
|
||||
"Task": "task",
|
||||
}
|
||||
return mapping.get(media_type)
|
||||
@ -0,0 +1,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()
|
||||
)
|
||||
|
||||
@ -612,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(
|
||||
@ -638,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(
|
||||
@ -764,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 = [
|
||||
@ -778,6 +815,7 @@ class Scrobble(TimeStampedModel):
|
||||
"is_paused",
|
||||
]
|
||||
),
|
||||
models.Index(fields=["user", "-timestamp"]),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@ -810,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)
|
||||
@ -829,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
|
||||
@ -1147,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
|
||||
@ -1463,7 +1504,25 @@ class Scrobble(TimeStampedModel):
|
||||
if media.calories:
|
||||
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
||||
|
||||
if mtype not in LONG_PLAY_MEDIA.values():
|
||||
scrobble_data.pop("long_play_complete", None)
|
||||
|
||||
scrobble = cls.create(scrobble_data)
|
||||
|
||||
if mtype in LONG_PLAY_MEDIA.values():
|
||||
last_finished = (
|
||||
cls.objects.filter(
|
||||
models.Q(**{key: media}),
|
||||
user_id=user_id,
|
||||
timestamp__lt=scrobble.timestamp,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_finished:
|
||||
scrobble.long_play_last_scrobble = last_finished
|
||||
scrobble.save(update_fields=["long_play_last_scrobble"])
|
||||
|
||||
return scrobble
|
||||
|
||||
@classmethod
|
||||
@ -1722,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
|
||||
|
||||
|
||||
@ -909,6 +909,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 +939,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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<th scope="col">Scrobbles</th>
|
||||
<th scope="col">Complete</th>
|
||||
<th scope="col">Start</th>
|
||||
<th scope="col">Finish</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -19,8 +20,9 @@
|
||||
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
|
||||
{% if request.user.is_authenticated %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
@ -61,7 +61,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>
|
||||
|
||||
@ -44,7 +44,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 %}
|
||||
|
||||
@ -67,6 +67,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 +77,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>
|
||||
|
||||
@ -83,7 +83,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>
|
||||
|
||||
Reference in New Issue
Block a user