This commit has no recorded session.
diff
commit ce4dc4003374252c740f58e41152fa48aaf497e8Author: Colin Powell <colin@unbl.ink>Date: Fri Jun 5 11:24:26 2026 -0400 [favorites] Add ability to favorite and add to Mopidy@@ -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:@@ -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",@@ -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,+ ),+ ),+ ]@@ -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) @@ -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",+ )@@ -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"],+ },+ ),+ ]@@ -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},+ )@@ -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,+ )@@ -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)@@ -166,4 +166,9 @@ urlpatterns = [ 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",+ ), ]@@ -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@@ -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 @@ -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,7 +69,7 @@ {% 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> {% endif %} {% if object.media_type == "Track" %}@@ -227,6 +252,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>