Compare commits

...

6 Commits
43.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
7a504e45de [release] Bump to version 44.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 44s
- Add favorite feature for scrobbles
2026-06-05 11:26:39 -04:00
7618d0ba30 [tooling] Add full push back to justfile
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:25:48 -04:00
ce4dc40033 [favorites] Add ability to favorite and add to Mopidy
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:24:26 -04:00
16 changed files with 1076 additions and 15 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,6 +486,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 [#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:
*** Description
Now that we can favorite a mopidy track and have it added to a Favorites playlist, it would
be great if we could also populate a monthly_mopidy_playlist_pattern in a user profile and,
if configured, you could press "Add to monthly playlist" button on a given track that has
a mopidy_uri in it's log, and it would be added to the playlist.
The patterns would be based on traditional Django date formatting patterns: https://gregbrown.co/code/date-format
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:
:ID: 2780ae5f-fe23-49a5-8b33-d19e7f3e8ec6
:END:
*** Description
Would be great to have a FavoriteMedia data model that would accept any media_type ID and a user_id
marking that media as a favorite for that user.
Additionally, for tracks, we should add the ability to set a "favorites_mopidy_playlist" in a user profile
and if populated, and a track media type is favorited, and the track has a mopidy_uri value in a scrobble log,
send a POST to the mopidy server RPC endpoint for the favorite playlist and add the track.
* Version 43.0 [5/5]
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
:PROPERTIES:
@ -651,6 +706,7 @@ title of the Weigh-in task.
:ID: 15894943-be1d-200f-8400-a136770ad9d2
:END:
* Version 40.1 [2/2]
** DONE [#A] Releases are still broken :bug:releases:tooling:
:PROPERTIES:

View File

@ -19,4 +19,5 @@ release kind="minor":
poetry run python scripts/release.py {{kind}}
push:
git push && git push gitea
git push --tags && git push --tags gitea

View File

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

View File

@ -32,6 +32,8 @@ class UserProfileForm(forms.ModelForm):
"ntfy_url",
"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 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0033_userprofile_mopidy_api_url"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="favorites_mopidy_playlist",
field=models.CharField(
blank=True,
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
max_length=255,
null=True,
),
),
]

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

@ -65,6 +65,14 @@ class UserProfile(TimeStampedModel):
ntfy_enabled = models.BooleanField(default=False)
mopidy_api_url = models.CharField(max_length=255, **BNULL)
favorites_mopidy_playlist = models.CharField(
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

@ -4,6 +4,7 @@ from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
FavoriteMedia,
KoReaderImport,
LastFmImport,
RetroarchImport,
@ -164,3 +165,28 @@ class ScrobbleAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request).exclude(timestamp__year=None)
return qs
@admin.register(FavoriteMedia)
class FavoriteMediaAdmin(admin.ModelAdmin):
list_display = ("user", "media_type", "sent_to_mopidy", "created")
list_filter = ("media_type", "sent_to_mopidy", "user")
date_hierarchy = "created"
raw_id_fields = (
"video",
"track",
"podcast_episode",
"sport_event",
"book",
"video_game",
"board_game",
"geo_location",
"task",
"mood",
"brick_set",
"trail",
"beer",
"web_page",
"life_event",
"birding_location",
)

View File

@ -0,0 +1,287 @@
# Generated by Django 4.2.30 on 2026-06-05 14:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0015_alter_boardgame_genre"),
("trails", "0009_trail_route_waypoint"),
("moods", "0008_alter_mood_genre"),
("birds", "0002_birdingcsvimport"),
("bricksets", "0005_alter_brickset_genre"),
("music", "0036_artist_similar_artists"),
("puzzles", "0006_alter_puzzle_genre"),
("videogames", "0015_alter_videogame_genre"),
("lifeevents", "0005_alter_lifeevent_genre"),
("beers", "0008_alter_beer_genre"),
("foods", "0007_alter_food_genre"),
("tasks", "0007_alter_task_genre"),
("books", "0036_alter_book_genre_alter_paper_genre"),
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
("webpages", "0009_alter_webpage_genre"),
("sports", "0018_alter_sportevent_genre"),
("locations", "0010_clean_start"),
("scrobbles", "0088_scalecsvimport_file_hash"),
]
operations = [
migrations.CreateModel(
name="FavoriteMedia",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
(
"media_type",
models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
],
max_length=20,
),
),
("sent_to_mopidy", models.BooleanField(default=False)),
(
"beer",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="beers.beer",
),
),
(
"birding_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="birds.birdinglocation",
),
),
(
"board_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="boardgames.boardgame",
),
),
(
"book",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.book",
),
),
(
"brick_set",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bricksets.brickset",
),
),
(
"channel",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.channel",
),
),
(
"food",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="foods.food",
),
),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="locations.geolocation",
),
),
(
"life_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="lifeevents.lifeevent",
),
),
(
"mood",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="moods.mood",
),
),
(
"paper",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.paper",
),
),
(
"podcast_episode",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="podcasts.podcastepisode",
),
),
(
"puzzle",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="puzzles.puzzle",
),
),
(
"sport_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="sports.sportevent",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="tasks.task",
),
),
(
"track",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="music.track",
),
),
(
"trail",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="trails.trail",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"video",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.video",
),
),
(
"video_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videogames.videogame",
),
),
(
"web_page",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="webpages.webpage",
),
),
],
options={
"ordering": ["-created"],
},
),
]

View File

@ -1719,3 +1719,131 @@ class Scrobble(TimeStampedModel):
if commit and "pages_read" in self.log:
self.save(update_fields=["log"])
class FavoriteMedia(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
user = models.ForeignKey(User, on_delete=models.CASCADE)
video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
podcast_episode = models.ForeignKey(
PodcastEpisode, on_delete=models.CASCADE, **BNULL
)
sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
birding_location = models.ForeignKey(
BirdingLocation, on_delete=models.CASCADE, **BNULL
)
media_type = models.CharField(
max_length=20, choices=Scrobble.MediaType.choices
)
sent_to_mopidy = models.BooleanField(default=False)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"{self.user} favorites {self.media_obj}"
@property
def media_obj(self):
media_obj = None
if self.video:
media_obj = self.video
if self.track:
media_obj = self.track
if self.podcast_episode:
media_obj = self.podcast_episode
if self.sport_event:
media_obj = self.sport_event
if self.book:
media_obj = self.book
if self.video_game:
media_obj = self.video_game
if self.board_game:
media_obj = self.board_game
if self.geo_location:
media_obj = self.geo_location
if self.web_page:
media_obj = self.web_page
if self.life_event:
media_obj = self.life_event
if self.mood:
media_obj = self.mood
if self.brick_set:
media_obj = self.brick_set
if self.trail:
media_obj = self.trail
if self.beer:
media_obj = self.beer
if self.puzzle:
media_obj = self.puzzle
if self.task:
media_obj = self.task
if self.food:
media_obj = self.food
if self.channel:
media_obj = self.channel
if self.birding_location:
media_obj = self.birding_location
return media_obj
@classmethod
def toggle(cls, media_obj, user):
media_type = media_obj.__class__.__name__
if media_type not in Scrobble.MediaType.list():
raise ValueError(f"Unknown media type: {media_type}")
fk_map = {
"Video": "video",
"Channel": "channel",
"Track": "track",
"PodcastEpisode": "podcast_episode",
"SportEvent": "sport_event",
"Book": "book",
"Paper": "paper",
"VideoGame": "video_game",
"BoardGame": "board_game",
"GeoLocation": "geo_location",
"Beer": "beer",
"Puzzle": "puzzle",
"Food": "food",
"Trail": "trail",
"Task": "task",
"WebPage": "web_page",
"LifeEvent": "life_event",
"Mood": "mood",
"BrickSet": "brick_set",
"BirdingLocation": "birding_location",
}
fk = fk_map.get(media_type)
if not fk:
raise ValueError(f"No FK mapping for media type: {media_type}")
existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
if existing:
existing.delete()
return None
return cls.objects.create(
user=user,
media_type=media_type,
**{fk: media_obj},
)

View File

@ -4,8 +4,14 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
from scrobbles.models import Scrobble
from scrobbles.tasks import CHARTABLE_MEDIA_TYPES, SCROBBLES_WITHOUT_CHARTS, update_charts_for_timestamp
from scrobbles.models import FavoriteMedia, Scrobble
from scrobbles.tasks import (
add_favorite_to_mopidy_playlist,
CHARTABLE_MEDIA_TYPES,
remove_favorite_from_mopidy_playlist,
SCROBBLES_WITHOUT_CHARTS,
update_charts_for_timestamp,
)
from scrobbles.utils import tokenize_title_to_tags
logger = logging.getLogger(__name__)
@ -72,3 +78,28 @@ def add_tags_from_task_title(sender, instance, **kwargs):
for tag in new_tags:
if tag not in existing_tags:
instance.tags.add(tag)
@receiver(post_save, sender=FavoriteMedia)
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
if not created:
return
if instance.media_type != Scrobble.MediaType.TRACK:
return
if not instance.track:
return
add_favorite_to_mopidy_playlist.delay(instance.id)
@receiver(post_delete, sender=FavoriteMedia)
def remove_from_mopidy_playlist_on_unfavorite(sender, instance, **kwargs):
if instance.media_type != Scrobble.MediaType.TRACK:
return
if not instance.track_id:
return
remove_favorite_from_mopidy_playlist.delay(
user_id=instance.user_id,
track_id=instance.track_id,
)

View File

@ -542,3 +542,37 @@ def send_mood_checkin():
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
send_mood_checkin_reminders()
@shared_task
def add_favorite_to_mopidy_playlist(favorite_id):
from scrobbles.models import FavoriteMedia
from scrobbles.utils import add_track_to_mopidy_favorites_playlist
favorite = FavoriteMedia.objects.filter(id=favorite_id).first()
if not favorite:
return
add_track_to_mopidy_favorites_playlist(favorite)
@shared_task
def remove_favorite_from_mopidy_playlist(user_id, track_id):
from music.models import Track
from scrobbles.utils import remove_track_from_mopidy_favorites_playlist
User = get_user_model()
try:
user = User.objects.get(id=user_id)
track = Track.objects.get(id=track_id)
except (User.DoesNotExist, Track.DoesNotExist):
return
import types
proxy = types.SimpleNamespace(
media_type="Track",
user=user,
user_id=user.id,
track=track,
)
remove_track_from_mopidy_favorites_playlist(proxy)

View File

@ -163,7 +163,17 @@ 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"),
path(
"favorite/<str:media_type>/<int:object_id>/toggle/",
views.toggle_favorite,
name="toggle-favorite",
),
]

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
@ -87,8 +88,7 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
return Scrobble.objects.filter(media_query, user=user)
def get_recently_played_board_games(user: User) -> dict:
...
def get_recently_played_board_games(user: User) -> dict: ...
def get_long_plays_in_progress(user: User) -> dict:
@ -423,6 +423,293 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
return {entry["day"]: entry["total_calories"] for entry in qs}
def _mopidy_rpc(profile, method, params=None):
rpc_url = profile.mopidy_api_url.rstrip("/") + "/rpc"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
}
if params:
payload["params"] = params
resp = requests.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
result = resp.json()
if result.get("error"):
raise RuntimeError(f'Mopidy error: {result["error"]}')
return result.get("result")
def _ensure_mopidy_playlist(profile):
playlist_name = profile.favorites_mopidy_playlist
# Strip any m3u: prefix and .m3u8 suffix the user may have included
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"},
)
logger.info(
"Created Mopidy favorites playlist",
extra={"uri": result.get("uri") if result else playlist_name},
)
return result
def add_track_to_mopidy_favorites_playlist(favorite):
from scrobbles.models import Scrobble
if favorite.media_type != "Track" or not favorite.track:
return
profile = favorite.user.profile
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
return
track = favorite.track
scrobble = (
Scrobble.objects.filter(
track=track,
user=favorite.user,
log__raw_data__mopidy_uri__isnull=False,
)
.order_by("-timestamp")
.first()
)
if not scrobble:
logger.warning(
"No Mopidy URI found for track",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
return
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
try:
playlist = _ensure_mopidy_playlist(profile)
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 Mopidy favorites playlist",
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
)
favorite.sent_to_mopidy = True
favorite.save(update_fields=["sent_to_mopidy"])
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", "Favorites"),
"tracks": existing_tracks,
"last_modified": playlist.get("last_modified"),
},
},
)
else:
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
favorite.sent_to_mopidy = True
favorite.save(update_fields=["sent_to_mopidy"])
logger.info(
"Added track to Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
except (requests.RequestException, RuntimeError) as e:
logger.debug(e)
logger.error(
"Failed to add track to Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
)
def resubmit_favorites_to_mopidy(user):
from scrobbles.models import FavoriteMedia
favorites = FavoriteMedia.objects.filter(
user=user,
media_type="Track",
track__isnull=False,
)
for favorite in favorites:
add_track_to_mopidy_favorites_playlist(favorite)
def remove_track_from_mopidy_favorites_playlist(favorite):
from scrobbles.models import Scrobble
if favorite.media_type != "Track" or not favorite.track:
return
profile = favorite.user.profile
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
return
track = favorite.track
scrobble = (
Scrobble.objects.filter(
track=track,
user=favorite.user,
log__raw_data__mopidy_uri__isnull=False,
)
.order_by("-timestamp")
.first()
)
if not scrobble:
logger.warning(
"No Mopidy URI found for track",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
return
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
try:
playlist = _ensure_mopidy_playlist(profile)
if playlist and playlist.get("uri"):
existing_tracks = playlist.get("tracks") or []
filtered = [
t for t in existing_tracks
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
]
if len(filtered) == len(existing_tracks):
logger.info(
"Track not found in Mopidy favorites playlist",
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
)
return
_mopidy_rpc(
profile,
"core.playlists.save",
{
"playlist": {
"__model__": "Playlist",
"uri": playlist["uri"],
"name": playlist.get("name", "Favorites"),
"tracks": filtered,
"last_modified": playlist.get("last_modified"),
},
},
)
logger.info(
"Removed track from Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
except (requests.RequestException, RuntimeError) as e:
logger.debug(e)
logger.error(
"Failed to remove track from Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
)
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:
@ -517,8 +804,6 @@ def tokenize_title_to_tags(title: str) -> list[str]:
cleaned = re.sub(r"[^\w\s]", "", cleaned)
words = [
w.lower()
for w in cleaned.split()
if w.lower() not in STOPWORDS and len(w) > 2
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
]
return words

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
@ -75,6 +76,7 @@ from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
FavoriteMedia,
KoReaderImport,
LastFmImport,
RetroarchImport,
@ -1033,6 +1035,89 @@ 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:
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
app_model_map = {
"Video": ("videos", "Video"),
"Channel": ("videos", "Channel"),
"Track": ("music", "Track"),
"PodcastEpisode": ("podcasts", "PodcastEpisode"),
"SportEvent": ("sports", "SportEvent"),
"Book": ("books", "Book"),
"Paper": ("books", "Paper"),
"VideoGame": ("videogames", "VideoGame"),
"BoardGame": ("boardgames", "BoardGame"),
"GeoLocation": ("locations", "GeoLocation"),
"Beer": ("beers", "Beer"),
"Puzzle": ("puzzles", "Puzzle"),
"Food": ("foods", "Food"),
"Trail": ("trails", "Trail"),
"Task": ("tasks", "Task"),
"WebPage": ("webpages", "WebPage"),
"LifeEvent": ("lifeevents", "LifeEvent"),
"Mood": ("moods", "Mood"),
"BrickSet": ("bricksets", "BrickSet"),
"BirdingLocation": ("birds", "BirdingLocation"),
}
app_label, model_name = app_model_map.get(media_type, (None, None))
if not app_label:
messages.add_message(
request, messages.ERROR, f"Unknown media type: {media_type}"
)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
model = apps.get_model(app_label, model_name)
media_obj = get_object_or_404(model, id=object_id)
result = FavoriteMedia.toggle(media_obj, request.user)
is_favorited = result is not None
if not is_favorited:
msg = f'Removed "{media_obj}" from favorites.'
else:
msg = f'Added "{media_obj}" to favorites.'
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JsonResponse({"is_favorited": is_favorited, "message": msg})
messages.add_message(request, messages.SUCCESS, msg)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def export(request):
@ -1183,6 +1268,13 @@ class ScrobbleDetailView(DetailView):
except EmptyPage:
context["related_scrobbles"] = paginator.page(paginator.num_pages)
if self.request.user.is_authenticated:
fk_field = self.MEDIA_FK_MAP.get(media_type)
if fk_field and media_obj:
context["is_favorited"] = FavoriteMedia.objects.filter(
user=self.request.user, **{fk_field: media_obj}
).exists()
return context

View File

@ -26,6 +26,21 @@
height: 400px;
border-radius: 4px;
}
.heart-icon {
cursor: pointer;
transition: fill 0.2s, stroke 0.2s;
}
.heart-icon.favorited {
fill: #e74c3c;
stroke: #e74c3c;
}
.heart-icon:not(.favorited) {
fill: none;
stroke: #888;
}
.heart-icon:not(.favorited):hover {
stroke: #e74c3c;
}
</style>
{% endblock %}
@ -33,9 +48,19 @@
<div class="row">
<h1>
<h1 class="d-flex align-items-center gap-2">
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
{% if user.is_authenticated and object.media_obj %}
<button id="favorite-btn"
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
data-favorited="{{ is_favorited|yesno:'true,false' }}"
class="btn btn-sm p-0 border-0 bg-transparent">
<svg width="20" height="20" viewBox="0 0 24 24" class="heart-icon{% if is_favorited %} favorited{% endif %}" id="heart-svg">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</button>
{% endif %}
</h1>
{% if object.media_type == "Task" and object.logdata.title %}
<h2>{{ object.logdata.title }}</h2>
@ -44,8 +69,14 @@
{% if object.media_type == "Track" and object.log.raw_data.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>
<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>
@ -227,6 +258,30 @@
{% block extra_js %}
{{ block.super }}
{{ log_form.media }}
<script>
function getCookie(name) {
let value = "; " + document.cookie;
let parts = value.split("; " + name + "=");
if (parts.length === 2) return parts.pop().split(";").shift();
}
document.getElementById("favorite-btn")?.addEventListener("click", function() {
var btn = this;
var url = btn.dataset.url;
fetch(url, {
method: "POST",
headers: {
"X-CSRFToken": getCookie("csrftoken"),
"X-Requested-With": "XMLHttpRequest",
},
})
.then(function(r) { return r.json(); })
.then(function(data) {
var heart = document.getElementById("heart-svg");
btn.dataset.favorited = data.is_favorited ? "true" : "false";
heart.classList.toggle("favorited", data.is_favorited);
});
});
</script>
{% if object.media_type == "Trail" and object.gpx_file %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>