Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8236f43026 | |||
| ea1b43d1b8 | |||
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c | |||
| bef7e683c5 | |||
| ec219ef3ea | |||
| dcc7229e90 | |||
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb | |||
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 |
184
PROJECT.org
184
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
|
||||||
@ -85,19 +86,7 @@ fetching and simple saving.
|
|||||||
**** Bookmarklet
|
**** Bookmarklet
|
||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
* Chores
|
* Backlog [0/12] :vrobbler:project:personal:
|
||||||
** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:
|
|
||||||
: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
|
||||||
@ -414,10 +403,7 @@ placed in the media directory:
|
|||||||
And this should all be done in a celery task that is just kicked off by the
|
And this should all be done in a celery task that is just kicked off by the
|
||||||
"Export" button on the frontend
|
"Export" button on the frontend
|
||||||
|
|
||||||
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
|
|
||||||
:PROPERTIES:
|
|
||||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
|
||||||
:END:
|
|
||||||
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
||||||
@ -489,6 +475,169 @@ 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 [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently we have webdav able to import post types of file-based incoming data,
|
||||||
|
usually in the form of CSVs but also gpx files, bgstats json files, and
|
||||||
|
audioscrobbler TSV files.
|
||||||
|
|
||||||
|
What if the user could specify via their profile (settings) which imports they
|
||||||
|
wanted to use IMAP for and which ones they wanted to use WebDAV for.
|
||||||
|
|
||||||
|
Then we'd have two celery tasks that would be kicked off periodically via
|
||||||
|
celerybeat, one for IMAP imports every 12 minutes and one for WebDAV every 3
|
||||||
|
minutes. Both would be responsible for checking if a user has an configured
|
||||||
|
imports of their type, check if an import needs to run, and dispatch the
|
||||||
|
needed import celery task. This is how the WebDAV celery task currently works.
|
||||||
|
|
||||||
|
This would also be an opporunity to clean up the code around WebDAV imports
|
||||||
|
and make them more re-usable for other import services.
|
||||||
|
|
||||||
|
** TODO [#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.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]
|
||||||
|
** DONE [#B] Mopidy favorites or monthly playlist adds should look at all scrobbles :bug:mopidy:favorites:tracks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0be7d11e-e268-2fd5-836a-e5b4d210e0fa
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When favoriting a track and trying to add it to the Moidy favorite playlist, it
|
||||||
|
sometimes happens that one scrobble did not come from Mopidy, but an earlier or
|
||||||
|
later one did.
|
||||||
|
|
||||||
|
Can we scan all the scrobbles of the track for a given user to see if any have
|
||||||
|
`mopidy_uri` in the log and if so, use that to send along to Mopidy?
|
||||||
|
|
||||||
|
|
||||||
|
* Version 45.0 [1/1]
|
||||||
|
** DONE [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Now that we can favorite a mopidy track and have it added to a Favorites playlist, it would
|
||||||
|
be great if we could also populate a monthly_mopidy_playlist_pattern in a user profile and,
|
||||||
|
if configured, you could press "Add to monthly playlist" button on a given track that has
|
||||||
|
a mopidy_uri in it's log, and it would be added to the playlist.
|
||||||
|
|
||||||
|
The patterns would be based on traditional Django date formatting patterns: https://gregbrown.co/code/date-format
|
||||||
|
|
||||||
|
So "Y m F" would yield "2026 05 May" if the link is clicked in May of 2026.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 44.0 [1/1]
|
||||||
|
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 2780ae5f-fe23-49a5-8b33-d19e7f3e8ec6
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Would be great to have a FavoriteMedia data model that would accept any media_type ID and a user_id
|
||||||
|
marking that media as a favorite for that user.
|
||||||
|
|
||||||
|
Additionally, for tracks, we should add the ability to set a "favorites_mopidy_playlist" in a user profile
|
||||||
|
and if populated, and a track media type is favorited, and the track has a mopidy_uri value in a scrobble log,
|
||||||
|
send a POST to the mopidy server RPC endpoint for the favorite playlist and add the track.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
* Version 43.0 [5/5]
|
* Version 43.0 [5/5]
|
||||||
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
|
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -651,6 +800,7 @@ title of the Weigh-in task.
|
|||||||
:ID: 15894943-be1d-200f-8400-a136770ad9d2
|
:ID: 15894943-be1d-200f-8400-a136770ad9d2
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
|
|
||||||
* Version 40.1 [2/2]
|
* Version 40.1 [2/2]
|
||||||
** DONE [#A] Releases are still broken :bug:releases:tooling:
|
** DONE [#A] Releases are still broken :bug:releases:tooling:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
9
justfile
9
justfile
@ -15,8 +15,11 @@ celery:
|
|||||||
celery-beat:
|
celery-beat:
|
||||||
poetry run celery -A vrobbler beat -l info
|
poetry run celery -A vrobbler beat -l info
|
||||||
|
|
||||||
|
push:
|
||||||
|
git push && git push gitea
|
||||||
|
git push --tags && git push --tags gitea
|
||||||
|
|
||||||
release kind="minor":
|
release kind="minor":
|
||||||
poetry run python scripts/release.py {{kind}}
|
poetry run python scripts/release.py {{kind}}
|
||||||
|
just push
|
||||||
push:
|
|
||||||
git push --tags && git push --tags gitea
|
|
||||||
|
|||||||
17
poetry.lock
generated
17
poetry.lock
generated
@ -5439,6 +5439,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 +6020,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 = "78ba52d0e6ea492efceb14fcd42ace25abfb66d42c3aff28f2fe1a31a9aa03b5"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "43.0"
|
version = "47.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
@ -62,6 +62,7 @@ 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"
|
||||||
|
|
||||||
[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
|
||||||
|
|||||||
@ -32,6 +32,8 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"ntfy_url",
|
"ntfy_url",
|
||||||
"ntfy_enabled",
|
"ntfy_enabled",
|
||||||
"mopidy_api_url",
|
"mopidy_api_url",
|
||||||
|
"favorites_mopidy_playlist",
|
||||||
|
"monthly_mopidy_playlist_pattern",
|
||||||
"redirect_to_webpage",
|
"redirect_to_webpage",
|
||||||
"enable_public_widgets",
|
"enable_public_widgets",
|
||||||
"widget_custom_css",
|
"widget_custom_css",
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0033_userprofile_mopidy_api_url"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="favorites_mopidy_playlist",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-05 17:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0034_userprofile_favorites_mopidy_playlist"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="monthly_mopidy_playlist_pattern",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -65,6 +65,14 @@ class UserProfile(TimeStampedModel):
|
|||||||
ntfy_enabled = models.BooleanField(default=False)
|
ntfy_enabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||||
|
favorites_mopidy_playlist = models.CharField(
|
||||||
|
max_length=255, **BNULL,
|
||||||
|
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||||
|
)
|
||||||
|
monthly_mopidy_playlist_pattern = models.CharField(
|
||||||
|
max_length=255, **BNULL,
|
||||||
|
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||||
|
)
|
||||||
|
|
||||||
redirect_to_webpage = models.BooleanField(default=True)
|
redirect_to_webpage = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from scrobbles.models import (
|
|||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
EBirdCSVImport,
|
EBirdCSVImport,
|
||||||
|
FavoriteMedia,
|
||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
@ -164,3 +165,28 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FavoriteMedia)
|
||||||
|
class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
||||||
|
list_filter = ("media_type", "sent_to_mopidy", "user")
|
||||||
|
date_hierarchy = "created"
|
||||||
|
raw_id_fields = (
|
||||||
|
"video",
|
||||||
|
"track",
|
||||||
|
"podcast_episode",
|
||||||
|
"sport_event",
|
||||||
|
"book",
|
||||||
|
"video_game",
|
||||||
|
"board_game",
|
||||||
|
"geo_location",
|
||||||
|
"task",
|
||||||
|
"mood",
|
||||||
|
"brick_set",
|
||||||
|
"trail",
|
||||||
|
"beer",
|
||||||
|
"web_page",
|
||||||
|
"life_event",
|
||||||
|
"birding_location",
|
||||||
|
)
|
||||||
|
|||||||
@ -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")
|
||||||
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("boardgames", "0015_alter_boardgame_genre"),
|
||||||
|
("trails", "0009_trail_route_waypoint"),
|
||||||
|
("moods", "0008_alter_mood_genre"),
|
||||||
|
("birds", "0002_birdingcsvimport"),
|
||||||
|
("bricksets", "0005_alter_brickset_genre"),
|
||||||
|
("music", "0036_artist_similar_artists"),
|
||||||
|
("puzzles", "0006_alter_puzzle_genre"),
|
||||||
|
("videogames", "0015_alter_videogame_genre"),
|
||||||
|
("lifeevents", "0005_alter_lifeevent_genre"),
|
||||||
|
("beers", "0008_alter_beer_genre"),
|
||||||
|
("foods", "0007_alter_food_genre"),
|
||||||
|
("tasks", "0007_alter_task_genre"),
|
||||||
|
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||||
|
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
|
||||||
|
("webpages", "0009_alter_webpage_genre"),
|
||||||
|
("sports", "0018_alter_sportevent_genre"),
|
||||||
|
("locations", "0010_clean_start"),
|
||||||
|
("scrobbles", "0088_scalecsvimport_file_hash"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FavoriteMedia",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"media_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("Paper", "Paper"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("Trail", "Trail"),
|
||||||
|
("Beer", "Beer"),
|
||||||
|
("Puzzle", "Puzzle"),
|
||||||
|
("Food", "Food"),
|
||||||
|
("Task", "Task"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
("Channel", "Channel"),
|
||||||
|
("BirdingLocation", "Birding location"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("sent_to_mopidy", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"beer",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="beers.beer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"birding_location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="birds.birdinglocation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"board_game",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="boardgames.boardgame",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"book",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="books.book",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"brick_set",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="bricksets.brickset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"channel",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="videos.channel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"food",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="foods.food",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"geo_location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="locations.geolocation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"life_event",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="lifeevents.lifeevent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mood",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="moods.mood",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"paper",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="books.paper",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"podcast_episode",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="podcasts.podcastepisode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"puzzle",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="puzzles.puzzle",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sport_event",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="sports.sportevent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"task",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="tasks.task",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"track",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="music.track",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trail",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="trails.trail",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"video",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="videos.video",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"video_game",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="videogames.videogame",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"web_page",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="webpages.webpage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -882,8 +882,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",
|
||||||
@ -1719,3 +1724,131 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
if commit and "pages_read" in self.log:
|
if commit and "pages_read" in self.log:
|
||||||
self.save(update_fields=["log"])
|
self.save(update_fields=["log"])
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteMedia(TimeStampedModel):
|
||||||
|
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
|
||||||
|
channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
|
||||||
|
track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
|
||||||
|
podcast_episode = models.ForeignKey(
|
||||||
|
PodcastEpisode, on_delete=models.CASCADE, **BNULL
|
||||||
|
)
|
||||||
|
sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
|
||||||
|
book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
|
||||||
|
paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
|
||||||
|
video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
|
||||||
|
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
||||||
|
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
||||||
|
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
||||||
|
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
||||||
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
||||||
|
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
||||||
|
task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
|
||||||
|
web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
|
||||||
|
life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
|
||||||
|
mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
|
||||||
|
brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
|
||||||
|
birding_location = models.ForeignKey(
|
||||||
|
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||||
|
)
|
||||||
|
media_type = models.CharField(
|
||||||
|
max_length=20, choices=Scrobble.MediaType.choices
|
||||||
|
)
|
||||||
|
sent_to_mopidy = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} favorites {self.media_obj}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_obj(self):
|
||||||
|
media_obj = None
|
||||||
|
if self.video:
|
||||||
|
media_obj = self.video
|
||||||
|
if self.track:
|
||||||
|
media_obj = self.track
|
||||||
|
if self.podcast_episode:
|
||||||
|
media_obj = self.podcast_episode
|
||||||
|
if self.sport_event:
|
||||||
|
media_obj = self.sport_event
|
||||||
|
if self.book:
|
||||||
|
media_obj = self.book
|
||||||
|
if self.video_game:
|
||||||
|
media_obj = self.video_game
|
||||||
|
if self.board_game:
|
||||||
|
media_obj = self.board_game
|
||||||
|
if self.geo_location:
|
||||||
|
media_obj = self.geo_location
|
||||||
|
if self.web_page:
|
||||||
|
media_obj = self.web_page
|
||||||
|
if self.life_event:
|
||||||
|
media_obj = self.life_event
|
||||||
|
if self.mood:
|
||||||
|
media_obj = self.mood
|
||||||
|
if self.brick_set:
|
||||||
|
media_obj = self.brick_set
|
||||||
|
if self.trail:
|
||||||
|
media_obj = self.trail
|
||||||
|
if self.beer:
|
||||||
|
media_obj = self.beer
|
||||||
|
if self.puzzle:
|
||||||
|
media_obj = self.puzzle
|
||||||
|
if self.task:
|
||||||
|
media_obj = self.task
|
||||||
|
if self.food:
|
||||||
|
media_obj = self.food
|
||||||
|
if self.channel:
|
||||||
|
media_obj = self.channel
|
||||||
|
if self.birding_location:
|
||||||
|
media_obj = self.birding_location
|
||||||
|
return media_obj
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def toggle(cls, media_obj, user):
|
||||||
|
media_type = media_obj.__class__.__name__
|
||||||
|
if media_type not in Scrobble.MediaType.list():
|
||||||
|
raise ValueError(f"Unknown media type: {media_type}")
|
||||||
|
|
||||||
|
fk_map = {
|
||||||
|
"Video": "video",
|
||||||
|
"Channel": "channel",
|
||||||
|
"Track": "track",
|
||||||
|
"PodcastEpisode": "podcast_episode",
|
||||||
|
"SportEvent": "sport_event",
|
||||||
|
"Book": "book",
|
||||||
|
"Paper": "paper",
|
||||||
|
"VideoGame": "video_game",
|
||||||
|
"BoardGame": "board_game",
|
||||||
|
"GeoLocation": "geo_location",
|
||||||
|
"Beer": "beer",
|
||||||
|
"Puzzle": "puzzle",
|
||||||
|
"Food": "food",
|
||||||
|
"Trail": "trail",
|
||||||
|
"Task": "task",
|
||||||
|
"WebPage": "web_page",
|
||||||
|
"LifeEvent": "life_event",
|
||||||
|
"Mood": "mood",
|
||||||
|
"BrickSet": "brick_set",
|
||||||
|
"BirdingLocation": "birding_location",
|
||||||
|
}
|
||||||
|
|
||||||
|
fk = fk_map.get(media_type)
|
||||||
|
if not fk:
|
||||||
|
raise ValueError(f"No FK mapping for media type: {media_type}")
|
||||||
|
|
||||||
|
existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
|
||||||
|
if existing:
|
||||||
|
existing.delete()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls.objects.create(
|
||||||
|
user=user,
|
||||||
|
media_type=media_type,
|
||||||
|
**{fk: media_obj},
|
||||||
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,14 @@ from django.db.models.signals import post_delete, post_save
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import FavoriteMedia, Scrobble
|
||||||
from scrobbles.tasks import CHARTABLE_MEDIA_TYPES, SCROBBLES_WITHOUT_CHARTS, update_charts_for_timestamp
|
from scrobbles.tasks import (
|
||||||
|
add_favorite_to_mopidy_playlist,
|
||||||
|
CHARTABLE_MEDIA_TYPES,
|
||||||
|
remove_favorite_from_mopidy_playlist,
|
||||||
|
SCROBBLES_WITHOUT_CHARTS,
|
||||||
|
update_charts_for_timestamp,
|
||||||
|
)
|
||||||
from scrobbles.utils import tokenize_title_to_tags
|
from scrobbles.utils import tokenize_title_to_tags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -72,3 +78,28 @@ def add_tags_from_task_title(sender, instance, **kwargs):
|
|||||||
for tag in new_tags:
|
for tag in new_tags:
|
||||||
if tag not in existing_tags:
|
if tag not in existing_tags:
|
||||||
instance.tags.add(tag)
|
instance.tags.add(tag)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=FavoriteMedia)
|
||||||
|
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
if instance.media_type != Scrobble.MediaType.TRACK:
|
||||||
|
return
|
||||||
|
if not instance.track:
|
||||||
|
return
|
||||||
|
|
||||||
|
add_favorite_to_mopidy_playlist.delay(instance.id)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=FavoriteMedia)
|
||||||
|
def remove_from_mopidy_playlist_on_unfavorite(sender, instance, **kwargs):
|
||||||
|
if instance.media_type != Scrobble.MediaType.TRACK:
|
||||||
|
return
|
||||||
|
if not instance.track_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
remove_favorite_from_mopidy_playlist.delay(
|
||||||
|
user_id=instance.user_id,
|
||||||
|
track_id=instance.track_id,
|
||||||
|
)
|
||||||
|
|||||||
@ -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__)
|
||||||
@ -542,3 +543,149 @@ def send_mood_checkin():
|
|||||||
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
|
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
|
||||||
|
|
||||||
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
|
||||||
|
def add_favorite_to_mopidy_playlist(favorite_id):
|
||||||
|
from scrobbles.models import FavoriteMedia
|
||||||
|
from scrobbles.utils import add_track_to_mopidy_favorites_playlist
|
||||||
|
|
||||||
|
favorite = FavoriteMedia.objects.filter(id=favorite_id).first()
|
||||||
|
if not favorite:
|
||||||
|
return
|
||||||
|
add_track_to_mopidy_favorites_playlist(favorite)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def remove_favorite_from_mopidy_playlist(user_id, track_id):
|
||||||
|
from music.models import Track
|
||||||
|
from scrobbles.utils import remove_track_from_mopidy_favorites_playlist
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
track = Track.objects.get(id=track_id)
|
||||||
|
except (User.DoesNotExist, Track.DoesNotExist):
|
||||||
|
return
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
proxy = types.SimpleNamespace(
|
||||||
|
media_type="Track",
|
||||||
|
user=user,
|
||||||
|
user_id=user.id,
|
||||||
|
track=track,
|
||||||
|
)
|
||||||
|
remove_track_from_mopidy_favorites_playlist(proxy)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def add_scrobble_to_mopidy_queue(scrobble_id):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||||
|
if not scrobble:
|
||||||
|
return
|
||||||
|
|
||||||
|
profile = scrobble.user.profile
|
||||||
|
mopidy_url = profile.mopidy_api_url
|
||||||
|
if not mopidy_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
track = scrobble.track if scrobble.media_type == "Track" else None
|
||||||
|
if not mopidy_uri and track:
|
||||||
|
sibling = (
|
||||||
|
Scrobble.objects.filter(track=track, user=scrobble.user)
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
for s in sibling:
|
||||||
|
uri = (s.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
if uri:
|
||||||
|
mopidy_uri = uri
|
||||||
|
break
|
||||||
|
|
||||||
|
if not mopidy_uri:
|
||||||
|
logger.warning(
|
||||||
|
"No Mopidy URI found for scrobble",
|
||||||
|
extra={"scrobble_id": scrobble_id, "user_id": scrobble.user_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
rpc_url = mopidy_url.rstrip("/") + "/rpc"
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "core.tracklist.add",
|
||||||
|
"params": {"uris": [mopidy_uri]},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
rpc_result = resp.json()
|
||||||
|
if rpc_result.get("error"):
|
||||||
|
logger.error(
|
||||||
|
"Mopidy error adding to queue",
|
||||||
|
extra={"error": rpc_result["error"], "scrobble_id": scrobble_id},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Added track to Mopidy queue",
|
||||||
|
extra={"scrobble_id": scrobble_id, "mopidy_uri": mopidy_uri},
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to add track to Mopidy queue",
|
||||||
|
extra={"scrobble_id": scrobble_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def add_scrobble_to_mopidy_monthly_playlist(scrobble_id):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.utils import add_track_to_mopidy_monthly_playlist
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||||
|
if not scrobble:
|
||||||
|
return
|
||||||
|
|
||||||
|
track = scrobble.track if scrobble.media_type == "Track" else None
|
||||||
|
if track:
|
||||||
|
sibling = (
|
||||||
|
Scrobble.objects.filter(track=track, user=scrobble.user)
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
for s in sibling:
|
||||||
|
if (s.log or {}).get("raw_data", {}).get("mopidy_uri"):
|
||||||
|
scrobble = s
|
||||||
|
break
|
||||||
|
|
||||||
|
add_track_to_mopidy_monthly_playlist(scrobble)
|
||||||
|
|||||||
@ -163,7 +163,17 @@ urlpatterns = [
|
|||||||
views.add_to_mopidy_queue,
|
views.add_to_mopidy_queue,
|
||||||
name="add-to-mopidy-queue",
|
name="add-to-mopidy-queue",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
|
||||||
|
views.add_to_mopidy_monthly_playlist,
|
||||||
|
name="add-to-mopidy-monthly-playlist",
|
||||||
|
),
|
||||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
||||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
||||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||||
|
path(
|
||||||
|
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
||||||
|
views.toggle_favorite,
|
||||||
|
name="toggle-favorite",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from django.db import models
|
|||||||
from django.db.models.fields.json import KeyTextTransform
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
from django.db.models.functions import Cast, TruncDate
|
from django.db.models.functions import Cast, TruncDate
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.dateformat import DateFormat
|
||||||
from profiles.models import UserProfile
|
from profiles.models import UserProfile
|
||||||
from profiles.utils import now_user_timezone
|
from profiles.utils import now_user_timezone
|
||||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||||
@ -87,8 +88,7 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
|
|||||||
return Scrobble.objects.filter(media_query, user=user)
|
return Scrobble.objects.filter(media_query, user=user)
|
||||||
|
|
||||||
|
|
||||||
def get_recently_played_board_games(user: User) -> dict:
|
def get_recently_played_board_games(user: User) -> dict: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def get_long_plays_in_progress(user: User) -> dict:
|
def get_long_plays_in_progress(user: User) -> dict:
|
||||||
@ -423,6 +423,288 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
|
|||||||
return {entry["day"]: entry["total_calories"] for entry in qs}
|
return {entry["day"]: entry["total_calories"] for entry in qs}
|
||||||
|
|
||||||
|
|
||||||
|
def _mopidy_rpc(profile, method, params=None):
|
||||||
|
rpc_url = profile.mopidy_api_url.rstrip("/") + "/rpc"
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": method,
|
||||||
|
}
|
||||||
|
if params:
|
||||||
|
payload["params"] = params
|
||||||
|
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
if result.get("error"):
|
||||||
|
raise RuntimeError(f'Mopidy error: {result["error"]}')
|
||||||
|
return result.get("result")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mopidy_playlist(profile):
|
||||||
|
playlist_name = profile.favorites_mopidy_playlist
|
||||||
|
# Strip any m3u: prefix and .m3u8 suffix the user may have included
|
||||||
|
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
|
||||||
|
for pl in playlists:
|
||||||
|
if pl.get("name") == playlist_name:
|
||||||
|
existing = _mopidy_rpc(
|
||||||
|
profile, "core.playlists.lookup", {"uri": pl["uri"]}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
except (requests.RequestException, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = _mopidy_rpc(
|
||||||
|
profile, "core.playlists.create",
|
||||||
|
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Created Mopidy favorites playlist",
|
||||||
|
extra={"uri": result.get("uri") if result else playlist_name},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _scrobble_with_mopidy_uri(track, user):
|
||||||
|
"""Find a scrobble for this track+user that has a mopidy_uri in its log."""
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
for scrobble in (
|
||||||
|
Scrobble.objects.filter(track=track, user=user)
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.iterator()
|
||||||
|
):
|
||||||
|
raw_data = scrobble.log.get("raw_data") or {}
|
||||||
|
if raw_data.get("mopidy_uri"):
|
||||||
|
return scrobble
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def add_track_to_mopidy_favorites_playlist(favorite):
|
||||||
|
if favorite.media_type != "Track" or not favorite.track:
|
||||||
|
return
|
||||||
|
|
||||||
|
profile = favorite.user.profile
|
||||||
|
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
track = favorite.track
|
||||||
|
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
|
||||||
|
|
||||||
|
if not scrobble:
|
||||||
|
logger.warning(
|
||||||
|
"No Mopidy URI found for track",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = _ensure_mopidy_playlist(profile)
|
||||||
|
if playlist and playlist.get("uri"):
|
||||||
|
existing_tracks = playlist.get("tracks") or []
|
||||||
|
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
|
||||||
|
if mopidy_uri in track_uris:
|
||||||
|
logger.info(
|
||||||
|
"Track already in Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
|
||||||
|
)
|
||||||
|
favorite.sent_to_mopidy = True
|
||||||
|
favorite.save(update_fields=["sent_to_mopidy"])
|
||||||
|
return
|
||||||
|
|
||||||
|
new_track = {"__model__": "Track", "uri": mopidy_uri}
|
||||||
|
existing_tracks.append(new_track)
|
||||||
|
_mopidy_rpc(
|
||||||
|
profile,
|
||||||
|
"core.playlists.save",
|
||||||
|
{
|
||||||
|
"playlist": {
|
||||||
|
"__model__": "Playlist",
|
||||||
|
"uri": playlist["uri"],
|
||||||
|
"name": playlist.get("name", "Favorites"),
|
||||||
|
"tracks": existing_tracks,
|
||||||
|
"last_modified": playlist.get("last_modified"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
|
||||||
|
|
||||||
|
favorite.sent_to_mopidy = True
|
||||||
|
favorite.save(update_fields=["sent_to_mopidy"])
|
||||||
|
logger.info(
|
||||||
|
"Added track to Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
except (requests.RequestException, RuntimeError) as e:
|
||||||
|
logger.debug(e)
|
||||||
|
logger.error(
|
||||||
|
"Failed to add track to Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resubmit_favorites_to_mopidy(user):
|
||||||
|
from scrobbles.models import FavoriteMedia
|
||||||
|
|
||||||
|
favorites = FavoriteMedia.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type="Track",
|
||||||
|
track__isnull=False,
|
||||||
|
)
|
||||||
|
for favorite in favorites:
|
||||||
|
add_track_to_mopidy_favorites_playlist(favorite)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_track_from_mopidy_favorites_playlist(favorite):
|
||||||
|
if favorite.media_type != "Track" or not favorite.track:
|
||||||
|
return
|
||||||
|
|
||||||
|
profile = favorite.user.profile
|
||||||
|
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
track = favorite.track
|
||||||
|
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
|
||||||
|
|
||||||
|
if not scrobble:
|
||||||
|
logger.warning(
|
||||||
|
"No Mopidy URI found for track",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = _ensure_mopidy_playlist(profile)
|
||||||
|
if playlist and playlist.get("uri"):
|
||||||
|
existing_tracks = playlist.get("tracks") or []
|
||||||
|
filtered = [
|
||||||
|
t for t in existing_tracks
|
||||||
|
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
|
||||||
|
]
|
||||||
|
if len(filtered) == len(existing_tracks):
|
||||||
|
logger.info(
|
||||||
|
"Track not found in Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_mopidy_rpc(
|
||||||
|
profile,
|
||||||
|
"core.playlists.save",
|
||||||
|
{
|
||||||
|
"playlist": {
|
||||||
|
"__model__": "Playlist",
|
||||||
|
"uri": playlist["uri"],
|
||||||
|
"name": playlist.get("name", "Favorites"),
|
||||||
|
"tracks": filtered,
|
||||||
|
"last_modified": playlist.get("last_modified"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Removed track from Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
except (requests.RequestException, RuntimeError) as e:
|
||||||
|
logger.debug(e)
|
||||||
|
logger.error(
|
||||||
|
"Failed to remove track from Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mopidy_playlist_by_name(profile, playlist_name):
|
||||||
|
"""Find or create a Mopidy playlist by name (without m3u: prefix handling)."""
|
||||||
|
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
|
||||||
|
try:
|
||||||
|
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
|
||||||
|
for pl in playlists:
|
||||||
|
if pl.get("name") == playlist_name:
|
||||||
|
existing = _mopidy_rpc(
|
||||||
|
profile, "core.playlists.lookup", {"uri": pl["uri"]}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
except (requests.RequestException, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = _mopidy_rpc(
|
||||||
|
profile, "core.playlists.create",
|
||||||
|
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def add_track_to_mopidy_monthly_playlist(scrobble):
|
||||||
|
"""Add a scrobbled track to a monthly Mopidy playlist based on the user's pattern."""
|
||||||
|
profile = scrobble.user.profile
|
||||||
|
pattern = profile.monthly_mopidy_playlist_pattern
|
||||||
|
if not pattern or not profile.mopidy_api_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
if not mopidy_uri:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = now_user_timezone(profile)
|
||||||
|
playlist_name = DateFormat(now).format(pattern)
|
||||||
|
if not playlist_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = _ensure_mopidy_playlist_by_name(profile, playlist_name)
|
||||||
|
if playlist and playlist.get("uri"):
|
||||||
|
existing_tracks = playlist.get("tracks") or []
|
||||||
|
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
|
||||||
|
if mopidy_uri in track_uris:
|
||||||
|
logger.info(
|
||||||
|
"Track already in monthly Mopidy playlist",
|
||||||
|
extra={"playlist": playlist_name, "mopidy_uri": mopidy_uri},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_track = {"__model__": "Track", "uri": mopidy_uri}
|
||||||
|
existing_tracks.append(new_track)
|
||||||
|
_mopidy_rpc(
|
||||||
|
profile,
|
||||||
|
"core.playlists.save",
|
||||||
|
{
|
||||||
|
"playlist": {
|
||||||
|
"__model__": "Playlist",
|
||||||
|
"uri": playlist["uri"],
|
||||||
|
"name": playlist.get("name", playlist_name),
|
||||||
|
"tracks": existing_tracks,
|
||||||
|
"last_modified": playlist.get("last_modified"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Added track to monthly Mopidy playlist",
|
||||||
|
extra={
|
||||||
|
"playlist": playlist_name,
|
||||||
|
"track_id": scrobble.media_obj.id,
|
||||||
|
"user_id": scrobble.user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (requests.RequestException, RuntimeError) as e:
|
||||||
|
logger.debug(e)
|
||||||
|
logger.error(
|
||||||
|
"Failed to add track to monthly Mopidy playlist",
|
||||||
|
extra={"playlist": playlist_name, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_last_part(url: str) -> str:
|
def remove_last_part(url: str) -> str:
|
||||||
url = url.rstrip("/")
|
url = url.rstrip("/")
|
||||||
if "/" not in url:
|
if "/" not in url:
|
||||||
@ -517,8 +799,35 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
|||||||
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
||||||
|
|
||||||
words = [
|
words = [
|
||||||
w.lower()
|
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
||||||
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
|
||||||
|
|||||||
@ -35,6 +35,7 @@ from django.http import (
|
|||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
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
|
||||||
@ -75,6 +76,7 @@ from scrobbles.models import (
|
|||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
EBirdCSVImport,
|
EBirdCSVImport,
|
||||||
|
FavoriteMedia,
|
||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
@ -990,47 +992,95 @@ def add_to_mopidy_queue(request, uuid):
|
|||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
raw_data = scrobble.log.get("raw_data", {})
|
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
||||||
mopidy_uri = raw_data.get("mopidy_uri")
|
|
||||||
logger.debug(mopidy_uri)
|
|
||||||
|
|
||||||
if not mopidy_uri:
|
task.delay(scrobble.id)
|
||||||
|
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
||||||
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def add_to_mopidy_monthly_playlist(request, uuid):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||||
|
profile = request.user.profile
|
||||||
|
pattern = profile.monthly_mopidy_playlist_pattern
|
||||||
|
|
||||||
|
if not pattern or not profile.mopidy_api_url:
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request,
|
request,
|
||||||
messages.ERROR,
|
messages.ERROR,
|
||||||
"No Mopidy URI found for this scrobble.",
|
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
rpc_url = mopidy_url.rstrip("/") + "/rpc"
|
now = now_user_timezone(profile)
|
||||||
payload = {
|
playlist_name = DateFormat(now).format(pattern)
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
from scrobbles.tasks import add_scrobble_to_mopidy_monthly_playlist as task
|
||||||
"method": "core.tracklist.add",
|
|
||||||
"params": {"uris": [mopidy_uri]},
|
task.delay(scrobble.id)
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.SUCCESS,
|
||||||
|
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
||||||
|
)
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def toggle_favorite(request, media_type, object_id):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
app_model_map = {
|
||||||
|
"Video": ("videos", "Video"),
|
||||||
|
"Channel": ("videos", "Channel"),
|
||||||
|
"Track": ("music", "Track"),
|
||||||
|
"PodcastEpisode": ("podcasts", "PodcastEpisode"),
|
||||||
|
"SportEvent": ("sports", "SportEvent"),
|
||||||
|
"Book": ("books", "Book"),
|
||||||
|
"Paper": ("books", "Paper"),
|
||||||
|
"VideoGame": ("videogames", "VideoGame"),
|
||||||
|
"BoardGame": ("boardgames", "BoardGame"),
|
||||||
|
"GeoLocation": ("locations", "GeoLocation"),
|
||||||
|
"Beer": ("beers", "Beer"),
|
||||||
|
"Puzzle": ("puzzles", "Puzzle"),
|
||||||
|
"Food": ("foods", "Food"),
|
||||||
|
"Trail": ("trails", "Trail"),
|
||||||
|
"Task": ("tasks", "Task"),
|
||||||
|
"WebPage": ("webpages", "WebPage"),
|
||||||
|
"LifeEvent": ("lifeevents", "LifeEvent"),
|
||||||
|
"Mood": ("moods", "Mood"),
|
||||||
|
"BrickSet": ("bricksets", "BrickSet"),
|
||||||
|
"BirdingLocation": ("birds", "BirdingLocation"),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
app_label, model_name = app_model_map.get(media_type, (None, None))
|
||||||
resp = requests.post(rpc_url, json=payload, timeout=10)
|
if not app_label:
|
||||||
resp.raise_for_status()
|
|
||||||
rpc_result = resp.json()
|
|
||||||
if rpc_result.get("error"):
|
|
||||||
messages.add_message(
|
|
||||||
request,
|
|
||||||
messages.ERROR,
|
|
||||||
f'Mopidy error: {rpc_result["error"]}',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = f'Added "{scrobble.media_obj}" to Mopidy queue.'
|
|
||||||
messages.add_message(request, messages.SUCCESS, msg)
|
|
||||||
except requests.RequestException as e:
|
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request,
|
request, messages.ERROR, f"Unknown media type: {media_type}"
|
||||||
messages.ERROR,
|
|
||||||
f"Failed to contact Mopidy: {e}",
|
|
||||||
)
|
)
|
||||||
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
model = apps.get_model(app_label, model_name)
|
||||||
|
media_obj = get_object_or_404(model, id=object_id)
|
||||||
|
result = FavoriteMedia.toggle(media_obj, request.user)
|
||||||
|
|
||||||
|
is_favorited = result is not None
|
||||||
|
if not is_favorited:
|
||||||
|
msg = f'Removed "{media_obj}" from favorites.'
|
||||||
|
else:
|
||||||
|
msg = f'Added "{media_obj}" to favorites.'
|
||||||
|
|
||||||
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||||
|
return JsonResponse({"is_favorited": is_favorited, "message": msg})
|
||||||
|
|
||||||
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@ -1183,6 +1233,24 @@ class ScrobbleDetailView(DetailView):
|
|||||||
except EmptyPage:
|
except EmptyPage:
|
||||||
context["related_scrobbles"] = paginator.page(paginator.num_pages)
|
context["related_scrobbles"] = paginator.page(paginator.num_pages)
|
||||||
|
|
||||||
|
if self.request.user.is_authenticated:
|
||||||
|
fk_field = self.MEDIA_FK_MAP.get(media_type)
|
||||||
|
if fk_field and media_obj:
|
||||||
|
context["is_favorited"] = FavoriteMedia.objects.filter(
|
||||||
|
user=self.request.user, **{fk_field: media_obj}
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if media_type == "Track" and media_obj:
|
||||||
|
scrobbles = Scrobble.objects.filter(
|
||||||
|
track=media_obj, user=self.object.user
|
||||||
|
).order_by("-timestamp")[:20]
|
||||||
|
context["has_mopidy_uri"] = any(
|
||||||
|
(s.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
for s in scrobbles
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context["has_mopidy_uri"] = False
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,127 @@
|
|||||||
|
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
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
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/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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:
|
||||||
|
league = self.league
|
||||||
|
if self.league and self.league.abbreviation_str:
|
||||||
|
league = self.league.abbreviation_str
|
||||||
|
return f"{self.title} - {league}"
|
||||||
|
|
||||||
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
|
return self.comp_str
|
||||||
|
|
||||||
|
@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,6 +163,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;">
|
||||||
|
|||||||
@ -26,6 +26,21 @@
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.heart-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: fill 0.2s, stroke 0.2s;
|
||||||
|
}
|
||||||
|
.heart-icon.favorited {
|
||||||
|
fill: #e74c3c;
|
||||||
|
stroke: #e74c3c;
|
||||||
|
}
|
||||||
|
.heart-icon:not(.favorited) {
|
||||||
|
fill: none;
|
||||||
|
stroke: #888;
|
||||||
|
}
|
||||||
|
.heart-icon:not(.favorited):hover {
|
||||||
|
stroke: #e74c3c;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -33,19 +48,35 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<h1>
|
<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 }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
|
||||||
|
{% if user.is_authenticated and object.media_obj %}
|
||||||
|
<button id="favorite-btn"
|
||||||
|
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
|
||||||
|
data-favorited="{{ is_favorited|yesno:'true,false' }}"
|
||||||
|
class="btn btn-sm p-0 border-0 bg-transparent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" class="heart-icon{% if is_favorited %} favorited{% endif %}" id="heart-svg">
|
||||||
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
{% if object.media_type == "Task" and object.logdata.title %}
|
{% if object.media_type == "Task" and object.logdata.title %}
|
||||||
<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 object.media_type == "Track" and object.log.raw_data.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 %}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if user.profile.monthly_mopidy_playlist_pattern %}
|
||||||
|
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.uuid %}" class="mb-1">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">add to monthly playlist</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.media_type == "Track" %}
|
{% 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>
|
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
|
||||||
@ -123,6 +154,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>
|
||||||
@ -130,6 +175,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 %}
|
||||||
@ -227,6 +279,30 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{{ log_form.media }}
|
{{ log_form.media }}
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
let value = "; " + document.cookie;
|
||||||
|
let parts = value.split("; " + name + "=");
|
||||||
|
if (parts.length === 2) return parts.pop().split(";").shift();
|
||||||
|
}
|
||||||
|
document.getElementById("favorite-btn")?.addEventListener("click", function() {
|
||||||
|
var btn = this;
|
||||||
|
var url = btn.dataset.url;
|
||||||
|
fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFToken": getCookie("csrftoken"),
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var heart = document.getElementById("heart-svg");
|
||||||
|
btn.dataset.favorited = data.is_favorited ? "true" : "false";
|
||||||
|
heart.classList.toggle("favorited", data.is_favorited);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% if object.media_type == "Trail" and object.gpx_file %}
|
{% 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://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 src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
|
||||||
|
|||||||
@ -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,8 +1,9 @@
|
|||||||
{% extends "base_detail.html" %}
|
{% extends "base_detail.html" %}
|
||||||
|
|
||||||
{% block title %}{{object.title}} - {{object.round.season.league}}{% endblock %}
|
{% block title %}{{object}}{% endblock %}
|
||||||
|
|
||||||
{% block details %}
|
{% block details %}
|
||||||
|
<h2>{{object.subtitle}}</h2>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>{{object.tv_series}}</h2>
|
<h2>{{object.tv_series}}</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user