Commit b1661827 authored by Agate's avatar Agate 💬

Resolve "Support browsing a specific library content"

parent ecc3ed3a
......@@ -21,6 +21,11 @@ urlpatterns = [
spa_views.library_playlist,
name="library_playlist",
),
urls.re_path(
r"^library/(?P<uuid>[0-9a-f-]+)/?$",
spa_views.library_library,
name="library_library",
),
urls.re_path(
r"^channels/(?P<uuid>[0-9a-f-]+)/?$",
audio_spa_views.channel_detail_uuid,
......
......@@ -98,6 +98,26 @@ class LibraryFollowViewSet(
update_follow(follow, approved=False)
return response.Response(status=204)
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__uuid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)
class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid"
......
......@@ -201,3 +201,26 @@ def find_alternate(response_text):
parser.feed(response_text)
except StopParsing:
return parser.result
def should_redirect_ap_to_html(accept_header):
if not accept_header:
return False
redirect_headers = [
"text/html",
]
no_redirect_headers = [
"application/json",
"application/activity+json",
"application/ld+json",
]
parsed_header = [ct.lower().strip() for ct in accept_header.split(",")]
for ct in parsed_header:
if ct in redirect_headers:
return True
if ct in no_redirect_headers:
return False
return True
from django import forms
from django.conf import settings
from django.core import paginator
from django.db.models import Prefetch
from django.http import HttpResponse
......@@ -7,6 +8,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils
......@@ -14,6 +16,12 @@ from funkwhale_api.music import utils as music_utils
from . import activity, authentication, models, renderers, serializers, utils, webfinger
def redirect_to_html(public_url):
response = HttpResponse(status=302)
response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url)
return response
class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
def has_permission(self, request, view):
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
......@@ -204,13 +212,18 @@ class MusicLibraryViewSet(
renderer_classes = renderers.get_ap_renderers()
serializer_class = serializers.LibrarySerializer
queryset = (
music_models.Library.objects.all().select_related("actor").filter(channel=None)
music_models.Library.objects.all()
.local()
.select_related("actor")
.filter(channel=None)
)
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
# XXX: implement this for actors, albums, tracks, artists
return redirect_to_html(lb.get_absolute_url())
conf = {
"id": lb.get_federation_id(),
"actor": lb.actor,
......
......@@ -41,8 +41,30 @@ class ChannelFilterSet(filters.FilterSet):
return queryset.filter(pk__in=ids)
class LibraryFilterSet(filters.FilterSet):
library = filters.CharFilter(field_name="_", method="filter_library")
def filter_library(self, queryset, name, value):
if not value:
return queryset
actor = utils.get_actor_from_request(self.request)
library = models.Library.objects.filter(uuid=value).viewable_by(actor).first()
if not library:
return queryset.none()
uploads = models.Upload.objects.filter(library=library)
uploads = uploads.playable_by(actor)
ids = uploads.values_list(self.Meta.library_filter_field, flat=True)
return queryset.filter(pk__in=ids)
class ArtistFilter(
audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"])
......@@ -62,6 +84,7 @@ class ArtistFilter(
}
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel"
library_filter_field = "track__artist"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
......@@ -70,6 +93,7 @@ class ArtistFilter(
class TrackFilter(
ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
......@@ -99,6 +123,7 @@ class TrackFilter(
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
include_channels_field = "artist__channel"
channel_filter_field = "track"
library_filter_field = "track"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
......@@ -156,6 +181,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
class AlbumFilter(
ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
......@@ -175,6 +201,7 @@ class AlbumFilter(
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
include_channels_field = "artist__channel"
channel_filter_field = "track__album"
library_filter_field = "track__album"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
......
......@@ -1110,6 +1110,12 @@ LIBRARY_PRIVACY_LEVEL_CHOICES = [
class LibraryQuerySet(models.QuerySet):
def local(self, include=True):
query = models.Q(actor__domain_id=settings.FEDERATION_HOSTNAME)
if not include:
query = ~query
return self.filter(query)
def with_follows(self, actor):
return self.prefetch_related(
models.Prefetch(
......@@ -1123,14 +1129,14 @@ class LibraryQuerySet(models.QuerySet):
from funkwhale_api.federation.models import LibraryFollow
if actor is None:
return Library.objects.filter(privacy_level="everyone")
return self.filter(privacy_level="everyone")
me_query = models.Q(privacy_level="me", actor=actor)
instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain)
followed_libraries = LibraryFollow.objects.filter(
actor=actor, approved=True
).values_list("target", flat=True)
return Library.objects.filter(
return self.filter(
me_query
| instance_query
| models.Q(privacy_level="everyone")
......@@ -1164,6 +1170,9 @@ class Library(federation_models.FederationMixin):
reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
)
def get_absolute_url(self):
return "/library/{}".format(self.uuid)
def save(self, **kwargs):
if not self.pk and not self.fid and self.actor.get_user():
self.fid = self.get_federation_id()
......
......@@ -292,3 +292,33 @@ def library_playlist(request, pk):
# twitter player is also supported in various software
metas += get_twitter_card_metas(type="playlist", id=obj.pk)
return metas
def library_library(request, uuid):
queryset = models.Library.objects.filter(uuid=uuid)
try:
obj = queryset.get()
except models.Library.DoesNotExist:
return []
library_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}),
)
metas = [
{"tag": "meta", "property": "og:url", "content": library_url},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": obj.name},
{"tag": "meta", "property": "og:description", "content": obj.description},
]
if preferences.get("federation__enabled"):
metas.append(
{
"tag": "link",
"rel": "alternate",
"type": "application/activity+json",
"href": obj.fid,
}
)
return metas
......@@ -286,3 +286,25 @@ def test_fetch_duplicate_bypass_with_force(
assert response.status_code == 201
assert response.data == api_serializers.FetchSerializer(fetch).data
fetch_task.assert_called_once_with(fetch_id=fetch.pk)
def test_library_follow_get_all(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
library = factories["music.Library"]()
follow = factories["federation.LibraryFollow"](target=library, actor=actor)
factories["federation.LibraryFollow"]()
factories["music.Library"]()
url = reverse("api:v1:federation:library-follows-all")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
"results": [
{
"uuid": str(follow.uuid),
"library": str(library.uuid),
"approved": follow.approved,
}
],
"count": 1,
}
......@@ -2,7 +2,14 @@ import pytest
from django.core.paginator import Paginator
from django.urls import reverse
from funkwhale_api.federation import actors, serializers, webfinger
from funkwhale_api.common import utils
from funkwhale_api.federation import (
actors,
serializers,
webfinger,
utils as federation_utils,
)
def test_authenticate_skips_anonymous_fetch_when_allow_list_enabled(
......@@ -159,7 +166,7 @@ def test_wellknown_webfinger_local(factories, api_client, settings, mocker):
@pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"])
def test_music_library_retrieve(factories, api_client, privacy_level):
library = factories["music.Library"](privacy_level=privacy_level)
library = factories["music.Library"](privacy_level=privacy_level, actor__local=True)
expected = serializers.LibrarySerializer(library).data
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
......@@ -170,7 +177,7 @@ def test_music_library_retrieve(factories, api_client, privacy_level):
def test_music_library_retrieve_excludes_channel_libraries(factories, api_client):
channel = factories["audio.Channel"]()
channel = factories["audio.Channel"](local=True)
library = channel.library
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
......@@ -180,7 +187,7 @@ def test_music_library_retrieve_excludes_channel_libraries(factories, api_client
def test_music_library_retrieve_page_public(factories, api_client):
library = factories["music.Library"](privacy_level="everyone")
library = factories["music.Library"](privacy_level="everyone", actor__local=True)
upload = factories["music.Upload"](library=library, import_status="finished")
id = library.get_federation_id()
expected = serializers.CollectionPageSerializer(
......@@ -253,7 +260,7 @@ def test_channel_upload_retrieve(factories, api_client):
@pytest.mark.parametrize("privacy_level", ["me", "instance"])
def test_music_library_retrieve_page_private(factories, api_client, privacy_level):
library = factories["music.Library"](privacy_level=privacy_level)
library = factories["music.Library"](privacy_level=privacy_level, actor__local=True)
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
response = api_client.get(url, {"page": 1})
......@@ -264,7 +271,7 @@ def test_music_library_retrieve_page_private(factories, api_client, privacy_leve
def test_music_library_retrieve_page_follow(
factories, api_client, authenticated_actor, approved, expected
):
library = factories["music.Library"](privacy_level="me")
library = factories["music.Library"](privacy_level="me", actor__local=True)
factories["federation.LibraryFollow"](
actor=authenticated_actor, target=library, approved=approved
)
......@@ -344,3 +351,35 @@ def test_music_upload_detail_private_approved_follow(
response = api_client.get(url)
assert response.status_code == 200
@pytest.mark.parametrize(
"accept_header,expected",
[
("text/html,application/xhtml+xml", True),
("text/html,application/json", True),
("", False),
(None, False),
("application/json", False),
("application/activity+json", False),
("application/json,text/html", False),
("application/activity+json,text/html", False),
],
)
def test_should_redirect_ap_to_html(accept_header, expected):
assert federation_utils.should_redirect_ap_to_html(accept_header) is expected
def test_music_library_retrieve_redirects_to_html_if_header_set(
factories, api_client, settings
):
library = factories["music.Library"](actor__local=True)
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
response = api_client.get(url, HTTP_ACCEPT="text/html")
expected_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_library", kwargs={"uuid": library.uuid}),
)
assert response.status_code == 302
assert response["Location"] == expected_url
......@@ -142,3 +142,45 @@ def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_
)
assert filterset.qs == [upload.track.album]
def test_library_filter_track(factories, queryset_equal_list, mocker, anonymous_user):
library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Track"]()
qs = upload.track.__class__.objects.all()
filterset = filters.TrackFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track]
def test_library_filter_album(factories, queryset_equal_list, mocker, anonymous_user):
library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Album"]()
qs = upload.track.album.__class__.objects.all()
filterset = filters.AlbumFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track.album]
def test_library_filter_artist(factories, queryset_equal_list, mocker, anonymous_user):
library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Artist"]()
qs = upload.track.artist.__class__.objects.all()
filterset = filters.ArtistFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track.artist]
......@@ -282,3 +282,32 @@ def test_library_playlist_empty(spa_html, no_api_auth, client, factories, settin
# we only test our custom metas, not the default ones
assert metas[: len(expected_metas)] == expected_metas
def test_library_library(spa_html, no_api_auth, client, factories, settings):
library = factories["music.Library"]()
url = "/library/{}".format(library.uuid)
response = client.get(url)
expected_metas = [
{
"tag": "meta",
"property": "og:url",
"content": utils.join_url(settings.FUNKWHALE_URL, url),
},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": library.name},
{"tag": "meta", "property": "og:description", "content": library.description},
{
"tag": "link",
"rel": "alternate",
"type": "application/activity+json",
"href": library.fid,
},
]
metas = utils.parse_meta(response.content.decode())
# we only test our custom metas, not the default ones
assert metas[: len(expected_metas)] == expected_metas
Can now browse a library content through the UI (#926)
<template>
<button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate v-if="isApproved" translate-context="Content/Library/Card.Button.Label/Verb">Unfollow</translate>
<translate v-else-if="isPending" translate-context="Content/Library/Card.Button.Label/Verb">Cancel follow request</translate>
<translate v-else translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate>
</button>
</template>
<script>
export default {
props: {
library: {type: Object},
},
computed: {
isPending () {
return this.follow && this.follow.approved === null
},
isApproved () {
return this.follow && (this.follow.approved === true || (this.follow.approved === null && this.library.privacy_level === 'everyone'))
},
follow () {
return this.$store.getters['libraries/follow'](this.library.uuid)
}
},
methods: {
toggle () {
if (this.isApproved || this.isPending) {
this.$emit('unfollowed')
} else {
this.$emit('followed')
}
this.$store.dispatch('libraries/toggle', this.library.uuid)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
......@@ -68,6 +68,7 @@ export default {
iconOnly: {type: Boolean, default: false},
artist: {type: Object, required: false},
album: {type: Object, required: false},
library: {type: Object, required: false},
isPlayable: {type: Boolean, required: false, default: null}
},
data () {
......@@ -196,6 +197,9 @@ export default {
} else if (self.album) {
let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'}
self.getTracksPage(1, params, resolve)
} else if (self.library) {
let params = {'library': self.library.uuid, 'ordering': '-creation_date'}
self.getTracksPage(1, params, resolve)
}
})
return getTracks.then((tracks) => {
......
......@@ -5,6 +5,7 @@
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
</h3>
<slot></slot>
<inline-search-bar v-model="query" v-if="search" @search="albums = []; fetchData()"></inline-search-bar>
<div class="ui hidden divider"></div>
<div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
......@@ -12,14 +13,9 @@
</div>
<album-card v-for="album in albums" :album="album" :key="album.id" />
</div>
<template v-if="!isLoading && albums.length === 0">
<div class="ui placeholder segment">
<div class="ui icon header">
<i class="compact disc icon"></i>
No results matching your query
</div>
</div>
</template>
<slot v-if="!isLoading && albums.length === 0" name="empty-state">
<empty-state @refresh="fetchData" :refresh="true"></empty-state>
</slot>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
......@@ -30,7 +26,6 @@
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import AlbumCard from '@/components/audio/album/Card'
......@@ -39,6 +34,7 @@ export default {
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
showCount: {type: Boolean, default: false},
search: {type: Boolean, default: false},
limit: {type: Number, default: 12},
},
components: {
......@@ -51,20 +47,19 @@ export default {
isLoading: false,
errors: null,
previousPage: null,
nextPage: null
nextPage: null,
query: '',
}
},
created () {
this.fetchData('albums/')
this.fetchData()
},
methods: {
fetchData (url) {
if (!url) {
return
}
url = url || 'albums/'
this.isLoading = true
let self = this
let params = _.clone(this.filters)
let params = {q: this.query, ...this.filters}
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
......@@ -91,7 +86,7 @@ export default {
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData('albums/')
this.fetchData()
}
}
}
......
......@@ -4,6 +4,7 @@
<slot name="title"></slot>
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<inline-search-bar v-model="query" v-if="search" @search="objects = []; fetchData()"></inline-search-bar>
<div class="ui hidden divider"></div>
<div class="ui five app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
......@@ -11,7 +12,9 @@
</div>
<artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card>
</div>
<div v-if="!isLoading && objects.length === 0">No results matching your query.</div>
<slot v-if="!isLoading && objects.length === 0" name="empty-state">
<empty-state @refresh="fetchData" :refresh="true"></empty-state>
</slot>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
......@@ -22,7 +25,6 @@
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import ArtistCard from "@/components/audio/artist/Card"
......@@ -31,6 +33,7 @@ export default {
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
header: {type: Boolean, default: true},
search: {type: Boolean, default: false},
},
components: {
ArtistCard,
......@@ -43,20 +46,19 @@ export default {
isLoading: false,
errors: null,
previousPage: null,
nextPage: null
nextPage: null,
query: '',
}
},
created () {
this.fetchData('artists/')
this.fetchData()
},
methods: {
fetchData (url) {
if (!url) {
return
}
url = url || 'artists/'
this.isLoading = true
let self = this
let params = _.clone(this.filters)
let params = {q: this.query, ...this.filters}
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
......@@ -83,7 +85,7 @@ export default {
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData('objects/')
this.fetchData()
}
}
}
......
......@@ -31,7 +31,7 @@
<td colspan="4" v-else>
<translate translate-context="*/*/*">N/A</translate>
</td>
<td colspan="2" class="align right">
<td colspan="2" v-if="displayActions" class="align right">
<track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon>
<play-button
class="play-button basic icon"
......@@ -59,6 +59,7 @@ export default {
track: {type: Object, required: true},
artist: {type: Object, required: false},
displayPosition: {type: Boolean, default: false},