Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 |
34
PROJECT.org
34
PROJECT.org
@ -489,6 +489,39 @@ whatever time KoReader reports, we need to know, given the date and the user
|
||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||
to GMT to save it in the database.
|
||||
|
||||
** TODO [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:
|
||||
: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 +684,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:
|
||||
|
||||
1
justfile
1
justfile
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "43.0"
|
||||
version = "44.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user