Compare commits

...

9 Commits
44.0 ... 46.0

Author SHA1 Message Date
4bf22c96e9 [release] Bump to version 46.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Successful in 43s
- Add sentiment parsing for Scrobbles with notes
2026-06-05 19:42:21 -04:00
dec7a79509 [scrobbles] Add basic sentiment analysis
All checks were successful
build / test (push) Successful in 2m4s
2026-06-05 19:35:45 -04:00
371e1d654c [tooling] Release in one step
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:58:35 -04:00
bef7e683c5 [release] Bump to version 45.1
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 2m2s
deploy / build-and-deploy (push) Successful in 31s
- Mopidy favorites or monthly playlist adds should look at all scrobbles
2026-06-05 14:42:11 -04:00
ec219ef3ea [tracks] Fix adding tracks without mopidy_uri 2026-06-05 14:41:37 -04:00
dcc7229e90 [tooling] Just release does it all now
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:06:45 -04:00
73665ef19e [release] Bump to version 45.0
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Successful in 33s
- Add ability to add mopidy tracks to Monthly playlists
2026-06-05 13:57:06 -04:00
2536e330af [tracks] Use todays date for creating monthly playlists
All checks were successful
build / test (push) Successful in 2m3s
2026-06-05 13:41:30 -04:00
99c056adeb [tracks] Allow adding tracks to monthly playlists 2026-06-05 13:29:49 -04:00
16 changed files with 509 additions and 72 deletions

View File

@ -93,11 +93,7 @@ fetching and simple saving.
: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:
* Backlog [0/12] :vrobbler:project:personal:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
:PROPERTIES:
: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
"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:
:PROPERTIES:
: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
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:
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
: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.
* Version 44.0 [1/1]
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
:PROPERTIES:

View File

@ -15,9 +15,11 @@ celery:
celery-beat:
poetry run celery -A vrobbler beat -l info
release kind="minor":
poetry run python scripts/release.py {{kind}}
push:
git push && git push gitea
git push --tags && git push --tags gitea
release kind="minor":
poetry run python scripts/release.py {{kind}}
just push

17
poetry.lock generated
View File

@ -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"]
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]]
name = "vine"
version = "5.1.0"
@ -6005,4 +6020,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.15"
content-hash = "bd3f14a9cfce403db426af98774f1e3c41b97283aa43f4bd80f84594ee0dd726"
content-hash = "78ba52d0e6ea492efceb14fcd42ace25abfb66d42c3aff28f2fe1a31a9aa03b5"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "44.0"
version = "46.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -62,6 +62,7 @@ recipe-scrapers = "^15.11.0"
gpxpy = "^1.6.2"
fitparse = "^1.2.0"
lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
[tool.poetry.group.test]
optional = true

View File

@ -124,7 +124,7 @@ def main():
if not done_items:
print("No DONE items found in Backlog — nothing to release.")
sys.exit(0)
sys.exit(1)
# ---------------------------------------------------------------
# 4. Build the new Version section text

View File

@ -33,6 +33,7 @@ class UserProfileForm(forms.ModelForm):
"ntfy_enabled",
"mopidy_api_url",
"favorites_mopidy_playlist",
"monthly_mopidy_playlist_pattern",
"redirect_to_webpage",
"enable_public_widgets",
"widget_custom_css",

View File

@ -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,
),
),
]

View File

@ -69,6 +69,10 @@ class UserProfile(TimeStampedModel):
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)

View File

@ -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")

View File

@ -882,8 +882,14 @@ class Scrobble(TimeStampedModel):
logger.warning("Log data could not be loaded", e)
return logdata_cls()
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
logdata_kwargs = {
k: v for k, v in log_dict.items()
if k in logdata_cls.__dataclass_fields__
}
try:
return logdata_cls(**log_dict)
return logdata_cls(**logdata_kwargs)
except ParseError as e:
logger.warning(
"Could not parse log data",

View File

@ -12,6 +12,7 @@ from charts.utils import (
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -544,6 +545,30 @@ def send_mood_checkin():
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
@ -576,3 +601,91 @@ def remove_favorite_from_mopidy_playlist(user_id, track_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)

View File

@ -163,6 +163,11 @@ urlpatterns = [
views.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>/finish/", views.scrobble_finish, name="finish"),
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),

View File

@ -15,6 +15,7 @@ from django.db import models
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast, TruncDate
from django.utils import timezone
from django.utils.dateformat import DateFormat
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA
@ -467,9 +468,22 @@ def _ensure_mopidy_playlist(profile):
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
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
@ -478,15 +492,7 @@ def add_track_to_mopidy_favorites_playlist(favorite):
return
track = favorite.track
scrobble = (
Scrobble.objects.filter(
track=track,
user=favorite.user,
log__raw_data__mopidy_uri__isnull=False,
)
.order_by("-timestamp")
.first()
)
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
if not scrobble:
logger.warning(
@ -495,7 +501,7 @@ def add_track_to_mopidy_favorites_playlist(favorite):
)
return
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
try:
playlist = _ensure_mopidy_playlist(profile)
@ -556,8 +562,6 @@ def resubmit_favorites_to_mopidy(user):
def remove_track_from_mopidy_favorites_playlist(favorite):
from scrobbles.models import Scrobble
if favorite.media_type != "Track" or not favorite.track:
return
@ -566,15 +570,7 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
return
track = favorite.track
scrobble = (
Scrobble.objects.filter(
track=track,
user=favorite.user,
log__raw_data__mopidy_uri__isnull=False,
)
.order_by("-timestamp")
.first()
)
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
if not scrobble:
logger.warning(
@ -583,7 +579,7 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
)
return
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
try:
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:
url = url.rstrip("/")
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
]
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

View File

@ -35,6 +35,7 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.dateformat import DateFormat
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
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)
raw_data = scrobble.log.get("raw_data", {})
mopidy_uri = raw_data.get("mopidy_uri")
logger.debug(mopidy_uri)
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
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(
request,
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)
rpc_url = mopidy_url.rstrip("/") + "/rpc"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "core.tracklist.add",
"params": {"uris": [mopidy_uri]},
}
now = now_user_timezone(profile)
playlist_name = DateFormat(now).format(pattern)
try:
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}",
)
from scrobbles.tasks import add_scrobble_to_mopidy_monthly_playlist as task
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)
@ -1243,6 +1240,17 @@ class ScrobbleDetailView(DetailView):
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

View File

@ -163,6 +163,10 @@ CELERY_BEAT_SCHEDULE = {
"task": "scrobbles.tasks.send_mood_checkin",
"schedule": crontab(hour="*/4", minute=0),
},
"backfill-scrobble-sentiment": {
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
"schedule": crontab(minute="0"),
},
}
INSTALLED_APPS = [

View File

@ -66,11 +66,17 @@
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<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">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
</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 %}
{% 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>
@ -148,6 +154,20 @@
{% if notes_html %}
<div class="mb-3">
<h4>Notes</h4>
<span class="badge fs-8
{% if sentiment.compound >= 0.5 %}bg-success
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
{% elif sentiment.compound > -0.05 %}bg-secondary
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
{% else %}bg-danger
{% endif %}">
{% if sentiment.compound >= 0.5 %}Positive
{% elif sentiment.compound >= 0.05 %}Slightly positive
{% elif sentiment.compound > -0.05 %}Neutral
{% elif sentiment.compound > -0.5 %}Slightly negative
{% else %}Negative
{% endif %}
</span>
<div class="notes-list">
{{ notes_html|safe }}
</div>
@ -155,6 +175,13 @@
{% endif %}
{% endwith %}
{% with sentiment=object.log.sentiment %}
{% if sentiment %}
<div class="mb-3">
</div>
{% endif %}
{% endwith %}
{% if object.logdata.avg_seconds_per_page %}
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
{% endif %}