Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17a7bb52fa | |||
| bbac142b40 | |||
| 5f55ec557f | |||
| 7f3076608f | |||
| 568772a0e6 | |||
| 91c3376256 | |||
| 58639c6fc1 | |||
| 228441ddc5 | |||
| 6341075f07 | |||
| a135b9f5f2 | |||
| 9088412d1e | |||
| c7339fbe31 | |||
| 4ce3dc03c5 | |||
| 5a4ef678a8 | |||
| 5ca22efeaa | |||
| 912ea8bfac | |||
| b541e1084d | |||
| c9b9da4abc | |||
| 8236f43026 | |||
| ea1b43d1b8 | |||
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c |
@ -14,3 +14,11 @@ 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The .envrc file can be loaded into a shell environment to allow access to most third party services
|
||||||
|
|
||||||
|
Care should be taken when using .envrc that we do not spam services we use in production with requests
|
||||||
|
|||||||
271
PROJECT.org
271
PROJECT.org
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
We should convert this PROJECT file to put tickets in a subdirectory, tickets, with each ticket having it's own shortid_title.org
|
We should convert this PROJECT file to put tickets in a subdirectory, tickets, with each ticket having it's own shortid_title.org
|
||||||
* Overview
|
* Overview
|
||||||
|
|
||||||
Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of
|
Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of
|
||||||
the shows and movies I was watching. More specifically, I broke my ankle a few
|
the shows and movies I was watching. More specifically, I broke my ankle a few
|
||||||
days after Christmas in 2022 and spent the next four months very slowly
|
days after Christmas in 2022 and spent the next four months very slowly
|
||||||
@ -16,6 +17,7 @@ tasks, Todoist tasks, web pages I've read and trails I've hiked has turned out
|
|||||||
to be sometimes cathartic and sometimes functional as I try to remember when I
|
to be sometimes cathartic and sometimes functional as I try to remember when I
|
||||||
did a thing.
|
did a thing.
|
||||||
|
|
||||||
|
|
||||||
* Features
|
* Features
|
||||||
** Beer
|
** Beer
|
||||||
*** Triggers
|
*** Triggers
|
||||||
@ -85,19 +87,8 @@ fetching and simple saving.
|
|||||||
**** Bookmarklet
|
**** Bookmarklet
|
||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
* Chores
|
|
||||||
** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:
|
* Backlog [0/14] :vrobbler:project:personal:
|
||||||
:PROPERTIES:
|
|
||||||
:ID: 514e9285-96f1-265f-56df-118c12f60918
|
|
||||||
:END:
|
|
||||||
:LOGBOOK:
|
|
||||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
|
||||||
:END:
|
|
||||||
* Backlog [0/13] :vrobbler:project:personal:
|
|
||||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
|
||||||
:PROPERTIES:
|
|
||||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
|
||||||
:END:
|
|
||||||
** 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 :vrobbler:personal:bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||||
@ -486,7 +477,7 @@ whatever time KoReader reports, we need to know, given the date and the user
|
|||||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||||
to GMT to save it in the database.
|
to GMT to save it in the database.
|
||||||
|
|
||||||
** TODO [#A] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||||
:END:
|
:END:
|
||||||
@ -509,6 +500,233 @@ 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:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Certain artists like "Simon & Garfunkel" are actually one artist. While we don't want to mess with splitting up
|
||||||
|
tracks into featured artists, we should have a "LITERAL_ARTIST_TITLES" constant that can have exceptions like
|
||||||
|
this put into it and then we stop trying to pull the artist apart when we run into it.
|
||||||
|
|
||||||
|
|
||||||
|
** TODO [#A] Before enriching anything, trust the POST data :feature:scrobbles:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Both Jellyfin and Mopidy provide a decent amount of metadata when they POST to our webhooks.
|
||||||
|
|
||||||
|
In most cases, we should be able to trust this data to created music tracks or videos rather
|
||||||
|
than going to third-party services to enrich. Thus, for tracks and videos we should search in
|
||||||
|
the local database for imdb_id or musicbrainz_id for the specific content and, if found, not
|
||||||
|
enrich further.
|
||||||
|
|
||||||
|
If not found, tracks and videos from mopidy and jellyfin should be created as completely as
|
||||||
|
possible using only the POST data from the webhooks, tagged the scrobble with "webhook-metadata-only"
|
||||||
|
and start the scrobble. A separate celery task should be kicked off to enrich the track or video
|
||||||
|
async with the POST data stored in the log["raw_data"] and used by the celery enrichment task
|
||||||
|
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
|
||||||
|
log a warning and move on.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 48.3 [1/1]
|
||||||
|
** DONE [#A] Fix bug in missing sqids dep :dependencies:project:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0b619837-729a-cd74-7a97-6fa2a148b27d
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 48.2 [1/1]
|
||||||
|
** DONE [#A] Lock down scrobbles and use sqids to share them :feature:sharing:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: a6e869f7-8012-7e83-8f68-d0a0ed4c3c6a
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently all scrobbles are public. Anyone with the uuid can view any other
|
||||||
|
scrobbles. We should use SQIDs to allow shareable links to scrobbles and then
|
||||||
|
make all scrobbles hidden by default.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 48.1 [2/2]
|
||||||
|
** DONE [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 684b8cd2-a3c1-4995-ba9e-7abdb02c37f2
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
We should have a management command that outputs a CSV file of track IDs where
|
||||||
|
the log["raw_data"]["Artist"] (for Jellyfin) or log["raw_data"]["artist"]
|
||||||
|
(mopidy) value does not match the Track.artists names.
|
||||||
|
|
||||||
|
And we should see the same thing for albums (log["raw_data"]["Album"] or
|
||||||
|
log["raw_data]["album"]).
|
||||||
|
|
||||||
|
It should output the fields "track_id", "track_artist_name", "track_album_name",
|
||||||
|
"raw_artist", "raw_album", "source"
|
||||||
|
|
||||||
|
Where source is either Jellyfin or Mopidy based on the keys.
|
||||||
|
|
||||||
|
Put the file /tmp/metadata-report.csv by default and overwrite exsiting reports.
|
||||||
|
|
||||||
|
The command should also accept a file-path to overide this default.
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#A] Date parsing failing in eBird imports :birds:ebird:importers:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 72e0254f-39d8-4843-9857-623e0362d77e
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
On line 45 in the apps/birds/importer.py file, the import is thorowing this
|
||||||
|
error:
|
||||||
|
|
||||||
|
#+begin_src python
|
||||||
|
ValueError: time data 'Jun 7, 2026, 5:15 PM' does not match format '%B %d, %Y %I:%M %p'
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
Historically other files starting on May 24 worked, so I suspect this is a
|
||||||
|
problem of the date formatter expecting Long month names and zero-padded days
|
||||||
|
and the only sample we had was a three-letter month (May) and days with two
|
||||||
|
digites(24 through the 31)
|
||||||
|
|
||||||
|
We should also add a "error_log" to the importers so that errors tha occur are
|
||||||
|
surfaced, even when 0 successful files were processed. And we should make sure
|
||||||
|
all importers do this as well.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* Version 48.0 [2/2]
|
||||||
|
** DONE [#B] Show team or player images on sport detail and scrobble detail :sports:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 68c17383-ee6e-4b5f-b3f5-1b637a0a3ea8
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
On the sport event detail page, we should show the images of the teams or
|
||||||
|
players invovled.
|
||||||
|
|
||||||
|
Also, those images for the sport event should be shown on the scrobble detail
|
||||||
|
page for sport event scrobble details.
|
||||||
|
|
||||||
|
** DONE [#B] Add fix_metadta method to Video instances :videos:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9df5404d-1b60-4eee-b7cf-1f7e6dfade65
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Turns out we don't have a fix_metadata method for videos. We should add that using
|
||||||
|
the basic logic from find_or_create on the Video model.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 47.2 [1/1]
|
||||||
|
** DONE [#B] Add OMDB source as backup when TMDB returns nothing :videos:metadata:imdb:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 20195445-7fdd-49be-9767-103b12da0bfb
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
TMDb works great for most cases. There are some edge cases, though where it does
|
||||||
|
not import videos, when TV shows are split up differently in TMDb than in IMDB.
|
||||||
|
One example I stumbled on is the 2020 reboot of Animaniacs. TMDb splits the
|
||||||
|
epiodes up in three parts, while they were always broadcast three-in-one, and
|
||||||
|
that's how IMDB lists them. Thus, the IMDB ID means nothing, and the videos end
|
||||||
|
up unenriched.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 47.1 [1/1]
|
||||||
|
** DONE [#A] Untangle the sports migrations errors :sports:bug:migrations:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 4d50ca2e-f45b-dde8-e3c9-cd84f353b349
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 47.0 [1/1]
|
||||||
|
** DONE [#B] Change sports scrobbling a bit :feature:sports:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: cd27d683-c847-4251-b3d1-8243f45c01ca
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently, the way we scrobble sports means that basically the same event will
|
||||||
|
never be scrobbled again. I will likely never watch the 2025 Monaco Grand Prix
|
||||||
|
again, but I will watch the Monaco Grand Prix again. But I also wont watch one
|
||||||
|
specific game between Arsenal and Man City twice, but I may watch those two
|
||||||
|
teams play multiple times.
|
||||||
|
|
||||||
|
What if instead of scrobbling a specific sports event on a specific date, we
|
||||||
|
make the unique Scrobblable item the players or teams in the event?
|
||||||
|
|
||||||
|
That would not work for races where the unique item would have to be the name of
|
||||||
|
the event.
|
||||||
|
|
||||||
|
Maybe that means SportEvent is too generic, and we'd need the event type to be
|
||||||
|
scrobble items.
|
||||||
|
|
||||||
|
A race, the Indy 500 or Coke 600, or Boston Marathon would be scrobblable, while
|
||||||
|
for games, the teams would be unique, so a game between Arsenal and Man City
|
||||||
|
would be unique (with extra logdata context for who's home and who's away, and
|
||||||
|
the location, could even have the weather per scrobble).
|
||||||
|
|
||||||
|
And finally, for Tennis, the title would be the round of the event, Roland
|
||||||
|
Garros Women's Semifinal, US Open Men's Final, Miami Invitational Round of 32,
|
||||||
|
with two players, or two teams and a start datetime, which is similar to what we
|
||||||
|
have now. The round becomes not a foreign key, but just a string, and we'd need
|
||||||
|
a FK to an organizer field which would replace league, and would be like "ATP
|
||||||
|
Tour" or "PGA Tour". Season would also need to be a string, and would be
|
||||||
|
something like: 2026 or 2024-2025.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Super Bowl
|
||||||
|
- Sochaux v Concarneau
|
||||||
|
- French Open Final
|
||||||
|
- Carlos Alcaraz v Jannik Sinner
|
||||||
|
|
||||||
|
We'd also want a script to reorganize existing sports events and move scrobbles
|
||||||
|
to the right place as best as we're able, and to flag sportsevents and scrobbles
|
||||||
|
that could not automatically be migrated with a unique tag like
|
||||||
|
"migration-failed"
|
||||||
|
|
||||||
|
Ultimately I think what we need is to greatly simplify the SportEvent to be just a placeholder
|
||||||
|
for a sport event type for a given league, then each scrobble holds the details of teams, players
|
||||||
|
start, thesportsdb_id, round and season.
|
||||||
|
|
||||||
|
Thus, I've already simplified that model, but what we need is a migration script that will move
|
||||||
|
existing complex SportEvent instances into very basic ones, and updating any scrobbles for those
|
||||||
|
events with a new SportEventLogData structure with all the specific details in it. We also need to
|
||||||
|
move the obj.round.season.league into the FK for the given event.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* Version 46.0 [1/1]
|
||||||
|
** DONE [#C] Add sentiment parsing for Scrobbles with notes :scrobbles:sentiment:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Not sure how useful this would be, but I wonder if we can add a `sentiment` JSONField
|
||||||
|
on each scrobble that can store the output of VADER over the notes in a scrobble with notes.
|
||||||
|
|
||||||
|
I'm not sure that the value prop here is worth the storage and processing time.
|
||||||
|
|
||||||
|
But if we do add it, it should be a process that scans for scrobbles with both notes and no
|
||||||
|
sentiment field value (unless --overwrite is used) and just run periodically.
|
||||||
|
|
||||||
|
|
||||||
* Version 45.1 [1/1]
|
* Version 45.1 [1/1]
|
||||||
** DONE [#B] Mopidy favorites or monthly playlist adds should look at all scrobbles :bug:mopidy:favorites:tracks:
|
** DONE [#B] Mopidy favorites or monthly playlist adds should look at all scrobbles :bug:mopidy:favorites:tracks:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -849,6 +1067,7 @@ Format should be: "%Y-%m-%dT%H:%M:%S.%fZ"
|
|||||||
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
|
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
|
|
||||||
* Version 39.0 [3/3]
|
* Version 39.0 [3/3]
|
||||||
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
|
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -903,6 +1122,7 @@ If the task is completed, don't touch it.
|
|||||||
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
|
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
|
|
||||||
* Version 38.0 [38/38]
|
* Version 38.0 [38/38]
|
||||||
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
|
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1294,6 +1514,7 @@ urllib.error.HTTPError: HTTP Error 500: Internal Server Error
|
|||||||
|
|
||||||
This may have already been resolved ... need to just confirm it.
|
This may have already been resolved ... need to just confirm it.
|
||||||
|
|
||||||
|
|
||||||
* Version 37.0 [4/4]
|
* Version 37.0 [4/4]
|
||||||
** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1312,11 +1533,13 @@ https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: e3e49a9a-67d2-8ad8-1114-6f05effee9b7
|
:ID: e3e49a9a-67d2-8ad8-1114-6f05effee9b7
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 36.0 [1/1]
|
* Version 36.0 [1/1]
|
||||||
** DONE [#A] Refactor how videos are scrobbled :vrobbler:vidoes:feature:personal:project:
|
** DONE [#A] Refactor how videos are scrobbled :vrobbler:vidoes:feature:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 6034a11d-5376-994d-9a4b-e1640e258cfa
|
:ID: 6034a11d-5376-994d-9a4b-e1640e258cfa
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 35.0 [3/3]
|
* Version 35.0 [3/3]
|
||||||
** DONE [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
|
** DONE [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1330,6 +1553,7 @@ https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: d1ba1ca1-509b-13a9-1307-b2dc94a2eafe
|
:ID: d1ba1ca1-509b-13a9-1307-b2dc94a2eafe
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 34.0 [4/4]
|
* Version 34.0 [4/4]
|
||||||
** DONE [#A] Use bgg-api for BoardGameGeek lookups :vrobbler:feature:boardgames:personal:project:
|
** DONE [#A] Use bgg-api for BoardGameGeek lookups :vrobbler:feature:boardgames:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1389,6 +1613,7 @@ TypeError: can only concatenate str (not "NoneType") to str
|
|||||||
|
|
||||||
A good lesson in using constants for things.
|
A good lesson in using constants for things.
|
||||||
|
|
||||||
|
|
||||||
* Version 33.0 [3/3]
|
* Version 33.0 [3/3]
|
||||||
** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
|
** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1403,6 +1628,7 @@ TypeError: can only concatenate str (not "NoneType") to str
|
|||||||
:ID: 4955cc34-0882-50db-92f7-f36a95bf57a4
|
:ID: 4955cc34-0882-50db-92f7-f36a95bf57a4
|
||||||
:END:
|
:END:
|
||||||
<2025-10-28 Tue>
|
<2025-10-28 Tue>
|
||||||
|
|
||||||
* Version 32.0 [2/2]
|
* Version 32.0 [2/2]
|
||||||
** DONE [#B] Save path to reading source on book scrobbles and show it on the detail page :vrobbler:feature:books:personal:project:
|
** DONE [#B] Save path to reading source on book scrobbles and show it on the detail page :vrobbler:feature:books:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1412,6 +1638,7 @@ TypeError: can only concatenate str (not "NoneType") to str
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 9fe09567-11a3-7083-53c7-07458a9591d0
|
:ID: 9fe09567-11a3-7083-53c7-07458a9591d0
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 31.0 [3/3]
|
* Version 31.0 [3/3]
|
||||||
** DONE [#A] Stop comic book webpage scrobbles from overwriting old scrobbles :vrobbler:personal:bug:books:scrobbling:
|
** DONE [#A] Stop comic book webpage scrobbles from overwriting old scrobbles :vrobbler:personal:bug:books:scrobbling:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1425,6 +1652,7 @@ TypeError: can only concatenate str (not "NoneType") to str
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 9a870c05-6d20-0803-d35d-c03fbe1d0ee1
|
:ID: 9a870c05-6d20-0803-d35d-c03fbe1d0ee1
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 30.0 [3/3]
|
* Version 30.0 [3/3]
|
||||||
** DONE [#A] Fix readcomicsonline browsing to update pages :vrobbler:books:feature:comicbook:personal:project:scrobbling:
|
** DONE [#A] Fix readcomicsonline browsing to update pages :vrobbler:books:feature:comicbook:personal:project:scrobbling:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1439,10 +1667,13 @@ TypeError: can only concatenate str (not "NoneType") to str
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: d22cec3f-117f-f203-33a5-efbefa8a5cee
|
:ID: d22cec3f-117f-f203-33a5-efbefa8a5cee
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 29.0 [1/1]
|
* Version 29.0 [1/1]
|
||||||
** DONE HOTFIX podcast lookups, final
|
** DONE HOTFIX podcast lookups, final
|
||||||
|
|
||||||
* Version 28.0 [1/1]
|
* Version 28.0 [1/1]
|
||||||
** DONE HOTFIX podcast lookups
|
** DONE HOTFIX podcast lookups
|
||||||
|
|
||||||
* Version 27.0 [3/3]
|
* Version 27.0 [3/3]
|
||||||
** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
|
** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1483,6 +1714,7 @@ it's annoying.
|
|||||||
actually be much more reliable than the current state of the podcast lookup
|
actually be much more reliable than the current state of the podcast lookup
|
||||||
which depends on the file to be name properly.
|
which depends on the file to be name properly.
|
||||||
|
|
||||||
|
|
||||||
* Version 26.0 [3/3]
|
* Version 26.0 [3/3]
|
||||||
** DONE Clean up templates for scrobble details :vrobbler:personal:bug:templates:
|
** DONE Clean up templates for scrobble details :vrobbler:personal:bug:templates:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1496,6 +1728,7 @@ it's annoying.
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: c03a38ce-b337-f4fa-adba-aee08d4329f5
|
:ID: c03a38ce-b337-f4fa-adba-aee08d4329f5
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 25.0 [3/3]
|
* Version 25.0 [3/3]
|
||||||
** DONE Add basic food templates and fix urls :food:vrobbler:personal:project:bug:urls:
|
** DONE Add basic food templates and fix urls :food:vrobbler:personal:project:bug:urls:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1509,6 +1742,7 @@ it's annoying.
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 7debfbaf-cdd8-f49b-57ff-804bfe7c9236
|
:ID: 7debfbaf-cdd8-f49b-57ff-804bfe7c9236
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 24.0 [2/2]
|
* Version 24.0 [2/2]
|
||||||
** DONE Clean up logdata for various media :personal:feature:project:vrobbler:logdata:
|
** DONE Clean up logdata for various media :personal:feature:project:vrobbler:logdata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1519,6 +1753,7 @@ it's annoying.
|
|||||||
:ID: 1a1c0aa6-0313-c8be-1676-5d6adddef0a4
|
:ID: 1a1c0aa6-0313-c8be-1676-5d6adddef0a4
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
|
|
||||||
* Version 23.0 [3/3]
|
* Version 23.0 [3/3]
|
||||||
** DONE Add dynamic forms for LogData classes :personal:feature:vrobbler:project:forms:logdata:
|
** DONE Add dynamic forms for LogData classes :personal:feature:vrobbler:project:forms:logdata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1529,16 +1764,19 @@ it's annoying.
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 99f6bd77-dc8f-6ed1-0321-32a52c944264
|
:ID: 99f6bd77-dc8f-6ed1-0321-32a52c944264
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 19.0 [1/1]
|
* Version 19.0 [1/1]
|
||||||
** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project:
|
** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
|
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 18.7 [1/1]
|
* Version 18.7 [1/1]
|
||||||
** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal:
|
** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7
|
:ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 18.4 [2/2]
|
* Version 18.4 [2/2]
|
||||||
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
|
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1552,11 +1790,13 @@ it's annoying.
|
|||||||
- Note taken on [2025-07-20 Sun 16:21]
|
- Note taken on [2025-07-20 Sun 16:21]
|
||||||
|
|
||||||
This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly.
|
This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly.
|
||||||
|
|
||||||
* Version 18.3 [1/1]
|
* Version 18.3 [1/1]
|
||||||
** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
|
** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75
|
:ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 18 [4/4]
|
* Version 18 [4/4]
|
||||||
** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal:
|
** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1678,6 +1918,7 @@ it's annoying.
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c
|
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 17.0 [6/6]
|
* Version 17.0 [6/6]
|
||||||
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
|
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1741,6 +1982,7 @@ Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
|
|||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
|
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* Version 0.16.0 [19/19]
|
* Version 0.16.0 [19/19]
|
||||||
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
|
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -1846,6 +2088,7 @@ out using that.
|
|||||||
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
||||||
This was fixed a while ago, but there's a new manifested bug. Going to create a
|
This was fixed a while ago, but there's a new manifested bug. Going to create a
|
||||||
separate bug tracking ticket for that.
|
separate bug tracking ticket for that.
|
||||||
|
|
||||||
* Version 0.11.4 [9/9]
|
* Version 0.11.4 [9/9]
|
||||||
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
|
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
|
||||||
CLOSED: [2023-03-07 Tue 11:11]
|
CLOSED: [2023-03-07 Tue 11:11]
|
||||||
|
|||||||
2
justfile
2
justfile
@ -21,5 +21,5 @@ push:
|
|||||||
|
|
||||||
release kind="minor":
|
release kind="minor":
|
||||||
poetry run python scripts/release.py {{kind}}
|
poetry run python scripts/release.py {{kind}}
|
||||||
@push
|
just push
|
||||||
|
|
||||||
|
|||||||
29
poetry.lock
generated
29
poetry.lock
generated
@ -4966,6 +4966,18 @@ files = [
|
|||||||
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
|
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqids"
|
||||||
|
version = "0.5.2"
|
||||||
|
description = "Generate YouTube-like ids from numbers."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "sqids-0.5.2-py3-none-any.whl", hash = "sha256:0089ba823e21fd44290c7225f02fb0b5140c36e41959c04d86d3f6f2513799be"},
|
||||||
|
{file = "sqids-0.5.2.tar.gz", hash = "sha256:5ac08f0c5c9b6814bc2e7c79ee5931e0849d25d95c50e415771b022a44f58af9"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@ -5439,6 +5451,21 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
|
|||||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
|
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vadersentiment"
|
||||||
|
version = "3.3.2"
|
||||||
|
description = "VADER Sentiment Analysis. VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and rule-based sentiment analysis tool that is specifically attuned to sentiments expressed in social media, and works well on texts from other domains."
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311"},
|
||||||
|
{file = "vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
requests = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vine"
|
name = "vine"
|
||||||
version = "5.1.0"
|
version = "5.1.0"
|
||||||
@ -6005,4 +6032,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 = "bd3f14a9cfce403db426af98774f1e3c41b97283aa43f4bd80f84594ee0dd726"
|
content-hash = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "45.1"
|
version = "48.3"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
@ -62,6 +62,8 @@ recipe-scrapers = "^15.11.0"
|
|||||||
gpxpy = "^1.6.2"
|
gpxpy = "^1.6.2"
|
||||||
fitparse = "^1.2.0"
|
fitparse = "^1.2.0"
|
||||||
lxml = ">=5.5.0"
|
lxml = ">=5.5.0"
|
||||||
|
vaderSentiment = "^3.3.2"
|
||||||
|
sqids = "^0.5.2"
|
||||||
|
|
||||||
[tool.poetry.group.test]
|
[tool.poetry.group.test]
|
||||||
optional = true
|
optional = true
|
||||||
|
|||||||
@ -124,7 +124,7 @@ def main():
|
|||||||
|
|
||||||
if not done_items:
|
if not done_items:
|
||||||
print("No DONE items found in Backlog — nothing to release.")
|
print("No DONE items found in Backlog — nothing to release.")
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# 4. Build the new Version section text
|
# 4. Build the new Version section text
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import tempfile
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -128,6 +129,23 @@ class TestBirdingCSVImportModel:
|
|||||||
assert imp.import_type == "Birding CSV"
|
assert imp.import_type == "Birding CSV"
|
||||||
assert "Birding" in str(imp)
|
assert "Birding" in str(imp)
|
||||||
|
|
||||||
|
def test_record_error(self, db, user):
|
||||||
|
imp = BirdingCSVImport.objects.create(user=user)
|
||||||
|
assert imp.error_log is None
|
||||||
|
imp.record_error("test error")
|
||||||
|
imp.refresh_from_db()
|
||||||
|
assert imp.error_log is not None
|
||||||
|
assert "test error" in imp.error_log
|
||||||
|
|
||||||
|
def test_record_error_appends(self, db, user):
|
||||||
|
imp = BirdingCSVImport.objects.create(user=user)
|
||||||
|
imp.record_error("first error")
|
||||||
|
imp.record_error("second error")
|
||||||
|
imp.refresh_from_db()
|
||||||
|
assert imp.error_log.count("\n") == 1
|
||||||
|
assert "first error" in imp.error_log
|
||||||
|
assert "second error" in imp.error_log
|
||||||
|
|
||||||
@pytest.mark.django_db(transaction=True)
|
@pytest.mark.django_db(transaction=True)
|
||||||
def test_process_via_model(self, user, birding_csv_file):
|
def test_process_via_model(self, user, birding_csv_file):
|
||||||
imp = BirdingCSVImport.objects.create(user=user)
|
imp = BirdingCSVImport.objects.create(user=user)
|
||||||
@ -137,3 +155,35 @@ class TestBirdingCSVImportModel:
|
|||||||
imp.refresh_from_db()
|
imp.refresh_from_db()
|
||||||
assert imp.process_count == 1
|
assert imp.process_count == 1
|
||||||
assert imp.processed_finished is not None
|
assert imp.processed_finished is not None
|
||||||
|
|
||||||
|
def test_record_error_on_bad_csv(self, user, db):
|
||||||
|
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||||
|
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(content)
|
||||||
|
file_path = f.name
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||||
|
assert len(scrobbles) == 0
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Could not parse date/time" in errors[0]
|
||||||
|
|
||||||
|
def test_record_error_on_bad_location(self, user, db):
|
||||||
|
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||||
|
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(content)
|
||||||
|
file_path = f.name
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||||
|
assert len(scrobbles) == 0
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Skipping rows with no location" in errors[0]
|
||||||
|
|||||||
@ -26,5 +26,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(BirdingCSVImport)
|
@admin.register(BirdingCSVImport)
|
||||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
|
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import csv
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from dateutil import parser
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
@ -35,11 +37,12 @@ def parse_coords(location_str):
|
|||||||
|
|
||||||
def parse_timestamp(date_str, time_str):
|
def parse_timestamp(date_str, time_str):
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
dt_str = f"{date_str} {time_str}".strip()
|
||||||
|
dt = parser.parse(dt_str)
|
||||||
return dt
|
return dt
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
dt = parser.parse(date_str)
|
||||||
return dt
|
return dt
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||||
@ -61,7 +64,7 @@ def parse_int(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def import_birding_csv(file_path, user_id):
|
def import_birding_csv(file_path, user_id, record_error=None):
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
new_scrobbles = []
|
new_scrobbles = []
|
||||||
|
|
||||||
@ -80,11 +83,17 @@ def import_birding_csv(file_path, user_id):
|
|||||||
|
|
||||||
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
||||||
if not location_str:
|
if not location_str:
|
||||||
logger.warning("Skipping rows with no location")
|
msg = "Skipping rows with no location"
|
||||||
|
logger.warning(msg)
|
||||||
|
if record_error:
|
||||||
|
record_error(msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
timestamp = parse_timestamp(date_str, time_str)
|
timestamp = parse_timestamp(date_str, time_str)
|
||||||
if not timestamp:
|
if not timestamp:
|
||||||
|
msg = f"Could not parse date/time: {date_str} {time_str}"
|
||||||
|
if record_error:
|
||||||
|
record_error(msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-08 14:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("birds", "0002_birdingcsvimport"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="birdingcsvimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -61,9 +61,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def bird_list(self) -> str:
|
def bird_list(self) -> str:
|
||||||
if self.birds:
|
if self.birds:
|
||||||
return ", ".join(
|
return ", ".join([BirdSightingEntry(**b).__str__() for b in self.birds])
|
||||||
[BirdSightingEntry(**b).__str__() for b in self.birds]
|
|
||||||
)
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def as_html(self) -> str:
|
def as_html(self) -> str:
|
||||||
@ -80,9 +78,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.area:
|
if self.area:
|
||||||
html_parts.append(
|
html_parts.append(f'<div class="birding-area">Area: {self.area}</div>')
|
||||||
f'<div class="birding-area">Area: {self.area}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.party_size:
|
if self.party_size:
|
||||||
html_parts.append(
|
html_parts.append(
|
||||||
@ -105,9 +101,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.guide:
|
if self.guide:
|
||||||
html_parts.append(
|
html_parts.append(f'<div class="birding-guide">Guide: {self.guide}</div>')
|
||||||
f'<div class="birding-guide">Guide: {self.guide}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.duration_minutes:
|
if self.duration_minutes:
|
||||||
html_parts.append(
|
html_parts.append(
|
||||||
@ -183,9 +177,7 @@ class Bird(TimeStampedModel):
|
|||||||
|
|
||||||
class BirdingLocation(ScrobblableMixin):
|
class BirdingLocation(ScrobblableMixin):
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
geo_location = models.ForeignKey(
|
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
|
||||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
|
||||||
)
|
|
||||||
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
|
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
@ -193,7 +185,7 @@ class BirdingLocation(ScrobblableMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self):
|
def subtitle(self):
|
||||||
return ""
|
return self.geo_location
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strings(self) -> ScrobblableConstants:
|
def strings(self) -> ScrobblableConstants:
|
||||||
@ -224,6 +216,7 @@ class BirdingCSVImport(TimeStampedModel):
|
|||||||
processed_finished = models.DateTimeField(**BNULL)
|
processed_finished = models.DateTimeField(**BNULL)
|
||||||
process_log = models.TextField(**BNULL)
|
process_log = models.TextField(**BNULL)
|
||||||
process_count = models.IntegerField(**BNULL)
|
process_count = models.IntegerField(**BNULL)
|
||||||
|
error_log = models.TextField(**BNULL)
|
||||||
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
|
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -269,9 +262,7 @@ class BirdingCSVImport(TimeStampedModel):
|
|||||||
self.save(update_fields=["process_log", "process_count"])
|
self.save(update_fields=["process_log", "process_count"])
|
||||||
return
|
return
|
||||||
for count, scrobble in enumerate(scrobbles):
|
for count, scrobble in enumerate(scrobbles):
|
||||||
scrobble_str = (
|
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
||||||
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
|
||||||
)
|
|
||||||
log_line = f"{scrobble_str}"
|
log_line = f"{scrobble_str}"
|
||||||
if count > 0:
|
if count > 0:
|
||||||
log_line = "\n" + log_line
|
log_line = "\n" + log_line
|
||||||
@ -279,6 +270,14 @@ class BirdingCSVImport(TimeStampedModel):
|
|||||||
self.process_count = len(scrobbles)
|
self.process_count = len(scrobbles)
|
||||||
self.save(update_fields=["process_log", "process_count"])
|
self.save(update_fields=["process_log", "process_count"])
|
||||||
|
|
||||||
|
def record_error(self, error_message):
|
||||||
|
log_line = f"{timezone.now().isoformat()}: {error_message}"
|
||||||
|
if self.error_log:
|
||||||
|
self.error_log += "\n" + log_line
|
||||||
|
else:
|
||||||
|
self.error_log = log_line
|
||||||
|
self.save(update_fields=["error_log"])
|
||||||
|
|
||||||
def scrobbles(self):
|
def scrobbles(self):
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
@ -297,6 +296,13 @@ class BirdingCSVImport(TimeStampedModel):
|
|||||||
from birds.importer import import_birding_csv
|
from birds.importer import import_birding_csv
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
|
try:
|
||||||
self.record_log(scrobbles)
|
scrobbles = import_birding_csv(
|
||||||
self.mark_finished()
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|||||||
@ -266,8 +266,9 @@ class BoardGame(ScrobblableMixin):
|
|||||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
@property
|
||||||
return self.title
|
def subtitle(self) -> str:
|
||||||
|
return self.publisher
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
||||||
|
|||||||
@ -173,11 +173,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if self.issue_number and "Issue" not in str(self.title):
|
return f"{self.title} - {self.subtitle}"
|
||||||
return f"{self.title} - Issue {self.issue_number}"
|
|
||||||
if self.volume_number and "Volume" not in str(self.title):
|
|
||||||
return f"{self.title} - Volume {self.volume_number}"
|
|
||||||
return f"{self.title}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.pages:
|
if self.pages:
|
||||||
@ -188,7 +184,12 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self):
|
def subtitle(self):
|
||||||
return f" by {self.author}"
|
subtitle = self.author
|
||||||
|
if self.issue_number and "Issue" not in str(self.title):
|
||||||
|
subtitle += " - Issue {self.issue_number}"
|
||||||
|
if self.volume_number and "Volume" not in str(self.title):
|
||||||
|
subtitle += " - Volume {self.volume_number}"
|
||||||
|
return subtitle
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strings(self) -> ScrobblableConstants:
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_source(raw_data):
|
||||||
|
if "Artist" in raw_data:
|
||||||
|
return "Jellyfin"
|
||||||
|
if "artist" in raw_data:
|
||||||
|
return "Mopidy"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_raw_values(raw_data, source):
|
||||||
|
if source == "Jellyfin":
|
||||||
|
return raw_data.get("Artist", ""), raw_data.get("Album", "")
|
||||||
|
return raw_data.get("artist", ""), raw_data.get("album", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(name):
|
||||||
|
return name.strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _artist_mismatch(raw_artist, track_artist_names):
|
||||||
|
if not raw_artist or not track_artist_names:
|
||||||
|
return False
|
||||||
|
track_names = [_normalize(n) for n in track_artist_names.split(" / ")]
|
||||||
|
raw = _normalize(raw_artist)
|
||||||
|
if raw in track_names:
|
||||||
|
return False
|
||||||
|
if raw == _normalize(track_artist_names):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _album_mismatch(raw_album, track_album_name):
|
||||||
|
if not raw_album or not track_album_name:
|
||||||
|
return False
|
||||||
|
return _normalize(raw_album) != _normalize(track_album_name)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Outputs a CSV of track IDs where raw metadata from scrobble logs "
|
||||||
|
"does not match the track's stored artists or album"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--file-path",
|
||||||
|
type=str,
|
||||||
|
default="/tmp/metadata-report.csv",
|
||||||
|
help="Output CSV file path (default: /tmp/metadata-report.csv)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
file_path = options["file_path"]
|
||||||
|
|
||||||
|
qs = (
|
||||||
|
Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK)
|
||||||
|
.exclude(log__isnull=True)
|
||||||
|
.exclude(log={})
|
||||||
|
.select_related("track__album")
|
||||||
|
.prefetch_related("track__artists")
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for scrobble in qs:
|
||||||
|
track = scrobble.track
|
||||||
|
if not track:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_data = scrobble.log.get("raw_data")
|
||||||
|
if not raw_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source = _get_source(raw_data)
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_artist, raw_album = _get_raw_values(raw_data, source)
|
||||||
|
if not raw_artist and not raw_album:
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_artist_names = " / ".join(
|
||||||
|
track.artists.all().values_list("name", flat=True)
|
||||||
|
)
|
||||||
|
track_album_name = track.album.name if track.album else ""
|
||||||
|
|
||||||
|
if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch(
|
||||||
|
raw_album, track_album_name
|
||||||
|
):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"track_id": track.id,
|
||||||
|
"track_artist_name": track_artist_names,
|
||||||
|
"track_album_name": track_album_name,
|
||||||
|
"raw_artist": raw_artist,
|
||||||
|
"raw_album": raw_album,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldnames = [
|
||||||
|
"track_id",
|
||||||
|
"track_artist_name",
|
||||||
|
"track_album_name",
|
||||||
|
"raw_artist",
|
||||||
|
"raw_album",
|
||||||
|
"source",
|
||||||
|
]
|
||||||
|
with open(file_path, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Wrote {len(rows)} mismatched track(s) to {file_path}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-09 15:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0035_userprofile_monthly_mopidy_playlist_pattern"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="default_scrobble_visibility",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("shared", "Shared"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
default="shared",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-09 16:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0036_userprofile_default_scrobble_visibility"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="default_scrobble_visibility",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("shared", "Shared"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
default="private",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -9,6 +9,11 @@ from django.utils.functional import cached_property
|
|||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from encrypted_field import EncryptedField
|
from encrypted_field import EncryptedField
|
||||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||||
|
VISIBILITY_CHOICES = (
|
||||||
|
("public", "Public"),
|
||||||
|
("shared", "Shared"),
|
||||||
|
("private", "Private"),
|
||||||
|
)
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -79,6 +84,12 @@ class UserProfile(TimeStampedModel):
|
|||||||
enable_public_widgets = models.BooleanField(default=False)
|
enable_public_widgets = models.BooleanField(default=False)
|
||||||
widget_custom_css = models.TextField(**BNULL)
|
widget_custom_css = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
default_scrobble_visibility = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=VISIBILITY_CHOICES,
|
||||||
|
default="private",
|
||||||
|
)
|
||||||
|
|
||||||
home_scrobble_limit = models.IntegerField(default=20)
|
home_scrobble_limit = models.IntegerField(default=20)
|
||||||
|
|
||||||
weigh_in_units = models.CharField(
|
weigh_in_units = models.CharField(
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from scrobbles.models import (
|
|||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
ScaleCSVImport,
|
ScaleCSVImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
from scrobbles.mixins import Genre
|
||||||
@ -20,6 +21,7 @@ class ScrobbleInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
raw_id_fields = (
|
raw_id_fields = (
|
||||||
"video",
|
"video",
|
||||||
|
"channel",
|
||||||
"podcast_episode",
|
"podcast_episode",
|
||||||
"track",
|
"track",
|
||||||
"video_game",
|
"video_game",
|
||||||
@ -30,6 +32,7 @@ class ScrobbleInline(admin.TabularInline):
|
|||||||
"board_game",
|
"board_game",
|
||||||
"geo_location",
|
"geo_location",
|
||||||
"task",
|
"task",
|
||||||
|
"puzzle",
|
||||||
"mood",
|
"mood",
|
||||||
"brick_set",
|
"brick_set",
|
||||||
"trail",
|
"trail",
|
||||||
@ -54,52 +57,44 @@ class ImportBaseAdmin(admin.ModelAdmin):
|
|||||||
"process_count",
|
"process_count",
|
||||||
"processed_finished",
|
"processed_finished",
|
||||||
"processing_started",
|
"processing_started",
|
||||||
|
"error_log",
|
||||||
)
|
)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AudioScrobblerTSVImport)
|
@admin.register(AudioScrobblerTSVImport)
|
||||||
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
|
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LastFmImport)
|
@admin.register(LastFmImport)
|
||||||
class LastFmImportAdmin(ImportBaseAdmin):
|
class LastFmImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(KoReaderImport)
|
@admin.register(KoReaderImport)
|
||||||
class KoReaderImportAdmin(ImportBaseAdmin):
|
class KoReaderImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(RetroarchImport)
|
@admin.register(RetroarchImport)
|
||||||
class RetroarchImportAdmin(ImportBaseAdmin):
|
class RetroarchImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class RetroarchImportAdmin(ImportBaseAdmin):
|
class RetroarchImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BGStatsImport)
|
@admin.register(BGStatsImport)
|
||||||
class BGStatsImportAdmin(ImportBaseAdmin):
|
class BGStatsImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EBirdCSVImport)
|
@admin.register(EBirdCSVImport)
|
||||||
class EBirdCSVImportAdmin(ImportBaseAdmin):
|
class EBirdCSVImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ScaleCSVImport)
|
@admin.register(ScaleCSVImport)
|
||||||
class ScaleCSVImportAdmin(ImportBaseAdmin):
|
class ScaleCSVImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TrailGPXImport)
|
@admin.register(TrailGPXImport)
|
||||||
class TrailGPXImportAdmin(ImportBaseAdmin):
|
class TrailGPXImportAdmin(ImportBaseAdmin): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Genre)
|
@admin.register(Genre)
|
||||||
@ -122,6 +117,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"in_progress",
|
"in_progress",
|
||||||
"is_paused",
|
"is_paused",
|
||||||
"played_to_completion",
|
"played_to_completion",
|
||||||
|
"visibility",
|
||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
raw_id_fields = (
|
raw_id_fields = (
|
||||||
@ -149,6 +145,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"is_paused",
|
"is_paused",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
"media_type",
|
"media_type",
|
||||||
|
"visibility",
|
||||||
"long_play_complete",
|
"long_play_complete",
|
||||||
"source",
|
"source",
|
||||||
"timezone",
|
"timezone",
|
||||||
@ -167,6 +164,14 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ShareViewLog)
|
||||||
|
class ShareViewLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("scrobble", "ip_address", "created")
|
||||||
|
list_filter = ("created",)
|
||||||
|
date_hierarchy = "created"
|
||||||
|
raw_id_fields = ("scrobble",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(FavoriteMedia)
|
@admin.register(FavoriteMedia)
|
||||||
class FavoriteMediaAdmin(admin.ModelAdmin):
|
class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||||
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from scrobbles.constants import Visibility
|
||||||
from scrobbles.models import (
|
from scrobbles.models import (
|
||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from rest_framework import permissions, viewsets
|
from rest_framework import permissions, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
from scrobbles.api.serializers import (
|
from scrobbles.api.serializers import (
|
||||||
AudioScrobblerTSVImportSerializer,
|
AudioScrobblerTSVImportSerializer,
|
||||||
KoReaderImportSerializer,
|
KoReaderImportSerializer,
|
||||||
@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset().filter(user=self.request.user)
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def regenerate_share_token(self, request, uuid=None):
|
||||||
|
scrobble = self.get_object()
|
||||||
|
scrobble.regenerate_share_token()
|
||||||
|
return Response({"share_url": scrobble.get_share_url()})
|
||||||
|
|
||||||
|
|
||||||
class KoReaderImportViewSet(viewsets.ModelViewSet):
|
class KoReaderImportViewSet(viewsets.ModelViewSet):
|
||||||
queryset = KoReaderImport.objects.all().order_by("-created")
|
queryset = KoReaderImport.objects.all().order_by("-created")
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
|
from django.db import models
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||||
|
|
||||||
|
class Visibility(models.TextChoices):
|
||||||
|
PUBLIC = "public", "Public"
|
||||||
|
SHARED = "shared", "Shared"
|
||||||
|
PRIVATE = "private", "Private"
|
||||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||||
|
|
||||||
LONG_PLAY_MEDIA = {
|
LONG_PLAY_MEDIA = {
|
||||||
|
|||||||
@ -112,9 +112,7 @@ class BaseLogData(JSONDataclass):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if dt is None:
|
if dt is None:
|
||||||
m = re.match(
|
m = re.match(r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned)
|
||||||
r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned
|
|
||||||
)
|
|
||||||
if m:
|
if m:
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(
|
dt = datetime.strptime(
|
||||||
@ -143,9 +141,25 @@ class BaseLogData(JSONDataclass):
|
|||||||
|
|
||||||
md = markdown.Markdown(extensions=["extra"])
|
md = markdown.Markdown(extensions=["extra"])
|
||||||
allowed_tags = [
|
allowed_tags = [
|
||||||
"p", "br", "strong", "em", "a", "ul", "ol", "li",
|
"p",
|
||||||
"code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",
|
"br",
|
||||||
"hr", "img",
|
"strong",
|
||||||
|
"em",
|
||||||
|
"a",
|
||||||
|
"ul",
|
||||||
|
"ol",
|
||||||
|
"li",
|
||||||
|
"code",
|
||||||
|
"pre",
|
||||||
|
"blockquote",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"hr",
|
||||||
|
"img",
|
||||||
]
|
]
|
||||||
|
|
||||||
note_items = []
|
note_items = []
|
||||||
@ -174,7 +188,9 @@ class BaseLogData(JSONDataclass):
|
|||||||
|
|
||||||
ts_html = ""
|
ts_html = ""
|
||||||
if ts:
|
if ts:
|
||||||
ts_html = f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
|
ts_html = (
|
||||||
|
f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
|
||||||
|
)
|
||||||
|
|
||||||
content_html = bleach.clean(
|
content_html = bleach.clean(
|
||||||
md.convert(text),
|
md.convert(text),
|
||||||
@ -194,6 +210,14 @@ class LongPlayLogData(JSONDataclass):
|
|||||||
long_play_complete: bool = False
|
long_play_complete: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SportEventLogData(BaseLogData):
|
||||||
|
thesportsdb_id: Optional[str] = None
|
||||||
|
start: Optional[str] = None
|
||||||
|
round_name: Optional[str] = None
|
||||||
|
season_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WithPeopleLogData(JSONDataclass):
|
class WithPeopleLogData(JSONDataclass):
|
||||||
with_people_ids: Optional[list[int]] = None
|
with_people_ids: Optional[list[int]] = None
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.utils import analyze_scrobble_sentiment
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Backfill VADER sentiment analysis for scrobbles with notes"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--commit",
|
||||||
|
action="store_true",
|
||||||
|
help="Actually update scrobble logs with sentiment data",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--overwrite",
|
||||||
|
action="store_true",
|
||||||
|
help="Re-analyze scrobbles that already have sentiment data",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
commit = options["commit"]
|
||||||
|
overwrite = options["overwrite"]
|
||||||
|
|
||||||
|
qs = Scrobble.objects.filter(
|
||||||
|
~models.Q(log__notes__isnull=True)
|
||||||
|
& ~models.Q(log__notes=[])
|
||||||
|
& ~models.Q(log__notes={})
|
||||||
|
)
|
||||||
|
if not overwrite:
|
||||||
|
qs = qs.filter(log__sentiment__isnull=True)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
analyzed_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
self.stdout.write(f"Found {total} scrobbles to process")
|
||||||
|
|
||||||
|
for scrobble in qs.iterator():
|
||||||
|
if commit:
|
||||||
|
analyzed = analyze_scrobble_sentiment(scrobble, overwrite=overwrite)
|
||||||
|
else:
|
||||||
|
notes_str = ""
|
||||||
|
if scrobble.logdata:
|
||||||
|
notes_str = scrobble.logdata.notes_as_str()
|
||||||
|
analyzed = bool(notes_str)
|
||||||
|
|
||||||
|
if analyzed:
|
||||||
|
analyzed_count += 1
|
||||||
|
if commit:
|
||||||
|
scores = (scrobble.log or {}).get("sentiment", {})
|
||||||
|
self.stdout.write(
|
||||||
|
f" Updated scrobble {scrobble.id}: compound={scores.get('compound', 'N/A')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f" [DRY RUN] Would analyze scrobble {scrobble.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"\nAnalyzed {analyzed_count} scrobbles, skipped {skipped_count}"
|
||||||
|
)
|
||||||
|
if not commit:
|
||||||
|
self.stdout.write("Run with --commit to persist changes")
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-08 14:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0089_favoritemedia"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="audioscrobblertsvimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bgstatsimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ebirdcsvimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="koreaderimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="lastfmimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="retroarchimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scalecsvimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="trailgpximport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-09 15:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0090_audioscrobblertsvimport_error_log_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="share_token",
|
||||||
|
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="visibility",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("shared", "Shared"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="shared",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_share_token(apps, schema_editor):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
batch = []
|
||||||
|
for scrobble in Scrobble.objects.filter(share_token__isnull=True).iterator(
|
||||||
|
chunk_size=500
|
||||||
|
):
|
||||||
|
scrobble.share_token = uuid4()
|
||||||
|
batch.append(scrobble)
|
||||||
|
if batch:
|
||||||
|
Scrobble.objects.bulk_update(batch, ["share_token"], batch_size=500)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0091_scrobble_share_token_scrobble_visibility"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
backfill_share_token,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-09 16:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0092_backfill_visibility_and_share_token"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="share_token",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="share_token_version",
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-09 16:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0093_remove_scrobble_share_token_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="share_view_count",
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="visibility",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("shared", "Shared"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="private",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ShareViewLog",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("user_agent", models.TextField(blank=True, null=True)),
|
||||||
|
("referrer", models.URLField(blank=True, max_length=2048, null=True)),
|
||||||
|
(
|
||||||
|
"scrobble",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="share_views",
|
||||||
|
to="scrobbles.scrobble",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -65,6 +65,9 @@ class ScrobblableMixin(TimeStampedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.title} - {self.subtitle}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def run_time_seconds(self) -> int:
|
def run_time_seconds(self) -> int:
|
||||||
run_time = 900
|
run_time = 900
|
||||||
|
|||||||
@ -18,6 +18,8 @@ from bricksets.models import BrickSet
|
|||||||
from charts.utils import build_charts
|
from charts.utils import build_charts
|
||||||
from dataclass_wizard.errors import ParseError
|
from dataclass_wizard.errors import ParseError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from scrobbles.constants import Visibility
|
||||||
|
from scrobbles.sqids import encode_scrobble_share
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -74,6 +76,7 @@ class BaseFileImportMixin(TimeStampedModel):
|
|||||||
processed_finished = models.DateTimeField(**BNULL)
|
processed_finished = models.DateTimeField(**BNULL)
|
||||||
process_log = models.TextField(**BNULL)
|
process_log = models.TextField(**BNULL)
|
||||||
process_count = models.IntegerField(**BNULL)
|
process_count = models.IntegerField(**BNULL)
|
||||||
|
error_log = models.TextField(**BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@ -158,6 +161,14 @@ class BaseFileImportMixin(TimeStampedModel):
|
|||||||
self.process_count = len(scrobbles)
|
self.process_count = len(scrobbles)
|
||||||
self.save(update_fields=["process_log", "process_count"])
|
self.save(update_fields=["process_log", "process_count"])
|
||||||
|
|
||||||
|
def record_error(self, error_message):
|
||||||
|
log_line = f"{timezone.now().isoformat()}: {error_message}"
|
||||||
|
if self.error_log:
|
||||||
|
self.error_log += "\n" + log_line
|
||||||
|
else:
|
||||||
|
self.error_log = log_line
|
||||||
|
self.save(update_fields=["error_log"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def upload_file_path(self):
|
def upload_file_path(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@ -213,9 +224,14 @@ class KoReaderImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
|
try:
|
||||||
self.record_log(scrobbles)
|
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
|
||||||
self.mark_finished()
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class AudioScrobblerTSVImport(BaseFileImportMixin):
|
class AudioScrobblerTSVImport(BaseFileImportMixin):
|
||||||
@ -255,10 +271,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
|
try:
|
||||||
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
|
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
|
||||||
self.record_log(scrobbles)
|
self.record_log(scrobbles)
|
||||||
self.mark_finished()
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class ScaleCSVImport(BaseFileImportMixin):
|
class ScaleCSVImport(BaseFileImportMixin):
|
||||||
@ -297,9 +317,14 @@ class ScaleCSVImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
|
try:
|
||||||
self.record_log(scrobbles)
|
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
|
||||||
self.mark_finished()
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class TrailGPXImport(BaseFileImportMixin):
|
class TrailGPXImport(BaseFileImportMixin):
|
||||||
@ -337,11 +362,16 @@ class TrailGPXImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
scrobbles = import_trail_gpx(
|
try:
|
||||||
self.upload_file_path, self.user.id, self.original_filename
|
scrobbles = import_trail_gpx(
|
||||||
)
|
self.upload_file_path, self.user.id, self.original_filename
|
||||||
self.record_log(scrobbles)
|
)
|
||||||
self.mark_finished()
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class LastFmImport(BaseFileImportMixin):
|
class LastFmImport(BaseFileImportMixin):
|
||||||
@ -391,11 +421,14 @@ class LastFmImport(BaseFileImportMixin):
|
|||||||
last_processed = last_import.processed_finished
|
last_processed = last_import.processed_finished
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
|
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)
|
except Exception as e:
|
||||||
self.mark_finished()
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class RetroarchImport(BaseFileImportMixin):
|
class RetroarchImport(BaseFileImportMixin):
|
||||||
@ -426,43 +459,48 @@ class RetroarchImport(BaseFileImportMixin):
|
|||||||
logger.info(f"You told me to force import from Retroarch")
|
logger.info(f"You told me to force import from Retroarch")
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
|
scrobbles = None
|
||||||
|
try:
|
||||||
|
if self.lrtl_file:
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
if self.lrtl_file:
|
tmpdir = tempfile.mkdtemp()
|
||||||
import os
|
try:
|
||||||
import tempfile
|
zip_path = os.path.join(tmpdir, "archive.zip")
|
||||||
import zipfile
|
with open(zip_path, "wb") as f:
|
||||||
|
f.write(self.lrtl_file.read())
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
zf.extractall(tmpdir)
|
||||||
|
os.unlink(zip_path)
|
||||||
|
scrobbles = retroarch.import_retroarch_lrtl_files(
|
||||||
|
tmpdir + "/",
|
||||||
|
self.user.id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
if not self.user.profile.retroarch_path:
|
||||||
|
logger.info(
|
||||||
|
"Trying to import Retroarch logs, but user has no retroarch_path configured"
|
||||||
|
)
|
||||||
|
self.mark_finished()
|
||||||
|
return
|
||||||
|
|
||||||
tmpdir = tempfile.mkdtemp()
|
|
||||||
try:
|
|
||||||
zip_path = os.path.join(tmpdir, "archive.zip")
|
|
||||||
with open(zip_path, "wb") as f:
|
|
||||||
f.write(self.lrtl_file.read())
|
|
||||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
||||||
zf.extractall(tmpdir)
|
|
||||||
os.unlink(zip_path)
|
|
||||||
scrobbles = retroarch.import_retroarch_lrtl_files(
|
scrobbles = retroarch.import_retroarch_lrtl_files(
|
||||||
tmpdir + "/",
|
self.user.profile.retroarch_path,
|
||||||
self.user.id,
|
self.user.id,
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
self.record_log(scrobbles)
|
||||||
else:
|
except Exception as e:
|
||||||
if not self.user.profile.retroarch_path:
|
self.record_error(f"Import failed: {e}")
|
||||||
logger.info(
|
logger.exception(f"Import failed for {self}")
|
||||||
"Tying to import Retroarch logs, but user has no retroarch_path configured"
|
finally:
|
||||||
)
|
self.mark_finished()
|
||||||
self.mark_finished()
|
|
||||||
return
|
|
||||||
|
|
||||||
scrobbles = retroarch.import_retroarch_lrtl_files(
|
|
||||||
self.user.profile.retroarch_path,
|
|
||||||
self.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.record_log(scrobbles)
|
|
||||||
self.mark_finished()
|
|
||||||
|
|
||||||
|
|
||||||
class BGStatsImport(BaseFileImportMixin):
|
class BGStatsImport(BaseFileImportMixin):
|
||||||
@ -499,17 +537,21 @@ class BGStatsImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
import json
|
from scrobbles.scrobblers import email_scrobble_board_game
|
||||||
|
|
||||||
from scrobbles.scrobblers import email_scrobble_board_game
|
with open(self.upload_file_path, "r", encoding="utf-8") as f:
|
||||||
|
parsed_json = json.load(f)
|
||||||
|
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
|
||||||
|
|
||||||
with open(self.upload_file_path, "r", encoding="utf-8") as f:
|
self.record_log(scrobbles)
|
||||||
parsed_json = json.load(f)
|
except Exception as e:
|
||||||
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
self.record_log(scrobbles)
|
finally:
|
||||||
self.mark_finished()
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class EBirdCSVImport(BaseFileImportMixin):
|
class EBirdCSVImport(BaseFileImportMixin):
|
||||||
@ -554,9 +596,16 @@ class EBirdCSVImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
|
try:
|
||||||
self.record_log(scrobbles)
|
scrobbles = import_birding_csv(
|
||||||
self.mark_finished()
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleQuerySet(models.QuerySet):
|
class ScrobbleQuerySet(models.QuerySet):
|
||||||
@ -586,6 +635,18 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShareViewLog(TimeStampedModel):
|
||||||
|
scrobble = models.ForeignKey(
|
||||||
|
"Scrobble", on_delete=models.CASCADE, related_name="share_views"
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(**BNULL)
|
||||||
|
user_agent = models.TextField(**BNULL)
|
||||||
|
referrer = models.URLField(max_length=2048, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"View of {self.scrobble} at {self.created}"
|
||||||
|
|
||||||
|
|
||||||
class Scrobble(TimeStampedModel):
|
class Scrobble(TimeStampedModel):
|
||||||
"""A scrobble tracks played media items by a user."""
|
"""A scrobble tracks played media items by a user."""
|
||||||
|
|
||||||
@ -647,6 +708,14 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_type = models.CharField(
|
media_type = models.CharField(
|
||||||
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
||||||
)
|
)
|
||||||
|
visibility = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=Visibility.choices,
|
||||||
|
default=Visibility.PRIVATE,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
share_token_version = models.PositiveIntegerField(default=0)
|
||||||
|
share_view_count = models.PositiveIntegerField(default=0)
|
||||||
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
|
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
|
||||||
|
|
||||||
# Time keeping
|
# Time keeping
|
||||||
@ -828,6 +897,16 @@ class Scrobble(TimeStampedModel):
|
|||||||
self.save(update_fields=["uuid"])
|
self.save(update_fields=["uuid"])
|
||||||
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
|
||||||
|
|
||||||
|
def get_share_url(self):
|
||||||
|
if self.visibility == Visibility.PRIVATE:
|
||||||
|
return None
|
||||||
|
sqid = encode_scrobble_share(self.id, self.share_token_version)
|
||||||
|
return reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
|
||||||
|
|
||||||
|
def regenerate_share_token(self):
|
||||||
|
self.share_token_version += 1
|
||||||
|
self.save(update_fields=["share_token_version"])
|
||||||
|
|
||||||
def push_to_archivebox(self):
|
def push_to_archivebox(self):
|
||||||
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
|
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
|
||||||
self.media_obj.push_to_archivebox
|
self.media_obj.push_to_archivebox
|
||||||
@ -882,8 +961,13 @@ class Scrobble(TimeStampedModel):
|
|||||||
logger.warning("Log data could not be loaded", e)
|
logger.warning("Log data could not be loaded", e)
|
||||||
return logdata_cls()
|
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(**log_dict)
|
return logdata_cls(**logdata_kwargs)
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Could not parse log data",
|
"Could not parse log data",
|
||||||
|
|||||||
@ -242,12 +242,13 @@ def manual_scrobble_event(
|
|||||||
):
|
):
|
||||||
data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
|
data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
|
||||||
|
|
||||||
event = SportEvent.find_or_create(data_dict)
|
event, logdata = SportEvent.find_or_create(data_dict)
|
||||||
scrobble_dict = {
|
scrobble_dict = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"playback_position_seconds": 0,
|
||||||
"source": "TheSportsDB",
|
"source": "TheSportsDB",
|
||||||
|
"log": logdata,
|
||||||
}
|
}
|
||||||
return Scrobble.create_or_update(event, user_id, scrobble_dict)
|
return Scrobble.create_or_update(event, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|||||||
36
vrobbler/apps/scrobbles/sqids.py
Normal file
36
vrobbler/apps/scrobbles/sqids.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from sqids import Sqids
|
||||||
|
|
||||||
|
_sqids = None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_alphabet() -> str:
|
||||||
|
import hashlib
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
digest = hashlib.sha256(settings.SECRET_KEY.encode()).hexdigest()
|
||||||
|
base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
seed = int(digest[:16], 16)
|
||||||
|
shuffled = list(base)
|
||||||
|
for i in range(len(shuffled) - 1, 0, -1):
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF
|
||||||
|
j = seed % (i + 1)
|
||||||
|
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
||||||
|
return "".join(shuffled)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sqids() -> Sqids:
|
||||||
|
global _sqids
|
||||||
|
if _sqids is None:
|
||||||
|
_sqids = Sqids(
|
||||||
|
alphabet=_make_alphabet(),
|
||||||
|
min_length=6,
|
||||||
|
)
|
||||||
|
return _sqids
|
||||||
|
|
||||||
|
|
||||||
|
def encode_scrobble_share(scrobble_id: int, version: int) -> str:
|
||||||
|
return get_sqids().encode([scrobble_id, version])
|
||||||
|
|
||||||
|
|
||||||
|
def decode_scrobble_share(sqid: str) -> list[int] | None:
|
||||||
|
return get_sqids().decode(sqid)
|
||||||
@ -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.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -544,6 +545,30 @@ def send_mood_checkin():
|
|||||||
send_mood_checkin_reminders()
|
send_mood_checkin_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def backfill_scrobble_sentiment():
|
||||||
|
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.utils import analyze_scrobble_sentiment
|
||||||
|
|
||||||
|
qs = Scrobble.objects.filter(
|
||||||
|
models.Q(log__notes__isnull=False)
|
||||||
|
& ~models.Q(log__notes=[])
|
||||||
|
& ~models.Q(log__notes={})
|
||||||
|
& models.Q(log__sentiment__isnull=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for scrobble in qs.iterator():
|
||||||
|
if analyze_scrobble_sentiment(scrobble):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Backfilled sentiment for %d scrobbles",
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def add_favorite_to_mopidy_playlist(favorite_id):
|
def add_favorite_to_mopidy_playlist(favorite_id):
|
||||||
from scrobbles.models import FavoriteMedia
|
from scrobbles.models import FavoriteMedia
|
||||||
|
|||||||
@ -153,11 +153,32 @@ urlpatterns = [
|
|||||||
name="long-plays",
|
name="long-plays",
|
||||||
),
|
),
|
||||||
path("scrobbles/", views.ScrobbleListView.as_view(), name="scrobble-list"),
|
path("scrobbles/", views.ScrobbleListView.as_view(), name="scrobble-list"),
|
||||||
|
path("explore/", views.ScrobbleExploreView.as_view(), name="explore"),
|
||||||
|
path(
|
||||||
|
"shared/<str:sqid>/",
|
||||||
|
views.ScrobbleShareView.as_view(),
|
||||||
|
name="shared-detail",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/",
|
"scrobbles/<slug:uuid>/",
|
||||||
views.ScrobbleDetailView.as_view(),
|
views.ScrobbleDetailView.as_view(),
|
||||||
name="detail",
|
name="detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"scrobbles/<slug:uuid>/regenerate-share-token/",
|
||||||
|
views.RegenerateShareTokenView.as_view(),
|
||||||
|
name="regenerate-share-token",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"scrobbles/<slug:uuid>/change-visibility/",
|
||||||
|
views.ChangeVisibilityView.as_view(),
|
||||||
|
name="change-visibility",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"scrobbles/<slug:uuid>/share-analytics/",
|
||||||
|
views.ScrobbleShareAnalyticsView.as_view(),
|
||||||
|
name="share-analytics",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
||||||
views.add_to_mopidy_queue,
|
views.add_to_mopidy_queue,
|
||||||
|
|||||||
@ -802,3 +802,32 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
|||||||
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
||||||
]
|
]
|
||||||
return words
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_scrobble_sentiment(scrobble, overwrite=False) -> bool:
|
||||||
|
"""Run VADER sentiment analysis on a scrobble's notes.
|
||||||
|
|
||||||
|
Stores result in log["sentiment"] as a dict with keys:
|
||||||
|
neg, neu, pos, compound.
|
||||||
|
|
||||||
|
Returns True if analyzed, False if skipped (no notes or already done).
|
||||||
|
"""
|
||||||
|
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
||||||
|
|
||||||
|
log = scrobble.log or {}
|
||||||
|
if not overwrite and log.get("sentiment") is not None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
notes_str = ""
|
||||||
|
if scrobble.logdata:
|
||||||
|
notes_str = scrobble.logdata.notes_as_str()
|
||||||
|
if not notes_str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
analyzer = SentimentIntensityAnalyzer()
|
||||||
|
scores = analyzer.polarity_scores(notes_str)
|
||||||
|
|
||||||
|
log["sentiment"] = scores
|
||||||
|
scrobble.log = log
|
||||||
|
scrobble.save(update_fields=["log"])
|
||||||
|
return True
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
from django.db.models import Count, Max, Q, Sum
|
from django.db.models import Count, F, Max, Q, Sum
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from rest_framework.authentication import TokenAuthentication
|
from rest_framework.authentication import TokenAuthentication
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
@ -38,7 +38,7 @@ from django.utils import timezone
|
|||||||
from django.utils.dateformat import DateFormat
|
from django.utils.dateformat import DateFormat
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic import DetailView, FormView, TemplateView
|
from django.views.generic import DetailView, FormView, TemplateView, View
|
||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from moods.models import Mood
|
from moods.models import Mood
|
||||||
@ -72,6 +72,8 @@ from scrobbles.constants import (
|
|||||||
)
|
)
|
||||||
from scrobbles.export import export_scrobbles
|
from scrobbles.export import export_scrobbles
|
||||||
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||||
|
from scrobbles.constants import Visibility
|
||||||
|
from scrobbles.sqids import decode_scrobble_share
|
||||||
from scrobbles.models import (
|
from scrobbles.models import (
|
||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
@ -83,6 +85,7 @@ from scrobbles.models import (
|
|||||||
ScaleCSVImport,
|
ScaleCSVImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
ScrobbleQuerySet,
|
ScrobbleQuerySet,
|
||||||
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
)
|
)
|
||||||
from scrobbles.scrobblers import *
|
from scrobbles.scrobblers import *
|
||||||
@ -184,7 +187,7 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context_data = super().get_context_data(**kwargs)
|
context_data = super().get_context_data(**kwargs)
|
||||||
scrobbles = []
|
scrobbles = []
|
||||||
if not self.request.user.is_anonymous:
|
if not self.request.user.is_anonymous and hasattr(self.object, "scrobble_set"):
|
||||||
scrobbles = self.object.scrobble_set.filter(
|
scrobbles = self.object.scrobble_set.filter(
|
||||||
user=self.request.user
|
user=self.request.user
|
||||||
).order_by("-timestamp")
|
).order_by("-timestamp")
|
||||||
@ -439,8 +442,7 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
|||||||
full_qs = getattr(self, "_full_queryset", None)
|
full_qs = getattr(self, "_full_queryset", None)
|
||||||
if full_qs is not None and getattr(self, "tag_list", []):
|
if full_qs is not None and getattr(self, "tag_list", []):
|
||||||
total = (
|
total = (
|
||||||
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"]
|
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"] or 0
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
ctx["total_time_seconds"] = total
|
ctx["total_time_seconds"] = total
|
||||||
return ctx
|
return ctx
|
||||||
@ -1137,6 +1139,15 @@ class ScrobbleDetailView(DetailView):
|
|||||||
slug_url_kwarg = "uuid"
|
slug_url_kwarg = "uuid"
|
||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
scrobble = super().get_object(queryset=queryset)
|
||||||
|
user = self.request.user
|
||||||
|
if scrobble.visibility == Visibility.PUBLIC:
|
||||||
|
return scrobble
|
||||||
|
if user.is_authenticated and scrobble.user == user:
|
||||||
|
return scrobble
|
||||||
|
raise Http404
|
||||||
|
|
||||||
def get_form_class(self):
|
def get_form_class(self):
|
||||||
return self.object.media_obj.logdata_cls().form()
|
return self.object.media_obj.logdata_cls().form()
|
||||||
|
|
||||||
@ -1245,8 +1256,7 @@ class ScrobbleDetailView(DetailView):
|
|||||||
track=media_obj, user=self.object.user
|
track=media_obj, user=self.object.user
|
||||||
).order_by("-timestamp")[:20]
|
).order_by("-timestamp")[:20]
|
||||||
context["has_mopidy_uri"] = any(
|
context["has_mopidy_uri"] = any(
|
||||||
(s.log or {}).get("raw_data", {}).get("mopidy_uri")
|
(s.log or {}).get("raw_data", {}).get("mopidy_uri") for s in scrobbles
|
||||||
for s in scrobbles
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context["has_mopidy_uri"] = False
|
context["has_mopidy_uri"] = False
|
||||||
@ -1254,6 +1264,93 @@ class ScrobbleDetailView(DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleShareView(TemplateView):
|
||||||
|
template_name = "scrobbles/scrobble_share.html"
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
sqid = self.kwargs.get("sqid")
|
||||||
|
decoded = decode_scrobble_share(sqid)
|
||||||
|
if not decoded or len(decoded) != 2:
|
||||||
|
raise Http404
|
||||||
|
scrobble_id, version = decoded
|
||||||
|
scrobble = get_object_or_404(Scrobble, id=scrobble_id)
|
||||||
|
if scrobble.share_token_version != version:
|
||||||
|
raise Http404
|
||||||
|
if scrobble.visibility not in (Visibility.PUBLIC, Visibility.SHARED):
|
||||||
|
raise Http404
|
||||||
|
Scrobble.objects.filter(id=scrobble.id).update(
|
||||||
|
share_view_count=F("share_view_count") + 1
|
||||||
|
)
|
||||||
|
scrobble.refresh_from_db(fields=["share_view_count"])
|
||||||
|
ShareViewLog.objects.create(
|
||||||
|
scrobble=scrobble,
|
||||||
|
ip_address=self.request.META.get("REMOTE_ADDR"),
|
||||||
|
user_agent=self.request.META.get("HTTP_USER_AGENT", "")[:500],
|
||||||
|
referrer=self.request.META.get("HTTP_REFERER", ""),
|
||||||
|
)
|
||||||
|
return scrobble
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
scrobble = self.get_object()
|
||||||
|
context["object"] = scrobble
|
||||||
|
context["log_form"] = None
|
||||||
|
context["related_scrobbles"] = Scrobble.objects.none()
|
||||||
|
context["has_mopidy_uri"] = False
|
||||||
|
if self.request.user.is_authenticated:
|
||||||
|
media_type = scrobble.media_type
|
||||||
|
fk_field = ScrobbleDetailView.MEDIA_FK_MAP.get(media_type)
|
||||||
|
media_obj = scrobble.media_obj
|
||||||
|
if fk_field and media_obj:
|
||||||
|
context["is_favorited"] = FavoriteMedia.objects.filter(
|
||||||
|
user=self.request.user, **{fk_field: media_obj}
|
||||||
|
).exists()
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleExploreView(ListView):
|
||||||
|
model = Scrobble
|
||||||
|
paginate_by = 100
|
||||||
|
template_name = "scrobbles/scrobble_explore.html"
|
||||||
|
queryset = Scrobble.objects.filter(visibility=Visibility.PUBLIC).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RegenerateShareTokenView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request, uuid):
|
||||||
|
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||||
|
scrobble.regenerate_share_token()
|
||||||
|
return redirect(scrobble.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeVisibilityView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request, uuid):
|
||||||
|
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||||
|
visibility = request.POST.get("visibility")
|
||||||
|
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
|
||||||
|
return redirect(scrobble.get_absolute_url())
|
||||||
|
scrobble.visibility = visibility
|
||||||
|
scrobble.save(update_fields=["visibility"])
|
||||||
|
return redirect(scrobble.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
|
||||||
|
model = Scrobble
|
||||||
|
slug_field = "uuid"
|
||||||
|
slug_url_kwarg = "uuid"
|
||||||
|
template_name = "scrobbles/scrobble_share_analytics.html"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Scrobble.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
scrobble = self.object
|
||||||
|
context["share_views"] = scrobble.share_views.order_by("-created")[:50]
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class BaseEmbeddableWidget(TemplateView):
|
class BaseEmbeddableWidget(TemplateView):
|
||||||
template_name = "scrobbles/embeddable_top_media.html"
|
template_name = "scrobbles/embeddable_top_media.html"
|
||||||
|
|
||||||
|
|||||||
@ -59,19 +59,27 @@ class SportEventAdmin(admin.ModelAdmin):
|
|||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
|
"league",
|
||||||
"event_type",
|
"event_type",
|
||||||
"start",
|
|
||||||
"comp_str",
|
"comp_str",
|
||||||
"round",
|
|
||||||
)
|
)
|
||||||
list_filter = ("round__season", "home_team", "away_team")
|
list_filter = ("league", "event_type")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
def comp_str(self, obj):
|
def comp_str(self, obj):
|
||||||
if obj.home_team:
|
teams = list(obj.teams.all())
|
||||||
return f"{obj.away_team} @ {obj.home_team}"
|
if len(teams) >= 2:
|
||||||
if obj.player_one:
|
return f"{teams[1]} v {teams[0]}"
|
||||||
return f"{obj.player_one} v {obj.player_two}"
|
|
||||||
|
players = list(obj.players.all())
|
||||||
|
if len(players) >= 2:
|
||||||
|
return f"{players[0]} v {players[1]}"
|
||||||
|
|
||||||
|
if len(players) == 1:
|
||||||
|
return str(players[0])
|
||||||
|
|
||||||
|
if len(teams) == 1:
|
||||||
|
return str(teams[0])
|
||||||
|
|||||||
0
vrobbler/apps/sports/management/__init__.py
Normal file
0
vrobbler/apps/sports/management/__init__.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from sports.models import League, Team
|
||||||
|
from sports.thesportsdb import enrich_league_logo, enrich_team_logo, has_logo
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Fetch missing league and team logos from TheSportsDB"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be done without making changes",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
|
||||||
|
leagues = League.objects.filter(thesportsdb_id__isnull=False)
|
||||||
|
for league in leagues:
|
||||||
|
if has_logo(league):
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(f"Would enrich logo for league: {league.name} ({league.thesportsdb_id})")
|
||||||
|
else:
|
||||||
|
enrich_league_logo(league)
|
||||||
|
|
||||||
|
teams = Team.objects.filter(thesportsdb_id__isnull=False)
|
||||||
|
for team in teams:
|
||||||
|
if has_logo(team):
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(f"Would enrich logo for team: {team.name} ({team.thesportsdb_id})")
|
||||||
|
else:
|
||||||
|
enrich_team_logo(team)
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-06 15:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("sports", "0018_alter_sportevent_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="league",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="sports.league",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_key(event):
|
||||||
|
if event.home_team_id and event.away_team_id:
|
||||||
|
return ("teams", event.league_id, event.home_team_id, event.away_team_id)
|
||||||
|
if event.player_one_id and event.player_two_id:
|
||||||
|
return ("players", event.league_id, event.player_one_id, event.player_two_id)
|
||||||
|
return ("title", event.league_id, event.event_type, (event.title or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def build_logdata(event):
|
||||||
|
logdata = {}
|
||||||
|
if event.thesportsdb_id:
|
||||||
|
logdata["thesportsdb_id"] = event.thesportsdb_id
|
||||||
|
if event.start:
|
||||||
|
logdata["start"] = (
|
||||||
|
event.start.isoformat()
|
||||||
|
if hasattr(event.start, "isoformat")
|
||||||
|
else str(event.start)
|
||||||
|
)
|
||||||
|
if event.round:
|
||||||
|
logdata["round_name"] = event.round.name or str(event.round)
|
||||||
|
if event.round.season:
|
||||||
|
logdata["season_name"] = event.round.season.name or str(event.round.season)
|
||||||
|
return logdata
|
||||||
|
|
||||||
|
|
||||||
|
def merge_scrobble_logs(scrobble, logdata):
|
||||||
|
existing_log = scrobble.log or {}
|
||||||
|
if isinstance(existing_log, str):
|
||||||
|
existing_log = json.loads(existing_log)
|
||||||
|
existing_log.update(logdata)
|
||||||
|
scrobble.log = existing_log
|
||||||
|
scrobble.save(update_fields=["log"])
|
||||||
|
|
||||||
|
|
||||||
|
def populate_league(event):
|
||||||
|
if event.league:
|
||||||
|
return
|
||||||
|
if event.round and event.round.season and event.round.season.league:
|
||||||
|
event.league = event.round.season.league
|
||||||
|
event.save(update_fields=["league"])
|
||||||
|
|
||||||
|
|
||||||
|
def populate_m2m(event):
|
||||||
|
if event.home_team_id:
|
||||||
|
event.teams.add(event.home_team_id)
|
||||||
|
if event.away_team_id:
|
||||||
|
event.teams.add(event.away_team_id)
|
||||||
|
if event.player_one_id:
|
||||||
|
event.players.add(event.player_one_id)
|
||||||
|
if event.player_two_id:
|
||||||
|
event.players.add(event.player_two_id)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_sport_event_data(apps, schema_editor):
|
||||||
|
SportEvent = apps.get_model("sports", "SportEvent")
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
canonical_events = {}
|
||||||
|
|
||||||
|
for event in SportEvent.objects.using(db_alias).iterator():
|
||||||
|
populate_league(event)
|
||||||
|
key = canonical_key(event)
|
||||||
|
|
||||||
|
canonical = canonical_events.get(key)
|
||||||
|
if not canonical:
|
||||||
|
canonical_events[key] = event
|
||||||
|
populate_m2m(event)
|
||||||
|
logdata = build_logdata(event)
|
||||||
|
for scrobble in (
|
||||||
|
Scrobble.objects.using(db_alias).filter(sport_event=event).iterator()
|
||||||
|
):
|
||||||
|
merge_scrobble_logs(scrobble, logdata)
|
||||||
|
else:
|
||||||
|
logdata = build_logdata(event)
|
||||||
|
for scrobble in (
|
||||||
|
Scrobble.objects.using(db_alias).filter(sport_event=event).iterator()
|
||||||
|
):
|
||||||
|
scrobble.sport_event = canonical
|
||||||
|
merge_scrobble_logs(scrobble, logdata)
|
||||||
|
scrobble.save(update_fields=["sport_event", "log"])
|
||||||
|
event.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("sports", "0019_sportevent_league_alter_sportevent_away_team_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="teams",
|
||||||
|
field=models.ManyToManyField(blank=True, to="sports.team"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="players",
|
||||||
|
field=models.ManyToManyField(blank=True, to="sports.player"),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_sport_event_data, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/sports/migrations/0021_team_logo.py
Normal file
20
vrobbler/apps/sports/migrations/0021_team_logo.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-07 03:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("sports", "0020_migrate_sport_event_data_to_logdata"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="team",
|
||||||
|
name="logo",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="sports/team-logos/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("sports", "0021_team_logo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="home_team",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="away_team",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="player_one",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sportevent",
|
||||||
|
name="player_two",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -68,6 +68,7 @@ class Season(TheSportsDbMixin):
|
|||||||
|
|
||||||
class Team(TheSportsDbMixin):
|
class Team(TheSportsDbMixin):
|
||||||
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
|
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
logo = models.ImageField(upload_to="sports/team-logos/", **BNULL)
|
||||||
|
|
||||||
|
|
||||||
class Player(TheSportsDbMixin):
|
class Player(TheSportsDbMixin):
|
||||||
@ -88,50 +89,61 @@ class Round(TheSportsDbMixin):
|
|||||||
class SportEvent(ScrobblableMixin):
|
class SportEvent(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "SPORT_COMPLETION_PERCENT", 90)
|
COMPLETION_PERCENT = getattr(settings, "SPORT_COMPLETION_PERCENT", 90)
|
||||||
|
|
||||||
thesportsdb_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
event_type = models.CharField(
|
event_type = models.CharField(
|
||||||
max_length=2,
|
max_length=2,
|
||||||
choices=SportEventType.choices,
|
choices=SportEventType.choices,
|
||||||
default=SportEventType.UNKNOWN,
|
default=SportEventType.UNKNOWN,
|
||||||
)
|
)
|
||||||
round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)
|
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
start = models.DateTimeField(**BNULL)
|
|
||||||
home_team = models.ForeignKey(
|
|
||||||
Team,
|
|
||||||
on_delete=models.DO_NOTHING,
|
|
||||||
related_name="home_event_set",
|
|
||||||
**BNULL,
|
|
||||||
)
|
|
||||||
away_team = models.ForeignKey(
|
|
||||||
Team,
|
|
||||||
on_delete=models.DO_NOTHING,
|
|
||||||
related_name="away_event_set",
|
|
||||||
**BNULL,
|
|
||||||
)
|
|
||||||
player_one = models.ForeignKey(
|
|
||||||
Player,
|
|
||||||
on_delete=models.DO_NOTHING,
|
|
||||||
related_name="player_one_set",
|
|
||||||
**BNULL,
|
|
||||||
)
|
|
||||||
player_two = models.ForeignKey(
|
|
||||||
Player,
|
|
||||||
on_delete=models.DO_NOTHING,
|
|
||||||
related_name="player_two_set",
|
|
||||||
**BNULL,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
@property
|
||||||
return (
|
def logdata_cls(self):
|
||||||
f"{self.start.date()} - {self.round} - {self.home_team} v {self.away_team}"
|
from scrobbles.dataclasses import SportEventLogData
|
||||||
)
|
|
||||||
|
return SportEventLogData
|
||||||
|
|
||||||
|
teams = models.ManyToManyField(Team, blank=True)
|
||||||
|
players = models.ManyToManyField(Player, blank=True)
|
||||||
|
|
||||||
|
# Deprecated - data migrated to scrobble.log via SportEventLogData
|
||||||
|
thesportsdb_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
start = models.DateTimeField(**BNULL)
|
||||||
|
round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
old_instance = None
|
||||||
|
try:
|
||||||
|
old_instance = UserProfile.objects.get(pk=self.pk)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not self.title or (old_instance and old_instance.title != self.title):
|
||||||
|
self.title = self.comp_str
|
||||||
|
|
||||||
|
super(SportEvent, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.title} - {self.subtitle}"
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("sports:event_detail", kwargs={"slug": self.uuid})
|
return reverse("sports:event_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self):
|
def subtitle(self) -> str:
|
||||||
return self.round.season.league
|
league = self.league
|
||||||
|
if self.league and self.league.abbreviation_str:
|
||||||
|
league = self.league.abbreviation_str
|
||||||
|
return f"{league} {self.get_event_type_display()}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def comp_str(self) -> str:
|
||||||
|
if self.players.exists():
|
||||||
|
return " v ".join(str(p) for p in self.players.all())
|
||||||
|
|
||||||
|
if self.teams.exists():
|
||||||
|
return " v ".join(str(t) for t in self.teams.all())
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strings(self) -> ScrobblableConstants:
|
def strings(self) -> ScrobblableConstants:
|
||||||
@ -147,16 +159,17 @@ class SportEvent(ScrobblableMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
url = ""
|
if self.league and self.league.logo:
|
||||||
if self.round.season.league.logo:
|
return self.league.logo.url
|
||||||
url = self.round.season.league.logo.url
|
return ""
|
||||||
return url
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, data_dict: Dict) -> "Event":
|
def find_or_create(cls, data_dict: Dict) -> tuple["Event", dict]:
|
||||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
"""Given a data dict from TheSportsDB, finds or creates a canonical
|
||||||
the video and, if need, TV Series, creating both if they don't yet
|
SportEvent by teams, players or title, and returns (event, logdata).
|
||||||
exist.
|
|
||||||
|
The logdata dict contains per-scrobble details (thesportsdb_id, start,
|
||||||
|
round/season names) that should be stored in the scrobble's log field.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Find or create our Sport
|
# Find or create our Sport
|
||||||
@ -187,32 +200,29 @@ class SportEvent(ScrobblableMixin):
|
|||||||
|
|
||||||
# Find or create our Round
|
# Find or create our Round
|
||||||
rid = data_dict.get("RoundId")
|
rid = data_dict.get("RoundId")
|
||||||
round, r_created = Round.objects.get_or_create(
|
round_obj, r_created = Round.objects.get_or_create(
|
||||||
thesportsdb_id=rid,
|
thesportsdb_id=rid,
|
||||||
season=season,
|
season=season,
|
||||||
name=rid,
|
name=rid,
|
||||||
)
|
)
|
||||||
if r_created:
|
if r_created:
|
||||||
round.season = season
|
round_obj.season = season
|
||||||
round.save(update_fields=["season"])
|
round_obj.save(update_fields=["season"])
|
||||||
|
|
||||||
# Set some special data for Tennis
|
# Build logdata with per-scrobble details
|
||||||
player_one = None
|
logdata = {}
|
||||||
player_two = None
|
logdata["thesportsdb_id"] = data_dict.get("EventId")
|
||||||
if data_dict.get("Sport") == "Tennis":
|
start = data_dict.get("Start")
|
||||||
event_name = data_dict.get("Name", "")
|
if start:
|
||||||
if not round.name:
|
logdata["start"] = (
|
||||||
round.name = get_round_name_from_event(event_name)
|
start.isoformat() if hasattr(start, "isoformat") else str(start)
|
||||||
round.save(update_fields=["name"])
|
)
|
||||||
|
if round_obj:
|
||||||
players_list = get_players_from_event(event_name)
|
logdata["round_name"] = round_obj.name or str(round_obj)
|
||||||
player_one = Player.objects.filter(name__icontains=players_list[0]).first()
|
if round_obj.season:
|
||||||
if not player_one:
|
logdata["season_name"] = round_obj.season.name or str(round_obj.season)
|
||||||
player_one = Player.objects.create(name=players_list[0])
|
|
||||||
player_two = Player.objects.filter(name__icontains=players_list[1]).first()
|
|
||||||
if not player_two:
|
|
||||||
player_two = Player.objects.create(name=players_list[1])
|
|
||||||
|
|
||||||
|
# Look up or create teams/players
|
||||||
home_team = None
|
home_team = None
|
||||||
away_team = None
|
away_team = None
|
||||||
if data_dict.get("HomeTeamName"):
|
if data_dict.get("HomeTeamName"):
|
||||||
@ -221,27 +231,73 @@ class SportEvent(ScrobblableMixin):
|
|||||||
"thesportsdb_id": data_dict.get("HomeTeamId", ""),
|
"thesportsdb_id": data_dict.get("HomeTeamId", ""),
|
||||||
"league": league,
|
"league": league,
|
||||||
}
|
}
|
||||||
home_team, _created = Team.objects.get_or_create(**home_team_dict)
|
home_team, ht_created = Team.objects.get_or_create(**home_team_dict)
|
||||||
|
if ht_created:
|
||||||
|
from sports.thesportsdb import enrich_team_logo
|
||||||
|
|
||||||
|
enrich_team_logo(home_team)
|
||||||
|
|
||||||
away_team_dict = {
|
away_team_dict = {
|
||||||
"name": data_dict.get("AwayTeamName", ""),
|
"name": data_dict.get("AwayTeamName", ""),
|
||||||
"thesportsdb_id": data_dict.get("AwayTeamId", ""),
|
"thesportsdb_id": data_dict.get("AwayTeamId", ""),
|
||||||
"league": league,
|
"league": league,
|
||||||
}
|
}
|
||||||
away_team, _created = Team.objects.get_or_create(**away_team_dict)
|
away_team, at_created = Team.objects.get_or_create(**away_team_dict)
|
||||||
|
if at_created:
|
||||||
|
from sports.thesportsdb import enrich_team_logo
|
||||||
|
|
||||||
event_dict = {
|
enrich_team_logo(away_team)
|
||||||
"thesportsdb_id": data_dict.get("EventId"),
|
|
||||||
"title": data_dict.get("Name"),
|
|
||||||
"event_type": sport.default_event_type,
|
|
||||||
"home_team": home_team,
|
|
||||||
"away_team": away_team,
|
|
||||||
"player_one": player_one,
|
|
||||||
"player_two": player_two,
|
|
||||||
"start": data_dict.get("Start"),
|
|
||||||
"round": round,
|
|
||||||
"base_run_time_seconds": data_dict.get("RunTime"),
|
|
||||||
}
|
|
||||||
event, _created = cls.objects.get_or_create(**event_dict)
|
|
||||||
|
|
||||||
return event
|
players_list = []
|
||||||
|
if data_dict.get("Sport") == "Tennis":
|
||||||
|
event_name = data_dict.get("Name", "")
|
||||||
|
if not round_obj.name:
|
||||||
|
round_obj.name = get_round_name_from_event(event_name)
|
||||||
|
round_obj.save(update_fields=["name"])
|
||||||
|
|
||||||
|
players_list = get_players_from_event(event_name)
|
||||||
|
|
||||||
|
# Find existing canonical event by teams, players, or title
|
||||||
|
event = None
|
||||||
|
if home_team and away_team:
|
||||||
|
event = (
|
||||||
|
cls.objects.filter(league=league, teams=home_team)
|
||||||
|
.filter(teams=away_team)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not event and players_list:
|
||||||
|
player_objs = []
|
||||||
|
for player_name in players_list:
|
||||||
|
player = Player.objects.filter(name__icontains=player_name).first()
|
||||||
|
if not player:
|
||||||
|
player = Player.objects.create(name=player_name)
|
||||||
|
player_objs.append(player)
|
||||||
|
qs = cls.objects.filter(league=league, players=player_objs[0])
|
||||||
|
for player in player_objs[1:]:
|
||||||
|
qs = qs.filter(players=player)
|
||||||
|
event = qs.first()
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
title = data_dict.get("Name", "").strip()
|
||||||
|
if title:
|
||||||
|
event = cls.objects.filter(league=league, title=title).first()
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
event = cls.objects.create(
|
||||||
|
title=data_dict.get("Name"),
|
||||||
|
event_type=sport.default_event_type,
|
||||||
|
league=league,
|
||||||
|
base_run_time_seconds=data_dict.get("RunTime"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure M2M is populated on the canonical event
|
||||||
|
if home_team and not event.teams.filter(id=home_team.id).exists():
|
||||||
|
event.teams.add(home_team)
|
||||||
|
if away_team and not event.teams.filter(id=away_team.id).exists():
|
||||||
|
event.teams.add(away_team)
|
||||||
|
for player_name in players_list:
|
||||||
|
player = Player.objects.filter(name__icontains=player_name).first()
|
||||||
|
if player and not event.players.filter(id=player.id).exists():
|
||||||
|
event.players.add(player)
|
||||||
|
|
||||||
|
return event, logdata
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from pysportsdb import TheSportsDbClient
|
from pysportsdb import TheSportsDbClient
|
||||||
from sports.models import Sport
|
from sports.models import League, Sport, Team
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -12,6 +14,84 @@ API_KEY = getattr(settings, "THESPORTSDB_API_KEY", "2")
|
|||||||
client = TheSportsDbClient(api_key=API_KEY)
|
client = TheSportsDbClient(api_key=API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def has_logo(league_or_team) -> bool:
|
||||||
|
"""Check if a model instance has a logo (handles both NULL and empty string)."""
|
||||||
|
return bool(league_or_team.logo and league_or_team.logo.name)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_league_logo(league: League) -> None:
|
||||||
|
"""Fetch the league badge from TheSportsDB and save it as the league logo."""
|
||||||
|
if not league.thesportsdb_id or has_logo(league):
|
||||||
|
return
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"https://www.thesportsdb.com/api/v1/json/{API_KEY}"
|
||||||
|
f"/lookupleague.php?id={league.thesportsdb_id}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
data = resp.json()
|
||||||
|
leagues = data.get("leagues", [])
|
||||||
|
if not leagues:
|
||||||
|
return
|
||||||
|
badge_url = leagues[0].get("strBadge")
|
||||||
|
if badge_url:
|
||||||
|
r = requests.get(badge_url, timeout=10)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{league.uuid or league.thesportsdb_id}.png"
|
||||||
|
league.logo.save(fname, ContentFile(r.content), save=True)
|
||||||
|
logger.info(
|
||||||
|
"Saved league logo from TheSportsDB",
|
||||||
|
extra={"league_id": league.id, "league_name": league.name},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to fetch league logo from TheSportsDB",
|
||||||
|
extra={"league_id": league.id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_team_logo(team: Team) -> None:
|
||||||
|
"""Fetch the team badge from TheSportsDB and save it as the team logo."""
|
||||||
|
if not team.thesportsdb_id or has_logo(team):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
badge_url = None
|
||||||
|
|
||||||
|
# Try direct lookup by thesportsdb_id first (more reliable)
|
||||||
|
url = (
|
||||||
|
f"https://www.thesportsdb.com/api/v1/json/{API_KEY}"
|
||||||
|
f"/lookupteam.php?id={team.thesportsdb_id}"
|
||||||
|
)
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
data = resp.json()
|
||||||
|
api_teams = data.get("teams", [])
|
||||||
|
if api_teams:
|
||||||
|
badge_url = api_teams[0].get("strBadge")
|
||||||
|
else:
|
||||||
|
# Fall back to name search
|
||||||
|
result = client.search_teams(team.name) or {}
|
||||||
|
api_teams = result.get("teams", [])
|
||||||
|
if api_teams:
|
||||||
|
badge_url = api_teams[0].get("strBadge")
|
||||||
|
|
||||||
|
if badge_url:
|
||||||
|
r = requests.get(badge_url, timeout=10)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{team.uuid or team.thesportsdb_id}.png"
|
||||||
|
team.logo.save(fname, ContentFile(r.content), save=True)
|
||||||
|
logger.info(
|
||||||
|
"Saved team logo from TheSportsDB",
|
||||||
|
extra={"team_id": team.id, "team_name": team.name},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to fetch team logo from TheSportsDB",
|
||||||
|
extra={"team_id": team.id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -23,6 +103,18 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
sport, _created = Sport.objects.get_or_create(thesportsdb_id=event.get("strSport"))
|
sport, _created = Sport.objects.get_or_create(thesportsdb_id=event.get("strSport"))
|
||||||
|
|
||||||
|
# Find or create the league and optionally enrich its logo
|
||||||
|
lid = event.get("idLeague")
|
||||||
|
league, l_created = League.objects.get_or_create(
|
||||||
|
thesportsdb_id=lid,
|
||||||
|
defaults={"name": event.get("strLeague", "")},
|
||||||
|
)
|
||||||
|
if l_created:
|
||||||
|
league.name = event.get("strLeague", "")
|
||||||
|
league.sport = sport
|
||||||
|
league.save(update_fields=["name", "sport"])
|
||||||
|
enrich_league_logo(league)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start = parse(event.get("strTimestamp"))
|
start = parse(event.get("strTimestamp"))
|
||||||
except:
|
except:
|
||||||
@ -38,7 +130,7 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
|||||||
"RunTime": sport.default_event_run_time_seconds,
|
"RunTime": sport.default_event_run_time_seconds,
|
||||||
"Sport": event.get("strSport"),
|
"Sport": event.get("strSport"),
|
||||||
"Season": event.get("strSeason"),
|
"Season": event.get("strSeason"),
|
||||||
"LeagueId": event.get("idLeague"),
|
"LeagueId": lid,
|
||||||
"LeagueName": event.get("strLeague"),
|
"LeagueName": event.get("strLeague"),
|
||||||
"HomeTeamId": event.get("idHomeTeam"),
|
"HomeTeamId": event.get("idHomeTeam"),
|
||||||
"HomeTeamName": event.get("strHomeTeam"),
|
"HomeTeamName": event.get("strHomeTeam"),
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
from django.views import generic
|
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
|
from vrobbler.apps.scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||||
|
|
||||||
|
|
||||||
class SportEventListView(generic.ListView):
|
class SportEventListView(ScrobbleableListView):
|
||||||
model = SportEvent
|
model = SportEvent
|
||||||
paginate_by = 50
|
paginate_by = 50
|
||||||
|
|
||||||
|
|
||||||
class SportEventDetailView(generic.DetailView):
|
class SportEventDetailView(ScrobbleableDetailView):
|
||||||
model = SportEvent
|
model = SportEvent
|
||||||
slug_field = "uuid"
|
slug_field = "uuid"
|
||||||
|
|||||||
@ -163,12 +163,9 @@ class VideoGame(LongPlayScrobblableMixin):
|
|||||||
platforms = models.ManyToManyField(VideoGamePlatform)
|
platforms = models.ManyToManyField(VideoGamePlatform)
|
||||||
retroarch_name = models.CharField(max_length=255, **BNULL)
|
retroarch_name = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self):
|
def subtitle(self):
|
||||||
return f" On {self.platforms.first()}"
|
return f"{self.platforms.first()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strings(self) -> ScrobblableConstants:
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
|||||||
@ -71,6 +71,7 @@ class VideoMetadata:
|
|||||||
twitch_id: Optional[str] = "",
|
twitch_id: Optional[str] = "",
|
||||||
base_run_time_seconds: int = 900,
|
base_run_time_seconds: int = 900,
|
||||||
):
|
):
|
||||||
|
self.title = ""
|
||||||
self.imdb_id = imdb_id
|
self.imdb_id = imdb_id
|
||||||
self.youtube_id = youtube_id
|
self.youtube_id = youtube_id
|
||||||
self.twitch_id = twitch_id
|
self.twitch_id = twitch_id
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from scrobbles.mixins import (
|
|||||||
)
|
)
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
from videos.metadata import VideoMetadata
|
from videos.metadata import VideoMetadata
|
||||||
|
from videos.sources.omdb import lookup_video_from_omdb
|
||||||
from videos.sources.tmdb import lookup_video_from_tmdb
|
from videos.sources.tmdb import lookup_video_from_tmdb
|
||||||
from videos.sources.youtube import lookup_video_from_youtube
|
from videos.sources.youtube import lookup_video_from_youtube
|
||||||
|
|
||||||
@ -224,6 +225,8 @@ class Series(TimeStampedModel):
|
|||||||
|
|
||||||
def is_episode_playing(self, user_id: int) -> bool:
|
def is_episode_playing(self, user_id: int) -> bool:
|
||||||
last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
|
last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
|
||||||
|
if not last_scrobble:
|
||||||
|
return False
|
||||||
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):
|
||||||
@ -255,9 +258,20 @@ class Series(TimeStampedModel):
|
|||||||
logger.info("Series not created and overwrite=False, returning")
|
logger.info("Series not created and overwrite=False, returning")
|
||||||
return series
|
return series
|
||||||
|
|
||||||
vdict, _, cover, genres = lookup_video_from_tmdb(
|
try:
|
||||||
imdb_id
|
metadata = lookup_video_from_tmdb(imdb_id)
|
||||||
).as_dict_with_cover_and_genres()
|
except Exception as e:
|
||||||
|
logger.warning(f"TMDB lookup failed for series {imdb_id}: {e}")
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
if not metadata or not metadata.title:
|
||||||
|
metadata = lookup_video_from_omdb(imdb_id)
|
||||||
|
|
||||||
|
if not metadata or not metadata.title:
|
||||||
|
logger.warning(f"No metadata found for series {imdb_id} from TMDB or OMDB")
|
||||||
|
return series
|
||||||
|
|
||||||
|
vdict, _, cover, genres = metadata.as_dict_with_cover_and_genres()
|
||||||
vdict.pop("video_type")
|
vdict.pop("video_type")
|
||||||
|
|
||||||
vdict["name"] = vdict.pop("title")
|
vdict["name"] = vdict.pop("title")
|
||||||
@ -406,6 +420,84 @@ class Video(ScrobblableMixin):
|
|||||||
fname = f"{self.title}_{self.uuid}.jpg"
|
fname = f"{self.title}_{self.uuid}.jpg"
|
||||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
def fix_metadata(self, force_update: bool = False) -> None:
|
||||||
|
if self.video_type == self.VideoType.YOUTUBE and self.youtube_id:
|
||||||
|
vdict, _, cover, genres = lookup_video_from_youtube(
|
||||||
|
self.youtube_id
|
||||||
|
).as_dict_with_cover_and_genres()
|
||||||
|
|
||||||
|
for k, v in vdict.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
if cover:
|
||||||
|
self.save_image_from_url(cover)
|
||||||
|
if genres:
|
||||||
|
self.genre.add(*genres)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.video_type == self.VideoType.TWITCH and self.twitch_id:
|
||||||
|
from videos.sources.twitch import lookup_video_from_twitch
|
||||||
|
|
||||||
|
metadata = lookup_video_from_twitch(self.twitch_id)
|
||||||
|
self.title = metadata.title or f"Twitch VOD {self.twitch_id}"
|
||||||
|
self.overview = metadata.overview
|
||||||
|
self.base_run_time_seconds = metadata.base_run_time_seconds
|
||||||
|
if metadata.upload_date:
|
||||||
|
self.upload_date = metadata.upload_date
|
||||||
|
if metadata.year:
|
||||||
|
self.year = metadata.year
|
||||||
|
self.video_type = Video.VideoType.TWITCH
|
||||||
|
|
||||||
|
if metadata.channel_id:
|
||||||
|
from videos.models import Channel
|
||||||
|
|
||||||
|
self.channel = Channel.objects.filter(
|
||||||
|
id=metadata.channel_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
if metadata.cover_url:
|
||||||
|
self.save_image_from_url(metadata.cover_url)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.imdb_id:
|
||||||
|
try:
|
||||||
|
metadata = lookup_video_from_tmdb(self.imdb_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"TMDB lookup failed for {self.imdb_id}: {e}")
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
if not metadata or not metadata.title:
|
||||||
|
metadata = lookup_video_from_omdb(self.imdb_id)
|
||||||
|
|
||||||
|
if not metadata or not metadata.title:
|
||||||
|
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
|
||||||
|
return
|
||||||
|
|
||||||
|
vdict, series_id, cover, genres = (
|
||||||
|
metadata.as_dict_with_cover_and_genres()
|
||||||
|
)
|
||||||
|
|
||||||
|
for k, v in vdict.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
if series_id:
|
||||||
|
self.tv_series = Series.find_or_create(imdb_id=series_id)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
if cover:
|
||||||
|
self.save_image_from_url(cover)
|
||||||
|
if genres:
|
||||||
|
self.genre.add(*genres)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"No metadata source available for {self} (type={self.video_type})"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_from_youtube_id(cls, youtube_id: str, overwrite: bool = False) -> "Video":
|
def get_from_youtube_id(cls, youtube_id: str, overwrite: bool = False) -> "Video":
|
||||||
video, created = cls.objects.get_or_create(youtube_id=youtube_id)
|
video, created = cls.objects.get_or_create(youtube_id=youtube_id)
|
||||||
@ -432,9 +524,20 @@ class Video(ScrobblableMixin):
|
|||||||
if not created and not overwrite:
|
if not created and not overwrite:
|
||||||
return video
|
return video
|
||||||
|
|
||||||
vdict, series_id, cover, genres = lookup_video_from_tmdb(
|
try:
|
||||||
imdb_id
|
metadata = lookup_video_from_tmdb(imdb_id)
|
||||||
).as_dict_with_cover_and_genres()
|
except Exception as e:
|
||||||
|
logger.warning(f"TMDB lookup failed for {imdb_id}: {e}")
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
if not metadata or not metadata.title:
|
||||||
|
metadata = lookup_video_from_omdb(imdb_id)
|
||||||
|
|
||||||
|
if not metadata or not metadata.title:
|
||||||
|
logger.warning(f"No metadata found for {imdb_id} from TMDB or OMDB")
|
||||||
|
return video
|
||||||
|
|
||||||
|
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
|
||||||
|
|
||||||
if created or overwrite:
|
if created or overwrite:
|
||||||
for k, v in vdict.items():
|
for k, v in vdict.items():
|
||||||
|
|||||||
83
vrobbler/apps/videos/sources/omdb.py
Normal file
83
vrobbler/apps/videos/sources/omdb.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from videos.metadata import VideoMetadata, VideoType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OMDB_API_KEY = getattr(settings, "OMDB_API_KEY", "")
|
||||||
|
|
||||||
|
OMDB_URL = "https://www.omdbapi.com/"
|
||||||
|
RUNTIME_RE = re.compile(r"(\d+)\s*min")
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_video_from_omdb(imdb_id: str) -> Optional[VideoMetadata]:
|
||||||
|
if not imdb_id.startswith("tt"):
|
||||||
|
imdb_id = f"tt{imdb_id}"
|
||||||
|
|
||||||
|
if not OMDB_API_KEY:
|
||||||
|
logger.warning("No OMDB API key configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
params = {"apikey": OMDB_API_KEY, "i": imdb_id, "plot": "full"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(OMDB_URL, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"OMDB API error for {imdb_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.get("Response") == "False":
|
||||||
|
logger.info(f"OMDB no result for {imdb_id}: {data.get('Error')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = VideoMetadata(imdb_id=imdb_id)
|
||||||
|
metadata.title = data.get("Title")
|
||||||
|
metadata.plot = data.get("Plot")
|
||||||
|
metadata.overview = data.get("Plot")
|
||||||
|
|
||||||
|
raw_year = data.get("Year")
|
||||||
|
if raw_year and raw_year.isdigit():
|
||||||
|
metadata.year = int(raw_year)
|
||||||
|
|
||||||
|
raw_rating = data.get("imdbRating")
|
||||||
|
if raw_rating and raw_rating != "N/A":
|
||||||
|
metadata.imdb_rating = raw_rating
|
||||||
|
|
||||||
|
raw_cover = data.get("Poster")
|
||||||
|
if raw_cover and raw_cover != "N/A":
|
||||||
|
metadata.cover_url = raw_cover
|
||||||
|
|
||||||
|
raw_runtime = data.get("Runtime")
|
||||||
|
if raw_runtime:
|
||||||
|
match = RUNTIME_RE.match(raw_runtime)
|
||||||
|
if match:
|
||||||
|
metadata.base_run_time_seconds = int(match.group(1)) * 60
|
||||||
|
|
||||||
|
media_type = data.get("Type")
|
||||||
|
if media_type == "movie":
|
||||||
|
metadata.video_type = VideoType.MOVIE.value
|
||||||
|
elif media_type in ("series", "episode"):
|
||||||
|
metadata.video_type = VideoType.TV_EPISODE.value
|
||||||
|
|
||||||
|
if media_type == "episode":
|
||||||
|
raw_season = data.get("Season")
|
||||||
|
if raw_season and raw_season != "N/A":
|
||||||
|
metadata.season_number = int(raw_season)
|
||||||
|
raw_episode = data.get("Episode")
|
||||||
|
if raw_episode and raw_episode != "N/A":
|
||||||
|
metadata.episode_number = int(raw_episode)
|
||||||
|
series_imdb_id = data.get("seriesID")
|
||||||
|
if series_imdb_id and series_imdb_id != "N/A":
|
||||||
|
metadata.tv_series_imdb_id = series_imdb_id
|
||||||
|
|
||||||
|
raw_genres = data.get("Genre")
|
||||||
|
if raw_genres:
|
||||||
|
metadata.genres = [g.strip() for g in raw_genres.split(",") if g.strip()]
|
||||||
|
|
||||||
|
return metadata
|
||||||
@ -3,14 +3,11 @@ import logging
|
|||||||
import pendulum
|
import pendulum
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from themoviedb import TMDb
|
from themoviedb import TMDb
|
||||||
from tmdbv3api import TV, Movie, TMDb as TMDb_direct
|
from tmdbv3api import TV, Movie
|
||||||
from videos.metadata import VideoMetadata, VideoType
|
from videos.metadata import VideoMetadata, VideoType
|
||||||
|
|
||||||
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
|
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
|
||||||
|
|
||||||
tmdb_direct = TMDb_direct()
|
|
||||||
tmdb_direct.api_key = TMDB_KEY
|
|
||||||
|
|
||||||
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
|
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
|
||||||
|
|
||||||
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
|
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
|
||||||
@ -36,7 +33,7 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
|
|||||||
video_metadata = VideoMetadata(imdb_id=imdb_id)
|
video_metadata = VideoMetadata(imdb_id=imdb_id)
|
||||||
|
|
||||||
media = None
|
media = None
|
||||||
show = None
|
show_data = None
|
||||||
if len(tmdb_result.movie_results) > 0:
|
if len(tmdb_result.movie_results) > 0:
|
||||||
media = Movie().details(tmdb_result.movie_results[0].id)
|
media = Movie().details(tmdb_result.movie_results[0].id)
|
||||||
video_metadata.video_type = VideoType.MOVIE.value
|
video_metadata.video_type = VideoType.MOVIE.value
|
||||||
|
|||||||
@ -32,7 +32,11 @@ class SeriesDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView
|
|||||||
context_data = super().get_context_data(**kwargs)
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
|
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
|
||||||
next_episode_id = self.object.last_scrobbled_episode(user_id).next_imdb_id or ""
|
next_episode_id = ""
|
||||||
|
if self.object.last_scrobbled_episode(user_id):
|
||||||
|
next_episode_id = (
|
||||||
|
self.object.last_scrobbled_episode(user_id).next_imdb_id or ""
|
||||||
|
)
|
||||||
if self.object.is_episode_playing(user_id):
|
if self.object.is_episode_playing(user_id):
|
||||||
next_episode_id = ""
|
next_episode_id = ""
|
||||||
if next_episode_id:
|
if next_episode_id:
|
||||||
|
|||||||
@ -60,6 +60,7 @@ THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
|
|||||||
PODCASTINDEX_API_KEY = os.getenv("VROBBLER_PODCASTINDEX_API_KEY", "")
|
PODCASTINDEX_API_KEY = os.getenv("VROBBLER_PODCASTINDEX_API_KEY", "")
|
||||||
PODCASTINDEX_API_SECRET = os.getenv("VROBBLER_PODCASTINDEX_API_SECRET", "")
|
PODCASTINDEX_API_SECRET = os.getenv("VROBBLER_PODCASTINDEX_API_SECRET", "")
|
||||||
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||||
|
OMDB_API_KEY = os.getenv("VROBBLER_OMDB_API_KEY", "")
|
||||||
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
||||||
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
||||||
IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID")
|
IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID")
|
||||||
@ -163,6 +164,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"task": "scrobbles.tasks.send_mood_checkin",
|
"task": "scrobbles.tasks.send_mood_checkin",
|
||||||
"schedule": crontab(hour="*/4", minute=0),
|
"schedule": crontab(hour="*/4", minute=0),
|
||||||
},
|
},
|
||||||
|
"backfill-scrobble-sentiment": {
|
||||||
|
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
||||||
|
"schedule": crontab(minute="0"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|||||||
@ -315,8 +315,7 @@
|
|||||||
{% for scrobble in now_playing_list %}
|
{% for scrobble in now_playing_list %}
|
||||||
<div class="now-playing">
|
<div class="now-playing">
|
||||||
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
|
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
|
||||||
<p><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></p>
|
<p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>
|
||||||
{% if scrobble.media_obj.subtitle %}<p><em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em></p>{% endif %}
|
|
||||||
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
|
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
|
||||||
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
|
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
|
||||||
<div class="progress-bar" style="margin-right:5px;">
|
<div class="progress-bar" style="margin-right:5px;">
|
||||||
|
|||||||
@ -50,7 +50,11 @@
|
|||||||
|
|
||||||
<h1 class="d-flex align-items-center gap-2">
|
<h1 class="d-flex align-items-center gap-2">
|
||||||
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
||||||
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
|
{% if object.media_obj.get_absolute_url %}
|
||||||
|
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
|
||||||
|
{{ object.media_obj.title }}
|
||||||
|
{% if object.media_obj.get_absolute_url %}</a>
|
||||||
|
{% endif %}
|
||||||
{% if user.is_authenticated and object.media_obj %}
|
{% if user.is_authenticated and object.media_obj %}
|
||||||
<button id="favorite-btn"
|
<button id="favorite-btn"
|
||||||
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
|
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
|
||||||
@ -62,10 +66,52 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
<h2>{{ object.media_obj.subtitle }}</h2>
|
||||||
|
<p>
|
||||||
|
{% if object.media_type == "SportEvent" %}
|
||||||
|
{% for team in object.media_obj.teams.all %}
|
||||||
|
<img src="{{team.logo.url}}" width=150 />
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% if object.media_type == "Task" and object.logdata.title %}
|
{% if object.media_type == "Task" and object.logdata.title %}
|
||||||
|
</p>
|
||||||
<h2>{{ object.logdata.title }}</h2>
|
<h2>{{ object.logdata.title }}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
||||||
|
|
||||||
|
{% if user.is_authenticated and object.user == user %}
|
||||||
|
<div class="mb-3 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="badge
|
||||||
|
{% if object.visibility == 'public' %}bg-success
|
||||||
|
{% elif object.visibility == 'shared' %}bg-warning text-dark
|
||||||
|
{% else %}bg-secondary
|
||||||
|
{% endif %}">
|
||||||
|
{{ object.get_visibility_display }}
|
||||||
|
</span>
|
||||||
|
<form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">
|
||||||
|
{% csrf_token %}
|
||||||
|
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
|
||||||
|
<option value="shared" {% if object.visibility == 'shared' %}selected{% endif %}>Shared (link)</option>
|
||||||
|
<option value="public" {% if object.visibility == 'public' %}selected{% endif %}>Public (explore)</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% if object.visibility == 'shared' and object.get_share_url %}
|
||||||
|
<span class="small text-muted">Share link:</span>
|
||||||
|
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
|
||||||
|
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
|
||||||
|
</form>
|
||||||
|
{% if object.share_view_count %}
|
||||||
|
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
|
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
|
||||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -154,6 +200,20 @@
|
|||||||
{% if notes_html %}
|
{% if notes_html %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h4>Notes</h4>
|
<h4>Notes</h4>
|
||||||
|
<span class="badge fs-8
|
||||||
|
{% if sentiment.compound >= 0.5 %}bg-success
|
||||||
|
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
|
||||||
|
{% elif sentiment.compound > -0.05 %}bg-secondary
|
||||||
|
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
|
||||||
|
{% else %}bg-danger
|
||||||
|
{% endif %}">
|
||||||
|
{% if sentiment.compound >= 0.5 %}Positive
|
||||||
|
{% elif sentiment.compound >= 0.05 %}Slightly positive
|
||||||
|
{% elif sentiment.compound > -0.05 %}Neutral
|
||||||
|
{% elif sentiment.compound > -0.5 %}Slightly negative
|
||||||
|
{% else %}Negative
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
<div class="notes-list">
|
<div class="notes-list">
|
||||||
{{ notes_html|safe }}
|
{{ notes_html|safe }}
|
||||||
</div>
|
</div>
|
||||||
@ -161,6 +221,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
{% with sentiment=object.log.sentiment %}
|
||||||
|
{% if sentiment %}
|
||||||
|
<div class="mb-3">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% if object.logdata.avg_seconds_per_page %}
|
{% if object.logdata.avg_seconds_per_page %}
|
||||||
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
|
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
140
vrobbler/templates/scrobbles/scrobble_explore.html
Normal file
140
vrobbler/templates/scrobbles/scrobble_explore.html
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load humanize %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||||
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||||
|
<h1 class="h2">Explore Public Scrobbles</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted">Recent public scrobbles from all users.</p>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Title</th>
|
||||||
|
<th scope="col">User</th>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.video %}
|
||||||
|
🎬 Video
|
||||||
|
{% elif scrobble.track %}
|
||||||
|
🎵 Track
|
||||||
|
{% elif scrobble.podcast_episode %}
|
||||||
|
🎙️ Podcast episode
|
||||||
|
{% elif scrobble.sport_event %}
|
||||||
|
⚽ Sport event
|
||||||
|
{% elif scrobble.book %}
|
||||||
|
📖 Book
|
||||||
|
{% elif scrobble.paper %}
|
||||||
|
📄 Paper
|
||||||
|
{% elif scrobble.video_game %}
|
||||||
|
🎮 Video game
|
||||||
|
{% elif scrobble.board_game %}
|
||||||
|
🎲 Board game
|
||||||
|
{% elif scrobble.geo_location %}
|
||||||
|
📍 GeoLocation
|
||||||
|
{% elif scrobble.trail %}
|
||||||
|
🥾 Trail
|
||||||
|
{% elif scrobble.beer %}
|
||||||
|
🍺 Beer
|
||||||
|
{% elif scrobble.puzzle %}
|
||||||
|
🧩 Puzzle
|
||||||
|
{% elif scrobble.food %}
|
||||||
|
🍔 Food
|
||||||
|
{% elif scrobble.task %}
|
||||||
|
✅ Task
|
||||||
|
{% elif scrobble.web_page %}
|
||||||
|
🌐 Web Page
|
||||||
|
{% elif scrobble.life_event %}
|
||||||
|
🎉 Life event
|
||||||
|
{% elif scrobble.mood %}
|
||||||
|
😊 Mood
|
||||||
|
{% elif scrobble.brick_set %}
|
||||||
|
🧱 Brick set
|
||||||
|
{% elif scrobble.channel %}
|
||||||
|
📺 Channel
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.video %}
|
||||||
|
<a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>
|
||||||
|
{% elif scrobble.track %}
|
||||||
|
<a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>
|
||||||
|
{% elif scrobble.video_game %}
|
||||||
|
<a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>
|
||||||
|
{% elif scrobble.book %}
|
||||||
|
<a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>
|
||||||
|
{% elif scrobble.food %}
|
||||||
|
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
|
||||||
|
{% elif scrobble.beer %}
|
||||||
|
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||||
|
{% elif scrobble.web_page %}
|
||||||
|
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
|
||||||
|
{% elif scrobble.podcast_episode %}
|
||||||
|
<a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>
|
||||||
|
{% elif scrobble.board_game %}
|
||||||
|
<a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>
|
||||||
|
{% elif scrobble.trail %}
|
||||||
|
<a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>
|
||||||
|
{% elif scrobble.puzzle %}
|
||||||
|
<a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>
|
||||||
|
{% elif scrobble.brick_set %}
|
||||||
|
<a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>
|
||||||
|
{% elif scrobble.task %}
|
||||||
|
<a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %}</a>
|
||||||
|
{% elif scrobble.life_event %}
|
||||||
|
<a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>
|
||||||
|
{% elif scrobble.mood %}
|
||||||
|
<a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>
|
||||||
|
{% elif scrobble.geo_location %}
|
||||||
|
<a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ scrobble.user.username }}</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.playback_position_seconds %}
|
||||||
|
{{ scrobble.playback_position_seconds|natural_duration }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">No public scrobbles found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if page_obj.has_previous or page_obj.has_next %}
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
200
vrobbler/templates/scrobbles/scrobble_share.html
Normal file
200
vrobbler/templates/scrobbles/scrobble_share.html
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load form_tags %}
|
||||||
|
{% load mathfilters %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{object.name}}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<style>
|
||||||
|
dl { border:none; }
|
||||||
|
dt {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
float:left;
|
||||||
|
margin: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
min-height: 1em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
#map {
|
||||||
|
height: 400px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Shared via link
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="d-flex align-items-center gap-2">
|
||||||
|
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
||||||
|
{% if object.media_obj.get_absolute_url %}
|
||||||
|
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
|
||||||
|
{{ object.media_obj.title }}
|
||||||
|
{% if object.media_obj.get_absolute_url %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<h2>{{ object.media_obj.subtitle }}</h2>
|
||||||
|
<p>
|
||||||
|
{% if object.media_type == "SportEvent" %}
|
||||||
|
{% for team in object.media_obj.teams.all %}
|
||||||
|
<img src="{{team.logo.url}}" width=150 />
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if object.media_type == "Task" and object.logdata.title %}
|
||||||
|
</p>
|
||||||
|
<h2>{{ object.logdata.title }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
||||||
|
{% if object.media_type == "Track" %}
|
||||||
|
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.media_type == "Task" and object.log.weight %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<dl class="row" style="max-width: 400px;">
|
||||||
|
<dt class="col-sm-5">Weight</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.weight }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
|
||||||
|
{% if object.log.body_fat %}
|
||||||
|
<dt class="col-sm-5">Body Fat</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.body_fat }}%</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.bmi %}
|
||||||
|
<dt class="col-sm-5">BMI</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.bmi }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.muscle %}
|
||||||
|
<dt class="col-sm-5">Muscle</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.muscle }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.bone %}
|
||||||
|
<dt class="col-sm-5">Bone</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.bone }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.water %}
|
||||||
|
<dt class="col-sm-5">Water</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.water }}%</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.visceral_fat %}
|
||||||
|
<dt class="col-sm-5">Visceral Fat</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.visceral_fat }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.waist %}
|
||||||
|
<dt class="col-sm-5">Waist</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.waist }} {% if object.log.unit_type == "imperial" %}in{% else %}cm{% endif %}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.lbm %}
|
||||||
|
<dt class="col-sm-5">Lean Mass</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.lbm }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.calories %}
|
||||||
|
<dt class="col-sm-5">Calories</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.calories }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.log.comment %}
|
||||||
|
<dt class="col-sm-5">Comment</dt>
|
||||||
|
<dd class="col-sm-7">{{ object.log.comment }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.media_type == "Task" and object.logdata.description %}
|
||||||
|
<p>{{ object.logdata.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object.media_type == "Trail" and object.gpx_file %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Tags:
|
||||||
|
{% if object.tags.all %}
|
||||||
|
{% for tag in object.tags.all %}
|
||||||
|
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
untagged
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% with notes_html=object.logdata.notes_as_html %}
|
||||||
|
{% if notes_html %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h4>Notes</h4>
|
||||||
|
<span class="badge fs-8
|
||||||
|
{% if sentiment.compound >= 0.5 %}bg-success
|
||||||
|
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
|
||||||
|
{% elif sentiment.compound > -0.05 %}bg-secondary
|
||||||
|
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
|
||||||
|
{% else %}bg-danger
|
||||||
|
{% endif %}">
|
||||||
|
{% if sentiment.compound >= 0.5 %}Positive
|
||||||
|
{% elif sentiment.compound >= 0.05 %}Slightly positive
|
||||||
|
{% elif sentiment.compound > -0.05 %}Neutral
|
||||||
|
{% elif sentiment.compound > -0.5 %}Slightly negative
|
||||||
|
{% else %}Negative
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="notes-list">
|
||||||
|
{{ notes_html|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% with sentiment=object.log.sentiment %}
|
||||||
|
{% if sentiment %}
|
||||||
|
<div class="mb-3">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if object.logdata.avg_seconds_per_page %}
|
||||||
|
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object.media_type == "BoardGame" and object.logdata.as_html %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5>Game Details</h5>
|
||||||
|
{{ object.logdata.as_html|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% if object.media_type == "Trail" and object.gpx_file %}
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
|
||||||
|
<script>
|
||||||
|
var map = L.map('map');
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
referrerPolicy: 'origin'
|
||||||
|
}).addTo(map);
|
||||||
|
var gpx = new L.GPX("{{ object.gpx_file.url|escapejs }}", {
|
||||||
|
async: true,
|
||||||
|
polyline_options: { color: '#e74c3c' }
|
||||||
|
});
|
||||||
|
gpx.on('loaded', function(e) {
|
||||||
|
map.fitBounds(e.target.getBounds());
|
||||||
|
});
|
||||||
|
gpx.addTo(map);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
68
vrobbler/templates/scrobbles/scrobble_share_analytics.html
Normal file
68
vrobbler/templates/scrobbles/scrobble_share_analytics.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
|
||||||
|
{% block title %}Share Analytics for {{ object.media_obj.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<h1>Share Analytics</h1>
|
||||||
|
<h2 class="text-muted">{{ object.media_obj.title }}</h2>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="badge
|
||||||
|
{% if object.visibility == 'public' %}bg-success
|
||||||
|
{% elif object.visibility == 'shared' %}bg-warning text-dark
|
||||||
|
{% else %}bg-secondary
|
||||||
|
{% endif %}">
|
||||||
|
{{ object.get_visibility_display }}
|
||||||
|
</span>
|
||||||
|
<span class="ms-2"><strong>{{ object.share_view_count }}</strong> view{{ object.share_view_count|pluralize }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if object.get_share_url %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="text-muted">Share link:</span>
|
||||||
|
<code class="small">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3 class="mt-4">View History</h3>
|
||||||
|
|
||||||
|
{% if share_views %}
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">IP Address</th>
|
||||||
|
<th scope="col">Referrer</th>
|
||||||
|
<th scope="col">User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for view in share_views %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ view.created|date:"M d, Y H:i" }}</td>
|
||||||
|
<td><code>{{ view.ip_address|default:"-" }}</code></td>
|
||||||
|
<td class="text-truncate" style="max-width: 200px;">
|
||||||
|
{% if view.referrer %}
|
||||||
|
<a href="{{ view.referrer }}" target="_blank" rel="noopener">{{ view.referrer }}</a>
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-truncate" style="max-width: 300px;">
|
||||||
|
<span title="{{ view.user_agent }}">{{ view.user_agent|default:"-"|truncatechars:60 }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No views yet. Share the link to see who visits.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ object.get_absolute_url }}" class="btn btn-secondary">Back to scrobble</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -110,7 +110,7 @@
|
|||||||
{% if sporting %}
|
{% if sporting %}
|
||||||
<div class="titles">
|
<div class="titles">
|
||||||
<p><a href="{{sporting.media_obj.get_absolute_url}}">{{sporting.media_obj}}</a></p>
|
<p><a href="{{sporting.media_obj.get_absolute_url}}">{{sporting.media_obj}}</a></p>
|
||||||
{% if sporting.media_obj.subtitle %}<p><em><a href="{{sporting.media_obj.subtitle.get_absolute_url}}">{{sporting.media_obj.subtitle}}</a></em></p>{% endif %}
|
{% if sporting.media_obj.subtitle %}<p><em><a href="{{sporting.get_absolute_url}}">{{sporting.media_obj.subtitle}}</a></em></p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p><small>{{sporting.timestamp|naturaltime}} from {{sporting.source}}</small></p>
|
<p><small>{{sporting.timestamp|naturaltime}} from {{sporting.source}}</small></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
{% extends "base_detail.html" %}
|
{% extends "base_detail.html" %}
|
||||||
|
|
||||||
{% block title %}{{object.title}} - {{object.round.season.league}}{% endblock %}
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
{% block details %}
|
{% block details %}
|
||||||
|
<h2>{{object.league}} {{object.get_event_type_display}}</h2>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>{{object.tv_series}}</h2>
|
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
|
{% for team in object.teams.all %}
|
||||||
|
<img src="{{team.logo.url}}" width=150 />
|
||||||
|
{% endfor %}
|
||||||
|
<hr />
|
||||||
<h3>Last scrobbles</h3>
|
<h3>Last scrobbles</h3>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user