sessions / 99c056adeb5b90c2e6087687ac1c0e02ab9afb13

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit 99c056adeb5b90c2e6087687ac1c0e02ab9afb13Author: Colin Powell <colin@unbl.ink>Date:   Fri Jun 5 13:29:49 2026 -0400    [tracks] Allow adding tracks to monthly playlistsdiff --git a/PROJECT.org b/PROJECT.orgindex 16ba092..0844c09 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -93,7 +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:+* Backlog [0/14] :vrobbler:project:personal: ** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment: :PROPERTIES: :ID:       37781d6a-f3b0-48b2-bf98-33c2c791cf85@@ -489,7 +489,7 @@ whatever time KoReader reports, we need to know, given the date and the user profile's historic timezone, how many hours to adjust the KoReader time to get to GMT to save it in the database. -** TODO [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:+** DONE [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks: :PROPERTIES: :ID:       c872ff0a-e71f-415f-b5a6-e62ea9634d14 :END:diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.pyindex fc00bc0..35ba2e8 100644--- a/vrobbler/apps/profiles/forms.py+++ b/vrobbler/apps/profiles/forms.py@@ -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",diff --git a/vrobbler/apps/profiles/migrations/0035_userprofile_monthly_mopidy_playlist_pattern.py b/vrobbler/apps/profiles/migrations/0035_userprofile_monthly_mopidy_playlist_pattern.pynew file mode 100644index 0000000..12a3f23--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0035_userprofile_monthly_mopidy_playlist_pattern.py@@ -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,+            ),+        ),+    ]diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.pyindex 12ee041..c4fad55 100644--- a/vrobbler/apps/profiles/models.py+++ b/vrobbler/apps/profiles/models.py@@ -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) diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.pyindex 64844ff..a76a47d 100644--- a/vrobbler/apps/scrobbles/urls.py+++ b/vrobbler/apps/scrobbles/urls.py@@ -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"),diff --git a/vrobbler/apps/scrobbles/utils.py b/vrobbler/apps/scrobbles/utils.pyindex 6b26780..ee316b6 100644--- a/vrobbler/apps/scrobbles/utils.py+++ b/vrobbler/apps/scrobbles/utils.py@@ -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@@ -625,6 +626,89 @@ 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.get("raw_data", {}).get("mopidy_uri")+    if not mopidy_uri:+        return++    playlist_name = DateFormat(scrobble.timestamp).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:diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex 3c476a1..29d6f4f 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -1034,6 +1034,38 @@ def add_to_mopidy_queue(request, uuid):     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,+            "Monthly playlist pattern or Mopidy API URL not configured in your profile.",+        )+        return redirect("scrobbles:detail", uuid=uuid)++    from scrobbles.utils import add_track_to_mopidy_monthly_playlist++    add_track_to_mopidy_monthly_playlist(scrobble)++    from django.utils.dateformat import DateFormat++    playlist_name = DateFormat(scrobble.timestamp).format(pattern)+    messages.add_message(+        request,+        messages.SUCCESS,+        f'Added "{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:diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.htmlindex 29bf97e..cdf75f3 100644--- a/vrobbler/templates/scrobbles/scrobble_detail.html+++ b/vrobbler/templates/scrobbles/scrobble_detail.html@@ -71,6 +71,12 @@   {% 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>