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