Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb |
34
PROJECT.org
34
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 [-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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "44.0"
|
||||
version = "45.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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,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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user