From e1331301769244305417b8a9340d18542ed4d428 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Thu, 12 Sep 2019 09:48:28 +0200 Subject: [PATCH] Playlist embed --- api/config/spa_urls.py | 5 ++ api/funkwhale_api/music/serializers.py | 33 +++++++++ api/funkwhale_api/music/spa_views.py | 57 ++++++++++++++ api/tests/music/test_spa_views.py | 74 +++++++++++++++++++ api/tests/music/test_views.py | 37 ++++++++++ .../changelog.d/playlist-embed.enhancement | 1 + front/src/EmbedFrame.vue | 15 +++- front/src/components/audio/EmbedWizard.vue | 2 +- front/src/views/playlists/Detail.vue | 34 ++++++++- 9 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 changes/changelog.d/playlist-embed.enhancement diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py index 071965b04..246338d2d 100644 --- a/api/config/spa_urls.py +++ b/api/config/spa_urls.py @@ -15,4 +15,9 @@ urlpatterns = [ spa_views.library_artist, name="library_artist", ), + urls.re_path( + r"^library/playlists/(?P<pk>\d+)/?$", + spa_views.library_playlist, + name="library_playlist", + ), ] diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 813d2656e..630f9f4e1 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -11,6 +11,7 @@ from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import routes from funkwhale_api.federation import utils as federation_utils +from funkwhale_api.playlists import models as playlists_models from funkwhale_api.tags.models import Tag from . import filters, models, tasks @@ -552,6 +553,38 @@ class OembedSerializer(serializers.Serializer): data["author_url"] = federation_utils.full_url( common_utils.spa_reverse("library_artist", kwargs={"pk": artist.pk}) ) + elif match.url_name == "library_playlist": + qs = playlists_models.Playlist.objects.filter( + pk=int(match.kwargs["pk"]), privacy_level="everyone" + ) + try: + obj = qs.get() + except playlists_models.Playlist.DoesNotExist: + raise serializers.ValidationError( + "No artist matching id {}".format(match.kwargs["pk"]) + ) + embed_type = "playlist" + embed_id = obj.pk + playlist_tracks = obj.playlist_tracks.exclude(track__album__cover="") + playlist_tracks = playlist_tracks.exclude(track__album__cover=None) + playlist_tracks = playlist_tracks.select_related("track__album").order_by( + "index" + ) + first_playlist_track = playlist_tracks.first() + + if first_playlist_track: + data["thumbnail_url"] = federation_utils.full_url( + first_playlist_track.track.album.cover.crop["400x400"].url + ) + data["thumbnail_width"] = 400 + data["thumbnail_height"] = 400 + data["title"] = obj.name + data["description"] = obj.name + data["author_name"] = obj.name + data["height"] = 400 + data["author_url"] = federation_utils.full_url( + common_utils.spa_reverse("library_playlist", kwargs={"pk": obj.pk}) + ) else: raise serializers.ValidationError( "Unsupported url: {}".format(validated_data["url"]) diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index 7fafedf61..5215dcdd8 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -5,6 +5,7 @@ from django.urls import reverse from django.db.models import Q from funkwhale_api.common import utils +from funkwhale_api.playlists import models as playlists_models from . import models from . import serializers @@ -203,3 +204,59 @@ def library_artist(request, pk): # twitter player is also supported in various software metas += get_twitter_card_metas(type="artist", id=obj.pk) return metas + + +def library_playlist(request, pk): + queryset = playlists_models.Playlist.objects.filter(pk=pk, privacy_level="everyone") + try: + obj = queryset.get() + except playlists_models.Playlist.DoesNotExist: + return [] + obj_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_playlist", kwargs={"pk": obj.pk}), + ) + # we use the first playlist track's album's cover as image + playlist_tracks = obj.playlist_tracks.exclude(track__album__cover="") + playlist_tracks = playlist_tracks.exclude(track__album__cover=None) + playlist_tracks = playlist_tracks.select_related("track__album").order_by("index") + first_playlist_track = playlist_tracks.first() + metas = [ + {"tag": "meta", "property": "og:url", "content": obj_url}, + {"tag": "meta", "property": "og:title", "content": obj.name}, + {"tag": "meta", "property": "og:type", "content": "music.playlist"}, + ] + + if first_playlist_track: + metas.append( + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url( + settings.FUNKWHALE_URL, + first_playlist_track.track.album.cover.crop["400x400"].url, + ), + } + ) + + if ( + models.Upload.objects.filter( + track__pk__in=[obj.playlist_tracks.values("track")] + ) + .playable_by(None) + .exists() + ): + metas.append( + { + "tag": "link", + "rel": "alternate", + "type": "application/json+oembed", + "href": ( + utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + + "?format=json&url={}".format(urllib.parse.quote_plus(obj_url)) + ), + } + ) + # twitter player is also supported in various software + metas += get_twitter_card_metas(type="playlist", id=obj.pk) + return metas diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py index b9397009c..bf85ab888 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -195,3 +195,77 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings): # we only test our custom metas, not the default ones assert metas[: len(expected_metas)] == expected_metas + + +def test_library_playlist(spa_html, no_api_auth, client, factories, settings): + playlist = factories["playlists.Playlist"](privacy_level="everyone") + track = factories["music.Upload"](playable=True).track + playlist.insert_many([track]) + + url = "/library/playlists/{}".format(playlist.pk) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:title", "content": playlist.name}, + {"tag": "meta", "property": "og:type", "content": "music.playlist"}, + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url( + settings.FUNKWHALE_URL, track.album.cover.crop["400x400"].url + ), + }, + { + "tag": "link", + "rel": "alternate", + "type": "application/json+oembed", + "href": ( + utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + + "?format=json&url={}".format( + urllib.parse.quote_plus(utils.join_url(settings.FUNKWHALE_URL, url)) + ) + ), + }, + {"tag": "meta", "property": "twitter:card", "content": "player"}, + { + "tag": "meta", + "property": "twitter:player", + "content": serializers.get_embed_url("playlist", id=playlist.id), + }, + {"tag": "meta", "property": "twitter:player:width", "content": "600"}, + {"tag": "meta", "property": "twitter:player:height", "content": "400"}, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas + + +def test_library_playlist_empty(spa_html, no_api_auth, client, factories, settings): + playlist = factories["playlists.Playlist"](privacy_level="everyone") + + url = "/library/playlists/{}".format(playlist.pk) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:title", "content": playlist.name}, + {"tag": "meta", "property": "og:type", "content": "music.playlist"}, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 6f05c6700..b9064cb05 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -832,6 +832,43 @@ def test_oembed_artist(factories, no_api_auth, api_client, settings): assert response.data == expected +def test_oembed_playlist(factories, no_api_auth, api_client, settings): + settings.FUNKWHALE_URL = "http://test" + settings.FUNKWHALE_EMBED_URL = "http://embed" + playlist = factories["playlists.Playlist"](privacy_level="everyone") + track = factories["music.Upload"](playable=True).track + playlist.insert_many([track]) + url = reverse("api:v1:oembed") + playlist_url = "https://test.com/library/playlists/{}".format(playlist.pk) + iframe_src = "http://embed?type=playlist&id={}".format(playlist.pk) + expected = { + "version": "1.0", + "type": "rich", + "provider_name": settings.APP_NAME, + "provider_url": settings.FUNKWHALE_URL, + "height": 400, + "width": 600, + "title": playlist.name, + "description": playlist.name, + "thumbnail_url": federation_utils.full_url( + track.album.cover.crop["400x400"].url + ), + "thumbnail_height": 400, + "thumbnail_width": 400, + "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format( + iframe_src + ), + "author_name": playlist.name, + "author_url": federation_utils.full_url( + utils.spa_reverse("library_playlist", kwargs={"pk": playlist.pk}) + ), + } + + response = api_client.get(url, {"url": playlist_url, "format": "json"}) + + assert response.data == expected + + @pytest.mark.parametrize( "factory_name, url_name", [ diff --git a/changes/changelog.d/playlist-embed.enhancement b/changes/changelog.d/playlist-embed.enhancement new file mode 100644 index 000000000..06c747770 --- /dev/null +++ b/changes/changelog.d/playlist-embed.enhancement @@ -0,0 +1 @@ +Support embeds on public playlists diff --git a/front/src/EmbedFrame.vue b/front/src/EmbedFrame.vue index 23ed92a0d..f44f1ba4d 100644 --- a/front/src/EmbedFrame.vue +++ b/front/src/EmbedFrame.vue @@ -139,7 +139,7 @@ export default { data () { return { time, - supportedTypes: ['track', 'album', 'artist'], + supportedTypes: ['track', 'album', 'artist', 'playlist'], baseUrl: '', error: null, type: null, @@ -235,6 +235,9 @@ export default { if (type === 'artist') { this.fetchTracks({artist: id, playable: true, ordering: "-release_date,disc_number,position"}) } + if (type === 'playlist') { + this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`) + } }, play (index) { this.currentIndex = index @@ -269,9 +272,10 @@ export default { self.isLoading = false; }) }, - fetchTracks (filters) { + fetchTracks (filters, path) { + path = path || "/api/v1/tracks/" let self = this - let url = `${this.baseUrl}/api/v1/tracks/` + let url = `${this.baseUrl}${path}` axios.get(url, {params: filters}).then(response => { self.tracks = self.parseTracks(response.data.results) self.isLoading = false; @@ -297,6 +301,11 @@ export default { }, parseTracks (tracks) { let self = this + if (this.type === 'playlist') { + tracks = tracks.map((t) => { + return t.track + }) + } return tracks.map(t => { return { id: t.id, diff --git a/front/src/components/audio/EmbedWizard.vue b/front/src/components/audio/EmbedWizard.vue index 13d9ffd65..0338f7ab9 100644 --- a/front/src/components/audio/EmbedWizard.vue +++ b/front/src/components/audio/EmbedWizard.vue @@ -50,7 +50,7 @@ export default { minHeight: 100, copied: false } - if (this.type === 'album') { + if (this.type === 'album' || this.type === 'artist' || this.type === 'playlist') { d.height = 330 d.minHeight = 250 } diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 6fe7a4c8a..c941fc491 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -31,6 +31,14 @@ <template v-if="edit"><translate translate-context="Content/Playlist/Button.Label/Verb">End edition</translate></template> <template v-else><translate translate-context="Content/*/Button.Label/Verb">Edit</translate></template> </button> + <button + class="ui icon labeled button" + v-if="playlist.privacy_level === 'everyone' && playlist.is_playable" + @click="showEmbedModal = !showEmbedModal"> + <i class="code icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> + </button> + <dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="labeled icon" :action="deletePlaylist"> <i class="trash icon"></i> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title/Call to action" :translate-params="{playlist: playlist.name}"> @@ -40,6 +48,23 @@ <div slot="modal-confirm"><translate translate-context="Popup/Playlist/Button.Label/Verb">Delete playlist</translate></div> </dangerous-button> </div> + <modal v-if="playlist.privacy_level === 'everyone' && playlist.is_playable" :show.sync="showEmbedModal"> + <div class="header"> + <translate translate-context="Popup/Album/Title/Verb">Embed this playlist on your website</translate> + </div> + <div class="content"> + <div class="description"> + <embed-wizard type="playlist" :id="playlist.id" /> + + </div> + </div> + <div class="actions"> + <div class="ui deny button"> + <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + </div> + </div> + </modal> + </section> <section class="ui vertical stripe segment"> <template v-if="edit"> @@ -61,6 +86,8 @@ import TrackTable from "@/components/audio/track/Table" import RadioButton from "@/components/radios/Button" import PlayButton from "@/components/audio/PlayButton" import PlaylistEditor from "@/components/playlists/Editor" +import EmbedWizard from "@/components/audio/EmbedWizard" +import Modal from '@/components/semantic/Modal' export default { props: { @@ -71,7 +98,9 @@ export default { PlaylistEditor, TrackTable, PlayButton, - RadioButton + RadioButton, + Modal, + EmbedWizard, }, data: function() { return { @@ -79,7 +108,8 @@ export default { isLoading: false, playlist: null, tracks: [], - playlistTracks: [] + playlistTracks: [], + showEmbedModal: false, } }, created: function() { -- GitLab