diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py index 294194e061614a338137a3519aa547823a2d1cf0..74d02eafed516bc1c08f30034c01cbf3be05ee4a 100644 --- a/api/funkwhale_api/favorites/activities.py +++ b/api/funkwhale_api/favorites/activities.py @@ -4,6 +4,7 @@ from funkwhale_api.common import channels from . import serializers record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer) +record.registry.register_serializer(serializers.AlbumFavoriteActivitySerializer) @record.registry.register_consumer("favorites.TrackFavorite") @@ -14,3 +15,13 @@ def broadcast_track_favorite_to_instance_activity(data, obj): channels.group_send( "instance_activity", {"type": "event.send", "text": "", "data": data} ) + + +@record.registry.register_consumer("favorites.AlbumFavorite") +def broadcast_album_favorite_to_instance_activity(data, obj): + if obj.user.privacy_level not in ["instance", "everyone"]: + return + + channels.group_send( + "instance_activity", {"type": "event.send", "text": "", "data": data} + ) diff --git a/api/funkwhale_api/favorites/admin.py b/api/funkwhale_api/favorites/admin.py index 05530b0c67dd0a9c42808ff2e24d721b32f911cf..bfbf96e4b3cc658cadae02c83ee5fded07031587 100644 --- a/api/funkwhale_api/favorites/admin.py +++ b/api/funkwhale_api/favorites/admin.py @@ -7,3 +7,9 @@ from . import models class TrackFavoriteAdmin(admin.ModelAdmin): list_display = ["user", "track", "creation_date"] list_select_related = ["user", "track"] + + +@admin.register(models.AlbumFavorite) +class AlbumFavoriteAdmin(admin.ModelAdmin): + list_display = ["user", "album", "creation_date"] + list_select_related = ["user", "album"] diff --git a/api/funkwhale_api/favorites/factories.py b/api/funkwhale_api/favorites/factories.py index fcc2f820425c80130a93b7c45c1dca7650da5c88..b663f140f8a1c155f553f85d7c46cfd30ae5c1c9 100644 --- a/api/funkwhale_api/favorites/factories.py +++ b/api/funkwhale_api/favorites/factories.py @@ -1,6 +1,7 @@ import factory from funkwhale_api.factories import registry, NoUpdateOnCreate +from funkwhale_api.music.factories import AlbumFactory from funkwhale_api.music.factories import TrackFactory from funkwhale_api.users.factories import UserFactory @@ -12,3 +13,12 @@ class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory): class Meta: model = "favorites.TrackFavorite" + + +@registry.register +class AlbumFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory): + album = factory.SubFactory(AlbumFactory) + user = factory.SubFactory(UserFactory) + + class Meta: + model = "favorites.AlbumFavorite" diff --git a/api/funkwhale_api/favorites/filters.py b/api/funkwhale_api/favorites/filters.py index 32c07a646bd80ca5675fc61fc2a45e25c31e8308..8e8193ce4cb6e41fce265b1aaecbb9d473fa68d0 100644 --- a/api/funkwhale_api/favorites/filters.py +++ b/api/funkwhale_api/favorites/filters.py @@ -17,3 +17,18 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet): hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[ "TRACK_FAVORITE" ] + + +class AlbumFavoriteFilter(moderation_filters.HiddenContentFilterSet): + q = fields.SearchFilter( + # TODO: not sure which values I should put there + search_fields=["track__title", "track__artist__name", "track__album__title"] + ) + scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) + + class Meta: + model = models.AlbumFavorite + fields = [] + hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[ + "ALBUM_FAVORITE" + ] diff --git a/api/funkwhale_api/favorites/migrations/0002_add_album_favorites.py b/api/funkwhale_api/favorites/migrations/0002_add_album_favorites.py new file mode 100644 index 0000000000000000000000000000000000000000..4cda9313b617a58184e8a4dd89354669c7f1d96c --- /dev/null +++ b/api/funkwhale_api/favorites/migrations/0002_add_album_favorites.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ("favorites", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AlbumFavorite", + fields=[ + ( + "id", + models.AutoField( + serialize=False, + auto_created=True, + verbose_name="ID", + primary_key=True, + ), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "album", + models.ForeignKey( + related_name="album_favorites", + to="music.Album", + on_delete=models.CASCADE, + ), + ), + ( + "user", + models.ForeignKey( + related_name="album_favorites", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ], + options={"ordering": ("-creation_date",)}, + ), + migrations.AlterUniqueTogether( + name="albumfavorite", unique_together=set([("album", "user")]) + ), + ] diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py index a6a80cebdd0e70d8d5a67293ef4fcbcad3d0e463..6f0e80478e3e48a3858fb0cc3aede8cb05e869b4 100644 --- a/api/funkwhale_api/favorites/models.py +++ b/api/funkwhale_api/favorites/models.py @@ -1,7 +1,7 @@ from django.db import models from django.utils import timezone -from funkwhale_api.music.models import Track +from funkwhale_api.music.models import Track, Album class TrackFavorite(models.Model): @@ -24,3 +24,25 @@ class TrackFavorite(models.Model): def get_activity_url(self): return "{}/favorites/tracks/{}".format(self.user.get_activity_url(), self.pk) + + +class AlbumFavorite(models.Model): + creation_date = models.DateTimeField(default=timezone.now) + user = models.ForeignKey( + "users.User", related_name="album_favorites", on_delete=models.CASCADE + ) + album = models.ForeignKey( + Album, related_name="album_favorites", on_delete=models.CASCADE + ) + + class Meta: + unique_together = ("album", "user") + ordering = ("-creation_date",) + + @classmethod + def add(cls, album, user): + favorite, created = cls.objects.get_or_create(user=user, album=album) + return favorite + + def get_activity_url(self): + return "{}/favorites/albums/{}".format(self.user.get_activity_url(), self.pk) diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index dd28dcd07cbaff216d9988f9641d890f069b0d50..e58f947d72428083514331d9f860c097d3da2f03 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.music.serializers import AlbumActivitySerializer, AlbumSerializer from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer @@ -45,3 +46,42 @@ class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFavorite fields = ("id", "track", "creation_date") + + +class AlbumFavoriteActivitySerializer(activity_serializers.ModelSerializer): + type = serializers.SerializerMethodField() + object = AlbumActivitySerializer(source="album") + actor = UserActivitySerializer(source="user") + published = serializers.DateTimeField(source="creation_date") + + class Meta: + model = models.AlbumFavorite + fields = ["id", "local_id", "object", "type", "actor", "published"] + + def get_actor(self, obj): + return UserActivitySerializer(obj.user).data + + def get_type(self, obj): + return "Like" + + +class UserAlbumFavoriteSerializer(serializers.ModelSerializer): + album = AlbumSerializer(read_only=True) + user = UserBasicSerializer(read_only=True) + actor = serializers.SerializerMethodField() + + class Meta: + model = models.AlbumFavorite + fields = ("id", "user", "album", "creation_date", "actor") + actor = serializers.SerializerMethodField() + + def get_actor(self, obj): + actor = obj.user.actor + if actor: + return federation_serializers.APIActorSerializer(actor).data + + +class UserAlbumFavoriteWriteSerializer(serializers.ModelSerializer): + class Meta: + model = models.AlbumFavorite + fields = ("id", "album", "creation_date") diff --git a/api/funkwhale_api/favorites/urls.py b/api/funkwhale_api/favorites/urls.py index 51f3078038e882aeb90a41bf1f4a1c870e7d9d56..30cc0d347ed1d9fe0cc7382ee2a0ebba319a3aba 100644 --- a/api/funkwhale_api/favorites/urls.py +++ b/api/funkwhale_api/favorites/urls.py @@ -4,5 +4,6 @@ from . import views router = routers.OptionalSlashRouter() router.register(r"tracks", views.TrackFavoriteViewSet, "tracks") +router.register(r"albums", views.AlbumFavoriteViewSet, "albums") urlpatterns = router.urls diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index db0c909001edef6f854216a9a22895a2a42c0fb0..3b69842a023e52f6af728d577dc9b7a6c39dfe83 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -6,7 +6,7 @@ from django.db.models import Prefetch from funkwhale_api.activity import record from funkwhale_api.common import fields, permissions -from funkwhale_api.music.models import Track +from funkwhale_api.music.models import Track, Album from funkwhale_api.music import utils as music_utils from funkwhale_api.users.oauth import permissions as oauth_permissions @@ -92,3 +92,85 @@ class TrackFavoriteViewSet( ) payload = {"results": favorites, "count": len(favorites)} return Response(payload, status=200) + + +class AlbumFavoriteViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + + filterset_class = filters.AlbumFavoriteFilter + serializer_class = serializers.UserAlbumFavoriteSerializer + queryset = models.AlbumFavorite.objects.all().select_related( + "user__actor__attachment_icon" + ) + permission_classes = [ + oauth_permissions.ScopePermission, + permissions.OwnerPermission, + ] + required_scope = "favorites" + anonymous_policy = "setting" + owner_checks = ["write"] + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.UserAlbumFavoriteSerializer + return serializers.UserAlbumFavoriteWriteSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance = self.perform_create(serializer) + serializer = self.get_serializer(instance=instance) + headers = self.get_success_headers(serializer.data) + record.send(instance) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.filter( + fields.privacy_level_query(self.request.user, "user__privacy_level") + ) + # TODO: not sure which values I should put there + albums = Album.objects.with_playable_uploads( + music_utils.get_actor_from_request(self.request) + ).select_related( + "artist", "album__artist", "attributed_to", "album__attachment_cover" + ) + queryset = queryset.prefetch_related(Prefetch("album", queryset=albums)) + return queryset + + def perform_create(self, serializer): + album = Album.objects.get(pk=serializer.data["album"]) + favorite = models.AlbumFavorite.add(album=album, user=self.request.user) + return favorite + + @action(methods=["delete", "post"], detail=False) + def remove(self, request, *args, **kwargs): + try: + pk = int(request.data["album"]) + favorite = request.user.album_favorites.get(album__pk=pk) + except (AttributeError, ValueError, models.AlbumFavorite.DoesNotExist): + return Response({}, status=400) + favorite.delete() + return Response([], status=status.HTTP_204_NO_CONTENT) + + @action(methods=["get"], detail=False) + def all(self, request, *args, **kwargs): + """ + Return all the favorite albums of the current user, with only limited + data to have a performant endpoint and avoid lots of queries just to + display favorites status in the UI + """ + if not request.user.is_authenticated: + return Response({"results": [], "count": 0}, status=200) + + favorites = list( + request.user.album_favorites.values("id", "album").order_by("id") + ) + payload = {"results": favorites, "count": len(favorites)} + return Response(payload, status=200) diff --git a/api/funkwhale_api/moderation/filters.py b/api/funkwhale_api/moderation/filters.py index 629ae685f95f36b75abb9a5e172f2f0de37620c4..02631fd247aafd2ebd232c323293a3bd22b3242b 100644 --- a/api/funkwhale_api/moderation/filters.py +++ b/api/funkwhale_api/moderation/filters.py @@ -12,6 +12,10 @@ USER_FILTER_CONFIG = { "TRACK_FAVORITE": { "target_artist": ["track__album__artist__pk", "track__artist__pk"] }, + "ALBUM_FAVORITE": { + # TODO: not sure which values I should put there + "target_artist": ["track__album__artist__pk", "track__artist__pk"] + }, } diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 82464e40ac16b61528dc503b2440324b5b6edef4..b3e814b68acd9e21992f3816370164cb5e2a0ae1 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -410,6 +410,11 @@ class Album(APIModelMixin): def get_moderation_url(self): return "/manage/library/albums/{}".format(self.pk) + def get_activity_url(self): + if self.mbid: + return "https://musicbrainz.org/recording/{}".format(self.mbid) + return settings.FUNKWHALE_URL + "/albums/{}".format(self.pk) + @classmethod def get_or_create_from_title(cls, title, **kwargs): kwargs.update({"title": title}) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index df4e701335e4ab7fedcdda4ffe5274ea6f7d61db..416a587e1815e1dacd5422d76a927e81f9545866 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -613,6 +613,19 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): return o.album.title +class AlbumActivitySerializer(activity_serializers.ModelSerializer): + type = serializers.SerializerMethodField() + name = serializers.CharField(source="title") + artist = serializers.CharField(source="artist.name") + + class Meta: + model = models.Album + fields = ["id", "local_id", "name", "type", "artist", "album"] + + def get_type(self, obj): + return "Audio" + + def get_embed_url(type, id): return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id) diff --git a/front/src/components/favorites/AlbumFavoriteIcon.vue b/front/src/components/favorites/AlbumFavoriteIcon.vue new file mode 100644 index 0000000000000000000000000000000000000000..ca74474c5f276fc17c75214aeae45d182bfdc804 --- /dev/null +++ b/front/src/components/favorites/AlbumFavoriteIcon.vue @@ -0,0 +1,53 @@ +<template> + <button + v-if="button" + :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']" + @click.stop="$store.dispatch('albumFavorites/toggle', album.id)" + > + <i class="heart icon" /> + <translate + v-if="isFavorite" + translate-context="Content/Track/Button.Message" + > + In favorites + </translate> + <translate + v-else + translate-context="Content/Track/*/Verb" + > + Add to favorites + </translate> + </button> + <button + v-else + :class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']" + :aria-label="title" + :title="title" + @click.stop="$store.dispatch('albumFavorites/toggle', album.id)" + > + <i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']" /> + </button> +</template> + +<script> +export default { + props: { + album: { type: Object, default: () => { return {} } }, + button: { type: Boolean, default: false }, + border: { type: Boolean, default: false } + }, + computed: { + title () { + if (this.isFavorite) { + return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites') + } else { + return this.$pgettext('Content/Track/*/Verb', 'Add to favorites') + } + }, + isFavorite () { + return this.$store.getters['albumFavorites/isFavorite'](this.album.id) + } + } + +} +</script> diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index 7662e8af96d5f59d60c5c2125d3cbc3d4fa399b9..8307ae1ad646e0433b3e291f1b1d83a560429dd9 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -173,6 +173,10 @@ :album="object" :is-playable="object.is_playable" /> + <album-favorite-icon + v-if="$store.state.auth.authenticated" + :album="object" + /> <div class="ui horizontal hidden divider" /> <album-dropdown :object="object" @@ -254,6 +258,7 @@ import PlayButton from '@/components/audio/PlayButton.vue' import TagsList from '@/components/tags/List.vue' import ArtistLabel from '@/components/audio/ArtistLabel.vue' import AlbumDropdown from './AlbumDropdown.vue' +import AlbumFavoriteIcon from '@/components/favorites/AlbumFavoriteIcon.vue' function groupByDisc (initial) { function inner (acc, track) { @@ -271,6 +276,7 @@ function groupByDisc (initial) { export default { components: { PlayButton, + AlbumFavoriteIcon, TagsList, ArtistLabel, AlbumDropdown diff --git a/front/src/store/albumFavorites.js b/front/src/store/albumFavorites.js new file mode 100644 index 0000000000000000000000000000000000000000..945adb1a92052250f28252f0ef093f78ecb605f6 --- /dev/null +++ b/front/src/store/albumFavorites.js @@ -0,0 +1,72 @@ +import axios from 'axios' +import logger from '@/logging' + +export default { + namespaced: true, + state: { + albums: [], + count: 0 + }, + mutations: { + album: (state, { id, value }) => { + if (value) { + if (state.albums.indexOf(id) === -1) { + state.albums.push(id) + } + } else { + const i = state.albums.indexOf(id) + if (i > -1) { + state.albums.splice(i, 1) + } + } + state.count = state.albums.length + }, + reset (state) { + state.albums = [] + state.count = 0 + } + }, + getters: { + isFavorite: (state) => (id) => { + return state.albums.indexOf(id) > -1 + } + }, + actions: { + set ({ commit, state }, { id, value }) { + commit('album', { id, value }) + if (value) { + return axios.post('favorites/albums/', { album: id }).then((response) => { + logger.default.info('Successfully added album to favorites') + }, (response) => { + logger.default.info('Error while adding album to favorites') + commit('album', { id, value: !value }) + }) + } else { + return axios.post('favorites/albums/remove/', { album: id }).then((response) => { + logger.default.info('Successfully removed album from favorites') + }, (response) => { + logger.default.info('Error while removing album from favorites') + commit('album', { id, value: !value }) + }) + } + }, + toggle ({ getters, dispatch }, id) { + dispatch('set', { id, value: !getters.isFavorite(id) }) + }, + fetch ({ dispatch, state, commit, rootState }, url) { + // will fetch favorites by batches from API to have them locally + const params = { + user: rootState.auth.profile.id, + page_size: 50, + ordering: '-creation_date' + } + const promise = axios.get('favorites/albums/all/', { params: params }) + return promise.then((response) => { + logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites') + response.data.results.forEach(result => { + commit('album', { id: result.album, value: true }) + }) + }) + } + } +} diff --git a/front/src/store/index.js b/front/src/store/index.js index a7954420346599c335856b82980522dc6239730c..5c7631447d62786a783c99f5896b1a8f35a8275e 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -2,6 +2,7 @@ import Vue from 'vue' import Vuex from 'vuex' import createPersistedState from 'vuex-persistedstate' +import albumFavorites from './albumFavorites' import favorites from './favorites' import channels from './channels' import libraries from './libraries' @@ -22,6 +23,7 @@ export default new Vuex.Store({ auth, channels, libraries, + albumFavorites, favorites, instance, moderation,