Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cc559752b | |||
| db3f9696fa | |||
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 | |||
| 52adcf83c7 | |||
| 0061623f7e | |||
| ec73e5151e | |||
| 2c90dd38b5 | |||
| c6b1e42d7a | |||
| fcf86d5b3f | |||
| 6fde9ec8d2 | |||
| 0f1882b21f | |||
| e819a2db0d | |||
| e03cf6c9b1 | |||
| 471e70ff7f | |||
| 255e335d7a | |||
| c8cf80b513 | |||
| b4180afbed | |||
| 37112babbb | |||
| fb775f2f58 | |||
| b26470c279 | |||
| d3b9ec815b | |||
| 19f2b5e801 | |||
| 9e3288a5ff | |||
| 06465919dd | |||
| 253e58eb48 | |||
| 5393996e47 | |||
| 1624f01e11 | |||
| 535dead7e8 | |||
| 3b97d49227 | |||
| ea7b0946bb | |||
| b8384166de | |||
| d2705758c6 | |||
| f4368c31f3 | |||
| 57f273b0cc | |||
| ac82292200 | |||
| 6a8432c08f | |||
| 5a2c41155c | |||
| 83a046111b | |||
| ab10758f40 | |||
| 88f16f0aaa | |||
| c1744fab37 | |||
| 042a3eb737 | |||
| 01d25e1b55 | |||
| c0be131e3d | |||
| 7d3f615ed7 | |||
| c2138b3ac6 | |||
| 947713d44a | |||
| 12b76837a3 | |||
| 102494ede7 | |||
| 96bda8d4ad | |||
| 46956d06d8 | |||
| 8a28d0675b | |||
| 5f6e75b14e | |||
| a96a42cdbf | |||
| c7f5d7d384 |
@ -15,6 +15,8 @@ ro class method should call the utility function.
|
|||||||
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||||
isort expectations.
|
isort expectations.
|
||||||
|
|
||||||
|
Imports in python files should always be top level if possible.
|
||||||
|
|
||||||
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
|
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
|
||||||
|
|
||||||
In local development, environment variables for various sensitive values live in a .envrc file
|
In local development, environment variables for various sensitive values live in a .envrc file
|
||||||
|
|||||||
467
PROJECT.org
467
PROJECT.org
@ -88,8 +88,8 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/14] :vrobbler:project:personal:
|
* Backlog [0/22] :vrobbler:project:personal:
|
||||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||||
:END:
|
:END:
|
||||||
@ -387,11 +387,11 @@ fetching and simple saving.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#+end_src
|
#+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:
|
:PROPERTIES:
|
||||||
:ID: 133bcf71-078f-4efa-a029-1eae4b4d146d
|
:ID: 133bcf71-078f-4efa-a029-1eae4b4d146d
|
||||||
:END:
|
:END:
|
||||||
** TODO [#C] Fix exporting so it works reliably :exporting:project:feature:
|
** TODO [#C] Fix exporting so it works reliably :exporting:feature:
|
||||||
|
|
||||||
*** Description
|
*** 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
|
And this should all be done in a celery task that is just kicked off by the
|
||||||
"Export" button on the frontend
|
"Export" button on the frontend
|
||||||
|
|
||||||
|
** TODO [#B] Add AllTrails as a source for Trail data :trails:feature:
|
||||||
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
||||||
:END:
|
: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
|
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.
|
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:
|
:PROPERTIES:
|
||||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||||
:END:
|
:END:
|
||||||
@ -440,7 +439,7 @@ added.
|
|||||||
They should also probably support markdown formatting and that should be
|
They should also probably support markdown formatting and that should be
|
||||||
displayed in the template.
|
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
|
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:
|
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||||
:PROPERTIES:
|
: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
|
This would also be an opporunity to clean up the code around WebDAV imports
|
||||||
and make them more re-usable for other import services.
|
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:
|
** TODO [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8
|
: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
|
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.
|
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:
|
** TODO [#A] Before enriching anything, trust the POST data :feature:scrobbles:metadata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0
|
:ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0
|
||||||
@ -509,15 +506,455 @@ log a warning and move on.
|
|||||||
We should have a global view `/favorites/` that shows the logged in users's
|
We should have a global view `/favorites/` that shows the logged in users's
|
||||||
favorited media objects.
|
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:
|
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||||
:END:
|
: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] 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 [#A] Deduplicate BGG plays before posting :boardgames:bgg:duplication:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: e9b842bf-0049-42e7-a060-f3ebd0067d2f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
No check for existing BGG plays before posting, which can create duplicates.
|
||||||
|
Should look up past plays by =bggeek_id= first.
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/boardgames/bgg.py~ (line 117)
|
||||||
|
|
||||||
|
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
We should rename `email_scrobble_board_game` to reflect the fact that it's just
|
||||||
|
a helper method to create board game scrobbles given a json blob. It's
|
||||||
|
independent of the email flow it was originally creatdd for
|
||||||
|
|
||||||
|
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||||
|
|
||||||
|
* Version 55.5 [1/1]
|
||||||
|
** DONE [#B] Fix bug in lastfm import for new users :importers:lastfm:music:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: d034966d-0c7f-e512-4cf8-7329c9026b6f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.4 [1/1]
|
||||||
|
** DONE [#A] Tighten up the speed of startup and first request :perf:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9ee8834c-6be2-d04b-df6d-56375504083f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.3 [3/3]
|
||||||
|
** DONE [#C] =alt_names= feature for artists (commented out / dead code) :music:dead-code:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: e22060a2-5f7a-4f33-9056-309ecd27159c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/music/models.py~ (line 236)
|
||||||
|
|
||||||
|
An entire block of code for tracking alternate artist names is commented
|
||||||
|
out. The TODO questions whether it even works. Review: either implement
|
||||||
|
properly or remove the dead code.
|
||||||
|
|
||||||
|
** DONE [#A] Put chart rebuilds in a lower priority task queue :charts:tasks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 43c90de0-fc1c-1139-dac7-9b7c82006b2e
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Check for existing book scrobble and update page count :books:scrobbling:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1a0609bc-6b16-4da4-96c1-59588229e4b4
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/scrobbles/scrobblers.py~ (line 330)
|
||||||
|
|
||||||
|
When scrobbling a book (comic), the code doesn't check for prior scrobbles to
|
||||||
|
update reading progress. Needed for proper page-count tracking.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.2 [2/2]
|
||||||
|
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8cb34852-b18f-e794-cd9b-fb1ecad70a0d
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Video game cleanup script should clear out broken images :metadata:videogames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: ca1f1ea9-0f79-082c-5ff7-867671faff4b
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.1 [1/1]
|
||||||
|
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fbc421b5-21a3-4aed-9062-c59192ead065
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.0 [3/3]
|
||||||
|
** DONE [#B] Use pk ID for scrobble detail view, not uuid :scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9cc3b285-e478-041e-394b-3d550aefbe1d
|
||||||
|
:END:
|
||||||
|
** DONE [#B] Display videogame screenshots on scrobble detail if they exist :videogames:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0406d082-20f6-0d12-76e2-f281c4801468
|
||||||
|
:END:
|
||||||
|
** DONE [#B] Add autotagging to webpages based on domain, title :webpages:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: f658435b-f7a0-42e6-b9f6-226678a77a55
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
For easier filtering, like we do with tasks, we should auto tag WebPage instances
|
||||||
|
based on the domain name split part by periods (so news.ycombinator.com tags: news, ycombinator, com)
|
||||||
|
|
||||||
|
And also based on the nouns in the title.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 54.5 [1/1]
|
||||||
|
** DONE Fix bug in generating mood trends :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8e75abfa-8e70-d85b-00a4-a4813bbce879
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.4 [2/2]
|
||||||
|
** DONE [#A] Remove all-time trends :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 53b231d1-7677-8cd3-1d88-dae110aba1e6
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
All time trends take forever to calculate and don't provide too much data
|
||||||
|
|
||||||
|
** DONE [#B] Add a trend around moods :moods:trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fba3f4ae-8f97-ee0b-e762-31630884518a
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.3 [1/1]
|
||||||
|
** DONE [#B] Fix bug in series metadata cleanup script :videos:metadta:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 85448702-907c-5d63-f5af-7795661d7c46
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.2 [4/4]
|
||||||
|
** DONE [#B] Add script to clean up TV series metadata :videos:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: a468b328-59d9-f84b-9ddb-087216783453
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Update youtube video detail pages with links to channel :videos:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8b87cb42-09e5-a3f5-136f-182f967fa81f
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Concurrent reading trend does not consolidate on single book :trends:reading:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fe220f55-7e0d-2a17-2477-a5aa7c4a1f2c
|
||||||
|
:END:
|
||||||
|
** DONE [#B] Trends dont seem to look very far back :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: ffcfba3f-5a93-9ee0-9680-666e6eccd684
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Specificially, looking at reading-pace when run on prod, it claims that I've
|
||||||
|
only had one reading session without music. Which may be true, but perhaps we
|
||||||
|
need to indicate what the time frame we're looking at is (month, week, year)
|
||||||
|
and provide a way to jump back and forward through time, same as charts.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 54.1 [1/1]
|
||||||
|
** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 4aa3b719-6b22-cae9-85f0-fac67b4fc753
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.0 [3/3]
|
||||||
|
** DONE [#B] Add peak hour, weekly rhythm and activity dist trends :trends:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 5fa52fac-d5f0-4369-bcaa-589c886b07d3
|
||||||
|
:END:
|
||||||
|
|
||||||
|
** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1d3beafd-62cb-4735-a465-edb37bf885db
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
File: ~vrobbler/apps/videos/models.py~ (line 140)
|
||||||
|
|
||||||
|
=Video.fix_metadata()= is a stub that logs "Not implemented yet" and returns.
|
||||||
|
Needs actual implementation to scrape channel metadata from YouTube.
|
||||||
|
|
||||||
|
** DONE [#A] Fix Amazon book scraper :amazon:scraper:broken:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c38aba25-0171-49ab-a9f3-acf2003da429
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/books/amazon.py~ (line 56)
|
||||||
|
|
||||||
|
The =scrape_data_from_amazon()= function is likely broken due to Amazon blocking
|
||||||
|
scrapers and changing HTML structure. Needs rewrite or replacement with a proper
|
||||||
|
API.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 53.1 [1/1]
|
||||||
|
** DONE [#A] Error with loading logdict :scrobbles:bug:logdata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 92d4fa16-4b90-47e0-95ae-472bdca582ce
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 53.0 [5/5]
|
||||||
|
** DONE [#B] Add a /trends/ page that shows trends based on scrobble data :feature:trends:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 03e9fe30-2bc6-4062-bb24-e95b98daf05b
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** 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.
|
||||||
|
|
||||||
|
Trends could be things like doing multiple things at the same time, like while driving, what
|
||||||
|
did we listen to this week, or while running, what were listening to this week?
|
||||||
|
|
||||||
|
Or more complicated trends like, how time per page changes based on the book I was reading, or if I was doing something else (music or sport event) while reading.
|
||||||
|
|
||||||
|
** DONE [#B] Notify users when Last.fm import completes :importers:notifications:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 92846b36-54c5-4b78-9c57-bdc401045fbe
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
After a bulk import from Last.fm, users receive no confirmation. Should add a notification (in-app, email, or similar).
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/scrobbles/importers/lastfm.py~ (line 96)
|
||||||
|
|
||||||
|
** DONE [#C] Cleaner =GeoLocationLogData= deserialization :models:refactoring:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 85465dbf-69b3-48cb-9df0-cd076c4470ab
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
Currently special-cases =GeoLocationLogData= by reaching into a nested ="movement_detection"= key. Should be handled at the LogData dataclass level.
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/scrobbles/models.py~ (line 977)
|
||||||
|
|
||||||
|
** DONE [#B] Webpage scrobbles should diff existing webpages content :webpages:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 25576197-258f-48d6-bfe9-e4172a0a1898
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Webpages change content between scrobbles. The current model stores the webpage content once, the
|
||||||
|
first time it's scrobbled. When a page has been seen before, we should move the existing content
|
||||||
|
to a new model HistoricalWebPage with the following fields:
|
||||||
|
|
||||||
|
webpage_id -> FK to WebPage
|
||||||
|
date -> date from existing WebPage content
|
||||||
|
domain -> same as existing WebPage content
|
||||||
|
extract -> copy of existing WebPage content
|
||||||
|
|
||||||
|
Once the HistoricalWebPage instance is successfully created, the new extract data
|
||||||
|
should be saved into the WebPage instance.
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#B] Make ArchiveBox push asynchronous :archivebox:async:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 17c116a7-5952-db37-e56c-2987c2fc456b
|
||||||
|
:END:
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
=push_to_archivebox()= runs synchronously during the request. Should be moved to a
|
||||||
|
Celery task or similar background worker.
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/webpages/models.py~ (line 133)
|
||||||
|
|
||||||
|
|
||||||
|
* 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]
|
* Version 51.2 [2/2]
|
||||||
** DONE [#A] Fix bug where last page of book gets separate scrobble :bug:books:importers:koreader:
|
** DONE [#A] Fix bug where last page of book gets separate scrobble :bug:books:importers:koreader:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
2
Procfile
2
Procfile
@ -1,2 +1,2 @@
|
|||||||
web: python manage.py runserver 0.0.0.0:8014
|
web: python manage.py runserver 0.0.0.0:8014
|
||||||
worker: celery -A vrobbler worker -l DEBUG
|
worker: celery -A vrobbler worker -Q default,charts -l DEBUG
|
||||||
|
|||||||
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.
25
poetry.lock
generated
25
poetry.lock
generated
@ -4270,6 +4270,29 @@ six = "*"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
testing = ["filelock"]
|
testing = ["filelock"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-amazon-paapi"
|
||||||
|
version = "6.3.0"
|
||||||
|
description = "Amazon Product Advertising API 5.0 wrapper for Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "python_amazon_paapi-6.3.0-py3-none-any.whl", hash = "sha256:b7cd852084a49d53c3ba2195531fccbc8c7f4124b2e82e2fda02b53d3b8de521"},
|
||||||
|
{file = "python_amazon_paapi-6.3.0.tar.gz", hash = "sha256:e525d69efcbe4f9566ec2b9b43fa3183c484d166d3852edb38b4df9c0b19cf1f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
certifi = ">=2023.0.0"
|
||||||
|
pydantic = ">=2.0.0"
|
||||||
|
python-dateutil = ">=2.8.0"
|
||||||
|
requests = ">=2.28.0"
|
||||||
|
six = ">=1.16.0"
|
||||||
|
urllib3 = ">=1.26.0,<3"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
async = ["httpx (>=0.27.0)", "typing-extensions (>=4.15.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
@ -6032,4 +6055,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.11,<3.15"
|
python-versions = ">=3.11,<3.15"
|
||||||
content-hash = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"
|
content-hash = "aafab54d3c3d674b917782bf449b7d6324ca2259fb58bff13a08caabe110c342"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "51.2"
|
version = "55.5"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
@ -64,6 +64,7 @@ fitparse = "^1.2.0"
|
|||||||
lxml = ">=5.5.0"
|
lxml = ">=5.5.0"
|
||||||
vaderSentiment = "^3.3.2"
|
vaderSentiment = "^3.3.2"
|
||||||
sqids = "^0.5.2"
|
sqids = "^0.5.2"
|
||||||
|
python-amazon-paapi = "^6.3.0"
|
||||||
|
|
||||||
[tool.poetry.group.test]
|
[tool.poetry.group.test]
|
||||||
optional = true
|
optional = true
|
||||||
|
|||||||
@ -519,7 +519,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
|||||||
"description": "Test description",
|
"description": "Test description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "First note" in response.content.decode()
|
assert "First note" in response.content.decode()
|
||||||
@ -545,7 +545,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
|||||||
"description": "Test description",
|
"description": "Test description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@ -574,7 +574,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
|||||||
"description": "Test description",
|
"description": "Test description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@ -597,7 +597,7 @@ def test_scrobble_detail_view_post_updates_log(client):
|
|||||||
"description": "Original description",
|
"description": "Original description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@ -896,7 +896,7 @@ def test_change_visibility_owner_can_change(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url, {"visibility": "shared"})
|
response = client.post(url, {"visibility": "shared"})
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
@ -919,7 +919,7 @@ def test_change_visibility_non_owner_gets_404(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(other)
|
client.force_login(other)
|
||||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url, {"visibility": "shared"})
|
response = client.post(url, {"visibility": "shared"})
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@ -937,7 +937,7 @@ def test_change_visibility_anonymous_redirects_to_login(client):
|
|||||||
task=task, media_type="Task", user=user, visibility="private",
|
task=task, media_type="Task", user=user, visibility="private",
|
||||||
timestamp=timezone.now(),
|
timestamp=timezone.now(),
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url, {"visibility": "shared"})
|
response = client.post(url, {"visibility": "shared"})
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert "/login/" in response.url
|
assert "/login/" in response.url
|
||||||
@ -956,7 +956,7 @@ def test_regenerate_share_token_invalidates_old_sqid(client):
|
|||||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url)
|
response = client.post(url)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
@ -985,7 +985,7 @@ def test_share_analytics_owner_can_view(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@ -1005,7 +1005,7 @@ def test_share_analytics_non_owner_gets_404(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(other)
|
client.force_login(other)
|
||||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@ -1027,7 +1027,7 @@ def test_share_analytics_shows_view_logs(client):
|
|||||||
client.get(share_url)
|
client.get(share_url)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
|
|||||||
@ -4,9 +4,15 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from vrobbler import context_processors
|
||||||
from vrobbler.context_processors import version_info
|
from vrobbler.context_processors import version_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_git_cache():
|
||||||
|
context_processors._GIT_COMMIT = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_request():
|
def mock_request():
|
||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|||||||
@ -27,6 +27,7 @@ class BeerAdmin(admin.ModelAdmin):
|
|||||||
"uuid",
|
"uuid",
|
||||||
"title",
|
"title",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("styles", "producer")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -27,4 +27,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
|||||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
||||||
|
raw_id_fields = ("user",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
|||||||
"uuid",
|
"uuid",
|
||||||
"geo_location",
|
"geo_location",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("geo_location",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ class BoardGameAdmin(admin.ModelAdmin):
|
|||||||
"title",
|
"title",
|
||||||
"published_year",
|
"published_year",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -27,6 +27,7 @@ class BookAdmin(admin.ModelAdmin):
|
|||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("authors",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
@ -34,11 +35,11 @@ class BookAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def issue_or_volume(self, obj):
|
def issue_or_volume(self, obj):
|
||||||
return obj.issue_number or obj.volume_number
|
return obj.subtitle
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Paper)
|
@admin.register(Paper)
|
||||||
class BookAdmin(admin.ModelAdmin):
|
class PaperAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
@ -47,6 +48,7 @@ class BookAdmin(admin.ModelAdmin):
|
|||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("authors",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import requests
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
USER_AGENT = "Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
|
||||||
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonAttribute(Enum):
|
|
||||||
SERIES = 0
|
|
||||||
PAGES = 1
|
|
||||||
LANGUAGE = 2
|
|
||||||
PUBLISHER = 3
|
|
||||||
PUB_DATE = 4
|
|
||||||
DIMENSIONS = 5
|
|
||||||
ISBN_10 = 6
|
|
||||||
ISBN_13 = 7
|
|
||||||
|
|
||||||
|
|
||||||
def strip_and_clean(text):
|
|
||||||
return text.strip("\n").rstrip().lstrip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_rating_from_soup(soup) -> Optional[int]:
|
|
||||||
rating = None
|
|
||||||
try:
|
|
||||||
potential_rating = soup.find("div", class_="allmusic-rating")
|
|
||||||
if potential_rating:
|
|
||||||
rating = int(strip_and_clean(potential_rating.get_text()))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return rating
|
|
||||||
|
|
||||||
|
|
||||||
def get_review_from_soup(soup) -> str:
|
|
||||||
review = ""
|
|
||||||
try:
|
|
||||||
potential_text = soup.find("div", class_="text")
|
|
||||||
if potential_text:
|
|
||||||
review = strip_and_clean(potential_text.get_text())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return review
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_data_from_amazon(url) -> dict:
|
|
||||||
data_dict = {}
|
|
||||||
headers = {"User-Agent": USER_AGENT}
|
|
||||||
r = requests.get(url, headers=headers)
|
|
||||||
if r.status_code == 200:
|
|
||||||
soup = BeautifulSoup(r.text, "html.parser")
|
|
||||||
# TODO Fix this scraper
|
|
||||||
data_dict["rating"] = get_rating_from_soup(soup)
|
|
||||||
data_dict["review"] = get_review_from_soup(soup)
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
|
|
||||||
def get_amazon_product_dict(amazon_id: str) -> dict:
|
|
||||||
data_dict = {}
|
|
||||||
url = ""
|
|
||||||
|
|
||||||
search_url = AMAZON_SEARCH_URL.format(amazon_id=amazon_id)
|
|
||||||
headers = {
|
|
||||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
|
||||||
"accept-language": "en-GB,en;q=0.9",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(search_url, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
logger.info(f"Bad http response from Amazon {response}")
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
results = soup.find("a", class_="a-link-normal")
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
logger.info(f"No search results for {amazon_id}")
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
product_url = "https://www.amazon.com" + str(results.get("href", ""))
|
|
||||||
|
|
||||||
data_dict = {}
|
|
||||||
response = requests.get(product_url, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
logger.info(f"Bad http response from Amazon {response}")
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
try:
|
|
||||||
data_dict["title"] = soup.findAll("span", class_="celwidget")[1].text.strip()
|
|
||||||
data_dict["cover_url"] = soup.find("img", class_="frontImage").get("src")
|
|
||||||
data_dict["summary"] = soup.findAll("div", class_="a-expander-content")[1].text
|
|
||||||
meta = soup.findAll("div", class_="rpi-attribute-value")
|
|
||||||
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
|
|
||||||
pages = meta[AmazonAttribute.PAGES.value].text
|
|
||||||
if "pages" in pages:
|
|
||||||
data_dict["pages"] = (
|
|
||||||
meta[AmazonAttribute.PAGES.value].text.split("pages")[0].strip()
|
|
||||||
)
|
|
||||||
except IndexError as e:
|
|
||||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
|
||||||
except AttributeError as e:
|
|
||||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
|
||||||
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
|
|
||||||
def lookup_book_from_amazon(amazon_id: str) -> dict:
|
|
||||||
top = {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": top.get("title"),
|
|
||||||
"isbn": isbn,
|
|
||||||
"openlibrary_id": ol_id,
|
|
||||||
"goodreads_id": get_first("id_goodreads", top),
|
|
||||||
"first_publish_year": top.get("first_publish_year"),
|
|
||||||
"first_sentence": first_sentence,
|
|
||||||
"pages": top.get("number_of_pages_median", None),
|
|
||||||
"cover_url": COVER_URL.format(id=ol_id),
|
|
||||||
"ol_author_id": ol_author_id,
|
|
||||||
"subject_key_list": top.get("subject_key", []),
|
|
||||||
}
|
|
||||||
@ -17,6 +17,7 @@ class MediaSourceTag(str, Enum):
|
|||||||
LOCG = "source_locg"
|
LOCG = "source_locg"
|
||||||
KOREADER = "source_koreader"
|
KOREADER = "source_koreader"
|
||||||
SEMANTIC_SCHOLAR = "source_semantic_scholar"
|
SEMANTIC_SCHOLAR = "source_semantic_scholar"
|
||||||
|
AMAZON = "source_amazon"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def choices(cls):
|
def choices(cls):
|
||||||
|
|||||||
@ -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:
|
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
"""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:
|
for scrobble in scrobbles:
|
||||||
# But if there's a next scrobble, set pages read to their starting page
|
if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:
|
||||||
if scrobble.previous and not scrobble.previous.long_play_complete:
|
|
||||||
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
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:
|
else:
|
||||||
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||||
|
|||||||
@ -160,7 +160,10 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _enrich_book(self, book, sleep_secs):
|
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.google import lookup_book_from_google
|
||||||
from books.sources.openlibrary import lookup_book_from_openlibrary as lookup_book_from_ol
|
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
|
author_name = book.author.name if book.author else None
|
||||||
book_dict = {}
|
book_dict = {}
|
||||||
|
|
||||||
is_comic = bool(book.readcomics_url) or (
|
cv_data = None
|
||||||
book.issue_number is not None or book.volume_number is not None
|
if book.comicvine_id:
|
||||||
)
|
cv_data = lookup_issue_by_comicvine_id(str(book.comicvine_id))
|
||||||
if is_comic and READCOMICSONLINE_URL in (book.readcomics_url or ""):
|
if not cv_data:
|
||||||
cv_data = lookup_comic_from_comicvine(title)
|
cv_data = lookup_comic_from_comicvine(title)
|
||||||
if cv_data:
|
if cv_data:
|
||||||
book_dict.update(cv_data)
|
book_dict.update(cv_data)
|
||||||
|
|
||||||
ol_data = lookup_book_from_ol(title, author=author_name)
|
ol_data = lookup_book_from_ol(title, author=author_name)
|
||||||
time.sleep(sleep_secs)
|
time.sleep(sleep_secs)
|
||||||
@ -261,6 +264,14 @@ class Command(BaseCommand):
|
|||||||
book.volume_number = data["volume_number"]
|
book.volume_number = data["volume_number"]
|
||||||
update_fields.append("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:
|
if update_fields:
|
||||||
book.save(update_fields=update_fields)
|
book.save(update_fields=update_fields)
|
||||||
self.stdout.write(f" [ENRICHED] {book} — {', '.join(update_fields)}")
|
self.stdout.write(f" [ENRICHED] {book} — {', '.join(update_fields)}")
|
||||||
@ -279,4 +290,12 @@ class Command(BaseCommand):
|
|||||||
book.genre.add(*new_genres)
|
book.genre.add(*new_genres)
|
||||||
self.stdout.write(f" [GENRES] {book} — added {len(new_genres)} 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
|
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,8 +20,10 @@ from books.openlibrary import (
|
|||||||
from books.sources.comicvine import (
|
from books.sources.comicvine import (
|
||||||
ComicVineClient,
|
ComicVineClient,
|
||||||
lookup_comic_from_comicvine,
|
lookup_comic_from_comicvine,
|
||||||
|
lookup_issue_by_comicvine_id,
|
||||||
)
|
)
|
||||||
from books.sources.google import lookup_book_from_google
|
from books.sources.google import lookup_book_from_google
|
||||||
|
from books.sources.amazon import lookup_book_from_amazon
|
||||||
from books.sources.openlibrary import (
|
from books.sources.openlibrary import (
|
||||||
lookup_book_from_openlibrary as lookup_book_from_ol,
|
lookup_book_from_openlibrary as lookup_book_from_ol,
|
||||||
)
|
)
|
||||||
@ -150,6 +152,8 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
first_sentence = models.TextField(**BNULL)
|
first_sentence = models.TextField(**BNULL)
|
||||||
# ComicVine
|
# ComicVine
|
||||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
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)
|
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||||
issue_number = models.IntegerField(**BNULL)
|
issue_number = models.IntegerField(**BNULL)
|
||||||
@ -221,7 +225,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def resume_start_url(self):
|
def resume_start_url(self):
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
|
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid}) + "?resume=1"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_from_comicvine(
|
def get_from_comicvine(
|
||||||
@ -236,13 +240,17 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
if not book_dict:
|
if not book_dict:
|
||||||
return book
|
return book
|
||||||
|
|
||||||
|
tags = book_dict.pop("tags", [])
|
||||||
|
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||||
|
|
||||||
for k, v in book_dict.items():
|
for k, v in book_dict.items():
|
||||||
setattr(book, k, v)
|
setattr(book, k, v)
|
||||||
book.save()
|
book.save()
|
||||||
|
|
||||||
genres = book_dict.get("genres", [])
|
|
||||||
if genres:
|
if genres:
|
||||||
book.genre.add(*genres)
|
book.genre.add(*genres)
|
||||||
|
if tags:
|
||||||
|
book.tags.add(*tags)
|
||||||
return book
|
return book
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -253,6 +261,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
url: str = "",
|
url: str = "",
|
||||||
enrich: bool = True,
|
enrich: bool = True,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
|
amazon_id: str | None = None,
|
||||||
):
|
):
|
||||||
"""Given a title, get a Book instance.
|
"""Given a title, get a Book instance.
|
||||||
|
|
||||||
@ -276,8 +285,10 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
book_dict = None
|
book_dict = None
|
||||||
source_tag = None
|
source_tag = None
|
||||||
|
tried_comicvine = False
|
||||||
if READCOMICSONLINE_URL in url:
|
if READCOMICSONLINE_URL in url:
|
||||||
book_dict = lookup_comic_from_comicvine(title)
|
book_dict = lookup_comic_from_comicvine(title)
|
||||||
|
tried_comicvine = True
|
||||||
if book_dict:
|
if book_dict:
|
||||||
source_tag = MediaSourceTag.COMICVINE
|
source_tag = MediaSourceTag.COMICVINE
|
||||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||||
@ -302,6 +313,23 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
if ol_data and ol_data.get("cover_url"):
|
if ol_data and ol_data.get("cover_url"):
|
||||||
book_dict["cover_url"] = ol_data["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
|
||||||
|
|
||||||
|
# Try Amazon PAAPI as a fallback when given an ASIN
|
||||||
|
if amazon_id and not book_dict:
|
||||||
|
amazon_data = lookup_book_from_amazon(amazon_id)
|
||||||
|
if amazon_data:
|
||||||
|
book_dict.update(amazon_data)
|
||||||
|
source_tag = MediaSourceTag.AMAZON
|
||||||
|
|
||||||
if not book_dict:
|
if not book_dict:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No book found in any source, using data as is",
|
"No book found in any source, using data as is",
|
||||||
@ -312,6 +340,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
authors = book_dict.pop("authors", [])
|
authors = book_dict.pop("authors", [])
|
||||||
cover_url = book_dict.pop("cover_url", "")
|
cover_url = book_dict.pop("cover_url", "")
|
||||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||||
|
tags = book_dict.pop("tags", [])
|
||||||
|
|
||||||
if authors:
|
if authors:
|
||||||
for author_str in authors:
|
for author_str in authors:
|
||||||
@ -331,6 +360,8 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
book.save_image_from_url(cover_url)
|
book.save_image_from_url(cover_url)
|
||||||
if genres:
|
if genres:
|
||||||
book.genre.add(*genres)
|
book.genre.add(*genres)
|
||||||
|
if tags:
|
||||||
|
book.tags.add(*tags)
|
||||||
book.authors.add(*author_list)
|
book.authors.add(*author_list)
|
||||||
if source_tag:
|
if source_tag:
|
||||||
book.tags.add(source_tag.value)
|
book.tags.add(source_tag.value)
|
||||||
@ -368,8 +399,14 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
data = lookup_comic_from_locg(str(self.title))
|
data = lookup_comic_from_locg(str(self.title))
|
||||||
|
|
||||||
if not data and COMICVINE_API_KEY:
|
if not data and COMICVINE_API_KEY:
|
||||||
logger.warn(f"Checking ComicVine for {self.title}")
|
if self.comicvine_id:
|
||||||
data = lookup_comic_from_comicvine(str(self.title))
|
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:
|
if not data:
|
||||||
logger.warn(f"Book not found in any sources: {self.title}")
|
logger.warn(f"Book not found in any sources: {self.title}")
|
||||||
@ -407,10 +444,10 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
)
|
)
|
||||||
data.pop("pages")
|
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", "")
|
cover_url = data.pop("cover_url", "")
|
||||||
|
|
||||||
subject_key_list = data.pop("subject_key_list", [])
|
subject_key_list = data.pop("subject_key_list", [])
|
||||||
|
tags = data.pop("tags", [])
|
||||||
|
|
||||||
# Fun trick for updating all fields at once
|
# Fun trick for updating all fields at once
|
||||||
Book.objects.filter(pk=self.id).update(**data)
|
Book.objects.filter(pk=self.id).update(**data)
|
||||||
@ -418,6 +455,8 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
if subject_key_list:
|
if subject_key_list:
|
||||||
self.genre.add(*subject_key_list)
|
self.genre.add(*subject_key_list)
|
||||||
|
if tags:
|
||||||
|
self.tags.add(*tags)
|
||||||
|
|
||||||
if cover_url:
|
if cover_url:
|
||||||
r = requests.get(cover_url)
|
r = requests.get(cover_url)
|
||||||
|
|||||||
123
vrobbler/apps/books/sources/amazon.py
Normal file
123
vrobbler/apps/books/sources/amazon.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
from amazon_paapi import AmazonApi
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_amazon_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> AmazonApi | None:
|
||||||
|
global _amazon_client
|
||||||
|
if _amazon_client is not None:
|
||||||
|
return _amazon_client
|
||||||
|
|
||||||
|
key = settings.AMAZON_PAAPI_ACCESS_KEY
|
||||||
|
secret = settings.AMAZON_PAAPI_SECRET_KEY
|
||||||
|
tag = settings.AMAZON_PAAPI_ASSOCIATE_TAG
|
||||||
|
country = settings.AMAZON_PAAPI_COUNTRY
|
||||||
|
|
||||||
|
if not all([key, secret, tag]):
|
||||||
|
logger.warning("Amazon PAAPI credentials not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_amazon_client = AmazonApi(key, secret, tag, country)
|
||||||
|
return _amazon_client
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_from_amazon(asin: str) -> dict:
|
||||||
|
book_dict: dict = {}
|
||||||
|
|
||||||
|
client = _get_client()
|
||||||
|
if not client:
|
||||||
|
return book_dict
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = client.get_items(
|
||||||
|
items=[asin],
|
||||||
|
Condition="New",
|
||||||
|
LanguagesOfPreference=["en_US"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Amazon PAAPI lookup failed for {asin}: {e}")
|
||||||
|
return book_dict
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
logger.info(f"No Amazon item found for {asin}")
|
||||||
|
return book_dict
|
||||||
|
|
||||||
|
item = items[0]
|
||||||
|
raw = item.to_dict()
|
||||||
|
item_info = raw.get("item_info", {}) or {}
|
||||||
|
|
||||||
|
book_dict["title"] = _get_nested(item_info, "title", "display_value")
|
||||||
|
if not book_dict.get("title"):
|
||||||
|
book_dict["title"] = _get_nested(item_info, "title", "value")
|
||||||
|
|
||||||
|
contributors = _get_nested(item_info, "by_line_info", "contributors") or []
|
||||||
|
authors = [
|
||||||
|
c["name"]
|
||||||
|
for c in contributors
|
||||||
|
if c.get("role", "").lower() in ("author", "artist", "writer")
|
||||||
|
]
|
||||||
|
if authors:
|
||||||
|
book_dict["authors"] = authors
|
||||||
|
|
||||||
|
publisher = _get_nested(item_info, "by_line_info", "manufacturer")
|
||||||
|
if publisher:
|
||||||
|
book_dict["publisher"] = publisher
|
||||||
|
|
||||||
|
isb_ns = _get_nested(item_info, "external_ids", "isb_ns")
|
||||||
|
if isb_ns and isinstance(isb_ns, list):
|
||||||
|
for isb in isb_ns:
|
||||||
|
if isinstance(isb, dict):
|
||||||
|
if isb.get("type") == "ISBN_13":
|
||||||
|
book_dict["isbn_13"] = isb.get("value")
|
||||||
|
elif isb.get("type") == "ISBN_10":
|
||||||
|
book_dict["isbn_10"] = isb.get("value")
|
||||||
|
|
||||||
|
pages_count = _get_nested(item_info, "content_info", "pages_count")
|
||||||
|
if pages_count and isinstance(pages_count, dict):
|
||||||
|
book_dict["pages"] = pages_count.get("value") or pages_count.get("display_value")
|
||||||
|
|
||||||
|
languages = _get_nested(item_info, "content_info", "languages") or []
|
||||||
|
if languages and isinstance(languages, list):
|
||||||
|
lang = languages[0]
|
||||||
|
if isinstance(lang, dict):
|
||||||
|
book_dict["language"] = lang.get("display_value") or lang.get("value")
|
||||||
|
|
||||||
|
pub_date = _get_nested(item_info, "content_info", "publication_date")
|
||||||
|
if not pub_date:
|
||||||
|
pub_date = _get_nested(item_info, "product_info", "release_date")
|
||||||
|
if pub_date and isinstance(pub_date, dict):
|
||||||
|
book_dict["publish_date"] = pub_date.get("display_value") or pub_date.get("value")
|
||||||
|
|
||||||
|
features = item_info.get("features") or []
|
||||||
|
if features and isinstance(features, list):
|
||||||
|
book_dict["summary"] = " ".join(features[:5])
|
||||||
|
|
||||||
|
images = raw.get("images", {}) or {}
|
||||||
|
primary = images.get("primary", {}) or {}
|
||||||
|
for size in ("large", "hi_res", "medium"):
|
||||||
|
candidate = primary.get(size, {}) or {}
|
||||||
|
url = candidate.get("url")
|
||||||
|
if url:
|
||||||
|
book_dict["cover_url"] = url
|
||||||
|
break
|
||||||
|
|
||||||
|
book_dict["detail_page_url"] = raw.get("detail_page_url")
|
||||||
|
|
||||||
|
return book_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nested(d: dict, *keys):
|
||||||
|
for key in keys:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return None
|
||||||
|
d = d.get(key)
|
||||||
|
return d
|
||||||
@ -17,8 +17,10 @@ class ComicVineClient(object):
|
|||||||
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
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/"
|
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
|
# 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
|
# be accepted, otherwise our request will be rejected with a
|
||||||
@ -197,6 +199,74 @@ class ComicVineClient(object):
|
|||||||
|
|
||||||
raise exception(message)
|
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:
|
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||||
original_title = title
|
original_title = title
|
||||||
@ -238,26 +308,113 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
|||||||
if not found_result:
|
if not found_result:
|
||||||
found_result = results[0]
|
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
|
cover_url = None
|
||||||
if found_result.get("image"):
|
if issue_data.get("image"):
|
||||||
cover_url = found_result["image"].get("original_url")
|
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 = {
|
data_dict = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"original_title": original_title,
|
"original_title": original_title,
|
||||||
"issue_number": found_result.get("issue_number"),
|
"issue_number": issue_data.get("issue_number"),
|
||||||
"volume_number": found_result.get("volume_number"),
|
"volume_number": issue_data.get("volume_number"),
|
||||||
|
"volume": volume_name,
|
||||||
|
"volume_comicvine_id": volume_cv_id,
|
||||||
|
"publisher": publisher_name,
|
||||||
"cover_url": cover_url,
|
"cover_url": cover_url,
|
||||||
"comicvine_id": found_result.get("id"),
|
"comicvine_id": issue_data.get("id"),
|
||||||
"comicvine_data": found_result,
|
"summary": issue_data.get("description"),
|
||||||
"summary": found_result.get("description"),
|
"publish_date": issue_data.get("cover_date"),
|
||||||
"publish_date": found_result.get("cover_date"),
|
"first_publish_year": (issue_data.get("cover_date") or "")[:4],
|
||||||
"first_publish_year": (found_result.get("cover_date") or "")[:4],
|
"tags": ["comicbook"],
|
||||||
}
|
}
|
||||||
|
|
||||||
return data_dict
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-19 16:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("charts", "0002_chartrecord_charts_char_user_id_1adcde_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("artist__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "artist"),
|
||||||
|
name="unique_chart_artist_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("album__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "album"),
|
||||||
|
name="unique_chart_album_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("track__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "track"),
|
||||||
|
name="unique_chart_track_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("tv_series__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "tv_series"),
|
||||||
|
name="unique_chart_tv_series_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("video__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "video"),
|
||||||
|
name="unique_chart_video_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("podcast__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "podcast"),
|
||||||
|
name="unique_chart_podcast_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("podcast_episode__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "podcast_episode"),
|
||||||
|
name="unique_chart_podcast_episode_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("board_game__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "board_game"),
|
||||||
|
name="unique_chart_board_game_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("trail__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "trail"),
|
||||||
|
name="unique_chart_trail_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("geo_location__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "geo_location"),
|
||||||
|
name="unique_chart_geo_location_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("food__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "food"),
|
||||||
|
name="unique_chart_food_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("book__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "book"),
|
||||||
|
name="unique_chart_book_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,6 +2,7 @@ import calendar
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
@ -60,10 +61,91 @@ class ChartRecord(TimeStampedModel):
|
|||||||
models.Index(fields=["user", "year", "geo_location", "rank"]),
|
models.Index(fields=["user", "year", "geo_location", "rank"]),
|
||||||
models.Index(fields=["user", "year", "food", "rank"]),
|
models.Index(fields=["user", "year", "food", "rank"]),
|
||||||
models.Index(fields=["user", "year", "book", "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", "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", "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"]),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "artist"],
|
||||||
|
condition=Q(artist__isnull=False),
|
||||||
|
name="unique_chart_artist_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "album"],
|
||||||
|
condition=Q(album__isnull=False),
|
||||||
|
name="unique_chart_album_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "track"],
|
||||||
|
condition=Q(track__isnull=False),
|
||||||
|
name="unique_chart_track_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "tv_series"],
|
||||||
|
condition=Q(tv_series__isnull=False),
|
||||||
|
name="unique_chart_tv_series_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "video"],
|
||||||
|
condition=Q(video__isnull=False),
|
||||||
|
name="unique_chart_video_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "podcast"],
|
||||||
|
condition=Q(podcast__isnull=False),
|
||||||
|
name="unique_chart_podcast_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "podcast_episode"],
|
||||||
|
condition=Q(podcast_episode__isnull=False),
|
||||||
|
name="unique_chart_podcast_episode_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "board_game"],
|
||||||
|
condition=Q(board_game__isnull=False),
|
||||||
|
name="unique_chart_board_game_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "trail"],
|
||||||
|
condition=Q(trail__isnull=False),
|
||||||
|
name="unique_chart_trail_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "geo_location"],
|
||||||
|
condition=Q(geo_location__isnull=False),
|
||||||
|
name="unique_chart_geo_location_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "food"],
|
||||||
|
condition=Q(food__isnull=False),
|
||||||
|
name="unique_chart_food_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "book"],
|
||||||
|
condition=Q(book__isnull=False),
|
||||||
|
name="unique_chart_book_period",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from typing import Optional
|
|||||||
import pytz
|
import pytz
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -186,60 +187,64 @@ def build_charts(
|
|||||||
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
||||||
|
|
||||||
media_field = f"{media_type}_id"
|
media_field = f"{media_type}_id"
|
||||||
records_to_create = []
|
|
||||||
records_to_update = []
|
|
||||||
|
|
||||||
existing = ChartRecord.objects.filter(
|
with transaction.atomic():
|
||||||
period_filter, user=user, **{media_field + "__isnull": False}
|
records_to_create = []
|
||||||
)
|
records_to_update = []
|
||||||
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
|
||||||
found_media_ids = set()
|
|
||||||
|
|
||||||
for result in results:
|
existing = ChartRecord.objects.select_for_update().filter(
|
||||||
media_id = result[config["values"]]
|
period_filter, user=user, **{media_field + "__isnull": False}
|
||||||
if media_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
found_media_ids.add(media_id)
|
|
||||||
|
|
||||||
chart_record_data = {
|
|
||||||
"user_id": user.id,
|
|
||||||
"year": year,
|
|
||||||
"month": month,
|
|
||||||
"week": week,
|
|
||||||
"day": day,
|
|
||||||
"rank": ranks[result["scrobble_count"]],
|
|
||||||
"count": result["scrobble_count"],
|
|
||||||
}
|
|
||||||
chart_record_data[media_field] = media_id
|
|
||||||
|
|
||||||
if media_id in existing_by_media_id:
|
|
||||||
existing_record = existing_by_media_id[media_id]
|
|
||||||
existing_record.rank = chart_record_data["rank"]
|
|
||||||
existing_record.count = chart_record_data["count"]
|
|
||||||
records_to_update.append(existing_record)
|
|
||||||
else:
|
|
||||||
records_to_create.append(ChartRecord(**chart_record_data))
|
|
||||||
|
|
||||||
ids_to_delete = [
|
|
||||||
r.id for r in existing if getattr(r, media_field) not in found_media_ids
|
|
||||||
]
|
|
||||||
if ids_to_delete:
|
|
||||||
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
|
||||||
|
|
||||||
if records_to_update:
|
|
||||||
ChartRecord.objects.bulk_update(
|
|
||||||
records_to_update, ["rank", "count"], batch_size=500
|
|
||||||
)
|
)
|
||||||
|
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
||||||
|
found_media_ids = set()
|
||||||
|
|
||||||
if records_to_create:
|
for result in results:
|
||||||
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
media_id = result[config["values"]]
|
||||||
|
if media_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(
|
found_media_ids.add(media_id)
|
||||||
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
|
||||||
f"chart records for {media_type}, period "
|
chart_record_data = {
|
||||||
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
"user_id": user.id,
|
||||||
)
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"week": week,
|
||||||
|
"day": day,
|
||||||
|
"rank": ranks[result["scrobble_count"]],
|
||||||
|
"count": result["scrobble_count"],
|
||||||
|
}
|
||||||
|
chart_record_data[media_field] = media_id
|
||||||
|
|
||||||
|
if media_id in existing_by_media_id:
|
||||||
|
existing_record = existing_by_media_id[media_id]
|
||||||
|
existing_record.rank = chart_record_data["rank"]
|
||||||
|
existing_record.count = chart_record_data["count"]
|
||||||
|
records_to_update.append(existing_record)
|
||||||
|
else:
|
||||||
|
records_to_create.append(ChartRecord(**chart_record_data))
|
||||||
|
|
||||||
|
ids_to_delete = [
|
||||||
|
r.id
|
||||||
|
for r in existing
|
||||||
|
if getattr(r, media_field) not in found_media_ids
|
||||||
|
]
|
||||||
|
if ids_to_delete:
|
||||||
|
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
||||||
|
|
||||||
|
if records_to_update:
|
||||||
|
ChartRecord.objects.bulk_update(
|
||||||
|
records_to_update, ["rank", "count"], batch_size=500
|
||||||
|
)
|
||||||
|
|
||||||
|
if records_to_create:
|
||||||
|
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
||||||
|
f"chart records for {media_type}, period "
|
||||||
|
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
||||||
|
|||||||
@ -438,12 +438,14 @@ class ChartRecordView(TemplateView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def get_available_years(self, user):
|
def get_available_years(self, user):
|
||||||
return list(
|
if not hasattr(self, "_available_years"):
|
||||||
ChartRecord.objects.filter(user=user)
|
self._available_years = list(
|
||||||
.values_list("year", flat=True)
|
ChartRecord.objects.filter(user=user)
|
||||||
.distinct()
|
.values_list("year", flat=True)
|
||||||
.order_by("-year")
|
.distinct()
|
||||||
)
|
.order_by("-year")
|
||||||
|
)
|
||||||
|
return self._available_years
|
||||||
|
|
||||||
def get_period_type(self):
|
def get_period_type(self):
|
||||||
date_param = self.request.GET.get("date")
|
date_param = self.request.GET.get("date")
|
||||||
|
|||||||
@ -21,6 +21,7 @@ class FoodAdmin(admin.ModelAdmin):
|
|||||||
"uuid",
|
"uuid",
|
||||||
"title",
|
"title",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("category",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -28,6 +28,14 @@ class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
|||||||
activity: str = ""
|
activity: str = ""
|
||||||
detected_at: str = ""
|
detected_at: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_log_dict(cls, log_dict: dict) -> dict:
|
||||||
|
instance_data = log_dict.get("movement_detection", {}).copy()
|
||||||
|
for field_name in ["description", "notes", "with_people_ids"]:
|
||||||
|
if field_name in log_dict:
|
||||||
|
instance_data[field_name] = log_dict[field_name]
|
||||||
|
return instance_data
|
||||||
|
|
||||||
|
|
||||||
class GeoLocation(ScrobblableMixin):
|
class GeoLocation(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from music.models import Artist, Album
|
from music.models import Artist, Album
|
||||||
|
|
||||||
|
CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def music_lists(request):
|
def music_lists(request):
|
||||||
|
artist_list = cache.get("music_lists_artist_list")
|
||||||
|
if artist_list is None:
|
||||||
|
artist_list = list(Artist.objects.all().only("id", "name"))
|
||||||
|
cache.set("music_lists_artist_list", artist_list, CACHE_TTL)
|
||||||
|
|
||||||
|
album_list = cache.get("music_lists_album_list")
|
||||||
|
if album_list is None:
|
||||||
|
album_list = list(Album.objects.all().only("id", "name"))
|
||||||
|
cache.set("music_lists_album_list", album_list, CACHE_TTL)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"artist_list": Artist.objects.all(),
|
"artist_list": artist_list,
|
||||||
"album_list": Album.objects.all(),
|
"album_list": album_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -236,19 +236,6 @@ class Artist(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
artist.fix_metadata()
|
artist.fix_metadata()
|
||||||
|
|
||||||
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
|
|
||||||
# If we did find our artist, but the found name is slightly differnt, record that
|
|
||||||
# if artist and alt_name:
|
|
||||||
# if not artist.alt_names:
|
|
||||||
# artist.alt_names = alt_name
|
|
||||||
# else:
|
|
||||||
# artist.alt_names += f"\\{alt_name}"
|
|
||||||
# logger.info(
|
|
||||||
# f"Add alt_name {alt_name} to artist {artist}",
|
|
||||||
# extra={"alt_name": alt_name, "artist_id": artist.id},
|
|
||||||
# )
|
|
||||||
# artist.save(update_fields=["alt_names"])
|
|
||||||
|
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
|
|
||||||
@ -600,8 +587,9 @@ class Track(ScrobblableMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} by {self.artist}"
|
return f"{self.title} by {self.artist}"
|
||||||
|
|
||||||
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return TrackLogData()
|
return TrackLogData
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_album(self):
|
def primary_album(self):
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from people.models import Person, PersonScrobble
|
|||||||
class PersonAdmin(admin.ModelAdmin):
|
class PersonAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "bgg_username", "bgstats_id")
|
list_display = ("name", "bgg_username", "bgstats_id")
|
||||||
|
raw_id_fields = ("user", "created_by")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class PodcastAdmin(admin.ModelAdmin):
|
|||||||
"producer",
|
"producer",
|
||||||
"active",
|
"active",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("producer",)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ class PodcastEpisodeAdmin(admin.ModelAdmin):
|
|||||||
"title",
|
"title",
|
||||||
"podcast",
|
"podcast",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("podcast",)
|
||||||
list_filter = ("podcast",)
|
list_filter = ("podcast",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from profiles.models import UserProfile
|
|||||||
@admin.register(UserProfile)
|
@admin.register(UserProfile)
|
||||||
class UserProfileAdmin(admin.ModelAdmin):
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
|
raw_id_fields = ("user",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
readonly_fields = ("timezone_change_log",)
|
readonly_fields = ("timezone_change_log",)
|
||||||
exclude = (
|
exclude = (
|
||||||
|
|||||||
@ -21,6 +21,7 @@ class PuzzleAdmin(admin.ModelAdmin):
|
|||||||
"uuid",
|
"uuid",
|
||||||
"title",
|
"title",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("manufacturer",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -19,35 +19,11 @@ from scrobbles.mixins import Genre
|
|||||||
class ScrobbleInline(admin.TabularInline):
|
class ScrobbleInline(admin.TabularInline):
|
||||||
model = Scrobble
|
model = Scrobble
|
||||||
extra = 0
|
extra = 0
|
||||||
raw_id_fields = (
|
per_page = 15
|
||||||
"video",
|
ordering = ("-timestamp",)
|
||||||
"channel",
|
show_change_link = True
|
||||||
"podcast_episode",
|
fields = ("timestamp", "media_type", "source", "in_progress")
|
||||||
"track",
|
readonly_fields = fields
|
||||||
"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",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImportBaseAdmin(admin.ModelAdmin):
|
class ImportBaseAdmin(admin.ModelAdmin):
|
||||||
@ -121,7 +97,9 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
raw_id_fields = (
|
raw_id_fields = (
|
||||||
|
"user",
|
||||||
"video",
|
"video",
|
||||||
|
"channel",
|
||||||
"podcast_episode",
|
"podcast_episode",
|
||||||
"track",
|
"track",
|
||||||
"sport_event",
|
"sport_event",
|
||||||
@ -140,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"long_play_last_scrobble",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_paused",
|
"is_paused",
|
||||||
@ -152,6 +131,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
ordering = ("-timestamp",)
|
ordering = ("-timestamp",)
|
||||||
|
readonly_fields = ("share_token_version", "share_view_count")
|
||||||
|
|
||||||
def media_name(self, obj):
|
def media_name(self, obj):
|
||||||
return obj.media_obj
|
return obj.media_obj
|
||||||
@ -178,14 +158,19 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ("media_type", "sent_to_mopidy", "user")
|
list_filter = ("media_type", "sent_to_mopidy", "user")
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
raw_id_fields = (
|
raw_id_fields = (
|
||||||
|
"user",
|
||||||
"video",
|
"video",
|
||||||
|
"channel",
|
||||||
"track",
|
"track",
|
||||||
"podcast_episode",
|
"podcast_episode",
|
||||||
"sport_event",
|
"sport_event",
|
||||||
"book",
|
"book",
|
||||||
|
"paper",
|
||||||
"video_game",
|
"video_game",
|
||||||
"board_game",
|
"board_game",
|
||||||
"geo_location",
|
"geo_location",
|
||||||
|
"puzzle",
|
||||||
|
"food",
|
||||||
"task",
|
"task",
|
||||||
"mood",
|
"mood",
|
||||||
"brick_set",
|
"brick_set",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import pytz
|
import pytz
|
||||||
|
from django.core.cache import cache
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||||
@ -19,6 +20,8 @@ MONTH_COLORS = [
|
|||||||
"#db7a7a", # Dec
|
"#db7a7a", # Dec
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CACHE_TTL = 60
|
||||||
|
|
||||||
|
|
||||||
def month_color(request):
|
def month_color(request):
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -27,15 +30,25 @@ def month_color(request):
|
|||||||
|
|
||||||
def now_playing(request):
|
def now_playing(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
now = timezone.now()
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return {}
|
return {}
|
||||||
return {
|
|
||||||
"now_playing_list": Scrobble.objects.filter(
|
cache_key = f"now_playing_list_{user.id}"
|
||||||
in_progress=True,
|
now_playing_list = cache.get(cache_key)
|
||||||
is_paused=False,
|
if now_playing_list is None:
|
||||||
user=user,
|
now_playing_list = list(
|
||||||
).exclude(
|
Scrobble.objects.filter(
|
||||||
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
in_progress=True,
|
||||||
|
is_paused=False,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
.exclude(
|
||||||
|
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
||||||
|
)
|
||||||
|
.select_related("track", "video", "podcast_episode")
|
||||||
)
|
)
|
||||||
|
cache.set(cache_key, now_playing_list, CACHE_TTL)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"now_playing_list": now_playing_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,18 @@ class BaseLogData(JSONDataclass):
|
|||||||
def override_fields(cls) -> dict:
|
def override_fields(cls) -> dict:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_log_dict(cls, log_dict: dict) -> dict:
|
||||||
|
"""Extract LogData keyword arguments from a stored log dict.
|
||||||
|
|
||||||
|
Override in subclasses to handle custom nesting/structure.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in log_dict.items()
|
||||||
|
if k in cls.__dataclass_fields__
|
||||||
|
}
|
||||||
|
|
||||||
def notes_as_str(self, separator: str = " | ") -> str:
|
def notes_as_str(self, separator: str = " | ") -> str:
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
@ -207,7 +219,7 @@ class BaseLogData(JSONDataclass):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LongPlayLogData(JSONDataclass):
|
class LongPlayLogData(JSONDataclass):
|
||||||
long_play_complete: bool = False
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@ -93,7 +93,6 @@ class LastFM:
|
|||||||
new_scrobbles.append(new_scrobble)
|
new_scrobbles.append(new_scrobble)
|
||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
# TODO Add a notification for users that their import is complete
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Last.fm import fnished",
|
f"Last.fm import fnished",
|
||||||
extra={
|
extra={
|
||||||
|
|||||||
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -114,7 +114,7 @@ class ScrobblableMixin(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def start_url(self):
|
def start_url(self):
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strings(self) -> ScrobblableConstants:
|
def strings(self) -> ScrobblableConstants:
|
||||||
@ -162,26 +162,21 @@ class LongPlayScrobblableMixin(ScrobblableMixin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_longplay_finish_url(self):
|
def get_longplay_finish_url(self):
|
||||||
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:longplay-finish", kwargs={"media_uuid": self.uuid})
|
||||||
|
|
||||||
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
||||||
return (
|
last = self.last_long_play_scrobble_for_user(user)
|
||||||
get_scrobbles_for_media(self, user)
|
if not last:
|
||||||
.filter(
|
return None
|
||||||
log__long_play_complete=False,
|
current = last
|
||||||
log__serial_scrobble_id__isnull=True,
|
while current.long_play_last_scrobble and not current.long_play_last_scrobble.long_play_complete:
|
||||||
)
|
current = current.long_play_last_scrobble
|
||||||
.order_by("timestamp")
|
return current
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
||||||
return (
|
return (
|
||||||
get_scrobbles_for_media(self, user)
|
get_scrobbles_for_media(self, user)
|
||||||
.filter(
|
.filter(long_play_complete=False)
|
||||||
log__long_play_complete=False,
|
.order_by("-timestamp")
|
||||||
log__serial_scrobble_id__isnull=False,
|
.first()
|
||||||
)
|
|
||||||
.order_by("timestamp")
|
|
||||||
.last()
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -51,7 +51,10 @@ from scrobbles.constants import (
|
|||||||
MEDIA_END_PADDING_SECONDS,
|
MEDIA_END_PADDING_SECONDS,
|
||||||
)
|
)
|
||||||
from scrobbles.importers.lastfm import LastFM
|
from scrobbles.importers.lastfm import LastFM
|
||||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
from scrobbles.notifications import (
|
||||||
|
LastFmImportNtfyNotification,
|
||||||
|
ScrobbleNtfyNotification,
|
||||||
|
)
|
||||||
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -428,6 +431,8 @@ class LastFmImport(BaseFileImportMixin):
|
|||||||
try:
|
try:
|
||||||
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
||||||
self.record_log(scrobbles)
|
self.record_log(scrobbles)
|
||||||
|
if scrobbles:
|
||||||
|
LastFmImportNtfyNotification(self, len(scrobbles)).send()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.record_error(f"Import failed: {e}")
|
self.record_error(f"Import failed: {e}")
|
||||||
logger.exception(f"Import failed for {self}")
|
logger.exception(f"Import failed for {self}")
|
||||||
@ -612,6 +617,30 @@ class EBirdCSVImport(BaseFileImportMixin):
|
|||||||
self.mark_finished()
|
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):
|
class ScrobbleQuerySet(models.QuerySet):
|
||||||
def with_related(self):
|
def with_related(self):
|
||||||
return self.select_related("user").prefetch_related(
|
return self.select_related("user").prefetch_related(
|
||||||
@ -638,6 +667,13 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
"birding_location",
|
"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):
|
class ShareViewLog(TimeStampedModel):
|
||||||
scrobble = models.ForeignKey(
|
scrobble = models.ForeignKey(
|
||||||
@ -762,8 +798,20 @@ class Scrobble(TimeStampedModel):
|
|||||||
format="JPEG",
|
format="JPEG",
|
||||||
options={"quality": 75},
|
options={"quality": 75},
|
||||||
)
|
)
|
||||||
|
screenshot_large = ImageSpecField(
|
||||||
|
source="screenshot",
|
||||||
|
processors=[ResizeToFit(800, 800)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 85},
|
||||||
|
)
|
||||||
long_play_seconds = models.BigIntegerField(**BNULL)
|
long_play_seconds = models.BigIntegerField(**BNULL)
|
||||||
long_play_complete = models.BooleanField(**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:
|
class Meta:
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -778,6 +826,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
"is_paused",
|
"is_paused",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
models.Index(fields=["user", "-timestamp"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -810,11 +859,14 @@ class Scrobble(TimeStampedModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
|
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
|
||||||
scrobbles_by_type = defaultdict(list)
|
scrobbles_by_type = defaultdict(list)
|
||||||
scrobbles = (
|
|
||||||
scrobble_qs.with_related()
|
if hasattr(scrobble_qs, "with_related"):
|
||||||
if hasattr(scrobble_qs, "with_related")
|
media_types_present = list(
|
||||||
else scrobble_qs
|
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:
|
for scrobble in scrobbles:
|
||||||
scrobbles_by_type[scrobble.media_type].append(scrobble)
|
scrobbles_by_type[scrobble.media_type].append(scrobble)
|
||||||
@ -829,7 +881,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
# Remove any locations without titles
|
# Remove any locations without titles
|
||||||
if "GeoLocation" in scrobbles_by_type.keys():
|
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:
|
if not loc_scrobble.media_obj.title:
|
||||||
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
|
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
|
||||||
scrobbles_by_type["GeoLocation_count"] -= 1
|
scrobbles_by_type["GeoLocation_count"] -= 1
|
||||||
@ -854,7 +906,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def finish_url(self) -> str:
|
def finish_url(self) -> str:
|
||||||
return reverse("scrobbles:finish", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:finish", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
class_name = self.media_obj.__class__.__name__
|
class_name = self.media_obj.__class__.__name__
|
||||||
@ -896,10 +948,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
return super(Scrobble, self).save(*args, **kwargs)
|
return super(Scrobble, self).save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
if not self.uuid:
|
return reverse("scrobbles:detail", kwargs={"pk": self.pk})
|
||||||
self.uuid = uuid4()
|
|
||||||
self.save(update_fields=["uuid"])
|
|
||||||
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def get_share_url(self):
|
def get_share_url(self):
|
||||||
if self.visibility == Visibility.PRIVATE:
|
if self.visibility == Visibility.PRIVATE:
|
||||||
@ -911,27 +960,6 @@ class Scrobble(TimeStampedModel):
|
|||||||
self.share_token_version += 1
|
self.share_token_version += 1
|
||||||
self.save(update_fields=["share_token_version"])
|
self.save(update_fields=["share_token_version"])
|
||||||
|
|
||||||
def push_to_archivebox(self):
|
|
||||||
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
|
|
||||||
self.media_obj.push_to_archivebox
|
|
||||||
)
|
|
||||||
|
|
||||||
if pushable_media and self.user.profile.archivebox_url:
|
|
||||||
try:
|
|
||||||
self.media_obj.push_to_archivebox(
|
|
||||||
url=self.user.profile.archivebox_url,
|
|
||||||
username=self.user.profile.archivebox_username,
|
|
||||||
password=self.user.profile.archivebox_password,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.info(
|
|
||||||
"Failed to push URL to archivebox",
|
|
||||||
extra={
|
|
||||||
"archivebox_url": self.user.profile.archivebox_url,
|
|
||||||
"archivebox_username": self.user.profile.archivebox_username,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata(self) -> Optional[logdata.BaseLogData]:
|
def logdata(self) -> Optional[logdata.BaseLogData]:
|
||||||
if self.media_obj:
|
if self.media_obj:
|
||||||
@ -953,24 +981,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
if not log_dict:
|
if not log_dict:
|
||||||
log_dict = {}
|
log_dict = {}
|
||||||
|
|
||||||
# Special handling for GeoLocationLogData - data is nested under 'movement_detection'
|
# Use LogData's from_log_dict to handle any custom nesting/structure
|
||||||
# TODO there's a better way to fix this this at the LogData level
|
logdata_kwargs = logdata_cls.from_log_dict(log_dict)
|
||||||
if logdata_cls.__name__ == "GeoLocationLogData":
|
|
||||||
instance_data = log_dict.get("movement_detection", {}).copy()
|
|
||||||
# Add top-level fields that GeoLocationLogData expects from BaseLogData/WithPeopleLogData
|
|
||||||
for field_name in ["description", "notes", "with_people_ids"]:
|
|
||||||
if field_name in log_dict:
|
|
||||||
instance_data[field_name] = log_dict[field_name]
|
|
||||||
try:
|
|
||||||
return logdata_cls(**instance_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Log data could not be loaded", e)
|
|
||||||
return logdata_cls()
|
|
||||||
|
|
||||||
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
|
|
||||||
logdata_kwargs = {
|
|
||||||
k: v for k, v in log_dict.items() if k in logdata_cls().__dataclass_fields__
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return logdata_cls(**logdata_kwargs)
|
return logdata_cls(**logdata_kwargs)
|
||||||
@ -1147,8 +1159,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
if self.is_long_play:
|
if self.is_long_play:
|
||||||
long_play_secs = 0
|
long_play_secs = 0
|
||||||
if self.previous and not self.previous.long_play_complete:
|
if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:
|
||||||
long_play_secs = self.previous.long_play_seconds or 0
|
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
|
||||||
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
||||||
|
|
||||||
return percent
|
return percent
|
||||||
@ -1463,7 +1475,25 @@ class Scrobble(TimeStampedModel):
|
|||||||
if media.calories:
|
if media.calories:
|
||||||
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
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)
|
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
|
return scrobble
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1722,8 +1752,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
# Set our playback seconds, and calc long play seconds
|
# Set our playback seconds, and calc long play seconds
|
||||||
self.playback_position_seconds = seconds_elapsed
|
self.playback_position_seconds = seconds_elapsed
|
||||||
if self.previous:
|
if self.long_play_last_scrobble:
|
||||||
past_seconds = self.previous.long_play_seconds or 0
|
past_seconds = self.long_play_last_scrobble.long_play_seconds or 0
|
||||||
|
|
||||||
self.long_play_seconds = past_seconds + seconds_elapsed
|
self.long_play_seconds = past_seconds + seconds_elapsed
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,27 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImportNtfyNotification(BasicNtfyNotification):
|
||||||
|
def __init__(self, lfm_import, scrobble_count):
|
||||||
|
super().__init__(lfm_import.user.profile)
|
||||||
|
self.ntfy_str = f"Imported {scrobble_count} scrobble(s) from Last.fm"
|
||||||
|
self.click_url = lfm_import.get_absolute_url()
|
||||||
|
self.title = "Last.fm Import Complete"
|
||||||
|
|
||||||
|
def send(self):
|
||||||
|
if self.profile and self.profile.ntfy_enabled and self.profile.ntfy_url:
|
||||||
|
requests.post(
|
||||||
|
self.profile.ntfy_url,
|
||||||
|
data=self.ntfy_str.encode(encoding="utf-8"),
|
||||||
|
headers={
|
||||||
|
"Title": self.title,
|
||||||
|
"Priority": "default",
|
||||||
|
"Tags": "musical_note",
|
||||||
|
"Click": self.click_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MoodNtfyNotification(BasicNtfyNotification):
|
class MoodNtfyNotification(BasicNtfyNotification):
|
||||||
def __init__(self, profile, **kwargs):
|
def __init__(self, profile, **kwargs):
|
||||||
super().__init__(profile)
|
super().__init__(profile)
|
||||||
|
|||||||
@ -32,6 +32,7 @@ from scrobbles.constants import (
|
|||||||
)
|
)
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
from scrobbles.tasks import push_scrobble_to_archivebox
|
||||||
from scrobbles.utils import (
|
from scrobbles.utils import (
|
||||||
convert_to_seconds,
|
convert_to_seconds,
|
||||||
extract_domain,
|
extract_domain,
|
||||||
@ -329,8 +330,6 @@ def manual_scrobble_book(
|
|||||||
|
|
||||||
source = READCOMICSONLINE_URL.replace("https://", "")
|
source = READCOMICSONLINE_URL.replace("https://", "")
|
||||||
|
|
||||||
# TODO: Check for scrobble of this book already and if so, update the page count
|
|
||||||
|
|
||||||
book = Book.find_or_create(title, url=url, enrich=True)
|
book = Book.find_or_create(title, url=url, enrich=True)
|
||||||
|
|
||||||
scrobble_dict = {
|
scrobble_dict = {
|
||||||
@ -1028,8 +1027,7 @@ def manual_scrobble_webpage(
|
|||||||
if action == "stop":
|
if action == "stop":
|
||||||
scrobble.stop(force_finish=True)
|
scrobble.stop(force_finish=True)
|
||||||
else:
|
else:
|
||||||
# possibly async this?
|
push_scrobble_to_archivebox.delay(scrobble.id)
|
||||||
scrobble.push_to_archivebox()
|
|
||||||
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -52,6 +53,11 @@ def _update_charts_for_timestamp(user, ts):
|
|||||||
if ts is None:
|
if ts is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock_key = f"chart_update_{user.id}"
|
||||||
|
if not cache.add(lock_key, "locked", timeout=30):
|
||||||
|
logger.info(f"Chart update already queued for user {user.id}, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
if timezone.is_naive(ts):
|
if timezone.is_naive(ts):
|
||||||
ts = timezone.make_aware(ts)
|
ts = timezone.make_aware(ts)
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from charts.utils import (
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -241,6 +242,11 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.error(f"User with id {user_id} not found")
|
logger.error(f"User with id {user_id} not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock_key = f"chart_update_running_{user_id}"
|
||||||
|
if not cache.add(lock_key, "locked", timeout=300):
|
||||||
|
logger.info(f"Chart update already running for user {user_id}, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
||||||
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
||||||
@ -250,6 +256,27 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[charts] Failed to update charts: {e}")
|
logger.error(f"[charts] Failed to update charts: {e}")
|
||||||
|
finally:
|
||||||
|
cache.delete(lock_key)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def push_scrobble_to_archivebox(scrobble_id):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||||
|
if not scrobble:
|
||||||
|
logger.warning(
|
||||||
|
"Scrobble %s not found for archivebox push", scrobble_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
webpage = scrobble.web_page
|
||||||
|
if not webpage:
|
||||||
|
logger.warning(
|
||||||
|
"Scrobble %s has no web_page for archivebox push", scrobble_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
webpage.push_to_archivebox(scrobble.user)
|
||||||
|
|
||||||
|
|
||||||
# ── Crontab replacements ──────────────────────────────────────────────────────
|
# ── Crontab replacements ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -44,7 +44,7 @@ urlpatterns = [
|
|||||||
name="lookup-manual-scrobble",
|
name="lookup-manual-scrobble",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"long-play-finish/<slug:uuid>/",
|
"long-play-finish/<slug:media_uuid>/",
|
||||||
views.scrobble_longplay_finish,
|
views.scrobble_longplay_finish,
|
||||||
name="longplay-finish",
|
name="longplay-finish",
|
||||||
),
|
),
|
||||||
@ -160,38 +160,38 @@ urlpatterns = [
|
|||||||
name="shared-detail",
|
name="shared-detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/",
|
"scrobbles/<int:pk>/",
|
||||||
views.ScrobbleDetailView.as_view(),
|
views.ScrobbleDetailView.as_view(),
|
||||||
name="detail",
|
name="detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/regenerate-share-token/",
|
"scrobbles/<int:pk>/regenerate-share-token/",
|
||||||
views.RegenerateShareTokenView.as_view(),
|
views.RegenerateShareTokenView.as_view(),
|
||||||
name="regenerate-share-token",
|
name="regenerate-share-token",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/change-visibility/",
|
"scrobbles/<int:pk>/change-visibility/",
|
||||||
views.ChangeVisibilityView.as_view(),
|
views.ChangeVisibilityView.as_view(),
|
||||||
name="change-visibility",
|
name="change-visibility",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/share-analytics/",
|
"scrobbles/<int:pk>/share-analytics/",
|
||||||
views.ScrobbleShareAnalyticsView.as_view(),
|
views.ScrobbleShareAnalyticsView.as_view(),
|
||||||
name="share-analytics",
|
name="share-analytics",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
"scrobbles/<int:pk>/add-to-mopidy-queue/",
|
||||||
views.add_to_mopidy_queue,
|
views.add_to_mopidy_queue,
|
||||||
name="add-to-mopidy-queue",
|
name="add-to-mopidy-queue",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
|
"scrobbles/<int:pk>/add-to-mopidy-monthly-playlist/",
|
||||||
views.add_to_mopidy_monthly_playlist,
|
views.add_to_mopidy_monthly_playlist,
|
||||||
name="add-to-mopidy-monthly-playlist",
|
name="add-to-mopidy-monthly-playlist",
|
||||||
),
|
),
|
||||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
path("scrobbles/<slug:media_uuid>/start/", views.scrobble_start, name="start"),
|
||||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
path("scrobbles/<int:pk>/finish/", views.scrobble_finish, name="finish"),
|
||||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
path("scrobbles/<int:pk>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||||
path(
|
path(
|
||||||
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
||||||
views.toggle_favorite,
|
views.toggle_favorite,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
@ -153,8 +154,9 @@ def import_lastfm_for_all_users(restart=False):
|
|||||||
last_processed = lfm_import.processed_finished
|
last_processed = lfm_import.processed_finished
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Not resuming failed LastFM import {lfm_import.id} for user {user_id}, use restart=True to restart"
|
"No existing LastFM import for user %s, "
|
||||||
"No existing LastFM import, we should start a monthly parsing of lastFm for this user going back to 2002"
|
"starting a monthly parse going back to 2002",
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -795,6 +797,7 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
|||||||
if not title:
|
if not title:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
title = html.unescape(title)
|
||||||
cleaned = re.sub(r"[\(\)\[\]\{\}]", "", title)
|
cleaned = re.sub(r"[\(\)\[\]\{\}]", "", title)
|
||||||
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
||||||
|
|
||||||
|
|||||||
@ -206,6 +206,19 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
|
|||||||
context_data["scrobbles"] = page_obj.object_list
|
context_data["scrobbles"] = page_obj.object_list
|
||||||
context_data["is_paginated"] = paginator.num_pages > 1
|
context_data["is_paginated"] = paginator.num_pages > 1
|
||||||
|
|
||||||
|
media = self.object
|
||||||
|
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
|
||||||
|
qs = media.scrobble_set.filter(user=self.request.user)
|
||||||
|
completed = qs.filter(long_play_complete=True).order_by("-timestamp").first()
|
||||||
|
if completed and completed.long_play_seconds:
|
||||||
|
context_data["long_play_total_seconds"] = completed.long_play_seconds
|
||||||
|
context_data["long_play_finished_date"] = completed.timestamp
|
||||||
|
else:
|
||||||
|
latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first()
|
||||||
|
if latest_finished and latest_finished.long_play_seconds:
|
||||||
|
context_data["long_play_total_seconds"] = latest_finished.long_play_seconds
|
||||||
|
context_data["long_play_finished_date"] = None
|
||||||
|
|
||||||
return context_data
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
@ -826,10 +839,10 @@ def import_audioscrobbler_file(request):
|
|||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def scrobble_start(request, uuid):
|
def scrobble_start(request, media_uuid):
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobble_start] called",
|
"[scrobble_start] called",
|
||||||
extra={"request": request, "uuid": uuid},
|
extra={"request": request, "media_uuid": media_uuid},
|
||||||
)
|
)
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = request.META.get("HTTP_REFERER")
|
success_url = request.META.get("HTTP_REFERER")
|
||||||
@ -840,14 +853,14 @@ def scrobble_start(request, uuid):
|
|||||||
media_obj = None
|
media_obj = None
|
||||||
for app, model in PLAY_AGAIN_MEDIA.items():
|
for app, model in PLAY_AGAIN_MEDIA.items():
|
||||||
media_model = apps.get_model(app_label=app, model_name=model)
|
media_model = apps.get_model(app_label=app, model_name=model)
|
||||||
media_obj = media_model.objects.filter(uuid=uuid).first()
|
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||||
if media_obj:
|
if media_obj:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not media_obj:
|
if not media_obj:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobble_start] media object not found",
|
"[scrobble_start] media object not found",
|
||||||
extra={"uuid": uuid, "user_id": user.id},
|
extra={"media_uuid": media_uuid, "user_id": user.id},
|
||||||
)
|
)
|
||||||
raise Exception("No media object provided to scrobble")
|
raise Exception("No media object provided to scrobble")
|
||||||
|
|
||||||
@ -884,7 +897,7 @@ def scrobble_start(request, uuid):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -902,25 +915,58 @@ def scrobble_start(request, uuid):
|
|||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def scrobble_longplay_finish(request, uuid):
|
def scrobble_longplay_finish(request, media_uuid):
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = request.META.get("HTTP_REFERER")
|
success_url = request.META.get("HTTP_REFERER")
|
||||||
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
|
# Try scrobble UUID first
|
||||||
|
scrobble = Scrobble.objects.filter(uuid=media_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
|
media_obj = None
|
||||||
for app, model in LONG_PLAY_MEDIA.items():
|
for app, model in LONG_PLAY_MEDIA.items():
|
||||||
media_model = apps.get_model(app_label=app, model_name=model)
|
media_model = apps.get_model(app_label=app, model_name=model)
|
||||||
media_obj = media_model.objects.filter(uuid=uuid).first()
|
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||||
if media_obj:
|
if media_obj:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not media_obj:
|
if not media_obj:
|
||||||
return
|
messages.add_message(
|
||||||
|
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
last_scrobble = media_obj.last_long_play_scrobble_for_user(user)
|
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.long_play_complete = True
|
||||||
last_scrobble.save(update_fields=["long_play_complete"])
|
last_scrobble.save(update_fields=["long_play_complete"])
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
@ -930,14 +976,14 @@ def scrobble_longplay_finish(request, uuid):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def scrobble_finish(request, uuid):
|
def scrobble_finish(request, pk):
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = request.META.get("HTTP_REFERER")
|
success_url = request.META.get("HTTP_REFERER")
|
||||||
if not success_url:
|
if not success_url:
|
||||||
@ -946,7 +992,7 @@ def scrobble_finish(request, uuid):
|
|||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
|
||||||
if scrobble:
|
if scrobble:
|
||||||
scrobble.stop(force_finish=True)
|
scrobble.stop(force_finish=True)
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
@ -961,14 +1007,14 @@ def scrobble_finish(request, uuid):
|
|||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def scrobble_cancel(request, uuid):
|
def scrobble_cancel(request, pk):
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = reverse_lazy("vrobbler-home")
|
success_url = reverse_lazy("vrobbler-home")
|
||||||
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
|
||||||
if scrobble:
|
if scrobble:
|
||||||
scrobble.cancel()
|
scrobble.cancel()
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
@ -982,11 +1028,11 @@ def scrobble_cancel(request, uuid):
|
|||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def add_to_mopidy_queue(request, uuid):
|
def add_to_mopidy_queue(request, pk):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
mopidy_url = request.user.profile.mopidy_api_url
|
mopidy_url = request.user.profile.mopidy_api_url
|
||||||
|
|
||||||
if not mopidy_url:
|
if not mopidy_url:
|
||||||
@ -995,22 +1041,22 @@ def add_to_mopidy_queue(request, uuid):
|
|||||||
messages.ERROR,
|
messages.ERROR,
|
||||||
"Mopidy API URL not configured in your profile settings.",
|
"Mopidy API URL not configured in your profile settings.",
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
||||||
|
|
||||||
task.delay(scrobble.id)
|
task.delay(scrobble.id)
|
||||||
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
||||||
messages.add_message(request, messages.SUCCESS, msg)
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def add_to_mopidy_monthly_playlist(request, uuid):
|
def add_to_mopidy_monthly_playlist(request, pk):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
pattern = profile.monthly_mopidy_playlist_pattern
|
pattern = profile.monthly_mopidy_playlist_pattern
|
||||||
|
|
||||||
@ -1020,7 +1066,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
|
|||||||
messages.ERROR,
|
messages.ERROR,
|
||||||
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
now = now_user_timezone(profile)
|
now = now_user_timezone(profile)
|
||||||
playlist_name = DateFormat(now).format(pattern)
|
playlist_name = DateFormat(now).format(pattern)
|
||||||
@ -1033,7 +1079,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
|
|||||||
messages.SUCCESS,
|
messages.SUCCESS,
|
||||||
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@ -1138,8 +1184,6 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
class ScrobbleDetailView(DetailView):
|
class ScrobbleDetailView(DetailView):
|
||||||
model = Scrobble
|
model = Scrobble
|
||||||
slug_field = "uuid"
|
|
||||||
slug_url_kwarg = "uuid"
|
|
||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
@ -1264,6 +1308,24 @@ class ScrobbleDetailView(DetailView):
|
|||||||
else:
|
else:
|
||||||
context["has_mopidy_uri"] = False
|
context["has_mopidy_uri"] = False
|
||||||
|
|
||||||
|
if self.object.is_long_play and fk_field:
|
||||||
|
all_scrobbles = Scrobble.objects.filter(
|
||||||
|
user=user, **{fk_field: media_obj}
|
||||||
|
)
|
||||||
|
completed = all_scrobbles.filter(
|
||||||
|
long_play_complete=True
|
||||||
|
).order_by("-timestamp").first()
|
||||||
|
if completed and completed.long_play_seconds:
|
||||||
|
context["long_play_total_seconds"] = completed.long_play_seconds
|
||||||
|
context["long_play_finished_date"] = completed.timestamp
|
||||||
|
else:
|
||||||
|
latest_finished = all_scrobbles.filter(
|
||||||
|
played_to_completion=True
|
||||||
|
).order_by("-timestamp").first()
|
||||||
|
if latest_finished and latest_finished.long_play_seconds:
|
||||||
|
context["long_play_total_seconds"] = latest_finished.long_play_seconds
|
||||||
|
context["long_play_finished_date"] = None
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@ -1321,15 +1383,15 @@ class ScrobbleExploreView(ListView):
|
|||||||
|
|
||||||
|
|
||||||
class RegenerateShareTokenView(LoginRequiredMixin, View):
|
class RegenerateShareTokenView(LoginRequiredMixin, View):
|
||||||
def post(self, request, uuid):
|
def post(self, request, pk):
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
scrobble.regenerate_share_token()
|
scrobble.regenerate_share_token()
|
||||||
return redirect(scrobble.get_absolute_url())
|
return redirect(scrobble.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
class ChangeVisibilityView(LoginRequiredMixin, View):
|
class ChangeVisibilityView(LoginRequiredMixin, View):
|
||||||
def post(self, request, uuid):
|
def post(self, request, pk):
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
visibility = request.POST.get("visibility")
|
visibility = request.POST.get("visibility")
|
||||||
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
|
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
|
||||||
return redirect(scrobble.get_absolute_url())
|
return redirect(scrobble.get_absolute_url())
|
||||||
@ -1340,8 +1402,6 @@ class ChangeVisibilityView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
|
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
|
||||||
model = Scrobble
|
model = Scrobble
|
||||||
slug_field = "uuid"
|
|
||||||
slug_url_kwarg = "uuid"
|
|
||||||
template_name = "scrobbles/scrobble_share_analytics.html"
|
template_name = "scrobbles/scrobble_share_analytics.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@ -1657,6 +1717,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
for scrobble in day_map[day_num]:
|
for scrobble in day_map[day_num]:
|
||||||
day_scrobbles.append(
|
day_scrobbles.append(
|
||||||
{
|
{
|
||||||
|
"id": scrobble.pk,
|
||||||
"uuid": scrobble.uuid,
|
"uuid": scrobble.uuid,
|
||||||
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
||||||
"title": (
|
"title": (
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class SportAdmin(admin.ModelAdmin):
|
|||||||
class LeagueAdmin(admin.ModelAdmin):
|
class LeagueAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "abbreviation_str")
|
list_display = ("name", "abbreviation_str")
|
||||||
|
raw_id_fields = ("sport",)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ class LeagueAdmin(admin.ModelAdmin):
|
|||||||
class PlayerAdmin(admin.ModelAdmin):
|
class PlayerAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "league", "team")
|
list_display = ("name", "league", "team")
|
||||||
|
raw_id_fields = ("league", "team")
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ class PlayerAdmin(admin.ModelAdmin):
|
|||||||
class SeasonAdmin(admin.ModelAdmin):
|
class SeasonAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "league")
|
list_display = ("name", "league")
|
||||||
|
raw_id_fields = ("league",)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@ -44,6 +47,7 @@ class SeasonAdmin(admin.ModelAdmin):
|
|||||||
class RoundAdmin(admin.ModelAdmin):
|
class RoundAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "season")
|
list_display = ("name", "season")
|
||||||
|
raw_id_fields = ("season",)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@ -51,6 +55,7 @@ class RoundAdmin(admin.ModelAdmin):
|
|||||||
class TeamAdmin(admin.ModelAdmin):
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "league")
|
list_display = ("name", "league")
|
||||||
|
raw_id_fields = ("league",)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@ -63,6 +68,7 @@ class SportEventAdmin(admin.ModelAdmin):
|
|||||||
"event_type",
|
"event_type",
|
||||||
"comp_str",
|
"comp_str",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("league", "teams", "players", "round")
|
||||||
list_filter = ("league", "event_type")
|
list_filter = ("league", "event_type")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
0
vrobbler/apps/trends/__init__.py
Normal file
0
vrobbler/apps/trends/__init__.py
Normal file
9
vrobbler/apps/trends/admin.py
Normal file
9
vrobbler/apps/trends/admin.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from trends.models import TrendResult
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TrendResult)
|
||||||
|
class TrendResultAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "trend_slug", "computed_at", "created")
|
||||||
|
list_filter = ("user", "trend_slug")
|
||||||
|
ordering = ("-computed_at",)
|
||||||
6
vrobbler/apps/trends/apps.py
Normal file
6
vrobbler/apps/trends/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TrendsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "trends"
|
||||||
0
vrobbler/apps/trends/management/__init__.py
Normal file
0
vrobbler/apps/trends/management/__init__.py
Normal file
82
vrobbler/apps/trends/management/commands/compute_trends.py
Normal file
82
vrobbler/apps/trends/management/commands/compute_trends.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Compute trends for all users (or a specific user)"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-id",
|
||||||
|
type=int,
|
||||||
|
help="Compute trends for a specific user only",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options["user_id"]:
|
||||||
|
user = User.objects.filter(id=options["user_id"]).first()
|
||||||
|
if not user:
|
||||||
|
self.stderr.write(
|
||||||
|
self.style.ERROR(f"User with id {options['user_id']} not found")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
users = [user]
|
||||||
|
else:
|
||||||
|
users = User.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
total_users = len(users)
|
||||||
|
self.stdout.write(f"Computing trends for {total_users} user(s)...")
|
||||||
|
|
||||||
|
overall_start = timezone.now()
|
||||||
|
ok_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
total_trends = len(TREND_REGISTRY)
|
||||||
|
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
|
||||||
|
user_start = timezone.now()
|
||||||
|
user_ok = 0
|
||||||
|
user_fail = 0
|
||||||
|
|
||||||
|
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||||
|
periods = get_supported_periods(slug)
|
||||||
|
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||||
|
for period in periods:
|
||||||
|
trend_start = timezone.now()
|
||||||
|
self.stdout.write(f" {period}... ", ending="")
|
||||||
|
try:
|
||||||
|
elapsed = compute_and_save_trend(user, slug, period)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"OK ({elapsed:.1f}s)"))
|
||||||
|
user_ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = (timezone.now() - trend_start).total_seconds()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"FAILED after {elapsed:.1f}s: {e}")
|
||||||
|
)
|
||||||
|
user_fail += 1
|
||||||
|
|
||||||
|
user_elapsed = (timezone.now() - user_start).total_seconds()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" {user}: {user_ok} OK, {user_fail} failed "
|
||||||
|
f"({user_elapsed:.1f}s total)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ok_count += user_ok
|
||||||
|
fail_count += user_fail
|
||||||
|
|
||||||
|
overall_elapsed = (timezone.now() - overall_start).total_seconds()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Done! {ok_count} OK, {fail_count} failed "
|
||||||
|
f"({overall_elapsed:.1f}s across {total_users} user(s))"
|
||||||
|
)
|
||||||
|
)
|
||||||
57
vrobbler/apps/trends/migrations/0001_initial.py
Normal file
57
vrobbler/apps/trends/migrations/0001_initial.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-16 14:52
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TrendResult",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("trend_slug", models.CharField(db_index=True, max_length=100)),
|
||||||
|
("computed_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("data", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("user", "trend_slug")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-17 14:32
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("trends", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="trendresult",
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="trendresult",
|
||||||
|
name="period",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("last_30", "Last 30 days"),
|
||||||
|
("last_90", "Last 90 days"),
|
||||||
|
("last_year", "Last year"),
|
||||||
|
("all_time", "All time"),
|
||||||
|
],
|
||||||
|
default="all_time",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="trendresult",
|
||||||
|
unique_together={("user", "trend_slug", "period")},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/trends/migrations/__init__.py
Normal file
0
vrobbler/apps/trends/migrations/__init__.py
Normal file
29
vrobbler/apps/trends/models.py
Normal file
29
vrobbler/apps/trends/models.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
PERIOD_CHOICES = [
|
||||||
|
("last_30", "Last 30 days"),
|
||||||
|
("last_90", "Last 90 days"),
|
||||||
|
("last_year", "Last year"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TrendResult(TimeStampedModel):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
trend_slug = models.CharField(max_length=100, db_index=True)
|
||||||
|
period = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=PERIOD_CHOICES,
|
||||||
|
default="last_30",
|
||||||
|
)
|
||||||
|
computed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["user", "trend_slug", "period"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} - {self.trend_slug} ({self.period})"
|
||||||
69
vrobbler/apps/trends/tasks.py
Normal file
69
vrobbler/apps/trends/tasks.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def compute_all_trends():
|
||||||
|
user_ids = list(User.objects.filter(is_active=True).values_list("id", flat=True))
|
||||||
|
logger.info("Dispatching trend computation for %d users", len(user_ids))
|
||||||
|
for uid in user_ids:
|
||||||
|
compute_user_trends.delay(uid)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def compute_user_trends(user_id):
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
logger.warning("User %s not found, skipping trends", user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(TREND_REGISTRY)
|
||||||
|
logger.info(
|
||||||
|
"Computing %d trends for user %s (%d)",
|
||||||
|
total,
|
||||||
|
user,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||||
|
compute_single_trend.delay(user_id, slug)
|
||||||
|
|
||||||
|
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def compute_single_trend(user_id, slug):
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
logger.warning("User %d not found for trend '%s', skipping", user_id, slug)
|
||||||
|
return
|
||||||
|
|
||||||
|
if slug not in TREND_REGISTRY:
|
||||||
|
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
periods = get_supported_periods(slug)
|
||||||
|
|
||||||
|
for period in periods:
|
||||||
|
logger.info("[%s/%s] Computing for user %d...", slug, period, user_id)
|
||||||
|
try:
|
||||||
|
elapsed = compute_and_save_trend(user, slug, period)
|
||||||
|
logger.info(
|
||||||
|
"[%s/%s] Completed for user %d in %.1fs",
|
||||||
|
slug,
|
||||||
|
period,
|
||||||
|
user_id,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[%s/%s] Failed for user %d", slug, period, user_id)
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.distribution %}
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Media Type</th>
|
||||||
|
<th class="text-end">Total</th>
|
||||||
|
<th class="text-end">Completed</th>
|
||||||
|
<th class="text-end">%</th>
|
||||||
|
<th>Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% with max=data.distribution.0.count %}
|
||||||
|
{% for entry in data.distribution %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.media_type }}</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td class="text-end">{{ entry.completed }}</td>
|
||||||
|
<td class="text-end">{{ entry.pct }}%</td>
|
||||||
|
<td style="width: 30%;">
|
||||||
|
{% if max > 0 %}
|
||||||
|
<div class="progress" style="height: 12px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: {{ entry.pct }}%;"
|
||||||
|
aria-valuenow="{{ entry.count }}"
|
||||||
|
aria-valuemin="0" aria-valuemax="{{ max }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No activity data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
<div class="row">
|
||||||
|
{% if data.trails %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h4>🥾 While on Trails</h4>
|
||||||
|
{% for trail in data.trails %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="card-title mb-1">
|
||||||
|
{% if trail.uuid %}
|
||||||
|
<a href="{% url 'trails:trail_detail' trail.uuid %}">{{ trail.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ trail.name }}
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">({{ trail.total_sessions }} sessions)</small>
|
||||||
|
</h6>
|
||||||
|
{% if trail.tracks %}
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th class="text-end">Plays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in trail.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||||
|
<td>{{ t.artist_name }}</td>
|
||||||
|
<td class="text-end">{{ t.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No concurrent listening data for trails.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.locations %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h4>📍 While at Locations</h4>
|
||||||
|
{% for loc in data.locations %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="card-title mb-1">
|
||||||
|
{% if loc.uuid %}
|
||||||
|
<a href="{% url 'locations:geolocation_detail' loc.uuid %}">{{ loc.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ loc.name }}
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">({{ loc.total_sessions }} sessions)</small>
|
||||||
|
</h6>
|
||||||
|
{% if loc.tracks %}
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th class="text-end">Plays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in loc.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||||
|
<td>{{ t.artist_name }}</td>
|
||||||
|
<td class="text-end">{{ t.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No concurrent listening data for locations.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not data.trails and not data.locations %}
|
||||||
|
<p class="text-muted">No concurrent listening data found.</p>
|
||||||
|
{% endif %}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<div class="row">
|
||||||
|
{% if data.books %}
|
||||||
|
{% for book in data.books %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="card-title mb-1">
|
||||||
|
{% if book.book_uuid %}
|
||||||
|
<a href="{% url 'books:book_detail' book.book_uuid %}">{{ book.book_title }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ book.book_title }}
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">({{ book.total_sessions }} listening sessions)</small>
|
||||||
|
</h6>
|
||||||
|
{% if book.tracks %}
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th class="text-end">Plays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in book.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||||
|
<td>{{ t.artist_name }}</td>
|
||||||
|
<td class="text-end">{{ t.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No concurrent reading data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
78
vrobbler/apps/trends/templates/trends/_mood_by_time.html
Normal file
78
vrobbler/apps/trends/templates/trends/_mood_by_time.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Hour of Day</h5>
|
||||||
|
{% if data.hours %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hour</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.hours %}
|
||||||
|
{% if entry.count > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if entry.hour == 0 %}
|
||||||
|
12 AM
|
||||||
|
{% elif entry.hour < 12 %}
|
||||||
|
{{ entry.hour }} AM
|
||||||
|
{% elif entry.hour == 12 %}
|
||||||
|
12 PM
|
||||||
|
{% else %}
|
||||||
|
{{ entry.hour|add:"-12" }} PM
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No hourly data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Day of Week</h5>
|
||||||
|
{% if data.days %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.days %}
|
||||||
|
{% if entry.count > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.day_name }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No daily data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.moods %}
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
||||||
|
· Positive: <strong>{{ data.positive_count }}</strong>
|
||||||
|
· Negative: <strong>{{ data.negative_count }}</strong>
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mood</th>
|
||||||
|
<th class="text-end">Count</th>
|
||||||
|
<th>Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% with max=data.moods.0.count %}
|
||||||
|
{% for entry in data.moods %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.mood }}</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td style="width: 40%;">
|
||||||
|
{% if max > 0 %}
|
||||||
|
<div class="progress" style="height: 12px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: {% widthratio entry.count max 100 %}%;"
|
||||||
|
aria-valuenow="{{ entry.count }}"
|
||||||
|
aria-valuemin="0" aria-valuemax="{{ max }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No mood distribution data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
47
vrobbler/apps/trends/templates/trends/_mood_streaks.html
Normal file
47
vrobbler/apps/trends/templates/trends/_mood_streaks.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.current_streak %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Current streak:</strong>
|
||||||
|
{{ data.current_streak.length }} consecutive
|
||||||
|
<span class="{% if data.current_streak.mood_type == 'positive' %}text-success{% else %}text-danger{% endif %}">
|
||||||
|
{{ data.current_streak.mood_type }}
|
||||||
|
</span>
|
||||||
|
check-ins since {{ data.current_streak.start_date }}.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.streaks %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Mood Type</th>
|
||||||
|
<th class="text-end">Length</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for streak in data.streaks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ forloop.counter }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="{% if streak.mood_type == 'positive' %}text-success{% elif streak.mood_type == 'negative' %}text-danger{% endif %}">
|
||||||
|
{{ streak.mood_type|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ streak.length }}</td>
|
||||||
|
<td>{{ streak.start_date }}</td>
|
||||||
|
<td>{{ streak.end_date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No streak data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
39
vrobbler/apps/trends/templates/trends/_mood_trajectory.html
Normal file
39
vrobbler/apps/trends/templates/trends/_mood_trajectory.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.trajectory %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
<th>Mood Bar</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.trajectory %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.date }}</td>
|
||||||
|
<td class="text-end">{{ entry.avg_quality }}</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td style="width: 40%;">
|
||||||
|
<div class="progress" style="height: 16px;">
|
||||||
|
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
|
||||||
|
aria-valuenow="{{ entry.avg_quality }}"
|
||||||
|
aria-valuemin="1" aria-valuemax="7">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No mood check-in data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
64
vrobbler/apps/trends/templates/trends/_mood_weather.html
Normal file
64
vrobbler/apps/trends/templates/trends/_mood_weather.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Weather Condition</h5>
|
||||||
|
{% if data.conditions %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.conditions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.condition }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No weather-linked mood data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Temperature Range</h5>
|
||||||
|
{% if data.temp_ranges %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Temp Range</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.temp_ranges %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.range }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No temperature-linked mood data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
52
vrobbler/apps/trends/templates/trends/_peak_hours.html
Normal file
52
vrobbler/apps/trends/templates/trends/_peak_hours.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.hours %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hour</th>
|
||||||
|
<th class="text-end">Scrobbles</th>
|
||||||
|
<th>Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% with total=data.hours|dictsortreversed:"count"|first %}
|
||||||
|
{% with max_count=total.count %}
|
||||||
|
{% for entry in data.hours %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if entry.hour == 0 %}
|
||||||
|
12 AM
|
||||||
|
{% elif entry.hour < 12 %}
|
||||||
|
{{ entry.hour }} AM
|
||||||
|
{% elif entry.hour == 12 %}
|
||||||
|
12 PM
|
||||||
|
{% else %}
|
||||||
|
{{ entry.hour|add:"-12" }} PM
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td style="width: 40%;">
|
||||||
|
{% if max_count > 0 %}
|
||||||
|
<div class="progress" style="height: 12px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: {{ entry.count|floatformat:0 }}%;"
|
||||||
|
aria-valuenow="{{ entry.count }}"
|
||||||
|
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No activity data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
57
vrobbler/apps/trends/templates/trends/_reading_pace.html
Normal file
57
vrobbler/apps/trends/templates/trends/_reading_pace.html
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<div class="row">
|
||||||
|
{% if current_period_label %}
|
||||||
|
<div class="col-12 mb-2">
|
||||||
|
<small class="text-muted">Period: {{ current_period_label }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🎵 Reading with Music</h5>
|
||||||
|
{% if data.with_music %}
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr>
|
||||||
|
<th>Avg session duration</th>
|
||||||
|
<td>{{ data.with_music.avg_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total reading time</th>
|
||||||
|
<td>{{ data.with_music.total_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Reading sessions</th>
|
||||||
|
<td>{{ data.with_music.sessions_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">No data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🔇 Reading without Music</h5>
|
||||||
|
{% if data.without_music %}
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr>
|
||||||
|
<th>Avg session duration</th>
|
||||||
|
<td>{{ data.without_music.avg_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total reading time</th>
|
||||||
|
<td>{{ data.without_music.total_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Reading sessions</th>
|
||||||
|
<td>{{ data.without_music.sessions_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">No data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
vrobbler/apps/trends/templates/trends/_trending_up.html
Normal file
38
vrobbler/apps/trends/templates/trends/_trending_up.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Media Type</th>
|
||||||
|
<th class="text-end">Recent ({{ current_period_label }})</th>
|
||||||
|
<th class="text-end">Previous ({{ current_period_label }})</th>
|
||||||
|
<th class="text-end">Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mt, info in data.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ mt }}</td>
|
||||||
|
<td class="text-end">{{ info.recent }}</td>
|
||||||
|
<td class="text-end">{{ info.previous }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if info.change_pct > 0 %}
|
||||||
|
<span class="text-success">+{{ info.change_pct }}%</span>
|
||||||
|
{% elif info.change_pct < 0 %}
|
||||||
|
<span class="text-danger">{{ info.change_pct }}%</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">0%</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No trending data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
42
vrobbler/apps/trends/templates/trends/_weekly_rhythm.html
Normal file
42
vrobbler/apps/trends/templates/trends/_weekly_rhythm.html
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.days %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th class="text-end">Scrobbles</th>
|
||||||
|
<th>Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% with total=data.days|dictsortreversed:"count"|first %}
|
||||||
|
{% with max_count=total.count %}
|
||||||
|
{% for entry in data.days %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.day_name }}</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td style="width: 40%;">
|
||||||
|
{% if max_count > 0 %}
|
||||||
|
<div class="progress" style="height: 12px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: {{ entry.count|floatformat:0 }}%;"
|
||||||
|
aria-valuenow="{{ entry.count }}"
|
||||||
|
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No weekly data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
86
vrobbler/apps/trends/templates/trends/trend_detail.html
Normal file
86
vrobbler/apps/trends/templates/trends/trend_detail.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ trend.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
||||||
|
<h2>{{ trend.icon }} {{ trend.title }}</h2>
|
||||||
|
<p class="text-muted">{{ trend.description }}</p>
|
||||||
|
|
||||||
|
{% if supported_periods|length > 1 %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<nav class="btn-group btn-group-sm" role="group">
|
||||||
|
{% for period_slug, period_label in supported_periods.items %}
|
||||||
|
<a href="?period={{ period_slug }}"
|
||||||
|
class="btn btn-sm {% if period_slug == current_period %}btn-primary{% else %}btn-outline-secondary{% endif %}">
|
||||||
|
{{ period_label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% if prev_period or next_period %}
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% if prev_period %}
|
||||||
|
<a href="?period={{ prev_period }}" class="btn btn-outline-secondary">« Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_period %}
|
||||||
|
<a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next »</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if computed_at %}
|
||||||
|
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if trend_not_found %}
|
||||||
|
<div class="alert alert-warning">Trend not found.</div>
|
||||||
|
|
||||||
|
{% elif data is None %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No data computed yet for this period. Trends are updated once daily, check back later.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif trend.slug == "concurrent-listening" %}
|
||||||
|
{% include "trends/_concurrent_listening.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "concurrent-reading" %}
|
||||||
|
{% include "trends/_concurrent_reading.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "reading-pace-vs-activity" %}
|
||||||
|
{% include "trends/_reading_pace.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "trending-up" %}
|
||||||
|
{% include "trends/_trending_up.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "peak-hours" %}
|
||||||
|
{% include "trends/_peak_hours.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "weekly-rhythm" %}
|
||||||
|
{% include "trends/_weekly_rhythm.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "activity-distribution" %}
|
||||||
|
{% include "trends/_activity_distribution.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-trajectory" %}
|
||||||
|
{% include "trends/_mood_trajectory.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-by-time" %}
|
||||||
|
{% include "trends/_mood_by_time.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-distribution" %}
|
||||||
|
{% include "trends/_mood_distribution.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-streaks" %}
|
||||||
|
{% include "trends/_mood_streaks.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-weather" %}
|
||||||
|
{% include "trends/_mood_weather.html" %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
39
vrobbler/apps/trends/templates/trends/trend_list.html
Normal file
39
vrobbler/apps/trends/templates/trends/trend_list.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Trends{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
{% if not user.is_authenticated %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">Log in to see your trends.</div>
|
||||||
|
</div>
|
||||||
|
{% elif not trends %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No trends computed yet. Trends are computed once daily, check back later.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for trend in trends %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<a href="{% url 'trends:trend-detail' trend.slug %}" class="stretched-link text-decoration-none">
|
||||||
|
{{ trend.icon }} {{ trend.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<p class="card-text text-muted">{{ trend.description }}</p>
|
||||||
|
{% if trend.computed_at %}
|
||||||
|
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
0
vrobbler/apps/trends/templatetags/__init__.py
Normal file
0
vrobbler/apps/trends/templatetags/__init__.py
Normal file
48
vrobbler/apps/trends/trends/__init__.py
Normal file
48
vrobbler/apps/trends/trends/__init__.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from trends.trends.activity import (
|
||||||
|
compute_activity_distribution,
|
||||||
|
compute_peak_hours,
|
||||||
|
compute_weekly_rhythm,
|
||||||
|
)
|
||||||
|
from trends.trends.concurrent import (
|
||||||
|
compute_concurrent_listening,
|
||||||
|
compute_concurrent_reading,
|
||||||
|
)
|
||||||
|
from trends.trends.mood import (
|
||||||
|
compute_mood_by_time,
|
||||||
|
compute_mood_distribution,
|
||||||
|
compute_mood_streaks,
|
||||||
|
compute_mood_trajectory,
|
||||||
|
compute_mood_weather,
|
||||||
|
)
|
||||||
|
from trends.trends.reading import compute_reading_pace_vs_activity
|
||||||
|
from trends.trends.trending import compute_trending_up
|
||||||
|
|
||||||
|
TREND_REGISTRY = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(slug):
|
||||||
|
def decorator(fn):
|
||||||
|
TREND_REGISTRY[slug] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
compute_activity_distribution = register("activity-distribution")(
|
||||||
|
compute_activity_distribution
|
||||||
|
)
|
||||||
|
# compute_concurrent_listening = register("concurrent-listening")(
|
||||||
|
# compute_concurrent_listening
|
||||||
|
# )
|
||||||
|
compute_concurrent_reading = register("concurrent-reading")(compute_concurrent_reading)
|
||||||
|
compute_mood_by_time = register("mood-by-time")(compute_mood_by_time)
|
||||||
|
compute_mood_distribution = register("mood-distribution")(compute_mood_distribution)
|
||||||
|
compute_mood_streaks = register("mood-streaks")(compute_mood_streaks)
|
||||||
|
compute_mood_trajectory = register("mood-trajectory")(compute_mood_trajectory)
|
||||||
|
compute_mood_weather = register("mood-weather")(compute_mood_weather)
|
||||||
|
compute_peak_hours = register("peak-hours")(compute_peak_hours)
|
||||||
|
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||||
|
compute_reading_pace_vs_activity
|
||||||
|
)
|
||||||
|
compute_trending_up = register("trending-up")(compute_trending_up)
|
||||||
|
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)
|
||||||
113
vrobbler/apps/trends/trends/activity.py
Normal file
113
vrobbler/apps/trends/trends/activity.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
from collections import OrderedDict, defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.db.models.functions import Extract
|
||||||
|
from django.utils import timezone
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def compute_peak_hours(user, period="all_time"):
|
||||||
|
"""Group scrobbles by hour of day (0-23) and count them.
|
||||||
|
|
||||||
|
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
|
||||||
|
"""
|
||||||
|
hours_qs = (
|
||||||
|
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
||||||
|
.annotate(hour=Extract("timestamp", "hour"))
|
||||||
|
.values("hour")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("hour")
|
||||||
|
)
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
raw = {row["hour"]: row["count"] for row in hours_qs}
|
||||||
|
for h in range(24):
|
||||||
|
hours.append({"hour": h, "count": raw.get(h, 0)})
|
||||||
|
|
||||||
|
return {"hours": hours}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_weekly_rhythm(user, period="all_time"):
|
||||||
|
"""Group scrobble counts by day of the week.
|
||||||
|
|
||||||
|
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
|
||||||
|
with human-readable day names.
|
||||||
|
"""
|
||||||
|
DAY_NAMES = OrderedDict(
|
||||||
|
[
|
||||||
|
(1, "Monday"),
|
||||||
|
(2, "Tuesday"),
|
||||||
|
(3, "Wednesday"),
|
||||||
|
(4, "Thursday"),
|
||||||
|
(5, "Friday"),
|
||||||
|
(6, "Saturday"),
|
||||||
|
(7, "Sunday"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
days_qs = (
|
||||||
|
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
||||||
|
.annotate(day=Extract("timestamp", "iso_week_day"))
|
||||||
|
.values("day")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("day")
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = {row["day"]: row["count"] for row in days_qs}
|
||||||
|
days = []
|
||||||
|
for idx, name in DAY_NAMES.items():
|
||||||
|
days.append(
|
||||||
|
{
|
||||||
|
"day_index": idx,
|
||||||
|
"day_name": name,
|
||||||
|
"count": raw.get(idx, 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"days": days}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_activity_distribution(user, period="all_time"):
|
||||||
|
"""Proportion of total scrobbles per media type.
|
||||||
|
|
||||||
|
Returns dict: {"distribution": [{"media_type": "...", "count": N,
|
||||||
|
"completed": N, "pct": float}, ...]} sorted by count desc, plus
|
||||||
|
"total_count".
|
||||||
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
filters = Q(user=user)
|
||||||
|
if start:
|
||||||
|
filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
|
dist_qs = (
|
||||||
|
Scrobble.objects.filter(filters)
|
||||||
|
.values("media_type")
|
||||||
|
.annotate(
|
||||||
|
count=Count("id"),
|
||||||
|
completed=Count("id", filter=Q(played_to_completion=True)),
|
||||||
|
)
|
||||||
|
.order_by("-count")
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = list(dist_qs)
|
||||||
|
total = sum(r["count"] for r in rows) or 1
|
||||||
|
|
||||||
|
distribution = []
|
||||||
|
for row in rows:
|
||||||
|
distribution.append(
|
||||||
|
{
|
||||||
|
"media_type": row["media_type"],
|
||||||
|
"count": row["count"],
|
||||||
|
"completed": row["completed"],
|
||||||
|
"pct": round((row["count"] / total) * 100, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"distribution": distribution,
|
||||||
|
"total_count": sum(r["count"] for r in rows),
|
||||||
|
}
|
||||||
275
vrobbler/apps/trends/trends/concurrent.py
Normal file
275
vrobbler/apps/trends/trends/concurrent.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def _range_for(scrobble):
|
||||||
|
start = scrobble.timestamp
|
||||||
|
end = scrobble.stop_timestamp
|
||||||
|
if end is None:
|
||||||
|
try:
|
||||||
|
end = start + datetime.timedelta(hours=12)
|
||||||
|
except AttributeError:
|
||||||
|
end = start
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
||||||
|
"""Find paired scrobbles that overlap in time with anchor scrobbles.
|
||||||
|
|
||||||
|
Returns a dict mapping each anchor scrobble PK to a list of
|
||||||
|
paired scrobble PKs that overlap with it.
|
||||||
|
"""
|
||||||
|
anchor_ranges = {s.pk: _range_for(s) for s in anchor_scrobbles}
|
||||||
|
paired_ranges = {s.pk: _range_for(s) for s in paired_scrobbles}
|
||||||
|
|
||||||
|
anchor_to_paired = defaultdict(list)
|
||||||
|
|
||||||
|
for a_pk, (a_start, a_end) in anchor_ranges.items():
|
||||||
|
for p_pk, (p_start, p_end) in paired_ranges.items():
|
||||||
|
if a_start <= p_end and p_start <= a_end:
|
||||||
|
anchor_to_paired[a_pk].append(p_pk)
|
||||||
|
|
||||||
|
return anchor_to_paired
|
||||||
|
|
||||||
|
|
||||||
|
def _get_media_name(scrobble):
|
||||||
|
"""Return the name of the media object associated with a scrobble."""
|
||||||
|
for attr in [
|
||||||
|
"trail",
|
||||||
|
"geo_location",
|
||||||
|
"book",
|
||||||
|
"track",
|
||||||
|
]:
|
||||||
|
obj = getattr(scrobble, attr, None)
|
||||||
|
if obj is not None:
|
||||||
|
return str(obj)
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_concurrent_listening(user, period="all_time"):
|
||||||
|
"""Find what music was listened to while on trails or at locations.
|
||||||
|
|
||||||
|
Returns a dict with two keys: 'trails' and 'locations', each containing
|
||||||
|
a list of entries with the trail/location name and the tracks listened to.
|
||||||
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
base_filters = Q(user=user, timestamp__isnull=False)
|
||||||
|
if start:
|
||||||
|
base_filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
base_filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
|
media_types_to_exclude_from_anchor = (
|
||||||
|
"Track",
|
||||||
|
"Book",
|
||||||
|
"Video",
|
||||||
|
"PodcastEpisode",
|
||||||
|
"VideoGame",
|
||||||
|
"BoardGame",
|
||||||
|
"Puzzle",
|
||||||
|
"Food",
|
||||||
|
"Beer",
|
||||||
|
"Task",
|
||||||
|
"WebPage",
|
||||||
|
"LifeEvent",
|
||||||
|
"Mood",
|
||||||
|
"BrickSet",
|
||||||
|
"Channel",
|
||||||
|
"BirdingLocation",
|
||||||
|
"Paper",
|
||||||
|
"SportEvent",
|
||||||
|
)
|
||||||
|
|
||||||
|
anchor_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
base_filters,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.exclude(media_type__in=media_types_to_exclude_from_anchor)
|
||||||
|
.select_related("trail", "geo_location")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
paired_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
base_filters,
|
||||||
|
media_type="Track",
|
||||||
|
stop_timestamp__isnull=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.select_related("track")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not anchor_scrobbles or not paired_scrobbles:
|
||||||
|
return {"trails": [], "locations": []}
|
||||||
|
|
||||||
|
anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)
|
||||||
|
|
||||||
|
paired_by_pk = {s.pk: s for s in paired_scrobbles}
|
||||||
|
|
||||||
|
trails = []
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
for anchor in anchor_scrobbles:
|
||||||
|
paired_pks = anchor_to_paired.get(anchor.pk, [])
|
||||||
|
if not paired_pks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tracks_by_name = defaultdict(int)
|
||||||
|
track_details = {}
|
||||||
|
for p_pk in paired_pks:
|
||||||
|
ps = paired_by_pk[p_pk]
|
||||||
|
track = ps.track
|
||||||
|
if track is None:
|
||||||
|
continue
|
||||||
|
name = str(track)
|
||||||
|
tracks_by_name[name] += 1
|
||||||
|
if name not in track_details:
|
||||||
|
track_details[name] = {
|
||||||
|
"track_name": name,
|
||||||
|
"track_uuid": str(track.uuid) if track.uuid else "",
|
||||||
|
"artist_name": str(track.artist) if track.artist else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor_name = _get_media_name(anchor)
|
||||||
|
entry = {
|
||||||
|
"name": anchor_name,
|
||||||
|
"uuid": "",
|
||||||
|
"total_sessions": len(paired_pks),
|
||||||
|
"tracks": sorted(
|
||||||
|
[
|
||||||
|
{**track_details[name], "count": count}
|
||||||
|
for name, count in tracks_by_name.items()
|
||||||
|
],
|
||||||
|
key=lambda x: x["count"],
|
||||||
|
reverse=True,
|
||||||
|
)[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
if anchor.media_type == "Trail":
|
||||||
|
entry["uuid"] = (
|
||||||
|
str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""
|
||||||
|
)
|
||||||
|
trails.append(entry)
|
||||||
|
else:
|
||||||
|
entry["uuid"] = (
|
||||||
|
str(anchor.geo_location.uuid)
|
||||||
|
if anchor.geo_location and anchor.geo_location.uuid
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
locations.append(entry)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||||
|
"locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[
|
||||||
|
:20
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_concurrent_reading(user, period="all_time"):
|
||||||
|
"""Find what music was listened to while reading books.
|
||||||
|
|
||||||
|
Returns a dict with key 'books' containing a list of entries with the
|
||||||
|
book title and the tracks listened to while reading.
|
||||||
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
base_filters = Q(user=user, timestamp__isnull=False)
|
||||||
|
if start:
|
||||||
|
base_filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
base_filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
|
anchor_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
base_filters,
|
||||||
|
media_type="Book",
|
||||||
|
stop_timestamp__isnull=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.select_related("book")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
paired_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
base_filters,
|
||||||
|
media_type="Track",
|
||||||
|
stop_timestamp__isnull=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.select_related("track")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not anchor_scrobbles or not paired_scrobbles:
|
||||||
|
return {"books": []}
|
||||||
|
|
||||||
|
anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)
|
||||||
|
paired_by_pk = {s.pk: s for s in paired_scrobbles}
|
||||||
|
|
||||||
|
books_by_uuid = {}
|
||||||
|
|
||||||
|
for anchor in anchor_scrobbles:
|
||||||
|
paired_pks = anchor_to_paired.get(anchor.pk, [])
|
||||||
|
if not paired_pks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
book = anchor.book
|
||||||
|
book_uuid = str(book.uuid) if book and book.uuid else ""
|
||||||
|
book_key = book_uuid or str(book) if book else "Unknown"
|
||||||
|
|
||||||
|
if book_key not in books_by_uuid:
|
||||||
|
books_by_uuid[book_key] = {
|
||||||
|
"book_title": str(book) if book else "Unknown",
|
||||||
|
"book_uuid": book_uuid,
|
||||||
|
"total_sessions": 0,
|
||||||
|
"tracks_by_name": defaultdict(int),
|
||||||
|
"track_details": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
books_by_uuid[book_key]["total_sessions"] += len(paired_pks)
|
||||||
|
|
||||||
|
for p_pk in paired_pks:
|
||||||
|
ps = paired_by_pk[p_pk]
|
||||||
|
track = ps.track
|
||||||
|
if track is None:
|
||||||
|
continue
|
||||||
|
name = str(track)
|
||||||
|
books_by_uuid[book_key]["tracks_by_name"][name] += 1
|
||||||
|
if name not in books_by_uuid[book_key]["track_details"]:
|
||||||
|
books_by_uuid[book_key]["track_details"][name] = {
|
||||||
|
"track_name": name,
|
||||||
|
"track_uuid": str(track.uuid) if track.uuid else "",
|
||||||
|
"artist_name": str(track.artist) if track.artist else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
books = []
|
||||||
|
for bd in books_by_uuid.values():
|
||||||
|
books.append(
|
||||||
|
{
|
||||||
|
"book_title": bd["book_title"],
|
||||||
|
"book_uuid": bd["book_uuid"],
|
||||||
|
"total_sessions": bd["total_sessions"],
|
||||||
|
"tracks": sorted(
|
||||||
|
[
|
||||||
|
{**bd["track_details"][name], "count": count}
|
||||||
|
for name, count in bd["tracks_by_name"].items()
|
||||||
|
],
|
||||||
|
key=lambda x: x["count"],
|
||||||
|
reverse=True,
|
||||||
|
)[:5],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||||
|
}
|
||||||
208
vrobbler/apps/trends/trends/mood.py
Normal file
208
vrobbler/apps/trends/trends/mood.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def _mood_scrobbles(user, period="last_30"):
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
filters = Q(user=user, media_type=Scrobble.MediaType.MOOD)
|
||||||
|
if start:
|
||||||
|
filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
filters &= Q(timestamp__lte=end)
|
||||||
|
return Scrobble.objects.filter(filters).select_related("mood")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_quality(raw):
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_quality(values):
|
||||||
|
nums = [v for v in values if v is not None]
|
||||||
|
if not nums:
|
||||||
|
return 0.0
|
||||||
|
return round(sum(nums) / len(nums), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_trajectory(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period).order_by("timestamp")
|
||||||
|
by_date = defaultdict(list)
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_quality(s.log.get("mood_quality"))
|
||||||
|
if quality is not None:
|
||||||
|
day_key = s.timestamp.strftime("%Y-%m-%d")
|
||||||
|
by_date[day_key].append(quality)
|
||||||
|
|
||||||
|
trajectory = []
|
||||||
|
for date_key in sorted(by_date):
|
||||||
|
values = by_date[date_key]
|
||||||
|
trajectory.append(
|
||||||
|
{
|
||||||
|
"date": date_key,
|
||||||
|
"avg_quality": _avg_quality(values),
|
||||||
|
"count": len(values),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"trajectory": trajectory}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_by_time(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
by_hour = defaultdict(list)
|
||||||
|
by_day = defaultdict(list)
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_quality(s.log.get("mood_quality"))
|
||||||
|
if quality is not None and s.timestamp:
|
||||||
|
by_hour[s.timestamp.hour].append(quality)
|
||||||
|
by_day[s.timestamp.isoweekday()].append(quality)
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
for h in range(24):
|
||||||
|
vals = by_hour.get(h, [])
|
||||||
|
hours.append(
|
||||||
|
{
|
||||||
|
"hour": h,
|
||||||
|
"avg_quality": _avg_quality(vals),
|
||||||
|
"count": len(vals),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DAY_NAMES = {
|
||||||
|
1: "Monday",
|
||||||
|
2: "Tuesday",
|
||||||
|
3: "Wednesday",
|
||||||
|
4: "Thursday",
|
||||||
|
5: "Friday",
|
||||||
|
6: "Saturday",
|
||||||
|
7: "Sunday",
|
||||||
|
}
|
||||||
|
days = []
|
||||||
|
for d in range(1, 8):
|
||||||
|
vals = by_day.get(d, [])
|
||||||
|
days.append(
|
||||||
|
{
|
||||||
|
"day_index": d,
|
||||||
|
"day_name": DAY_NAMES[d],
|
||||||
|
"avg_quality": _avg_quality(vals),
|
||||||
|
"count": len(vals),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"hours": hours, "days": days}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_distribution(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
mood_counts = Counter()
|
||||||
|
type_counts = Counter()
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
if s.mood and s.mood.title:
|
||||||
|
mood_counts[s.mood.title] += 1
|
||||||
|
mood_type = s.log.get("mood_type")
|
||||||
|
if mood_type:
|
||||||
|
type_counts[mood_type] += 1
|
||||||
|
|
||||||
|
moods = [
|
||||||
|
{"mood": mood, "count": count}
|
||||||
|
for mood, count in mood_counts.most_common()
|
||||||
|
]
|
||||||
|
total = sum(mood_counts.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"moods": moods,
|
||||||
|
"total": total,
|
||||||
|
"positive_count": type_counts.get("positive", 0),
|
||||||
|
"negative_count": type_counts.get("negative", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_streaks(user, period="last_30"):
|
||||||
|
scrobbles = list(
|
||||||
|
_mood_scrobbles(user, period).order_by("timestamp")
|
||||||
|
)
|
||||||
|
if not scrobbles:
|
||||||
|
return {"streaks": [], "current_streak": None}
|
||||||
|
|
||||||
|
streaks = []
|
||||||
|
current_start = scrobbles[0].timestamp.date()
|
||||||
|
current_type = scrobbles[0].log.get("mood_type") or "unknown"
|
||||||
|
current_length = 1
|
||||||
|
|
||||||
|
for s in scrobbles[1:]:
|
||||||
|
mood_type = s.log.get("mood_type") or "unknown"
|
||||||
|
if mood_type == current_type:
|
||||||
|
current_length += 1
|
||||||
|
else:
|
||||||
|
streaks.append(
|
||||||
|
{
|
||||||
|
"start_date": current_start.isoformat(),
|
||||||
|
"end_date": scrobbles[scrobbles.index(s) - 1].timestamp.date().isoformat(),
|
||||||
|
"mood_type": current_type,
|
||||||
|
"length": current_length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current_start = s.timestamp.date()
|
||||||
|
current_type = mood_type
|
||||||
|
current_length = 1
|
||||||
|
|
||||||
|
streaks.append(
|
||||||
|
{
|
||||||
|
"start_date": current_start.isoformat(),
|
||||||
|
"end_date": scrobbles[-1].timestamp.date().isoformat(),
|
||||||
|
"mood_type": current_type,
|
||||||
|
"length": current_length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
streaks.sort(key=lambda x: x["length"], reverse=True)
|
||||||
|
|
||||||
|
current_streak = {
|
||||||
|
"mood_type": current_type,
|
||||||
|
"length": current_length,
|
||||||
|
"start_date": current_start.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"streaks": streaks[:10], "current_streak": current_streak}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_weather(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
by_condition = defaultdict(list)
|
||||||
|
by_temp_range = defaultdict(list)
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_quality(s.log.get("mood_quality"))
|
||||||
|
if quality is None:
|
||||||
|
continue
|
||||||
|
desc = s.log.get("weather_description")
|
||||||
|
temp = s.log.get("weather_temp")
|
||||||
|
if desc:
|
||||||
|
by_condition[desc].append(quality)
|
||||||
|
if temp is not None:
|
||||||
|
try:
|
||||||
|
temp_f = float(temp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
bucket = f"{(int(temp_f) // 10) * 10}-{(int(temp_f) // 10) * 10 + 9}F"
|
||||||
|
by_temp_range[bucket].append(quality)
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
{"condition": cond, "avg_quality": _avg_quality(vals), "count": len(vals)}
|
||||||
|
for cond, vals in sorted(by_condition.items(), key=lambda x: len(x[1]), reverse=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
temp_ranges = [
|
||||||
|
{"range": rng, "avg_quality": _avg_quality(vals), "count": len(vals)}
|
||||||
|
for rng, vals in sorted(by_temp_range.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"conditions": conditions, "temp_ranges": temp_ranges}
|
||||||
93
vrobbler/apps/trends/trends/reading.py
Normal file
93
vrobbler/apps/trends/trends/reading.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def compute_reading_pace_vs_activity(user, period="all_time"):
|
||||||
|
"""Compare reading pace (seconds per session) when music is playing vs. not.
|
||||||
|
|
||||||
|
For each Book scrobble with a playback_position_seconds value, checks
|
||||||
|
whether there is an overlapping Track scrobble and groups the data.
|
||||||
|
Returns average session duration for both groups.
|
||||||
|
"""
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
base_filters = Q(user=user, timestamp__isnull=False)
|
||||||
|
if start:
|
||||||
|
base_filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
base_filters &= Q(timestamp__lte=end)
|
||||||
|
|
||||||
|
book_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
base_filters,
|
||||||
|
media_type="Book",
|
||||||
|
playback_position_seconds__isnull=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.select_related("book")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not book_scrobbles:
|
||||||
|
return {"with_music": None, "without_music": None}
|
||||||
|
|
||||||
|
track_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
base_filters,
|
||||||
|
media_type="Track",
|
||||||
|
played_to_completion=True,
|
||||||
|
).order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
track_ranges = []
|
||||||
|
for ts in track_scrobbles:
|
||||||
|
p_start = ts.timestamp
|
||||||
|
p_end = ts.stop_timestamp
|
||||||
|
if p_end is None:
|
||||||
|
try:
|
||||||
|
p_end = p_start + datetime.timedelta(hours=12)
|
||||||
|
except AttributeError:
|
||||||
|
p_end = p_start
|
||||||
|
track_ranges.append((p_start, p_end))
|
||||||
|
|
||||||
|
with_music_durations = []
|
||||||
|
without_music_durations = []
|
||||||
|
|
||||||
|
for bs in book_scrobbles:
|
||||||
|
b_start = bs.timestamp
|
||||||
|
b_end = bs.stop_timestamp
|
||||||
|
if b_end is None:
|
||||||
|
try:
|
||||||
|
b_end = b_start + datetime.timedelta(hours=12)
|
||||||
|
except AttributeError:
|
||||||
|
b_end = b_start
|
||||||
|
|
||||||
|
has_overlap = False
|
||||||
|
for p_start, p_end in track_ranges:
|
||||||
|
if b_start <= p_end and p_start <= b_end:
|
||||||
|
has_overlap = True
|
||||||
|
break
|
||||||
|
|
||||||
|
duration = bs.playback_position_seconds
|
||||||
|
if has_overlap:
|
||||||
|
with_music_durations.append(duration)
|
||||||
|
else:
|
||||||
|
without_music_durations.append(duration)
|
||||||
|
|
||||||
|
def _stats(durations):
|
||||||
|
if not durations:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"avg_seconds": int(sum(durations) / len(durations)),
|
||||||
|
"sessions_count": len(durations),
|
||||||
|
"total_seconds": sum(durations),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"with_music": _stats(with_music_durations),
|
||||||
|
"without_music": _stats(without_music_durations),
|
||||||
|
}
|
||||||
67
vrobbler/apps/trends/trends/trending.py
Normal file
67
vrobbler/apps/trends/trends/trending.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.utils import timezone
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def compute_trending_up(user, period="last_30"):
|
||||||
|
"""Compare scrobble counts per media type between two periods.
|
||||||
|
|
||||||
|
Compares the most recent N days against the N days before that,
|
||||||
|
returning the count for each period and the percentage change.
|
||||||
|
The period controls the window size (e.g. 30, 90, 365 days).
|
||||||
|
|
||||||
|
Returns a dict keyed by media_type with count and change info.
|
||||||
|
"""
|
||||||
|
from trends.utils import get_period_days
|
||||||
|
|
||||||
|
days = get_period_days(period) or 30
|
||||||
|
now = timezone.now()
|
||||||
|
recent_start = now - timezone.timedelta(days=days)
|
||||||
|
previous_start = recent_start - timezone.timedelta(days=days)
|
||||||
|
|
||||||
|
recent_counts = defaultdict(int)
|
||||||
|
for row in (
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
timestamp__gte=recent_start,
|
||||||
|
timestamp__lte=now,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.values("media_type")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
):
|
||||||
|
recent_counts[row["media_type"]] = row["count"]
|
||||||
|
|
||||||
|
previous_counts = defaultdict(int)
|
||||||
|
for row in (
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
timestamp__gte=previous_start,
|
||||||
|
timestamp__lt=recent_start,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.values("media_type")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
):
|
||||||
|
previous_counts[row["media_type"]] = row["count"]
|
||||||
|
|
||||||
|
all_types = set(list(recent_counts.keys()) + list(previous_counts.keys()))
|
||||||
|
changes = {}
|
||||||
|
for mt in sorted(all_types):
|
||||||
|
rc = recent_counts.get(mt, 0)
|
||||||
|
pc = previous_counts.get(mt, 0)
|
||||||
|
if pc > 0:
|
||||||
|
change_pct = round(((rc - pc) / pc) * 100, 1)
|
||||||
|
elif rc > 0:
|
||||||
|
change_pct = 100.0
|
||||||
|
else:
|
||||||
|
change_pct = 0.0
|
||||||
|
changes[mt] = {
|
||||||
|
"recent": rc,
|
||||||
|
"previous": pc,
|
||||||
|
"change_pct": change_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
9
vrobbler/apps/trends/urls.py
Normal file
9
vrobbler/apps/trends/urls.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from trends.views import TrendDetailView, TrendListView
|
||||||
|
|
||||||
|
app_name = "trends"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("trends/", TrendListView.as_view(), name="trends-home"),
|
||||||
|
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
|
||||||
|
]
|
||||||
84
vrobbler/apps/trends/utils.py
Normal file
84
vrobbler/apps/trends/utils.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from trends.models import PERIOD_CHOICES, TrendResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PERIOD_DAYS = {
|
||||||
|
"last_30": 30,
|
||||||
|
"last_90": 90,
|
||||||
|
"last_year": 365,
|
||||||
|
}
|
||||||
|
|
||||||
|
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
||||||
|
|
||||||
|
TIME_BOUND_TRENDS = {
|
||||||
|
"activity-distribution",
|
||||||
|
"concurrent-reading",
|
||||||
|
"concurrent-listening",
|
||||||
|
"mood-by-time",
|
||||||
|
"mood-distribution",
|
||||||
|
"mood-streaks",
|
||||||
|
"mood-trajectory",
|
||||||
|
"mood-weather",
|
||||||
|
"peak-hours",
|
||||||
|
"reading-pace-vs-activity",
|
||||||
|
"trending-up",
|
||||||
|
"weekly-rhythm",
|
||||||
|
}
|
||||||
|
|
||||||
|
TREND_PERIOD_OVERRIDES = {
|
||||||
|
"trending-up": ["last_30", "last_90", "last_year"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_supported_periods(trend_slug):
|
||||||
|
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||||
|
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||||
|
return {s: PERIOD_LABELS[s] for s in slugs}
|
||||||
|
return dict(PERIOD_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_days(period):
|
||||||
|
return PERIOD_DAYS.get(period)
|
||||||
|
|
||||||
|
|
||||||
|
def get_date_range(period):
|
||||||
|
days = get_period_days(period)
|
||||||
|
if days is None:
|
||||||
|
return None, None
|
||||||
|
now = timezone.now()
|
||||||
|
return now - timedelta(days=days), now
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_nav(current_period, trend_slug):
|
||||||
|
supported = get_supported_periods(trend_slug)
|
||||||
|
keys = list(supported.keys())
|
||||||
|
try:
|
||||||
|
idx = keys.index(current_period)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
prev_period = keys[idx - 1] if idx > 0 else None
|
||||||
|
next_period = keys[idx + 1] if idx < len(keys) - 1 else None
|
||||||
|
return prev_period, next_period
|
||||||
|
|
||||||
|
|
||||||
|
def compute_and_save_trend(user, slug, period="last_30"):
|
||||||
|
"""Compute a single trend for a given period and persist the result.
|
||||||
|
|
||||||
|
Returns elapsed seconds on success, raises on failure.
|
||||||
|
"""
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
|
||||||
|
fn = TREND_REGISTRY[slug]
|
||||||
|
start = timezone.now()
|
||||||
|
data = fn(user, period=period)
|
||||||
|
TrendResult.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
trend_slug=slug,
|
||||||
|
period=period,
|
||||||
|
defaults={"data": data, "computed_at": timezone.now()},
|
||||||
|
)
|
||||||
|
return (timezone.now() - start).total_seconds()
|
||||||
146
vrobbler/apps/trends/views.py
Normal file
146
vrobbler/apps/trends/views.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from trends.models import TrendResult
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
from trends.utils import get_period_nav, get_supported_periods
|
||||||
|
|
||||||
|
TREND_METADATA = {
|
||||||
|
"activity-distribution": {
|
||||||
|
"title": "Activity Distribution",
|
||||||
|
"description": "How your scrobbles are divided across media types.",
|
||||||
|
"icon": "📊",
|
||||||
|
},
|
||||||
|
"concurrent-listening": {
|
||||||
|
"title": "Concurrent Listening",
|
||||||
|
"description": "What music were you listening to while on trails or at locations?",
|
||||||
|
"icon": "🎧",
|
||||||
|
},
|
||||||
|
"concurrent-reading": {
|
||||||
|
"title": "Concurrent Reading",
|
||||||
|
"description": "What music did you listen to while reading books?",
|
||||||
|
"icon": "📖",
|
||||||
|
},
|
||||||
|
"mood-trajectory": {
|
||||||
|
"title": "Mood Trajectory",
|
||||||
|
"description": "How your mood quality has changed over time.",
|
||||||
|
"icon": "📈",
|
||||||
|
},
|
||||||
|
"mood-by-time": {
|
||||||
|
"title": "Mood by Time",
|
||||||
|
"description": "How your mood varies by hour of day and day of week.",
|
||||||
|
"icon": "🕐",
|
||||||
|
},
|
||||||
|
"mood-distribution": {
|
||||||
|
"title": "Mood Distribution",
|
||||||
|
"description": "Which moods you feel most often.",
|
||||||
|
"icon": "🎭",
|
||||||
|
},
|
||||||
|
"mood-streaks": {
|
||||||
|
"title": "Mood Streaks",
|
||||||
|
"description": "Your longest runs of positive and negative moods.",
|
||||||
|
"icon": "🔥",
|
||||||
|
},
|
||||||
|
"mood-weather": {
|
||||||
|
"title": "Mood & Weather",
|
||||||
|
"description": "How weather conditions correlate with your mood.",
|
||||||
|
"icon": "🌤",
|
||||||
|
},
|
||||||
|
"peak-hours": {
|
||||||
|
"title": "Peak Activity Hours",
|
||||||
|
"description": "What time of day are you most active?",
|
||||||
|
"icon": "🕐",
|
||||||
|
},
|
||||||
|
"reading-pace-vs-activity": {
|
||||||
|
"title": "Reading Pace vs Music",
|
||||||
|
"description": "Compare how long you read per session with and without concurrent music.",
|
||||||
|
"icon": "📊",
|
||||||
|
},
|
||||||
|
"trending-up": {
|
||||||
|
"title": "Trending Media Types",
|
||||||
|
"description": "Which media types have you been consuming more or less of recently?",
|
||||||
|
"icon": "📈",
|
||||||
|
},
|
||||||
|
"weekly-rhythm": {
|
||||||
|
"title": "Weekly Rhythm",
|
||||||
|
"description": "Which days of the week see the most scrobble activity?",
|
||||||
|
"icon": "📅",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TrendListView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "trends/trend_list.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
results = TrendResult.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
).order_by("trend_slug", "-computed_at")
|
||||||
|
|
||||||
|
latest_by_slug = {}
|
||||||
|
for r in results:
|
||||||
|
if r.trend_slug not in latest_by_slug:
|
||||||
|
latest_by_slug[r.trend_slug] = r
|
||||||
|
|
||||||
|
trends = []
|
||||||
|
for slug in TREND_REGISTRY:
|
||||||
|
meta = TREND_METADATA.get(slug, {})
|
||||||
|
result = latest_by_slug.get(slug)
|
||||||
|
trends.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"title": meta.get("title", slug),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"icon": meta.get("icon", ""),
|
||||||
|
"computed_at": result.computed_at if result else None,
|
||||||
|
"has_data": result is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ctx["trends"] = trends
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "trends/trend_detail.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
slug = kwargs["trend_slug"]
|
||||||
|
|
||||||
|
if slug not in TREND_REGISTRY:
|
||||||
|
ctx["trend_not_found"] = True
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
period = self.request.GET.get("period", "last_30")
|
||||||
|
|
||||||
|
meta = TREND_METADATA.get(slug, {})
|
||||||
|
ctx["trend"] = {
|
||||||
|
"slug": slug,
|
||||||
|
"title": meta.get("title", slug),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"icon": meta.get("icon", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
supported = get_supported_periods(slug)
|
||||||
|
ctx["supported_periods"] = supported
|
||||||
|
ctx["current_period"] = period
|
||||||
|
ctx["current_period_label"] = supported.get(period, "")
|
||||||
|
|
||||||
|
prev_period, next_period = get_period_nav(period, slug)
|
||||||
|
ctx["prev_period"] = prev_period
|
||||||
|
ctx["next_period"] = next_period
|
||||||
|
|
||||||
|
result = TrendResult.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
trend_slug=slug,
|
||||||
|
period=period,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
ctx["computed_at"] = result.computed_at
|
||||||
|
ctx["data"] = result.data
|
||||||
|
else:
|
||||||
|
ctx["computed_at"] = None
|
||||||
|
ctx["data"] = None
|
||||||
|
|
||||||
|
return ctx
|
||||||
@ -26,6 +26,7 @@ class GameAdmin(admin.ModelAdmin):
|
|||||||
"main_story_time",
|
"main_story_time",
|
||||||
"release_year",
|
"release_year",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("platforms",)
|
||||||
search_fields = (
|
search_fields = (
|
||||||
"title",
|
"title",
|
||||||
"alternative_name",
|
"alternative_name",
|
||||||
|
|||||||
@ -10,26 +10,27 @@ def hrs_to_secs(hrs: float) -> int:
|
|||||||
return int(hrs * 60 * 60)
|
return int(hrs * 60 * 60)
|
||||||
|
|
||||||
|
|
||||||
def lookup_game_from_hltb(name_or_id: str) -> Optional[dict]:
|
def lookup_game_from_hltb(name_or_id: str, search_by_title: bool = False) -> Optional[dict]:
|
||||||
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
|
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
|
||||||
the data in a dictonary mapped to our internal game fields
|
the data in a dictonary mapped to our internal game fields
|
||||||
|
|
||||||
"""
|
"""
|
||||||
hltb_game = {}
|
hltb_game = {}
|
||||||
|
|
||||||
try:
|
if not search_by_title:
|
||||||
hltb_id = int(name_or_id)
|
try:
|
||||||
except ValueError:
|
hltb_id = int(name_or_id)
|
||||||
hltb_id = None
|
except ValueError:
|
||||||
|
hltb_id = None
|
||||||
|
|
||||||
if hltb_id:
|
if hltb_id:
|
||||||
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
||||||
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
||||||
|
|
||||||
if not hltb_game:
|
if not hltb_game:
|
||||||
results = HowLongToBeat().search(name_or_id)
|
results = HowLongToBeat().search(name_or_id)
|
||||||
if not results:
|
if not results:
|
||||||
logger.warn(f"Lookup of game on HLtB failed for ID {name_or_id}")
|
logger.warn(f"Lookup of game on HLtB failed via search {name_or_id!r}")
|
||||||
return
|
return
|
||||||
|
|
||||||
hltb_game = results[0]
|
hltb_game = results[0]
|
||||||
|
|||||||
@ -19,6 +19,7 @@ GAMES_URL = "https://api.igdb.com/v4/games"
|
|||||||
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
|
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
|
||||||
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
|
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
|
||||||
COVER_URL = "https://api.igdb.com/v4/covers"
|
COVER_URL = "https://api.igdb.com/v4/covers"
|
||||||
|
PLATFORMS_URL = "https://api.igdb.com/v4/platforms"
|
||||||
|
|
||||||
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
|
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
|
||||||
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
|
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
|
||||||
@ -35,6 +36,20 @@ def get_igdb_token() -> str:
|
|||||||
return results.get("access_token")
|
return results.get("access_token")
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_platform_names(platform_ids: list, headers: dict) -> list:
|
||||||
|
"""Resolve IGDB platform IDs to platform names"""
|
||||||
|
if not platform_ids:
|
||||||
|
return []
|
||||||
|
ids_str = ",".join(str(pid) for pid in platform_ids)
|
||||||
|
body = f"fields name; where id = ({ids_str});"
|
||||||
|
resp = requests.post(PLATFORMS_URL, data=body, headers=headers)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warn(f"Failed to resolve platform IDs {platform_ids}")
|
||||||
|
return []
|
||||||
|
results = json.loads(resp.content)
|
||||||
|
return [p["name"] for p in results if "name" in p]
|
||||||
|
|
||||||
|
|
||||||
def lookup_game_id_from_gdb(name: str) -> str:
|
def lookup_game_id_from_gdb(name: str) -> str:
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@ -62,9 +77,10 @@ def lookup_game_id_from_gdb(name: str) -> str:
|
|||||||
"details": results.get("details"),
|
"details": results.get("details"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Sort our result by IDs so we always get the lowest ID, which is likely to be the least esoteric game
|
# Sort results by release date (oldest first) to prefer the original game
|
||||||
results = sorted(results, key=lambda k: k.get("game", 250000))
|
results = [r for r in results if r.get("game")]
|
||||||
return results[0].get("game", "")
|
results = sorted(results, key=lambda k: k.get("published_at") or 9999999999)
|
||||||
|
return results[0].get("game", "") if results else ""
|
||||||
|
|
||||||
|
|
||||||
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||||
@ -118,6 +134,16 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
|||||||
for genre in game.get("genres"):
|
for genre in game.get("genres"):
|
||||||
genres.append(genre["name"])
|
genres.append(genre["name"])
|
||||||
|
|
||||||
|
platforms = []
|
||||||
|
if "release_dates" in game.keys():
|
||||||
|
platform_ids = set()
|
||||||
|
for rd in game["release_dates"]:
|
||||||
|
pid = rd.get("platform")
|
||||||
|
if pid is not None:
|
||||||
|
platform_ids.add(pid)
|
||||||
|
if platform_ids:
|
||||||
|
platforms = lookup_platform_names(list(platform_ids), headers)
|
||||||
|
|
||||||
game_dict = {
|
game_dict = {
|
||||||
"igdb_id": game.get("id"),
|
"igdb_id": game.get("id"),
|
||||||
"title": game.get("name"),
|
"title": game.get("name"),
|
||||||
@ -129,6 +155,7 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
|||||||
"release_date": release_date,
|
"release_date": release_date,
|
||||||
"summary": game.get("summary"),
|
"summary": game.get("summary"),
|
||||||
"genres": genres,
|
"genres": genres,
|
||||||
|
"platforms": platforms,
|
||||||
}
|
}
|
||||||
|
|
||||||
return game_dict
|
return game_dict
|
||||||
|
|||||||
0
vrobbler/apps/videogames/management/__init__.py
Normal file
0
vrobbler/apps/videogames/management/__init__.py
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MISSING_ALL = [
|
||||||
|
"cover",
|
||||||
|
"screenshot",
|
||||||
|
"summary",
|
||||||
|
"rating",
|
||||||
|
"release_date",
|
||||||
|
"release_year",
|
||||||
|
"igdb_id",
|
||||||
|
"hltb_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
MISSING_GROUPS = {
|
||||||
|
"cover": lambda g: not bool(g.cover),
|
||||||
|
"screenshot": lambda g: not bool(g.screenshot),
|
||||||
|
"summary": lambda g: not g.summary,
|
||||||
|
"rating": lambda g: g.rating is None,
|
||||||
|
"release_date": lambda g: g.release_date is None,
|
||||||
|
"release_year": lambda g: g.release_year is None,
|
||||||
|
"igdb_id": lambda g: g.igdb_id is None,
|
||||||
|
"hltb_id": lambda g: g.hltb_id is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _game_matches(game, flags):
|
||||||
|
if not flags:
|
||||||
|
return False
|
||||||
|
for flag in flags:
|
||||||
|
fn = MISSING_GROUPS.get(flag)
|
||||||
|
if fn and fn(game):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Backfill missing metadata on video games from IGDB and HowLongToBeat"
|
||||||
|
|
||||||
|
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 games 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)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Re-fetch metadata even if data already exists",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--game-id",
|
||||||
|
type=int,
|
||||||
|
help="Only process a specific game by ID",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fix-broken-images",
|
||||||
|
action="store_true",
|
||||||
|
help="Check and refetch broken/deleted game images (cover, screenshot, hltb_cover)",
|
||||||
|
)
|
||||||
|
for flag in MISSING_ALL:
|
||||||
|
parser.add_argument(
|
||||||
|
f"--missing-{flag}",
|
||||||
|
dest="missing_flags",
|
||||||
|
action="append_const",
|
||||||
|
const=flag,
|
||||||
|
help=f"Process games missing {flag}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
dest="all_missing",
|
||||||
|
help="Process games missing any metadata field",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from videogames.models import VideoGame
|
||||||
|
|
||||||
|
commit = options["commit"]
|
||||||
|
batch_size = options["batch_size"]
|
||||||
|
sleep_secs = options["sleep"]
|
||||||
|
force = options["force"]
|
||||||
|
game_id = options["game_id"]
|
||||||
|
fix_broken_images = options.get("fix_broken_images", False)
|
||||||
|
flags = options.get("missing_flags") or []
|
||||||
|
all_missing = options["all_missing"]
|
||||||
|
|
||||||
|
if all_missing:
|
||||||
|
flags = MISSING_ALL
|
||||||
|
|
||||||
|
if not flags and not game_id and not force and not fix_broken_images:
|
||||||
|
self.stdout.write(
|
||||||
|
"No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if game_id:
|
||||||
|
qs = VideoGame.objects.filter(id=game_id)
|
||||||
|
else:
|
||||||
|
qs = VideoGame.objects.all()
|
||||||
|
|
||||||
|
if flags:
|
||||||
|
qs = [g for g in qs.iterator() if _game_matches(g, flags)]
|
||||||
|
else:
|
||||||
|
qs = list(qs)
|
||||||
|
|
||||||
|
total = len(qs)
|
||||||
|
self.stdout.write(f"Found {total} games to process")
|
||||||
|
|
||||||
|
if not commit:
|
||||||
|
self.stdout.write(
|
||||||
|
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
title_mismatches = []
|
||||||
|
enriched = 0
|
||||||
|
skipped = 0
|
||||||
|
stats = {
|
||||||
|
"cover_fixed": 0,
|
||||||
|
"screenshot_fixed": 0,
|
||||||
|
"summary_fixed": 0,
|
||||||
|
"rating_fixed": 0,
|
||||||
|
"release_date_fixed": 0,
|
||||||
|
"release_year_fixed": 0,
|
||||||
|
"igdb_id_found": 0,
|
||||||
|
"hltb_id_found": 0,
|
||||||
|
"images_fixed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched_any = bool(flags or game_id or force)
|
||||||
|
|
||||||
|
if enriched_any:
|
||||||
|
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||||
|
batch = qs[offset : offset + batch_size]
|
||||||
|
for game in batch:
|
||||||
|
result = self._enrich_game(game, sleep_secs, force)
|
||||||
|
self._check_retroarch_name(game, title_mismatches)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fix_broken_images:
|
||||||
|
broken_stats = self._fix_broken_images(qs, sleep_secs)
|
||||||
|
stats["images_fixed"] = broken_stats["images_fixed"]
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"\nResults (commit={commit}):\n"
|
||||||
|
f" Games enriched: {enriched}\n"
|
||||||
|
f" Games skipped: {skipped}\n"
|
||||||
|
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||||
|
f" Screenshots fixed: {stats['screenshot_fixed']}\n"
|
||||||
|
f" Summaries fixed: {stats['summary_fixed']}\n"
|
||||||
|
f" Ratings fixed: {stats['rating_fixed']}\n"
|
||||||
|
f" Release dates fixed: {stats['release_date_fixed']}\n"
|
||||||
|
f" Release years fixed: {stats['release_year_fixed']}\n"
|
||||||
|
f" IGDB IDs found: {stats['igdb_id_found']}\n"
|
||||||
|
f" HLtB IDs found: {stats['hltb_id_found']}"
|
||||||
|
)
|
||||||
|
if fix_broken_images:
|
||||||
|
self.stdout.write(f" Broken images fixed: {stats['images_fixed']}")
|
||||||
|
|
||||||
|
if title_mismatches:
|
||||||
|
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
|
||||||
|
for retroarch_name, title, game_id in title_mismatches:
|
||||||
|
self.stdout.write(
|
||||||
|
f" Game #{game_id}: retroarch_name={retroarch_name!r} vs title={title!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clean_retroarch_name(self, name):
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
name = name.strip()
|
||||||
|
if "(" in name:
|
||||||
|
name = name.split("(")[0].strip()
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _check_retroarch_name(self, game, mismatches):
|
||||||
|
if not game.retroarch_name:
|
||||||
|
return
|
||||||
|
cleaned = self._clean_retroarch_name(game.retroarch_name)
|
||||||
|
if cleaned.lower() != game.title.lower():
|
||||||
|
mismatches.append((game.retroarch_name, game.title, game.id))
|
||||||
|
if "retroarch-mismatch" not in game.tags.names():
|
||||||
|
game.tags.add("retroarch-mismatch")
|
||||||
|
self.stdout.write(
|
||||||
|
f" [TAG] {game} — tagged as retroarch-mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enrich_game(self, game, sleep_secs, force):
|
||||||
|
from videogames.igdb import lookup_game_id_from_gdb, lookup_game_from_igdb
|
||||||
|
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||||
|
|
||||||
|
search_name = self._clean_retroarch_name(game.retroarch_name) or game.title
|
||||||
|
|
||||||
|
changed = {}
|
||||||
|
|
||||||
|
if not game.hltb_id:
|
||||||
|
hltb_data = None
|
||||||
|
if search_name:
|
||||||
|
hltb_data = lookup_game_from_hltb(search_name, search_by_title=True)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not hltb_data and game.title and game.title != search_name:
|
||||||
|
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if hltb_data:
|
||||||
|
result = self._apply_hltb_data(game, hltb_data, force)
|
||||||
|
if result:
|
||||||
|
changed.update(result)
|
||||||
|
|
||||||
|
igdb_data = None
|
||||||
|
if not game.igdb_id and search_name:
|
||||||
|
igdb_id = lookup_game_id_from_gdb(search_name)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if igdb_id:
|
||||||
|
igdb_data = lookup_game_from_igdb(str(igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
elif game.igdb_id:
|
||||||
|
igdb_data = lookup_game_from_igdb(str(game.igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
|
||||||
|
if igdb_data:
|
||||||
|
igdb_title = igdb_data.get("title", "")
|
||||||
|
igdb_title_clean = self._clean_retroarch_name(igdb_title)
|
||||||
|
if igdb_title_clean.lower() == search_name.lower():
|
||||||
|
if game.igdb_id is None and igdb_data.get("igdb_id"):
|
||||||
|
game.igdb_id = int(igdb_data["igdb_id"])
|
||||||
|
game.save(update_fields=["igdb_id"])
|
||||||
|
changed["igdb_id_found"] = True
|
||||||
|
self.stdout.write(f" [IGDB_ID] {game} — found IGDB ID {game.igdb_id}")
|
||||||
|
result = self._apply_igdb_data(game, igdb_data, force)
|
||||||
|
if result:
|
||||||
|
changed.update(result)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f" [IGDB] {game} — title mismatch (IGDB: {igdb_title!r} vs expected: {search_name!r}), re-searching…"
|
||||||
|
)
|
||||||
|
resolved = False
|
||||||
|
for candidate in (search_name, game.title if game.title != search_name else None):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
new_id = lookup_game_id_from_gdb(candidate)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not new_id:
|
||||||
|
continue
|
||||||
|
new_data = lookup_game_from_igdb(str(new_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not new_data:
|
||||||
|
continue
|
||||||
|
new_title = new_data.get("title", "")
|
||||||
|
new_title_clean = self._clean_retroarch_name(new_title)
|
||||||
|
if new_title_clean.lower() == candidate.lower():
|
||||||
|
game.igdb_id = int(new_id)
|
||||||
|
if new_title and new_title != game.title:
|
||||||
|
game.title = new_title
|
||||||
|
changed["title_updated"] = True
|
||||||
|
self.stdout.write(f" [TITLE] {game} — updated title to {new_title!r} from IGDB")
|
||||||
|
game.save(update_fields=["igdb_id"] + (["title"] if changed.get("title_updated") else []))
|
||||||
|
changed["igdb_id_found"] = True
|
||||||
|
self.stdout.write(f" [IGDB_ID] {game} — re-found IGDB ID {game.igdb_id}")
|
||||||
|
if "igdb-mismatch" in game.tags.names():
|
||||||
|
game.tags.remove("igdb-mismatch")
|
||||||
|
self.stdout.write(f" [TAG] {game} — removed igdb-mismatch tag")
|
||||||
|
result = self._apply_igdb_data(game, new_data, force)
|
||||||
|
if result:
|
||||||
|
changed.update(result)
|
||||||
|
resolved = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not resolved and "igdb-mismatch" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-mismatch")
|
||||||
|
self.stdout.write(f" [TAG] {game} — tagged igdb-mismatch")
|
||||||
|
|
||||||
|
# If retroarch-mismatch tag exists but no longer applies, remove it
|
||||||
|
if "retroarch-mismatch" in game.tags.names():
|
||||||
|
cleaned = self._clean_retroarch_name(game.retroarch_name or "")
|
||||||
|
if cleaned.lower() == game.title.lower():
|
||||||
|
game.tags.remove("retroarch-mismatch")
|
||||||
|
self.stdout.write(f" [TAG] {game} — removed retroarch-mismatch tag (title now matches)")
|
||||||
|
|
||||||
|
return changed if changed else None
|
||||||
|
|
||||||
|
def _apply_igdb_data(self, game, data, force):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
changed = {
|
||||||
|
"cover_fixed": False,
|
||||||
|
"screenshot_fixed": False,
|
||||||
|
"summary_fixed": False,
|
||||||
|
"rating_fixed": False,
|
||||||
|
"release_date_fixed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
if data.get("alternative_name") and not game.alternative_name:
|
||||||
|
game.alternative_name = data["alternative_name"]
|
||||||
|
update_fields.append("alternative_name")
|
||||||
|
|
||||||
|
if data.get("summary") and (not game.summary or force):
|
||||||
|
game.summary = data["summary"]
|
||||||
|
update_fields.append("summary")
|
||||||
|
changed["summary_fixed"] = True
|
||||||
|
|
||||||
|
if data.get("rating") is not None and (game.rating is None or force):
|
||||||
|
game.rating = data["rating"]
|
||||||
|
update_fields.append("rating")
|
||||||
|
changed["rating_fixed"] = True
|
||||||
|
|
||||||
|
if data.get("rating_count") is not None and (game.rating_count is None or force):
|
||||||
|
game.rating_count = data["rating_count"]
|
||||||
|
update_fields.append("rating_count")
|
||||||
|
|
||||||
|
if data.get("release_date") and (game.release_date is None or force):
|
||||||
|
game.release_date = data["release_date"]
|
||||||
|
update_fields.append("release_date")
|
||||||
|
changed["release_date_fixed"] = True
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
game.save(update_fields=update_fields)
|
||||||
|
self.stdout.write(f" [IGDB] {game} — {', '.join(update_fields)}")
|
||||||
|
|
||||||
|
cover_url = data.get("cover_url")
|
||||||
|
if cover_url:
|
||||||
|
r = requests.get(cover_url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
changed["cover_fixed"] = True
|
||||||
|
self.stdout.write(f" [COVER] {game} — cover saved from IGDB")
|
||||||
|
|
||||||
|
screenshot_url = data.get("screenshot_url")
|
||||||
|
if screenshot_url:
|
||||||
|
r = requests.get(screenshot_url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||||
|
changed["screenshot_fixed"] = True
|
||||||
|
self.stdout.write(f" [SCREENSHOT] {game} — screenshot saved from IGDB")
|
||||||
|
|
||||||
|
genres = data.get("genres", [])
|
||||||
|
if genres:
|
||||||
|
existing = set(game.genre.names())
|
||||||
|
new_genres = [g for g in genres if g not in existing]
|
||||||
|
if new_genres:
|
||||||
|
game.genre.add(*new_genres)
|
||||||
|
self.stdout.write(f" [GENRES] {game} — added {len(new_genres)} genres")
|
||||||
|
|
||||||
|
platforms = data.get("platforms", [])
|
||||||
|
if platforms:
|
||||||
|
existing = set(game.platforms.values_list("name", flat=True))
|
||||||
|
new_platforms = [p for p in platforms if p not in existing]
|
||||||
|
if new_platforms:
|
||||||
|
from videogames.models import VideoGamePlatform
|
||||||
|
|
||||||
|
for name in new_platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||||
|
|
||||||
|
if "igdb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-enriched")
|
||||||
|
self.stdout.write(f" [TAG] {game} — tagged igdb-enriched")
|
||||||
|
|
||||||
|
return changed if any(changed.values()) else None
|
||||||
|
|
||||||
|
def _apply_hltb_data(self, game, data, force):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
changed = {
|
||||||
|
"hltb_id_found": False,
|
||||||
|
"release_year_fixed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
hltb_title = data.get("title", "")
|
||||||
|
if hltb_title and hltb_title != game.title:
|
||||||
|
game.title = hltb_title
|
||||||
|
update_fields.append("title")
|
||||||
|
self.stdout.write(f" [TITLE] {game} — updated title to {hltb_title!r}")
|
||||||
|
|
||||||
|
if data.get("hltb_id") and (game.hltb_id is None or force):
|
||||||
|
game.hltb_id = data["hltb_id"]
|
||||||
|
update_fields.append("hltb_id")
|
||||||
|
changed["hltb_id_found"] = True
|
||||||
|
self.stdout.write(f" [HLTB_ID] {game} — found HLtB ID {data['hltb_id']}")
|
||||||
|
|
||||||
|
if data.get("release_year") and (game.release_year is None or force):
|
||||||
|
game.release_year = data["release_year"]
|
||||||
|
update_fields.append("release_year")
|
||||||
|
changed["release_year_fixed"] = True
|
||||||
|
|
||||||
|
if data.get("main_story_time") and (game.main_story_time is None or force):
|
||||||
|
game.main_story_time = data["main_story_time"]
|
||||||
|
update_fields.append("main_story_time")
|
||||||
|
|
||||||
|
if data.get("main_extra_time") and (game.main_extra_time is None or force):
|
||||||
|
game.main_extra_time = data["main_extra_time"]
|
||||||
|
update_fields.append("main_extra_time")
|
||||||
|
|
||||||
|
if data.get("completionist_time") and (game.completionist_time is None or force):
|
||||||
|
game.completionist_time = data["completionist_time"]
|
||||||
|
update_fields.append("completionist_time")
|
||||||
|
|
||||||
|
if data.get("hltb_score") is not None and (game.hltb_score is None or force):
|
||||||
|
game.hltb_score = data["hltb_score"]
|
||||||
|
update_fields.append("hltb_score")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
game.save(update_fields=update_fields)
|
||||||
|
self.stdout.write(f" [HLTB] {game} — {', '.join(update_fields)}")
|
||||||
|
|
||||||
|
cover_url = data.get("cover_url")
|
||||||
|
if cover_url:
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(cover_url, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
self.stdout.write(f" [HLTB_COVER] {game} — cover saved from HLtB")
|
||||||
|
|
||||||
|
platforms = data.get("platforms", [])
|
||||||
|
if platforms:
|
||||||
|
existing = set(game.platforms.values_list("name", flat=True))
|
||||||
|
new_platforms = [p for p in platforms if p not in existing]
|
||||||
|
if new_platforms:
|
||||||
|
from videogames.models import VideoGamePlatform
|
||||||
|
|
||||||
|
for name in new_platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||||
|
|
||||||
|
if "hltb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("hltb-enriched")
|
||||||
|
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
|
||||||
|
|
||||||
|
return changed if any(changed.values()) else None
|
||||||
|
|
||||||
|
def _fix_broken_images(self, games, sleep_secs):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from videogames.igdb import lookup_game_from_igdb
|
||||||
|
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||||
|
|
||||||
|
stats = {"cover_fixed": 0, "screenshot_fixed": 0, "images_fixed": 0}
|
||||||
|
|
||||||
|
for game in games:
|
||||||
|
for field_name, source in [
|
||||||
|
("cover", "igdb"),
|
||||||
|
("screenshot", "igdb"),
|
||||||
|
("hltb_cover", "hltb"),
|
||||||
|
]:
|
||||||
|
field = getattr(game, field_name)
|
||||||
|
if not field.name:
|
||||||
|
continue
|
||||||
|
if field.storage.exists(field.name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" [IMAGE] {game} — {field_name} is broken (file missing), refetching…"
|
||||||
|
)
|
||||||
|
|
||||||
|
if source == "igdb" and game.igdb_id:
|
||||||
|
data = lookup_game_from_igdb(str(game.igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
url = data.get("cover_url" if field_name == "cover" else "screenshot_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
r = requests.get(url)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
getattr(game, field_name).save(fname, ContentFile(r.content), save=True)
|
||||||
|
stats["images_fixed"] += 1
|
||||||
|
self.stdout.write(f" [IMAGE] {game} — {field_name} refetched from IGDB")
|
||||||
|
|
||||||
|
elif source == "hltb" and game.hltb_id:
|
||||||
|
data = lookup_game_from_hltb(str(game.hltb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
url = data.get("cover_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(url, headers=headers)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
stats["images_fixed"] += 1
|
||||||
|
self.stdout.write(f" [IMAGE] {game} — hltb_cover refetched from HLtB")
|
||||||
|
|
||||||
|
return stats
|
||||||
@ -215,12 +215,16 @@ class VideoGame(LongPlayScrobblableMixin):
|
|||||||
def fix_metadata(self, force_update: bool = False):
|
def fix_metadata(self, force_update: bool = False):
|
||||||
from videogames.utils import (
|
from videogames.utils import (
|
||||||
get_or_create_videogame,
|
get_or_create_videogame,
|
||||||
|
load_game_data_from_hltb,
|
||||||
load_game_data_from_igdb,
|
load_game_data_from_igdb,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.hltb_id and force_update:
|
if self.hltb_id and force_update:
|
||||||
get_or_create_videogame(str(self.hltb_id), force_update)
|
get_or_create_videogame(str(self.hltb_id), force_update)
|
||||||
|
|
||||||
|
if not self.hltb_id:
|
||||||
|
load_game_data_from_hltb(self.id)
|
||||||
|
|
||||||
if not self.igdb_id:
|
if not self.igdb_id:
|
||||||
# This almost never works without intervention
|
# This almost never works without intervention
|
||||||
self.igdb_id = lookup_game_id_from_gdb(self.title)
|
self.igdb_id = lookup_game_id_from_gdb(self.title)
|
||||||
|
|||||||
@ -153,6 +153,13 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Queued scrobble for game {found_game.id}")
|
logger.info(f"Queued scrobble for game {found_game.id}")
|
||||||
|
|
||||||
|
log_data = {"emulated": True}
|
||||||
|
if last_scrobble and last_scrobble.log:
|
||||||
|
prev = last_scrobble.log
|
||||||
|
if prev.get("emulator"):
|
||||||
|
log_data["emulator"] = prev["emulator"]
|
||||||
|
|
||||||
new_scrobbles.append(
|
new_scrobbles.append(
|
||||||
Scrobble(
|
Scrobble(
|
||||||
video_game_id=found_game.id,
|
video_game_id=found_game.id,
|
||||||
@ -168,6 +175,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
source="Retroarch",
|
source="Retroarch",
|
||||||
media_type=Scrobble.MediaType.VIDEO_GAME,
|
media_type=Scrobble.MediaType.VIDEO_GAME,
|
||||||
|
log=log_data,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)
|
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
|||||||
@ -7,8 +7,6 @@ from videogames.howlongtobeat import lookup_game_from_hltb
|
|||||||
from videogames.igdb import lookup_game_from_igdb
|
from videogames.igdb import lookup_game_from_igdb
|
||||||
from videogames.models import VideoGame, VideoGamePlatform
|
from videogames.models import VideoGame, VideoGamePlatform
|
||||||
|
|
||||||
from vrobbler.apps.videogames.exceptions import GameNotFound
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -16,22 +14,33 @@ def get_or_create_videogame(
|
|||||||
name_or_id: str,
|
name_or_id: str,
|
||||||
force_update: bool = False,
|
force_update: bool = False,
|
||||||
) -> Optional[VideoGame]:
|
) -> Optional[VideoGame]:
|
||||||
"""Look up game by name or ID from HowLongToBeat"""
|
"""Look up game by name or ID from HowLongToBeat, then enrich with IGDB"""
|
||||||
|
|
||||||
game_dict = lookup_game_from_hltb(name_or_id)
|
hltb_data = lookup_game_from_hltb(name_or_id)
|
||||||
|
|
||||||
if not game_dict:
|
if hltb_data:
|
||||||
game_dict = lookup_game_from_igdb(name_or_id)
|
game = _create_update_from_dict(hltb_data, force_update)
|
||||||
|
else:
|
||||||
|
igdb_data = lookup_game_from_igdb(name_or_id)
|
||||||
|
if igdb_data:
|
||||||
|
game = _create_update_from_dict(igdb_data, force_update)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
if not game_dict:
|
if game:
|
||||||
return
|
game.fix_metadata()
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def _create_update_from_dict(
|
||||||
|
game_dict: dict, force_update: bool = False
|
||||||
|
) -> Optional[VideoGame]:
|
||||||
|
|
||||||
# Create missing platforms and prep for loading after create
|
|
||||||
platform_ids = []
|
platform_ids = []
|
||||||
if "platforms" in game_dict.keys():
|
if "platforms" in game_dict.keys():
|
||||||
platforms = game_dict.get("platforms", [])
|
platforms = game_dict.get("platforms", [])
|
||||||
if platforms:
|
if platforms:
|
||||||
for platform in game_dict.get("platforms", []):
|
for platform in platforms:
|
||||||
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
|
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
|
||||||
platform_ids.append(p.id)
|
platform_ids.append(p.id)
|
||||||
game_dict.pop("platforms")
|
game_dict.pop("platforms")
|
||||||
@ -48,7 +57,7 @@ def get_or_create_videogame(
|
|||||||
|
|
||||||
title = game_dict.get("title")
|
title = game_dict.get("title")
|
||||||
if not title:
|
if not title:
|
||||||
raise GameNotFound(name_or_id)
|
return None
|
||||||
|
|
||||||
hltb_id = game_dict.get("hltb_id")
|
hltb_id = game_dict.get("hltb_id")
|
||||||
igdb_id = game_dict.get("igdb_id")
|
igdb_id = game_dict.get("igdb_id")
|
||||||
@ -69,21 +78,19 @@ def get_or_create_videogame(
|
|||||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||||
game.refresh_from_db()
|
game.refresh_from_db()
|
||||||
|
|
||||||
# Associate plaforms
|
|
||||||
if platform_ids:
|
if platform_ids:
|
||||||
game.platforms.add(*platform_ids)
|
game.platforms.add(*platform_ids)
|
||||||
|
|
||||||
if genres:
|
if genres:
|
||||||
game.genre.add(*genres)
|
game.genre.add(*genres)
|
||||||
|
|
||||||
if not game.screenshot and screenshot_url:
|
if screenshot_url:
|
||||||
r = requests.get(screenshot_url)
|
r = requests.get(screenshot_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{game.title}_{game.uuid}.jpg"
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
# Go get cover image if the URL is present
|
if cover_url:
|
||||||
if cover_url and not game.hltb_cover:
|
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
r = requests.get(cover_url, headers=headers)
|
r = requests.get(cover_url, headers=headers)
|
||||||
logger.debug(r.status_code)
|
logger.debug(r.status_code)
|
||||||
@ -91,12 +98,89 @@ def get_or_create_videogame(
|
|||||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
logger.debug("Loaded cover image from HLtB")
|
logger.debug("Loaded cover image from HLtB")
|
||||||
game.fix_metadata()
|
|
||||||
|
tag = "hltb-enriched" if hltb_id else "igdb-enriched"
|
||||||
|
if tag not in game.tags.names():
|
||||||
|
game.tags.add(tag)
|
||||||
|
logger.info(f"Game {game} tagged {tag}")
|
||||||
|
|
||||||
return game
|
return game
|
||||||
|
|
||||||
|
|
||||||
def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoGame]:
|
def load_game_data_from_hltb(
|
||||||
|
game_id: int, expected_title: str = ""
|
||||||
|
) -> Optional[VideoGame]:
|
||||||
|
"""Look up HLtB data for an existing game and apply it"""
|
||||||
|
game = VideoGame.objects.filter(id=game_id).first()
|
||||||
|
if not game:
|
||||||
|
logger.warn(f"Video game with ID {game_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Looking up HLtB data for {game}")
|
||||||
|
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||||
|
if not hltb_data:
|
||||||
|
logger.warn(f"No HLtB data found for {game}")
|
||||||
|
return
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
hltb_title = hltb_data.get("title", "")
|
||||||
|
if hltb_title and hltb_title != game.title:
|
||||||
|
game.title = hltb_title
|
||||||
|
update_fields.append("title")
|
||||||
|
logger.info(f"Game {game.id} title updated to {hltb_title!r}")
|
||||||
|
|
||||||
|
if hltb_data.get("hltb_id") and (game.hltb_id is None):
|
||||||
|
game.hltb_id = hltb_data["hltb_id"]
|
||||||
|
update_fields.append("hltb_id")
|
||||||
|
|
||||||
|
if hltb_data.get("release_year") and (game.release_year is None):
|
||||||
|
game.release_year = hltb_data["release_year"]
|
||||||
|
update_fields.append("release_year")
|
||||||
|
|
||||||
|
if hltb_data.get("main_story_time") and (game.main_story_time is None):
|
||||||
|
game.main_story_time = hltb_data["main_story_time"]
|
||||||
|
update_fields.append("main_story_time")
|
||||||
|
|
||||||
|
if hltb_data.get("main_extra_time") and (game.main_extra_time is None):
|
||||||
|
game.main_extra_time = hltb_data["main_extra_time"]
|
||||||
|
update_fields.append("main_extra_time")
|
||||||
|
|
||||||
|
if hltb_data.get("completionist_time") and (game.completionist_time is None):
|
||||||
|
game.completionist_time = hltb_data["completionist_time"]
|
||||||
|
update_fields.append("completionist_time")
|
||||||
|
|
||||||
|
if hltb_data.get("hltb_score") is not None and (game.hltb_score is None):
|
||||||
|
game.hltb_score = hltb_data["hltb_score"]
|
||||||
|
update_fields.append("hltb_score")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
game.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
platforms = hltb_data.get("platforms", [])
|
||||||
|
if platforms:
|
||||||
|
for name in platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
|
||||||
|
cover_url = hltb_data.get("cover_url")
|
||||||
|
if cover_url:
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(cover_url, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
if "hltb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("hltb-enriched")
|
||||||
|
logger.info(f"Game {game} tagged hltb-enriched")
|
||||||
|
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def load_game_data_from_igdb(
|
||||||
|
game_id: int, igdb_id: str = "", expected_title: str = ""
|
||||||
|
) -> Optional[VideoGame]:
|
||||||
"""Look up game, if it doesn't exist, lookup data from igdb"""
|
"""Look up game, if it doesn't exist, lookup data from igdb"""
|
||||||
game = VideoGame.objects.filter(id=game_id).first()
|
game = VideoGame.objects.filter(id=game_id).first()
|
||||||
if not game:
|
if not game:
|
||||||
@ -116,25 +200,68 @@ def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoG
|
|||||||
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
|
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
igdb_title = game_dict.get("title", "")
|
||||||
|
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||||
|
expected = expected_title or game.title
|
||||||
|
if igdb_title_clean.lower() != expected.lower():
|
||||||
|
logger.info(
|
||||||
|
f"IGDB title {igdb_title!r} doesn't match expected {expected!r} for {game} — re-searching…"
|
||||||
|
)
|
||||||
|
from videogames.igdb import lookup_game_id_from_gdb
|
||||||
|
|
||||||
|
new_id = lookup_game_id_from_gdb(expected)
|
||||||
|
if new_id:
|
||||||
|
new_data = lookup_game_from_igdb(str(new_id))
|
||||||
|
if new_data:
|
||||||
|
new_title = new_data.get("title", "")
|
||||||
|
new_title_clean = new_title.split(" (")[0].strip() if " (" in new_title else new_title
|
||||||
|
if new_title_clean.lower() == expected.lower():
|
||||||
|
game_dict = new_data
|
||||||
|
igdb_id = int(new_id)
|
||||||
|
if game.igdb_id != igdb_id:
|
||||||
|
game.igdb_id = igdb_id
|
||||||
|
game.save(update_fields=["igdb_id"])
|
||||||
|
logger.info(f"Game {game} IGDB ID updated to {igdb_id}")
|
||||||
|
|
||||||
|
igdb_title = game_dict.get("title", "")
|
||||||
|
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||||
|
if igdb_title_clean.lower() != expected.lower():
|
||||||
|
if "igdb-mismatch" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-mismatch")
|
||||||
|
logger.info(
|
||||||
|
f"Game {game} tagged igdb-mismatch (IGDB: {igdb_title!r} vs expected: {expected!r})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
screenshot_url = game_dict.pop("screenshot_url")
|
screenshot_url = game_dict.pop("screenshot_url")
|
||||||
cover_url = game_dict.pop("cover_url")
|
cover_url = game_dict.pop("cover_url")
|
||||||
genres = game_dict.pop("genres")
|
genres = game_dict.pop("genres")
|
||||||
|
platforms = game_dict.pop("platforms", [])
|
||||||
|
|
||||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||||
game.refresh_from_db()
|
game.refresh_from_db()
|
||||||
|
|
||||||
game.genre.add(*genres)
|
game.genre.add(*genres)
|
||||||
|
|
||||||
if not game.screenshot and screenshot_url:
|
if platforms:
|
||||||
|
for name in platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
|
||||||
|
if screenshot_url:
|
||||||
r = requests.get(screenshot_url)
|
r = requests.get(screenshot_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{game.title}_{game.uuid}.jpg"
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
if not game.cover and cover_url:
|
if cover_url:
|
||||||
r = requests.get(cover_url)
|
r = requests.get(cover_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{game.title}_{game.uuid}.jpg"
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
game.cover.save(fname, ContentFile(r.content), save=True)
|
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
if "igdb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-enriched")
|
||||||
|
logger.info(f"Game {game} tagged igdb-enriched")
|
||||||
|
|
||||||
return game
|
return game
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from videos.models import Video, Series
|
from videos.models import Video, Series
|
||||||
|
|
||||||
|
CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def video_lists(request):
|
def video_lists(request):
|
||||||
|
movie_list = cache.get("video_lists_movie_list")
|
||||||
|
if movie_list is None:
|
||||||
|
movie_list = list(
|
||||||
|
Video.objects.filter(video_type=Video.VideoType.MOVIE).only("id", "title")
|
||||||
|
)
|
||||||
|
cache.set("video_lists_movie_list", movie_list, CACHE_TTL)
|
||||||
|
|
||||||
|
series_list = cache.get("video_lists_series_list")
|
||||||
|
if series_list is None:
|
||||||
|
series_list = list(Series.objects.all().only("id", "name"))
|
||||||
|
cache.set("video_lists_series_list", series_list, CACHE_TTL)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE),
|
"movie_list": movie_list,
|
||||||
"series_list": Series.objects.all(),
|
"series_list": series_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import models, transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Enrich YouTube and Twitch channel metadata from upstream APIs"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Overwrite existing channel name and cover image",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be done without making changes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--youtube-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Only process channels with a youtube_id",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--twitch-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Only process channels with a twitch_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from videos.models import Channel
|
||||||
|
|
||||||
|
force = options["force"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
youtube_only = options["youtube_only"]
|
||||||
|
twitch_only = options["twitch_only"]
|
||||||
|
|
||||||
|
qs = Channel.objects.all()
|
||||||
|
|
||||||
|
if youtube_only:
|
||||||
|
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
||||||
|
elif twitch_only:
|
||||||
|
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
|
||||||
|
else:
|
||||||
|
qs = qs.filter(
|
||||||
|
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
|
||||||
|
).exclude(youtube_id="", twitch_id="")
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
self.stdout.write(f"Processing {total} channels")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
for channel in qs.iterator():
|
||||||
|
source = "youtube" if channel.youtube_id else "twitch"
|
||||||
|
identifier = channel.youtube_id or channel.twitch_id
|
||||||
|
self.stdout.write(
|
||||||
|
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
errors = 0
|
||||||
|
for channel in qs.iterator():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
channel.fix_metadata(force=force)
|
||||||
|
updated += 1
|
||||||
|
source = "youtube" if channel.youtube_id else "twitch"
|
||||||
|
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f" Error updating channel {channel.name}: {e}")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"\nDone! {updated} channels updated, {errors} errors")
|
||||||
|
)
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import models, transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Enrich TV series metadata from TMDB/OMDB APIs"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Overwrite existing cover image",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be done without making changes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--imdb-id",
|
||||||
|
type=str,
|
||||||
|
help="Only process series with this imdb_id",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--needs-metadata",
|
||||||
|
action="store_true",
|
||||||
|
help="Only process series missing imdb_id or cover image",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from videos.models import Series
|
||||||
|
|
||||||
|
force = options["force"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
imdb_id = options["imdb_id"]
|
||||||
|
needs_metadata = options["needs_metadata"]
|
||||||
|
|
||||||
|
qs = Series.objects.all()
|
||||||
|
if imdb_id:
|
||||||
|
qs = qs.filter(imdb_id=imdb_id)
|
||||||
|
if needs_metadata:
|
||||||
|
qs = qs.filter(
|
||||||
|
models.Q(imdb_id__isnull=True)
|
||||||
|
| models.Q(imdb_id="")
|
||||||
|
| models.Q(cover_image__isnull=True)
|
||||||
|
| models.Q(cover_image="")
|
||||||
|
)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
self.stdout.write(f"Processing {total} series")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
for series in qs.iterator():
|
||||||
|
has_imdb = bool(series.imdb_id)
|
||||||
|
has_image = bool(series.cover_image)
|
||||||
|
self.stdout.write(
|
||||||
|
f" [DRY RUN] Would fix {series.name}"
|
||||||
|
f" (imdb_id={'✓' if has_imdb else '✗'}"
|
||||||
|
f", image={'✓' if has_image else '✗'})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
errors = 0
|
||||||
|
for series in qs.iterator():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
series.fix_metadata(force_update=force)
|
||||||
|
updated += 1
|
||||||
|
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f" Error updating series {series.name} (imdb_id={series.imdb_id}): {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"\nDone! {updated} series updated, {errors} errors")
|
||||||
|
)
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-16 19:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="custom_url",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -58,6 +59,8 @@ class Channel(ScrobblableMixin):
|
|||||||
)
|
)
|
||||||
youtube_id = models.CharField(max_length=255, **BNULL)
|
youtube_id = models.CharField(max_length=255, **BNULL)
|
||||||
twitch_id = models.CharField(max_length=255, **BNULL)
|
twitch_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
custom_url = models.CharField(max_length=255, **BNULL)
|
||||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -77,6 +80,28 @@ class Channel(ScrobblableMixin):
|
|||||||
def title(self):
|
def title(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def safe_cover_image_url(self) -> str:
|
||||||
|
if self.cover_image:
|
||||||
|
try:
|
||||||
|
if self.cover_image.storage.exists(self.cover_image.name):
|
||||||
|
return self.cover_medium.url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "/static/images/not-found.jpg"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def youtube_url(self) -> str:
|
||||||
|
if self.youtube_id:
|
||||||
|
return YOUTUBE_CHANNEL_URL + self.youtube_id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def twitch_url(self) -> str:
|
||||||
|
if self.twitch_id:
|
||||||
|
return f"https://www.twitch.tv/{self.twitch_id}"
|
||||||
|
return ""
|
||||||
|
|
||||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||||
if not self.cover_image or (force_update and url):
|
if not self.cover_image or (force_update and url):
|
||||||
r = requests.get(url)
|
r = requests.get(url)
|
||||||
@ -92,7 +117,7 @@ class Channel(ScrobblableMixin):
|
|||||||
played_query = models.Q()
|
played_query = models.Q()
|
||||||
return Scrobble.objects.filter(
|
return Scrobble.objects.filter(
|
||||||
played_query,
|
played_query,
|
||||||
channel=self,
|
models.Q(channel=self) | models.Q(video__channel=self),
|
||||||
user=user_id,
|
user=user_id,
|
||||||
).order_by("-timestamp")
|
).order_by("-timestamp")
|
||||||
|
|
||||||
@ -138,8 +163,74 @@ class Channel(ScrobblableMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def fix_metadata(self, force: bool = False):
|
def fix_metadata(self, force: bool = False):
|
||||||
# TODO Scrape channel info from Youtube
|
if self.youtube_id:
|
||||||
logger.warning("Not implemented yet")
|
GOOGLE_CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels?part=snippet,topicDetails&id={channel_id}&key={key}"
|
||||||
|
url = GOOGLE_CHANNELS_URL.format(
|
||||||
|
channel_id=self.youtube_id,
|
||||||
|
key=settings.GOOGLE_API_KEY,
|
||||||
|
)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from Google for channel",
|
||||||
|
extra={"response": response},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
items = json.loads(response.content).get("items", [])
|
||||||
|
if not items:
|
||||||
|
logger.warning(f"No YouTube channel data for {self.youtube_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
snippet = items[0].get("snippet", {})
|
||||||
|
channel_name = snippet.get("title", "")
|
||||||
|
if channel_name and (not self.name or force):
|
||||||
|
self.name = channel_name
|
||||||
|
|
||||||
|
channel_description = snippet.get("description", "")
|
||||||
|
if channel_description and (not self.description or force):
|
||||||
|
self.description = channel_description
|
||||||
|
|
||||||
|
custom_url = snippet.get("customUrl", "")
|
||||||
|
if custom_url and (not self.custom_url or force):
|
||||||
|
self.custom_url = custom_url
|
||||||
|
|
||||||
|
thumbnails = snippet.get("thumbnails", {})
|
||||||
|
cover_url = (
|
||||||
|
thumbnails.get("high", {}).get("url")
|
||||||
|
or thumbnails.get("medium", {}).get("url")
|
||||||
|
or thumbnails.get("default", {}).get("url")
|
||||||
|
)
|
||||||
|
if cover_url:
|
||||||
|
self.save_image_from_url(cover_url, force_update=force)
|
||||||
|
|
||||||
|
topic_details = items[0].get("topicDetails", {})
|
||||||
|
topic_categories = topic_details.get("topicCategories", [])
|
||||||
|
if topic_categories:
|
||||||
|
if force:
|
||||||
|
self.genre.clear()
|
||||||
|
for category_url in topic_categories:
|
||||||
|
topic_name = category_url.rstrip("/").split("/")[-1]
|
||||||
|
topic_name = topic_name.replace("_", " ").replace("-", " ")
|
||||||
|
self.genre.add(topic_name)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.twitch_id:
|
||||||
|
from videos.sources.twitch import lookup_channel_from_twitch
|
||||||
|
|
||||||
|
metadata = lookup_channel_from_twitch(self.twitch_id)
|
||||||
|
if metadata.name and (not self.name or force):
|
||||||
|
self.name = metadata.name
|
||||||
|
if metadata.profile_image_url:
|
||||||
|
self.save_image_from_url(metadata.profile_image_url, force_update=force)
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(f"No youtube_id or twitch_id set for channel {self}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@ -230,25 +321,41 @@ class Series(TimeStampedModel):
|
|||||||
return not last_scrobble.played_to_completion
|
return not last_scrobble.played_to_completion
|
||||||
|
|
||||||
def fix_metadata(self, force_update=False):
|
def fix_metadata(self, force_update=False):
|
||||||
name_or_id = self.name
|
from tmdbv3api import TV
|
||||||
if self.imdb_id:
|
|
||||||
name_or_id = self.imdb_id
|
|
||||||
video_metadata: VideoMetadata = lookup_video_from_tmdb(name_or_id)
|
|
||||||
|
|
||||||
if not video_metadata.title:
|
if not self.imdb_id:
|
||||||
logger.warning(f"No imdb data for {self}")
|
tv = TV()
|
||||||
|
results = tv.search(self.name)
|
||||||
|
if results:
|
||||||
|
show_id = results[0].id
|
||||||
|
external_ids = tv.external_ids(show_id)
|
||||||
|
if external_ids and external_ids.imdb_id:
|
||||||
|
self.imdb_id = external_ids.imdb_id
|
||||||
|
self.save(update_fields=["imdb_id"])
|
||||||
|
else:
|
||||||
|
logger.warning(f"No IMDB ID found on TMDB for {self}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.warning(f"No results on TMDB for {self.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
video_metadata = lookup_video_from_tmdb(self.imdb_id)
|
||||||
|
if not video_metadata or not video_metadata.title:
|
||||||
|
logger.warning(f"No metadata for {self}")
|
||||||
return
|
return
|
||||||
|
|
||||||
cover_url = imdb_dict.get("cover_url")
|
if video_metadata.cover_url and (not self.cover_image or force_update):
|
||||||
|
r = requests.get(video_metadata.cover_url)
|
||||||
if (not self.cover_image or force_update) and cover_url:
|
|
||||||
r = requests.get(cover_url)
|
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{self.name}_{self.uuid}.jpg"
|
fname = f"{self.name}_{self.uuid}.jpg"
|
||||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
if genres := imdb_dict.get("genres"):
|
self.plot = video_metadata.plot or ""
|
||||||
self.genre.add(*genres)
|
self.imdb_rating = getattr(video_metadata, "imdb_rating", None)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
if video_metadata.genres:
|
||||||
|
self.genre.add(*video_metadata.genres)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, imdb_id: str, overwrite: bool = True):
|
def find_or_create(cls, imdb_id: str, overwrite: bool = True):
|
||||||
@ -452,9 +559,7 @@ class Video(ScrobblableMixin):
|
|||||||
if metadata.channel_id:
|
if metadata.channel_id:
|
||||||
from videos.models import Channel
|
from videos.models import Channel
|
||||||
|
|
||||||
self.channel = Channel.objects.filter(
|
self.channel = Channel.objects.filter(id=metadata.channel_id).first()
|
||||||
id=metadata.channel_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@ -476,9 +581,7 @@ class Video(ScrobblableMixin):
|
|||||||
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
|
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
|
||||||
return
|
return
|
||||||
|
|
||||||
vdict, series_id, cover, genres = (
|
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
|
||||||
metadata.as_dict_with_cover_and_genres()
|
|
||||||
)
|
|
||||||
|
|
||||||
for k, v in vdict.items():
|
for k, v in vdict.items():
|
||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user