Commit 20282539 authored by Agate's avatar Agate 💬

Merge branch '284-browse-redesign' into 'develop'

Resolve "UX, UI : Browse Library"

Closes #284

See merge request funkwhale/funkwhale!317
parents dc5eb115 99a37dcb
......@@ -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
......
......@@ -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")
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"])
......
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")
......
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()
......
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"])
......
......@@ -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)
......
......@@ -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(
......
......@@ -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,
......
......@@ -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
......
......@@ -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):
......
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
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
......@@ -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
......@@ -32,6 +32,9 @@
<router-link class="item" to="/about">
<translate>About this instance</translate>
</router-link>
<router-link class="item" :to="{name: 'library.request'}">
<translate>Request music</translate>
</router-link>
<a href="https://funkwhale.audio" class="item" target="_blank"><translate>Official website</translate></a>
<a href="https://docs.funkwhale.audio" class="item" target="_blank"><translate>Documentation</translate></a>
<a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
......
......@@ -3,10 +3,10 @@
<div class="ui vertical center aligned stripe segment">
<div class="ui text container">
<h1 class="ui huge header">
<template v-if="instance.name.value" :template-params="{instance: instance.name}">
<translate v-if="instance.name.value" :translate-params="{instance: instance.name.value}">
About %{ instance }
</template>
<template v-else="instance.name.value"><translate>About this instance</translate></template>
</translate>
<translate v-else>About this instance</translate>
</h1>
<stats></stats>
</div>
......
......@@ -2,7 +2,7 @@
<div :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]">
<div class="ui inverted segment header-wrapper">
<search-bar @search="isCollapsed = false">
<router-link :title="'Funkwhale'" :to="{name: 'index'}">
<router-link :title="'Funkwhale'" :to="{name: logoUrl}">
<i class="logo bordered inverted orange big icon">
<logo class="logo"></logo>
</i>
......@@ -39,7 +39,7 @@
<translate :translate-params="{username: $store.state.auth.username}">
Logged in as %{ username }
</translate>
<img class="ui avatar right floated circular mini image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
<img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link>
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link>
......@@ -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;
}
</style>
<template>
<div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']">
<span :title="title" :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
<button
v-if="!dropdownOnly"
:title="labels.addToQueue"
@click="addNext(true)"
:disabled="!playable"
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
<i class="ui play icon"></i>
<template v-if="!discrete"><slot><translate>Play</translate></slot></template>
:class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])">
<i :class="[playIconClass, 'icon']"></i>
<template v-if="!discrete && !iconOnly"><slot><translate>Play</translate></slot></template>
</button>
<div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
<i class="dropdown icon"></i>
<div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
<i :class="dropdownIconClasses.concat(['icon'])"></i>
<div class="menu">
<div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><translate>Add to queue</translate></div>
<div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><translate>Play next</translate></div>
<div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><translate>Play now</translate></div>
</div>
</div>
</div>
</span>
</template>
<script>
......@@ -28,8 +29,13 @@ export default {
// we can either have a single or multiple tracks to play when clicked
tracks: {type: Array, required: false},
track: {type: Object, required: false},
dropdownIconClasses: {type: Array, required: false, default: () => { return ['dropdown'] }},
playIconClass: {type: String, required: false, default: 'play icon'},
buttonClasses: {type: Array, required: false, default: () => { return ['button'] }},
playlist: {type: Object, required: false},
discrete: {type: Boolean, default: false},
dropdownOnly: {type: Boolean, default: false},
iconOnly: {type: Boolean, default: false},
artist: {type: Number, required: false},
album: {type: Number, required: false}
},
......
<template>
<div>
<h3 class="ui header">
<slot name="title"></slot>
</h3>
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle left', 'icon']">
</i>
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle right', 'icon']">
</i>
<div class="ui hidden divider"></div>
<div class="ui five cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<div class="card" v-for="album in albums" :key="album.id">
<div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover}]" :style="getImageStyle(album)">
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button>
</div>
<div class="content">
<router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}">
{{ album.title|truncate(25) }}
</router-link>
<div class="description">
<span>
<router-link :title="album.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}">
{{ album.artist.name|truncate(23) }}
</router-link>
</span>
</div>
</div>
<div class="extra content">
<human-date class="left floated" :date="album.creation_date"></human-date>
<play-button class="right floated basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album.id"></play-button>
</div>
</div>
</div>
</div>
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton'
export default {
props: {
filters: {type: Object, required: true}
},
components: {
PlayButton
},
data () {
return {
albums: [],
limit: 12,
isLoading: false,
errors: null,
previousPage: null,
nextPage: null
}
},
created () {
this.fetchData('albums/')