Compare commits

...

3 Commits
44.0 ... 45.0

Author SHA1 Message Date
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
9 changed files with 185 additions and 7 deletions

View File

@ -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 [-1/13] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
@ -414,10 +414,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 +486,31 @@ 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 [#A] 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 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 +526,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

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "44.0"
version = "45.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

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

@ -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
@ -625,6 +626,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.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:

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
@ -1034,6 +1035,37 @@ 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)
now = now_user_timezone(profile)
playlist_name = DateFormat(now).format(pattern)
from scrobbles.utils import add_track_to_mopidy_monthly_playlist
add_track_to_mopidy_monthly_playlist(scrobble)
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:

View File

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