Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c | |||
| bef7e683c5 | |||
| ec219ef3ea | |||
| dcc7229e90 | |||
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb |
71
PROJECT.org
71
PROJECT.org
@ -93,11 +93,7 @@ fetching and simple saving.
|
|||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||||
:END:
|
:END:
|
||||||
* Backlog [0/13] :vrobbler:project:personal:
|
* Backlog [0/12] :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 +410,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,7 +482,64 @@ 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] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:
|
** 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.
|
||||||
|
|
||||||
|
* 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:
|
:PROPERTIES:
|
||||||
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
|
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
|
||||||
:END:
|
:END:
|
||||||
@ -505,6 +555,7 @@ The patterns would be based on traditional Django date formatting patterns: http
|
|||||||
|
|
||||||
So "Y m F" would yield "2026 05 May" if the link is clicked in May of 2026.
|
So "Y m F" would yield "2026 05 May" if the link is clicked in May of 2026.
|
||||||
|
|
||||||
|
|
||||||
* Version 44.0 [1/1]
|
* Version 44.0 [1/1]
|
||||||
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
|
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
8
justfile
8
justfile
@ -15,9 +15,11 @@ celery:
|
|||||||
celery-beat:
|
celery-beat:
|
||||||
poetry run celery -A vrobbler beat -l info
|
poetry run celery -A vrobbler beat -l info
|
||||||
|
|
||||||
release kind="minor":
|
|
||||||
poetry run python scripts/release.py {{kind}}
|
|
||||||
|
|
||||||
push:
|
push:
|
||||||
git push && git push gitea
|
git push && git push gitea
|
||||||
git push --tags && git push --tags gitea
|
git push --tags && git push --tags gitea
|
||||||
|
|
||||||
|
release kind="minor":
|
||||||
|
poetry run python scripts/release.py {{kind}}
|
||||||
|
just push
|
||||||
|
|
||||||
|
|||||||
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 = "44.0"
|
version = "46.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
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"ntfy_enabled",
|
"ntfy_enabled",
|
||||||
"mopidy_api_url",
|
"mopidy_api_url",
|
||||||
"favorites_mopidy_playlist",
|
"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 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -69,6 +69,10 @@ class UserProfile(TimeStampedModel):
|
|||||||
max_length=255, **BNULL,
|
max_length=255, **BNULL,
|
||||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
@ -882,8 +882,14 @@ 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",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from charts.utils import (
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -544,6 +545,30 @@ def send_mood_checkin():
|
|||||||
send_mood_checkin_reminders()
|
send_mood_checkin_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def backfill_scrobble_sentiment():
|
||||||
|
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.utils import analyze_scrobble_sentiment
|
||||||
|
|
||||||
|
qs = Scrobble.objects.filter(
|
||||||
|
models.Q(log__notes__isnull=False)
|
||||||
|
& ~models.Q(log__notes=[])
|
||||||
|
& ~models.Q(log__notes={})
|
||||||
|
& models.Q(log__sentiment__isnull=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for scrobble in qs.iterator():
|
||||||
|
if analyze_scrobble_sentiment(scrobble):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Backfilled sentiment for %d scrobbles",
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def add_favorite_to_mopidy_playlist(favorite_id):
|
def add_favorite_to_mopidy_playlist(favorite_id):
|
||||||
from scrobbles.models import FavoriteMedia
|
from scrobbles.models import FavoriteMedia
|
||||||
@ -576,3 +601,91 @@ def remove_favorite_from_mopidy_playlist(user_id, track_id):
|
|||||||
track=track,
|
track=track,
|
||||||
)
|
)
|
||||||
remove_track_from_mopidy_favorites_playlist(proxy)
|
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,6 +163,11 @@ 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"),
|
||||||
|
|||||||
@ -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
|
||||||
@ -467,9 +468,22 @@ def _ensure_mopidy_playlist(profile):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def add_track_to_mopidy_favorites_playlist(favorite):
|
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
|
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:
|
if favorite.media_type != "Track" or not favorite.track:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -478,15 +492,7 @@ def add_track_to_mopidy_favorites_playlist(favorite):
|
|||||||
return
|
return
|
||||||
|
|
||||||
track = favorite.track
|
track = favorite.track
|
||||||
scrobble = (
|
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
|
||||||
Scrobble.objects.filter(
|
|
||||||
track=track,
|
|
||||||
user=favorite.user,
|
|
||||||
log__raw_data__mopidy_uri__isnull=False,
|
|
||||||
)
|
|
||||||
.order_by("-timestamp")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -495,7 +501,7 @@ def add_track_to_mopidy_favorites_playlist(favorite):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
|
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
playlist = _ensure_mopidy_playlist(profile)
|
playlist = _ensure_mopidy_playlist(profile)
|
||||||
@ -556,8 +562,6 @@ def resubmit_favorites_to_mopidy(user):
|
|||||||
|
|
||||||
|
|
||||||
def remove_track_from_mopidy_favorites_playlist(favorite):
|
def remove_track_from_mopidy_favorites_playlist(favorite):
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
|
|
||||||
if favorite.media_type != "Track" or not favorite.track:
|
if favorite.media_type != "Track" or not favorite.track:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -566,15 +570,7 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
|
|||||||
return
|
return
|
||||||
|
|
||||||
track = favorite.track
|
track = favorite.track
|
||||||
scrobble = (
|
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
|
||||||
Scrobble.objects.filter(
|
|
||||||
track=track,
|
|
||||||
user=favorite.user,
|
|
||||||
log__raw_data__mopidy_uri__isnull=False,
|
|
||||||
)
|
|
||||||
.order_by("-timestamp")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -583,7 +579,7 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
|
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
playlist = _ensure_mopidy_playlist(profile)
|
playlist = _ensure_mopidy_playlist(profile)
|
||||||
@ -625,6 +621,90 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
@ -722,3 +802,32 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
|||||||
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
||||||
]
|
]
|
||||||
return words
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_scrobble_sentiment(scrobble, overwrite=False) -> bool:
|
||||||
|
"""Run VADER sentiment analysis on a scrobble's notes.
|
||||||
|
|
||||||
|
Stores result in log["sentiment"] as a dict with keys:
|
||||||
|
neg, neu, pos, compound.
|
||||||
|
|
||||||
|
Returns True if analyzed, False if skipped (no notes or already done).
|
||||||
|
"""
|
||||||
|
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
||||||
|
|
||||||
|
log = scrobble.log or {}
|
||||||
|
if not overwrite and log.get("sentiment") is not None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
notes_str = ""
|
||||||
|
if scrobble.logdata:
|
||||||
|
notes_str = scrobble.logdata.notes_as_str()
|
||||||
|
if not notes_str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
analyzer = SentimentIntensityAnalyzer()
|
||||||
|
scores = analyzer.polarity_scores(notes_str)
|
||||||
|
|
||||||
|
log["sentiment"] = scores
|
||||||
|
scrobble.log = log
|
||||||
|
scrobble.save(update_fields=["log"])
|
||||||
|
return True
|
||||||
|
|||||||
@ -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
|
||||||
@ -991,46 +992,42 @@ 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,
|
|
||||||
"method": "core.tracklist.add",
|
|
||||||
"params": {"uris": [mopidy_uri]},
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
from scrobbles.tasks import add_scrobble_to_mopidy_monthly_playlist as task
|
||||||
resp = requests.post(rpc_url, json=payload, timeout=10)
|
|
||||||
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(
|
|
||||||
request,
|
|
||||||
messages.ERROR,
|
|
||||||
f"Failed to contact Mopidy: {e}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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)
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
|
||||||
@ -1243,6 +1240,17 @@ class ScrobbleDetailView(DetailView):
|
|||||||
user=self.request.user, **{fk_field: media_obj}
|
user=self.request.user, **{fk_field: media_obj}
|
||||||
).exists()
|
).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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -66,11 +66,17 @@
|
|||||||
<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>
|
||||||
@ -148,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>
|
||||||
@ -155,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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user