From ce4dc4003374252c740f58e41152fa48aaf497e8 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 5 Jun 2026 11:24:26 -0400 Subject: [PATCH] [favorites] Add ability to favorite and add to Mopidy --- PROJECT.org | 15 + vrobbler/apps/profiles/forms.py | 1 + ...4_userprofile_favorites_mopidy_playlist.py | 23 ++ vrobbler/apps/profiles/models.py | 4 + vrobbler/apps/scrobbles/admin.py | 26 ++ .../migrations/0089_favoritemedia.py | 287 ++++++++++++++++++ vrobbler/apps/scrobbles/models.py | 128 ++++++++ vrobbler/apps/scrobbles/signals.py | 35 ++- vrobbler/apps/scrobbles/tasks.py | 34 +++ vrobbler/apps/scrobbles/urls.py | 5 + vrobbler/apps/scrobbles/utils.py | 210 ++++++++++++- vrobbler/apps/scrobbles/views.py | 60 ++++ .../templates/scrobbles/scrobble_detail.html | 53 +++- 13 files changed, 872 insertions(+), 9 deletions(-) create mode 100644 vrobbler/apps/profiles/migrations/0034_userprofile_favorites_mopidy_playlist.py create mode 100644 vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py diff --git a/PROJECT.org b/PROJECT.org index b030ac5..5c2bbf0 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -489,6 +489,21 @@ 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. + +** 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: diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.py index 03493c8..fc00bc0 100644 --- a/vrobbler/apps/profiles/forms.py +++ b/vrobbler/apps/profiles/forms.py @@ -32,6 +32,7 @@ class UserProfileForm(forms.ModelForm): "ntfy_url", "ntfy_enabled", "mopidy_api_url", + "favorites_mopidy_playlist", "redirect_to_webpage", "enable_public_widgets", "widget_custom_css", diff --git a/vrobbler/apps/profiles/migrations/0034_userprofile_favorites_mopidy_playlist.py b/vrobbler/apps/profiles/migrations/0034_userprofile_favorites_mopidy_playlist.py new file mode 100644 index 0000000..795e23c --- /dev/null +++ b/vrobbler/apps/profiles/migrations/0034_userprofile_favorites_mopidy_playlist.py @@ -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, + ), + ), + ] diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.py index 1e56bac..12ee041 100644 --- a/vrobbler/apps/profiles/models.py +++ b/vrobbler/apps/profiles/models.py @@ -65,6 +65,10 @@ 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", + ) redirect_to_webpage = models.BooleanField(default=True) diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 9c5ba9d..1e4fd4b 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/vrobbler/apps/scrobbles/admin.py @@ -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", + ) diff --git a/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py b/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py new file mode 100644 index 0000000..0ea09ba --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py @@ -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"], + }, + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 1800396..8b9eca5 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -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}, + ) diff --git a/vrobbler/apps/scrobbles/signals.py b/vrobbler/apps/scrobbles/signals.py index ff2e1ff..9a1f0d4 100644 --- a/vrobbler/apps/scrobbles/signals.py +++ b/vrobbler/apps/scrobbles/signals.py @@ -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, + ) diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py index 507646c..775afa2 100644 --- a/vrobbler/apps/scrobbles/tasks.py +++ b/vrobbler/apps/scrobbles/tasks.py @@ -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) diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index 2e9adb2..64844ff 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -166,4 +166,9 @@ urlpatterns = [ path("scrobbles//start/", views.scrobble_start, name="start"), path("scrobbles//finish/", views.scrobble_finish, name="finish"), path("scrobbles//cancel/", views.scrobble_cancel, name="cancel"), + path( + "favorite///toggle/", + views.toggle_favorite, + name="toggle-favorite", + ), ] diff --git a/vrobbler/apps/scrobbles/utils.py b/vrobbler/apps/scrobbles/utils.py index 049b155..6b26780 100644 --- a/vrobbler/apps/scrobbles/utils.py +++ b/vrobbler/apps/scrobbles/utils.py @@ -87,8 +87,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 +422,209 @@ 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 remove_last_part(url: str) -> str: url = url.rstrip("/") if "/" not in url: @@ -517,8 +719,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 diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 5015c92..3c476a1 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -75,6 +75,7 @@ from scrobbles.models import ( AudioScrobblerTSVImport, BGStatsImport, EBirdCSVImport, + FavoriteMedia, KoReaderImport, LastFmImport, RetroarchImport, @@ -1033,6 +1034,58 @@ def add_to_mopidy_queue(request, uuid): 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 +1236,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 diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html index dc5609c..29bf97e 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -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; + } {% endblock %} @@ -33,9 +48,19 @@
-

+

{% 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 %}{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}{% endif %} +{% if user.is_authenticated and object.media_obj %} + +{% endif %}

{% if object.media_type == "Task" and object.logdata.title %}

{{ object.logdata.title }}

@@ -44,7 +69,7 @@ {% if object.media_type == "Track" and object.log.raw_data.mopidy_uri and user.profile.mopidy_api_url %}
{% csrf_token %} - +
{% endif %} {% if object.media_type == "Track" %} @@ -227,6 +252,30 @@ {% block extra_js %} {{ block.super }} {{ log_form.media }} + {% if object.media_type == "Trail" and object.gpx_file %}