Playlist embed
This commit is contained in:
parent
39f6f51e4e
commit
e133130176
|
@ -15,4 +15,9 @@ urlpatterns = [
|
||||||
spa_views.library_artist,
|
spa_views.library_artist,
|
||||||
name="library_artist",
|
name="library_artist",
|
||||||
),
|
),
|
||||||
|
urls.re_path(
|
||||||
|
r"^library/playlists/(?P<pk>\d+)/?$",
|
||||||
|
spa_views.library_playlist,
|
||||||
|
name="library_playlist",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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.common import utils as common_utils
|
||||||
from funkwhale_api.federation import routes
|
from funkwhale_api.federation import routes
|
||||||
from funkwhale_api.federation import utils as federation_utils
|
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 funkwhale_api.tags.models import Tag
|
||||||
|
|
||||||
from . import filters, models, tasks
|
from . import filters, models, tasks
|
||||||
|
@ -552,6 +553,38 @@ class OembedSerializer(serializers.Serializer):
|
||||||
data["author_url"] = federation_utils.full_url(
|
data["author_url"] = federation_utils.full_url(
|
||||||
common_utils.spa_reverse("library_artist", kwargs={"pk": artist.pk})
|
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:
|
else:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"Unsupported url: {}".format(validated_data["url"])
|
"Unsupported url: {}".format(validated_data["url"])
|
||||||
|
|
|
@ -5,6 +5,7 @@ from django.urls import reverse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from funkwhale_api.common import utils
|
from funkwhale_api.common import utils
|
||||||
|
from funkwhale_api.playlists import models as playlists_models
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
@ -203,3 +204,59 @@ def library_artist(request, pk):
|
||||||
# twitter player is also supported in various software
|
# twitter player is also supported in various software
|
||||||
metas += get_twitter_card_metas(type="artist", id=obj.pk)
|
metas += get_twitter_card_metas(type="artist", id=obj.pk)
|
||||||
return metas
|
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
|
||||||
|
|
|
@ -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
|
# we only test our custom metas, not the default ones
|
||||||
assert metas[: len(expected_metas)] == expected_metas
|
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
|
||||||
|
|
|
@ -832,6 +832,43 @@ def test_oembed_artist(factories, no_api_auth, api_client, settings):
|
||||||
assert response.data == expected
|
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(
|
@pytest.mark.parametrize(
|
||||||
"factory_name, url_name",
|
"factory_name, url_name",
|
||||||
[
|
[
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Support embeds on public playlists
|
|
@ -139,7 +139,7 @@ export default {
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
supportedTypes: ['track', 'album', 'artist'],
|
supportedTypes: ['track', 'album', 'artist', 'playlist'],
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
error: null,
|
error: null,
|
||||||
type: null,
|
type: null,
|
||||||
|
@ -235,6 +235,9 @@ export default {
|
||||||
if (type === 'artist') {
|
if (type === 'artist') {
|
||||||
this.fetchTracks({artist: id, playable: true, ordering: "-release_date,disc_number,position"})
|
this.fetchTracks({artist: id, playable: true, ordering: "-release_date,disc_number,position"})
|
||||||
}
|
}
|
||||||
|
if (type === 'playlist') {
|
||||||
|
this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
play (index) {
|
play (index) {
|
||||||
this.currentIndex = index
|
this.currentIndex = index
|
||||||
|
@ -269,9 +272,10 @@ export default {
|
||||||
self.isLoading = false;
|
self.isLoading = false;
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchTracks (filters) {
|
fetchTracks (filters, path) {
|
||||||
|
path = path || "/api/v1/tracks/"
|
||||||
let self = this
|
let self = this
|
||||||
let url = `${this.baseUrl}/api/v1/tracks/`
|
let url = `${this.baseUrl}${path}`
|
||||||
axios.get(url, {params: filters}).then(response => {
|
axios.get(url, {params: filters}).then(response => {
|
||||||
self.tracks = self.parseTracks(response.data.results)
|
self.tracks = self.parseTracks(response.data.results)
|
||||||
self.isLoading = false;
|
self.isLoading = false;
|
||||||
|
@ -297,6 +301,11 @@ export default {
|
||||||
},
|
},
|
||||||
parseTracks (tracks) {
|
parseTracks (tracks) {
|
||||||
let self = this
|
let self = this
|
||||||
|
if (this.type === 'playlist') {
|
||||||
|
tracks = tracks.map((t) => {
|
||||||
|
return t.track
|
||||||
|
})
|
||||||
|
}
|
||||||
return tracks.map(t => {
|
return tracks.map(t => {
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|
|
@ -50,7 +50,7 @@ export default {
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
copied: false
|
copied: false
|
||||||
}
|
}
|
||||||
if (this.type === 'album') {
|
if (this.type === 'album' || this.type === 'artist' || this.type === 'playlist') {
|
||||||
d.height = 330
|
d.height = 330
|
||||||
d.minHeight = 250
|
d.minHeight = 250
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,14 @@
|
||||||
<template v-if="edit"><translate translate-context="Content/Playlist/Button.Label/Verb">End edition</translate></template>
|
<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>
|
<template v-else><translate translate-context="Content/*/Button.Label/Verb">Edit</translate></template>
|
||||||
</button>
|
</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">
|
<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>
|
<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}">
|
<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>
|
<div slot="modal-confirm"><translate translate-context="Popup/Playlist/Button.Label/Verb">Delete playlist</translate></div>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
</div>
|
</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>
|
||||||
<section class="ui vertical stripe segment">
|
<section class="ui vertical stripe segment">
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
|
@ -61,6 +86,8 @@ import TrackTable from "@/components/audio/track/Table"
|
||||||
import RadioButton from "@/components/radios/Button"
|
import RadioButton from "@/components/radios/Button"
|
||||||
import PlayButton from "@/components/audio/PlayButton"
|
import PlayButton from "@/components/audio/PlayButton"
|
||||||
import PlaylistEditor from "@/components/playlists/Editor"
|
import PlaylistEditor from "@/components/playlists/Editor"
|
||||||
|
import EmbedWizard from "@/components/audio/EmbedWizard"
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -71,7 +98,9 @@ export default {
|
||||||
PlaylistEditor,
|
PlaylistEditor,
|
||||||
TrackTable,
|
TrackTable,
|
||||||
PlayButton,
|
PlayButton,
|
||||||
RadioButton
|
RadioButton,
|
||||||
|
Modal,
|
||||||
|
EmbedWizard,
|
||||||
},
|
},
|
||||||
data: function() {
|
data: function() {
|
||||||
return {
|
return {
|
||||||
|
@ -79,7 +108,8 @@ export default {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
playlist: null,
|
playlist: null,
|
||||||
tracks: [],
|
tracks: [],
|
||||||
playlistTracks: []
|
playlistTracks: [],
|
||||||
|
showEmbedModal: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: function() {
|
created: function() {
|
||||||
|
|
Loading…
Reference in New Issue