This commit has no recorded session.
diff
commit 99c056adeb5b90c2e6087687ac1c0e02ab9afb13Author: Colin Powell <colin@unbl.ink>Date: Fri Jun 5 13:29:49 2026 -0400 [tracks] Allow adding tracks to monthly playlists@@ -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:@@ -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",@@ -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, 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) @@ -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"),@@ -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:@@ -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:@@ -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>