diff --git a/api/config/settings/local.py b/api/config/settings/local.py index 9f0119cee..b8df4bdb7 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -39,6 +39,7 @@ DEBUG_TOOLBAR_CONFIG = { "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], "SHOW_TEMPLATE_CONTEXT": True, "SHOW_TOOLBAR_CALLBACK": lambda request: True, + "JQUERY_URL": "", } # django-extensions diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 3cafb80f0..16171aa34 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -2,8 +2,8 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.music.serializers import TrackActivitySerializer -from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer +from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer from . import models @@ -26,6 +26,15 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer): + track = TrackSerializer(read_only=True) + user = UserBasicSerializer(read_only=True) + + class Meta: + model = models.TrackFavorite + fields = ("id", "user", "track", "creation_date") + + +class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFavorite fields = ("id", "track", "creation_date") diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 4d1c1e756..61b5bee6c 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -1,9 +1,10 @@ from rest_framework import mixins, status, viewsets from rest_framework.decorators import list_route +from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response from funkwhale_api.activity import record -from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.common import fields, permissions from funkwhale_api.music.models import Track from . import models, serializers @@ -18,7 +19,17 @@ class TrackFavoriteViewSet( serializer_class = serializers.UserTrackFavoriteSerializer queryset = models.TrackFavorite.objects.all() - permission_classes = [ConditionalAuthentication] + permission_classes = [ + permissions.ConditionalAuthentication, + permissions.OwnerPermission, + IsAuthenticatedOrReadOnly, + ] + owner_checks = ["write"] + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.UserTrackFavoriteSerializer + return serializers.UserTrackFavoriteWriteSerializer def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -32,7 +43,10 @@ class TrackFavoriteViewSet( ) def get_queryset(self): - return self.queryset.filter(user=self.request.user) + queryset = super().get_queryset() + return queryset.filter( + fields.privacy_level_query(self.request.user, "user__privacy_level") + ) def perform_create(self, serializer): track = Track.objects.get(pk=serializer.data["track"]) diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index e49322798..2254aee8c 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.music.serializers import TrackActivitySerializer -from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer +from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer from . import models @@ -25,6 +25,20 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer): class ListeningSerializer(serializers.ModelSerializer): + track = TrackSerializer(read_only=True) + user = UserBasicSerializer(read_only=True) + + class Meta: + model = models.Listening + fields = ("id", "user", "track", "creation_date") + + def create(self, validated_data): + validated_data["user"] = self.context["user"] + + return super().create(validated_data) + + +class ListeningWriteSerializer(serializers.ModelSerializer): class Meta: model = models.Listening fields = ("id", "user", "track", "creation_date") diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index e104a2aa3..6c7ef3991 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -1,17 +1,36 @@ -from rest_framework import mixins, permissions, viewsets +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticatedOrReadOnly from funkwhale_api.activity import record +from funkwhale_api.common import fields, permissions from . import models, serializers class ListeningViewSet( - mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, ): serializer_class = serializers.ListeningSerializer - queryset = models.Listening.objects.all() - permission_classes = [permissions.IsAuthenticated] + queryset = ( + models.Listening.objects.all() + .select_related("track__artist", "track__album__artist", "user") + .prefetch_related("track__files") + ) + permission_classes = [ + permissions.ConditionalAuthentication, + permissions.OwnerPermission, + IsAuthenticatedOrReadOnly, + ] + owner_checks = ["write"] + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.ListeningSerializer + return serializers.ListeningWriteSerializer def perform_create(self, serializer): r = super().perform_create(serializer) @@ -20,7 +39,9 @@ class ListeningViewSet( def get_queryset(self): queryset = super().get_queryset() - return queryset.filter(user=self.request.user) + return queryset.filter( + fields.privacy_level_query(self.request.user, "user__privacy_level") + ) def get_serializer_context(self): context = super().get_serializer_context() diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py index ae9f0226f..144b0f049 100644 --- a/api/funkwhale_api/playlists/filters.py +++ b/api/funkwhale_api/playlists/filters.py @@ -1,3 +1,4 @@ +from django.db.models import Count from django_filters import rest_framework as filters from funkwhale_api.music import utils @@ -7,10 +8,23 @@ from . import models class PlaylistFilter(filters.FilterSet): q = filters.CharFilter(name="_", method="filter_q") + listenable = filters.BooleanFilter(name="_", method="filter_listenable") class Meta: model = models.Playlist - fields = {"user": ["exact"], "name": ["exact", "icontains"], "q": "exact"} + fields = { + "user": ["exact"], + "name": ["exact", "icontains"], + "q": "exact", + "listenable": "exact", + } + + def filter_listenable(self, queryset, name, value): + queryset = queryset.annotate(plts_count=Count("playlist_tracks")) + if value: + return queryset.filter(plts_count__gt=0) + else: + return queryset.filter(plts_count=0) def filter_q(self, queryset, name, value): query = utils.get_query(value, ["name", "user__username"]) diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index e9df4624d..d2504d848 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -3,12 +3,41 @@ from django.utils import timezone from rest_framework import exceptions from funkwhale_api.common import fields, preferences +from funkwhale_api.music import models as music_models class PlaylistQuerySet(models.QuerySet): def with_tracks_count(self): return self.annotate(_tracks_count=models.Count("playlist_tracks")) + def with_duration(self): + return self.annotate( + duration=models.Sum("playlist_tracks__track__files__duration") + ) + + def with_covers(self): + album_prefetch = models.Prefetch( + "album", queryset=music_models.Album.objects.only("cover") + ) + track_prefetch = models.Prefetch( + "track", + queryset=music_models.Track.objects.prefetch_related(album_prefetch).only( + "id", "album_id" + ), + ) + + plt_prefetch = models.Prefetch( + "playlist_tracks", + queryset=PlaylistTrack.objects.all() + .exclude(track__album__cover=None) + .exclude(track__album__cover="") + .order_by("index") + .only("id", "playlist_id", "track_id") + .prefetch_related(track_prefetch), + to_attr="plts_for_cover", + ) + return self.prefetch_related(plt_prefetch) + class Playlist(models.Model): name = models.CharField(max_length=50) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index 17cc06b10..71b8f315a 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -65,6 +65,8 @@ class PlaylistTrackWriteSerializer(serializers.ModelSerializer): class PlaylistSerializer(serializers.ModelSerializer): tracks_count = serializers.SerializerMethodField(read_only=True) + duration = serializers.SerializerMethodField(read_only=True) + album_covers = serializers.SerializerMethodField(read_only=True) user = UserBasicSerializer(read_only=True) class Meta: @@ -72,11 +74,13 @@ class PlaylistSerializer(serializers.ModelSerializer): fields = ( "id", "name", - "tracks_count", "user", "modification_date", "creation_date", "privacy_level", + "tracks_count", + "album_covers", + "duration", ) read_only_fields = ["id", "modification_date", "creation_date"] @@ -87,6 +91,36 @@ class PlaylistSerializer(serializers.ModelSerializer): # no annotation? return obj.playlist_tracks.count() + def get_duration(self, obj): + try: + return obj.duration + except AttributeError: + # no annotation? + return 0 + + def get_album_covers(self, obj): + try: + plts = obj.plts_for_cover + except AttributeError: + return [] + + covers = [] + max_covers = 5 + for plt in plts: + url = plt.track.album.cover.url + if url in covers: + continue + covers.append(url) + if len(covers) >= max_covers: + break + + full_urls = [] + for url in covers: + if "request" in self.context: + url = self.context["request"].build_absolute_uri(url) + full_urls.append(url) + return full_urls + class PlaylistAddManySerializer(serializers.Serializer): tracks = serializers.PrimaryKeyRelatedField( diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 21e35f50a..8db076a86 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -24,6 +24,8 @@ class PlaylistViewSet( models.Playlist.objects.all() .select_related("user") .annotate(tracks_count=Count("playlist_tracks")) + .with_covers() + .with_duration() ) permission_classes = [ permissions.ConditionalAuthentication, diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index fd007e234..a13a44c81 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -45,12 +45,6 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): return "Person" -class UserBasicSerializer(serializers.ModelSerializer): - class Meta: - model = models.User - fields = ["id", "username", "name", "date_joined"] - - avatar_field = VersatileImageFieldSerializer( allow_null=True, sizes=[ @@ -62,6 +56,14 @@ avatar_field = VersatileImageFieldSerializer( ) +class UserBasicSerializer(serializers.ModelSerializer): + avatar = avatar_field + + class Meta: + model = models.User + fields = ["id", "username", "name", "date_joined", "avatar"] + + class UserWriteSerializer(serializers.ModelSerializer): avatar = avatar_field diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index cd75b0d26..6ef323db5 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -4,6 +4,8 @@ import pytest from django.urls import reverse from funkwhale_api.favorites.models import TrackFavorite +from funkwhale_api.music import serializers as music_serializers +from funkwhale_api.users import serializers as users_serializers def test_user_can_add_favorite(factories): @@ -15,21 +17,25 @@ def test_user_can_add_favorite(factories): assert f.user == user -def test_user_can_get_his_favorites(factories, logged_in_client, client): +def test_user_can_get_his_favorites(api_request, factories, logged_in_client, client): + r = api_request.get("/") favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) url = reverse("api:v1:favorites:tracks-list") response = logged_in_client.get(url) - expected = [ { - "track": favorite.track.pk, + "user": users_serializers.UserBasicSerializer( + favorite.user, context={"request": r} + ).data, + "track": music_serializers.TrackSerializer( + favorite.track, context={"request": r} + ).data, "id": favorite.id, "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), } ] - parsed_json = json.loads(response.content.decode("utf-8")) - - assert expected == parsed_json["results"] + assert response.status_code == 200 + assert response.data["results"] == expected def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_muted): diff --git a/api/tests/favorites/test_views.py b/api/tests/favorites/test_views.py new file mode 100644 index 000000000..7c3aed402 --- /dev/null +++ b/api/tests/favorites/test_views.py @@ -0,0 +1,13 @@ +import pytest + +from django.urls import reverse + + +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) +def test_privacy_filter(preferences, level, factories, api_client): + preferences["common__api_authentication_required"] = False + factories["favorites.TrackFavorite"](user__privacy_level=level) + url = reverse("api:v1:favorites:tracks-list") + response = api_client.get(url) + assert response.status_code == 200 + assert response.data["count"] == 0 diff --git a/api/tests/history/test_views.py b/api/tests/history/test_views.py new file mode 100644 index 000000000..8ec927710 --- /dev/null +++ b/api/tests/history/test_views.py @@ -0,0 +1,13 @@ +import pytest + +from django.urls import reverse + + +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) +def test_privacy_filter(preferences, level, factories, api_client): + preferences["common__api_authentication_required"] = False + factories["history.Listening"](user__privacy_level=level) + url = reverse("api:v1:history:listenings-list") + response = api_client.get(url) + assert response.status_code == 200 + assert response.data["count"] == 0 diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 677288070..42569f7a3 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -63,3 +63,40 @@ def test_update_insert_is_called_when_index_is_provided(factories, mocker): insert.assert_called_once_with(playlist, plt, 0) assert plt.index == 0 assert first.index == 1 + + +def test_playlist_serializer_include_covers(factories, api_request): + playlist = factories["playlists.Playlist"]() + t1 = factories["music.Track"]() + t2 = factories["music.Track"]() + t3 = factories["music.Track"](album__cover=None) + t4 = factories["music.Track"]() + t5 = factories["music.Track"]() + t6 = factories["music.Track"]() + t7 = factories["music.Track"]() + + playlist.insert_many([t1, t2, t3, t4, t5, t6, t7]) + request = api_request.get("/") + qs = playlist.__class__.objects.with_covers().with_tracks_count() + + expected = [ + request.build_absolute_uri(t1.album.cover.url), + request.build_absolute_uri(t2.album.cover.url), + request.build_absolute_uri(t4.album.cover.url), + request.build_absolute_uri(t5.album.cover.url), + request.build_absolute_uri(t6.album.cover.url), + ] + + serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request}) + assert serializer.data["album_covers"] == expected + + +def test_playlist_serializer_include_duration(factories, api_request): + playlist = factories["playlists.Playlist"]() + tf1 = factories["music.TrackFile"](duration=15) + tf2 = factories["music.TrackFile"](duration=30) + playlist.insert_many([tf1.track, tf2.track]) + qs = playlist.__class__.objects.with_duration().with_tracks_count() + + serializer = serializers.PlaylistSerializer(qs.get()) + assert serializer.data["duration"] == 45 diff --git a/front/src/App.vue b/front/src/App.vue index 11ef9f13d..b53b36f16 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -32,6 +32,9 @@ About this instance + + Request music + Official website Documentation diff --git a/front/src/components/About.vue b/front/src/components/About.vue index c9e1e23c8..438fed67d 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -3,10 +3,10 @@

- - + + About this instance

diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 938a48070..6f744d74f 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -2,7 +2,7 @@
- + @@ -39,7 +39,7 @@ Logged in as %{ username } - + Logout Login @@ -237,6 +237,13 @@ export default { set (value) { this.tracksChangeBuffer = value } + }, + logoUrl () { + if (this.$store.state.auth.authenticated) { + return 'library.index' + } else { + return 'index' + } } }, methods: { @@ -433,8 +440,9 @@ $sidebar-color: #3d3e3f; } } } -.avatar { +.ui.tiny.avatar.image { position: relative; top: -0.5em; + width: 3em; } diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 6c5ebbc2d..ad85e72ce 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,22 +1,23 @@ + diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 5870ac799..ef3660ee2 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -5,7 +5,7 @@ - + diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue new file mode 100644 index 000000000..7c727b402 --- /dev/null +++ b/front/src/components/audio/track/Widget.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index ae3c47e5c..8d2e80470 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -65,7 +65,7 @@ import PasswordInput from '@/components/forms/PasswordInput' export default { props: { - invitation: {type: String, required: false, default: null}, + defaultInvitation: {type: String, required: false, default: null}, next: {type: String, default: '/'} }, components: { @@ -78,7 +78,8 @@ export default { password: '', isLoadingInstanceSetting: true, errors: [], - isLoading: false + isLoading: false, + invitation: this.defaultInvitation } }, created () { diff --git a/front/src/components/common/Duration.vue b/front/src/components/common/Duration.vue new file mode 100644 index 000000000..85b070fcd --- /dev/null +++ b/front/src/components/common/Duration.vue @@ -0,0 +1,22 @@ + + diff --git a/front/src/components/common/UserLink.vue b/front/src/components/common/UserLink.vue new file mode 100644 index 000000000..0ae4d4ec8 --- /dev/null +++ b/front/src/components/common/UserLink.vue @@ -0,0 +1,34 @@ + + + + diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 4ad09f704..6865ac1bc 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -8,6 +8,14 @@ import Username from '@/components/common/Username' Vue.component('username', Username) +import UserLink from '@/components/common/UserLink' + +Vue.component('user-link', UserLink) + +import Duration from '@/components/common/Duration' + +Vue.component('duration', Duration) + import DangerousButton from '@/components/common/DangerousButton' Vue.component('dangerous-button', DangerousButton) diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index ce6627e18..0bb16e1dd 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -1,32 +1,29 @@