Merge branch 'embed' into 'develop'

Fix #578: iframe embed

Closes #578

See merge request funkwhale/funkwhale!496
This commit is contained in:
Eliot Berriot 2018-12-19 15:12:38 +01:00
commit 24b8cb66df
31 changed files with 1819 additions and 106 deletions

View File

@ -31,6 +31,7 @@ subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic"
v1_patterns += [
url(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
url(
r"^instance/",
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),

View File

@ -70,7 +70,16 @@ else:
FUNKWHALE_PROTOCOL = _parsed.scheme
FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
FUNKWHALE_SPA_HTML_ROOT = env(
"FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/"
)
FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int(
"FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15
)
FUNKWHALE_EMBED_URL = env(
"FUNKWHALE_EMBED_URL", default=FUNKWHALE_SPA_HTML_ROOT + "embed.html"
)
APP_NAME = "Funkwhale"
# XXX: deprecated, see #186
FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
@ -159,7 +168,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# MIDDLEWARE CONFIGURATION
# ------------------------------------------------------------------------------
MIDDLEWARE = (
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
"funkwhale_api.common.middleware.SPAFallbackMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
@ -305,6 +314,7 @@ FILE_UPLOAD_PERMISSIONS = 0o644
# URL Configuration
# ------------------------------------------------------------------------------
ROOT_URLCONF = "config.urls"
SPA_URLCONF = "config.spa_urls"
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.routing.application"
@ -400,7 +410,13 @@ if AUTH_LDAP_ENABLED:
AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
CACHE_DEFAULT = "redis://127.0.0.1:6379/0"
CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)}
CACHES = {
"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT),
"local": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "local-cache",
},
}
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"

18
api/config/spa_urls.py Normal file
View File

@ -0,0 +1,18 @@
from django import urls
from funkwhale_api.music import spa_views
urlpatterns = [
urls.re_path(
r"^library/tracks/(?P<pk>\d+)/?$", spa_views.library_track, name="library_track"
),
urls.re_path(
r"^library/albums/(?P<pk>\d+)/?$", spa_views.library_album, name="library_album"
),
urls.re_path(
r"^library/artists/(?P<pk>\d+)/?$",
spa_views.library_artist,
name="library_artist",
),
]

View File

@ -0,0 +1,137 @@
import html
import requests
from django import http
from django.conf import settings
from django.core.cache import caches
from django import urls
from . import preferences
from . import utils
EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
def should_fallback_to_spa(path):
if path == "/":
return True
return not any([path.startswith(m) for m in EXCLUDED_PATHS])
def serve_spa(request):
html = get_spa_html(settings.FUNKWHALE_SPA_HTML_ROOT)
head, tail = html.split("</head>", 1)
if not preferences.get("common__api_authentication_required"):
try:
request_tags = get_request_head_tags(request) or []
except urls.exceptions.Resolver404:
# we don't have any custom tags for this route
request_tags = []
else:
# API is not open, we don't expose any custom data
request_tags = []
default_tags = get_default_head_tags(request.path)
unique_attributes = ["name", "property"]
final_tags = request_tags
skip = []
for t in final_tags:
for attr in unique_attributes:
if attr in t:
skip.append(t[attr])
for t in default_tags:
existing = False
for attr in unique_attributes:
if t.get(attr) in skip:
existing = True
break
if not existing:
final_tags.append(t)
# let's inject our meta tags in the HTML
head += "\n" + "\n".join(render_tags(final_tags)) + "\n</head>"
return http.HttpResponse(head + tail)
def get_spa_html(spa_url):
cache_key = "spa-html:{}".format(spa_url)
cached = caches["local"].get(cache_key)
if cached:
return cached
response = requests.get(
utils.join_url(spa_url, "index.html"),
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
)
response.raise_for_status()
content = response.text
caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION)
return content
def get_default_head_tags(path):
instance_name = preferences.get("instance__name")
short_description = preferences.get("instance__short_description")
app_name = settings.APP_NAME
parts = [instance_name, app_name]
return [
{"tag": "meta", "property": "og:type", "content": "website"},
{
"tag": "meta",
"property": "og:site_name",
"content": " - ".join([p for p in parts if p]),
},
{"tag": "meta", "property": "og:description", "content": short_description},
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, "/front/favicon.png"),
},
{
"tag": "meta",
"property": "og:url",
"content": utils.join_url(settings.FUNKWHALE_URL, path),
},
]
def render_tags(tags):
"""
Given a dict like {'tag': 'meta', 'hello': 'world'}
return a html ready tag like
<meta hello="world" />
"""
for tag in tags:
yield "<{tag} {attrs} />".format(
tag=tag.pop("tag"),
attrs=" ".join(
[
'{}="{}"'.format(a, html.escape(str(v)))
for a, v in sorted(tag.items())
if v
]
),
)
def get_request_head_tags(request):
match = urls.resolve(request.path, urlconf=settings.SPA_URLCONF)
return match.func(request, *match.args, **match.kwargs)
class SPAFallbackMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if response.status_code == 404 and should_fallback_to_spa(request.path):
return serve_spa(request)
return response

View File

@ -3,9 +3,12 @@ from django.utils.deconstruct import deconstructible
import os
import shutil
import uuid
import xml.etree.ElementTree as ET
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from django.conf import settings
from django import urls
from django.db import transaction
@ -107,3 +110,32 @@ def chunk_queryset(source_qs, chunk_size):
if nb_items < chunk_size:
return
def join_url(start, end):
if start.endswith("/") and end.startswith("/"):
return start + end[1:]
if not start.endswith("/") and not end.startswith("/"):
return start + "/" + end
return start + end
def spa_reverse(name, args=[], kwargs={}):
return urls.reverse(name, urlconf=settings.SPA_URLCONF, args=args, kwargs=kwargs)
def spa_resolve(path):
return urls.resolve(path, urlconf=settings.SPA_URLCONF)
def parse_meta(html):
# dirty but this is only for testing so we don't really care,
# we convert the html string to xml so it can be parsed as xml
html = '<?xml version="1.0"?>' + html
tree = ET.fromstring(html)
meta = [elem for elem in tree.iter() if elem.tag in ["meta", "link"]]
return [dict([("tag", elem.tag)] + list(elem.items())) for elem in meta]

View File

@ -1,12 +1,18 @@
import urllib.parse
from django.db import transaction
from django import urls
from django.conf import settings
from rest_framework import serializers
from taggit.models import Tag
from versatileimagefield.serializers import VersatileImageFieldSerializer
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import preferences
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 . import filters, models, tasks
@ -380,3 +386,98 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
def get_type(self, obj):
return "Audio"
class OembedSerializer(serializers.Serializer):
format = serializers.ChoiceField(choices=["json"])
url = serializers.URLField()
maxheight = serializers.IntegerField(required=False)
maxwidth = serializers.IntegerField(required=False)
def validate(self, validated_data):
try:
match = common_utils.spa_resolve(
urllib.parse.urlparse(validated_data["url"]).path
)
except urls.exceptions.Resolver404:
raise serializers.ValidationError(
"Invalid URL {}".format(validated_data["url"])
)
data = {
"version": 1.0,
"type": "rich",
"provider_name": "{} - {}".format(
preferences.get("instance__name"), settings.APP_NAME
),
"provider_url": settings.FUNKWHALE_URL,
"height": validated_data.get("maxheight") or 400,
"width": validated_data.get("maxwidth") or 600,
}
embed_id = None
embed_type = None
if match.url_name == "library_track":
qs = models.Track.objects.select_related("artist", "album__artist").filter(
pk=int(match.kwargs["pk"])
)
try:
track = qs.get()
except models.Track.DoesNotExist:
raise serializers.ValidationError(
"No track matching id {}".format(match.kwargs["pk"])
)
embed_type = "track"
embed_id = track.pk
data["title"] = "{} by {}".format(track.title, track.artist.name)
if track.album.cover:
data["thumbnail_url"] = federation_utils.full_url(
track.album.cover.crop["400x400"].url
)
data["description"] = track.full_name
data["author_name"] = track.artist.name
data["height"] = 150
data["author_url"] = federation_utils.full_url(
common_utils.spa_reverse(
"library_artist", kwargs={"pk": track.artist.pk}
)
)
elif match.url_name == "library_album":
qs = models.Album.objects.select_related("artist").filter(
pk=int(match.kwargs["pk"])
)
try:
album = qs.get()
except models.Album.DoesNotExist:
raise serializers.ValidationError(
"No album matching id {}".format(match.kwargs["pk"])
)
embed_type = "album"
embed_id = album.pk
if album.cover:
data["thumbnail_url"] = federation_utils.full_url(
album.cover.crop["400x400"].url
)
data["title"] = "{} by {}".format(album.title, album.artist.name)
data["description"] = "{} by {}".format(album.title, album.artist.name)
data["author_name"] = album.artist.name
data["height"] = 400
data["author_url"] = federation_utils.full_url(
common_utils.spa_reverse(
"library_artist", kwargs={"pk": album.artist.pk}
)
)
else:
raise serializers.ValidationError(
"Unsupported url: {}".format(validated_data["url"])
)
data[
"html"
] = '<iframe width="{}" height="{}" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
data["width"],
data["height"],
settings.FUNKWHALE_EMBED_URL
+ "?type={}&id={}".format(embed_type, embed_id),
)
return data
def create(self, data):
return data

View File

@ -0,0 +1,161 @@
from django.conf import settings
from django.urls import reverse
from funkwhale_api.common import utils
from . import models
def library_track(request, pk):
queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist")
try:
obj = queryset.get()
except models.Track.DoesNotExist:
return []
track_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_track", kwargs={"pk": obj.pk}),
)
metas = [
{"tag": "meta", "property": "og:url", "content": track_url},
{"tag": "meta", "property": "og:title", "content": obj.title},
{"tag": "meta", "property": "og:type", "content": "music.song"},
{"tag": "meta", "property": "music:album:disc", "content": obj.disc_number},
{"tag": "meta", "property": "music:album:track", "content": obj.position},
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
),
},
{
"tag": "meta",
"property": "music:album",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
),
},
]
if obj.album.cover:
metas.append(
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, obj.album.cover.url),
}
)
if obj.uploads.playable_by(None).exists():
metas.append(
{
"tag": "meta",
"property": "og:audio",
"content": utils.join_url(settings.FUNKWHALE_URL, obj.listen_url),
}
)
metas.append(
{
"tag": "link",
"rel": "alternate",
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?url={}".format(track_url)
),
}
)
return metas
def library_album(request, pk):
queryset = models.Album.objects.filter(pk=pk).select_related("artist")
try:
obj = queryset.get()
except models.Album.DoesNotExist:
return []
album_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_album", kwargs={"pk": obj.pk}),
)
metas = [
{"tag": "meta", "property": "og:url", "content": album_url},
{"tag": "meta", "property": "og:title", "content": obj.title},
{"tag": "meta", "property": "og:type", "content": "music.album"},
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
),
},
]
if obj.release_date:
metas.append(
{
"tag": "meta",
"property": "music:release_date",
"content": str(obj.release_date),
}
)
if obj.cover:
metas.append(
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, obj.cover.url),
}
)
if models.Upload.objects.filter(track__album=obj).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"))
+ "?url={}".format(album_url)
),
}
)
return metas
def library_artist(request, pk):
queryset = models.Artist.objects.filter(pk=pk)
try:
obj = queryset.get()
except models.Artist.DoesNotExist:
return []
artist_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}),
)
# we use latest album's cover as artist image
latest_album = (
obj.albums.exclude(cover="").exclude(cover=None).order_by("release_date").last()
)
metas = [
{"tag": "meta", "property": "og:url", "content": artist_url},
{"tag": "meta", "property": "og:title", "content": obj.name},
{"tag": "meta", "property": "og:type", "content": "profile"},
]
if latest_album and latest_album.cover:
metas.append(
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(
settings.FUNKWHALE_URL, latest_album.cover.url
),
}
)
return metas

View File

@ -508,3 +508,13 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
except AttributeError:
first_arg = [i.conf for i in instance_or_qs if i.conf]
return super().get_serializer(*((first_arg,) + args[1:]), **kwargs)
class OembedView(views.APIView):
permission_classes = [common_permissions.ConditionalAuthentication]
def get(self, request, *args, **kwargs):
serializer = serializers.OembedSerializer(data=request.GET)
serializer.is_valid(raise_exception=True)
embed_data = serializer.save()
return Response(embed_data)

View File

@ -0,0 +1,137 @@
import pytest
from funkwhale_api.common import middleware
def test_spa_fallback_middleware_no_404(mocker):
get_response = mocker.Mock()
get_response.return_value = mocker.Mock(status_code=200)
request = mocker.Mock(path="/")
m = middleware.SPAFallbackMiddleware(get_response)
assert m(request) == get_response.return_value
def test_spa_middleware_calls_should_fallback_false(mocker):
get_response = mocker.Mock()
get_response.return_value = mocker.Mock(status_code=404)
should_falback = mocker.patch.object(
middleware, "should_fallback_to_spa", return_value=False
)
request = mocker.Mock(path="/")
m = middleware.SPAFallbackMiddleware(get_response)
assert m(request) == get_response.return_value
should_falback.assert_called_once_with(request.path)
def test_spa_middleware_should_fallback_true(mocker):
get_response = mocker.Mock()
get_response.return_value = mocker.Mock(status_code=404)
request = mocker.Mock(path="/")
mocker.patch.object(middleware, "should_fallback_to_spa", return_value=True)
serve_spa = mocker.patch.object(middleware, "serve_spa")
m = middleware.SPAFallbackMiddleware(get_response)
assert m(request) == serve_spa.return_value
serve_spa.assert_called_once_with(request)
@pytest.mark.parametrize(
"path,expected",
[("/", True), ("/federation", False), ("/api", False), ("/an/spa/path/", True)],
)
def test_should_fallback(path, expected, mocker):
assert middleware.should_fallback_to_spa(path) is expected
def test_serve_spa_from_cache(mocker, settings, preferences, no_api_auth):
request = mocker.Mock(path="/")
get_spa_html = mocker.patch.object(
middleware, "get_spa_html", return_value="<html><head></head></html>"
)
mocker.patch.object(
middleware,
"get_default_head_tags",
return_value=[
{"tag": "meta", "property": "og:title", "content": "default title"},
{"tag": "meta", "property": "og:site_name", "content": "default site name"},
],
)
get_request_head_tags = mocker.patch.object(
middleware,
"get_request_head_tags",
return_value=[
{"tag": "meta", "property": "og:title", "content": "custom title"},
{
"tag": "meta",
"property": "og:description",
"content": "custom description",
},
],
)
response = middleware.serve_spa(request)
assert response.status_code == 200
expected = [
"<html><head>",
'<meta content="custom title" property="og:title" />',
'<meta content="custom description" property="og:description" />',
'<meta content="default site name" property="og:site_name" />',
"</head></html>",
]
get_spa_html.assert_called_once_with(settings.FUNKWHALE_SPA_HTML_ROOT)
get_request_head_tags.assert_called_once_with(request)
assert response.content == "\n".join(expected).encode()
def test_get_default_head_tags(preferences, settings):
settings.APP_NAME = "Funkwhale"
preferences["instance__name"] = "Hello"
preferences["instance__short_description"] = "World"
expected = [
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:site_name", "content": "Hello - Funkwhale"},
{"tag": "meta", "property": "og:description", "content": "World"},
{
"tag": "meta",
"property": "og:image",
"content": settings.FUNKWHALE_URL + "/front/favicon.png",
},
{"tag": "meta", "property": "og:url", "content": settings.FUNKWHALE_URL + "/"},
]
assert middleware.get_default_head_tags("/") == expected
def test_get_spa_html_from_cache(local_cache):
local_cache.set("spa-html:http://test", "hello world")
assert middleware.get_spa_html("http://test") == "hello world"
def test_get_spa_html_from_http(local_cache, r_mock, mocker, settings):
cache_set = mocker.spy(local_cache, "set")
url = "http://test"
r_mock.get(url + "/index.html", text="hello world")
assert middleware.get_spa_html("http://test") == "hello world"
cache_set.assert_called_once_with(
"spa-html:{}".format(url),
"hello world",
settings.FUNKWHALE_SPA_HTML_CACHE_DURATION,
)
def test_get_route_head_tags(mocker, settings):
match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock())
resolve = mocker.patch("django.urls.resolve", return_value=match)
request = mocker.Mock(path="/tracks/42")
tags = middleware.get_request_head_tags(request)
assert tags == match.func.return_value
match.func.assert_called_once_with(request, *[], **{"pk": 42})
resolve.assert_called_once_with(request.path, urlconf=settings.SPA_URLCONF)

View File

@ -13,7 +13,7 @@ import factory
import pytest
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.core.cache import cache as django_cache, caches
from django.core.files import uploadedfile
from django.utils import timezone
from django.test import client
@ -100,6 +100,12 @@ def cache():
django_cache.clear()
@pytest.fixture(autouse=True)
def local_cache():
yield caches["local"]
caches["local"].clear()
@pytest.fixture
def factories(db):
"""
@ -382,3 +388,15 @@ def temp_signal(mocker):
@pytest.fixture()
def stdout():
yield io.StringIO()
@pytest.fixture
def spa_html(r_mock, settings):
yield r_mock.get(
settings.FUNKWHALE_SPA_HTML_ROOT + "index.html", text="<head></head>"
)
@pytest.fixture
def no_api_auth(preferences):
preferences["common__api_authentication_required"] = False

View File

@ -0,0 +1,148 @@
from django.urls import reverse
from funkwhale_api.common import utils
def test_library_track(spa_html, no_api_auth, client, factories, settings):
track = factories["music.Upload"](playable=True, track__disc_number=1).track
url = "/library/tracks/{}".format(track.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": track.title},
{"tag": "meta", "property": "og:type", "content": "music.song"},
{
"tag": "meta",
"property": "music:album:disc",
"content": str(track.disc_number),
},
{
"tag": "meta",
"property": "music:album:track",
"content": str(track.position),
},
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}),
),
},
{
"tag": "meta",
"property": "music:album",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_album", kwargs={"pk": track.album.pk}),
),
},
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, track.album.cover.url),
},
{
"tag": "meta",
"property": "og:audio",
"content": utils.join_url(settings.FUNKWHALE_URL, track.listen_url),
},
{
"tag": "link",
"rel": "alternate",
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?url={}".format(utils.join_url(settings.FUNKWHALE_URL, url))
),
},
]
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_album(spa_html, no_api_auth, client, factories, settings):
track = factories["music.Upload"](playable=True, track__disc_number=1).track
album = track.album
url = "/library/albums/{}".format(album.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": album.title},
{"tag": "meta", "property": "og:type", "content": "music.album"},
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}),
),
},
{
"tag": "meta",
"property": "music:release_date",
"content": str(album.release_date),
},
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, album.cover.url),
},
{
"tag": "link",
"rel": "alternate",
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?url={}".format(utils.join_url(settings.FUNKWHALE_URL, url))
),
},
]
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_artist(spa_html, no_api_auth, client, factories, settings):
album = factories["music.Album"]()
artist = album.artist
url = "/library/artists/{}".format(artist.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": artist.name},
{"tag": "meta", "property": "og:type", "content": "profile"},
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, album.cover.url),
},
]
metas = utils.parse_meta(response.content.decode())
# we only test our custom metas, not the default ones
assert metas[: len(expected_metas)] == expected_metas

View File

@ -6,8 +6,10 @@ import pytest
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.music import licenses, models, serializers, tasks, views
from funkwhale_api.common import utils
from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import licenses, models, serializers, tasks, views
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@ -570,3 +572,74 @@ def test_detail_license(api_client, preferences):
response = api_client.get(url)
assert response.data == expected
def test_oembed_track(factories, no_api_auth, api_client, settings, preferences):
settings.FUNKWHALE_URL = "http://test"
settings.FUNKWHALE_EMBED_URL = "http://embed"
preferences["instance__name"] = "Hello"
track = factories["music.Track"]()
url = reverse("api:v1:oembed")
track_url = "https://test.com/library/tracks/{}".format(track.pk)
iframe_src = "http://embed?type=track&id={}".format(track.pk)
expected = {
"version": 1.0,
"type": "rich",
"provider_name": "{} - {}".format(
preferences["instance__name"], settings.APP_NAME
),
"provider_url": settings.FUNKWHALE_URL,
"height": 150,
"width": 600,
"title": "{} by {}".format(track.title, track.artist.name),
"description": track.full_name,
"thumbnail_url": federation_utils.full_url(
track.album.cover.crop["400x400"].url
),
"html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
iframe_src
),
"author_name": track.artist.name,
"author_url": federation_utils.full_url(
utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk})
),
}
response = api_client.get(url, {"url": track_url, "format": "json"})
assert response.data == expected
def test_oembed_album(factories, no_api_auth, api_client, settings, preferences):
settings.FUNKWHALE_URL = "http://test"
settings.FUNKWHALE_EMBED_URL = "http://embed"
preferences["instance__name"] = "Hello"
track = factories["music.Track"]()
album = track.album
url = reverse("api:v1:oembed")
album_url = "https://test.com/library/albums/{}".format(album.pk)
iframe_src = "http://embed?type=album&id={}".format(album.pk)
expected = {
"version": 1.0,
"type": "rich",
"provider_name": "{} - {}".format(
preferences["instance__name"], settings.APP_NAME
),
"provider_url": settings.FUNKWHALE_URL,
"height": 400,
"width": 600,
"title": "{} by {}".format(album.title, album.artist.name),
"description": "{} by {}".format(album.title, album.artist.name),
"thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url),
"html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
iframe_src
),
"author_name": album.artist.name,
"author_url": federation_utils.full_url(
utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk})
),
}
response = api_client.get(url, {"url": album_url, "format": "json"})
assert response.data == expected

View File

@ -0,0 +1,48 @@
Allow embedding of albums and tracks available in public libraries via an <iframe> (#578)
Iframe widget to embed public tracks and albums [manual action required]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Funkwhale now support embedding a lightweight audio player on external websites
for album and tracks that are available in public libraries. Important pages,
such as artist, album and track pages also include OpenGraph tags that will
enable previews on compatible apps (like sharing a Funkwhale track link on Mastodon
or Twitter).
To achieve that, we had to tweak the way Funkwhale front-end is served. You'll have
to modify your nginx configuration when upgrading to keep your instance working.
**On docker setups**, edit your ``/srv/funkwhale/nginx/funkwhale.template`` and replace
the ``location /api/`` and `location /` blocks by the following snippets::
location / {
include /etc/nginx/funkwhale_proxy.conf;
# this is needed if you have file import via upload enabled
client_max_body_size ${NGINX_MAX_BODY_SIZE};
proxy_pass http://funkwhale-api/;
}
location /front/ {
alias /frontend;
}
The change of configuration will be picked when restarting your nginx container.
**On non-docker setups**, edit your ``/etc/nginx/sites-available/funkwhale.conf`` file,
and replace the ``location /api/`` and `location /` blocks by the following snippets::
location / {
include /etc/nginx/funkwhale_proxy.conf;
# this is needed if you have file import via upload enabled
client_max_body_size ${NGINX_MAX_BODY_SIZE};
proxy_pass http://funkwhale-api/;
}
location /front/ {
alias ${FUNKWHALE_FRONTEND_PATH};
}
Replace ``${FUNKWHALE_FRONTEND_PATH}`` by the corresponding variable from your .env file,
which should be ``/srv/funkwhale/front/dist`` by default, then reload your nginx process with
``sudo systemctl reload nginx``.

View File

@ -24,17 +24,14 @@ server {
root /frontend;
location / {
try_files $uri $uri/ @rewrites;
}
location @rewrites {
rewrite ^(.+)$ /index.html last;
}
location /api/ {
include /etc/nginx/funkwhale_proxy.conf;
# this is needed if you have file import via upload enabled
client_max_body_size ${NGINX_MAX_BODY_SIZE};
proxy_pass http://funkwhale-api/api/;
proxy_pass http://funkwhale-api/;
}
location /front/ {
alias /frontend;
}
location /federation/ {

View File

@ -44,17 +44,14 @@ server {
root ${FUNKWHALE_FRONTEND_PATH};
location / {
try_files $uri $uri/ @rewrites;
}
location @rewrites {
rewrite ^(.+)$ /index.html last;
}
location /api/ {
include /etc/nginx/funkwhale_proxy.conf;
# this is needed if you have file import via upload enabled
client_max_body_size ${NGINX_MAX_BODY_SIZE};
proxy_pass http://funkwhale-api/api/;
proxy_pass http://funkwhale-api/;
}
location /front/ {
alias ${FUNKWHALE_FRONTEND_PATH};
}
location /federation/ {

42
dev.yml
View File

@ -10,20 +10,13 @@ services:
- "HOST=0.0.0.0"
- "VUE_PORT=${VUE_PORT-8080}"
ports:
- "${VUE_PORT-8080}:${VUE_PORT-8080}"
- "${VUE_PORT-8080}"
volumes:
- "./front:/app"
- "/app/node_modules"
- "./po:/po"
networks:
- federation
- internal
labels:
traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}"
traefik.enable: "true"
traefik.federation.protocol: "http"
traefik.federation.port: "${VUE_PORT-8080}"
postgres:
env_file:
@ -66,7 +59,7 @@ services:
- "CACHE_URL=redis://redis:6379/0"
volumes:
- ./api:/app
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
- "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
networks:
- internal
api:
@ -76,10 +69,10 @@ services:
build:
context: ./api
dockerfile: docker/Dockerfile.test
command: python /app/manage.py runserver 0.0.0.0:12081
command: python /app/manage.py runserver 0.0.0.0:${FUNKWHALE_API_PORT-5000}
volumes:
- ./api:/app
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
- "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
environment:
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
@ -99,22 +92,35 @@ services:
- .env
image: nginx
environment:
- "VUE_PORT=${VUE_PORT-8080}"
- "NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE-30M}"
- "FUNKWHALE_API_IP=${FUNKHALE_API_IP-api}"
- "FUNKWHALE_API_PORT=${FUNKWHALE_API_PORT-5000}"
- "FUNKWHALE_FRONT_IP=${FUNKHALE_FRONT_IP-front}"
- "FUNKWHALE_FRONT_PORT=${VUE_PORT-8080}"
- "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME- }"
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
links:
- api
- front
volumes:
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf.template:ro
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
- ./api/funkwhale_api/media:/protected/media
ports:
- "6001"
- "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
- "${MEDIA_ROOT-./api/funkwhale_api/media}:/protected/media:ro"
networks:
- federation
- internal
labels:
traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}"
traefik.enable: "true"
traefik.federation.protocol: "http"
traefik.federation.port: "80"
traefik.frontend.passHostHeader: true
traefik.docker.network: federation
docs:
build: docs
command: python serve.py

View File

@ -32,26 +32,57 @@ http {
'' close;
}
upstream funkwhale-api {
server ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT};
}
upstream funkwhale-front {
server ${FUNKWHALE_FRONT_IP}:${FUNKWHALE_FRONT_PORT};
}
server {
listen 6001;
listen 80;
charset utf-8;
client_max_body_size 30M;
include /etc/nginx/funkwhale_proxy.conf;
location /front/ {
proxy_pass http://funkwhale-front/front/;
}
location /front-server/ {
proxy_pass http://funkwhale-front/;
}
location / {
include /etc/nginx/funkwhale_proxy.conf;
# this is needed if you have file import via upload enabled
client_max_body_size ${NGINX_MAX_BODY_SIZE};
proxy_pass http://funkwhale-api/;
}
# You can comment this if you do not plan to use the Subsonic API
location /rest/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/api/subsonic/rest/;
}
location /media/ {
alias /protected/media/;
}
location /_protected/media {
# this is an internal location that is used to serve
# audio files once correct permission / authentication
# has been checked on API side
internal;
alias /protected/media;
}
location /_protected/music {
# this is an internal location that is used to serve
# audio files once correct permission / authentication
# has been checked on API side
# Set this to the same value as your MUSIC_DIRECTORY_PATH setting
internal;
alias /music;
}
location / {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/;
}
location /rest/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/api/subsonic/rest/;
}
}
}

View File

@ -1,18 +1,8 @@
#!/bin/bash -eux
FORWARDED_PORT="$VUE_PORT"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME// /}"
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
echo
FUNKWHALE_HOSTNAME="$COMPOSE_PROJECT_NAME.funkwhale.test"
FORWARDED_PORT="443"
fi
echo "Copying template file..."
cp /etc/nginx/funkwhale_proxy.conf{.template,}
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf
sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf
sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf
sed -i "s/proxy_set_header X-Forwarded-Proto \$scheme/proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO}/" /etc/nginx/funkwhale_proxy.conf
cat /etc/nginx/funkwhale_proxy.conf
nginx -g "daemon off;"
envsubst "`env | awk -F = '{printf \" $$%s\", $$1}'`" \
< /etc/nginx/nginx.conf.template \
> /etc/nginx/nginx.conf \
&& cat /etc/nginx/nginx.conf \
&& nginx-debug -g 'daemon off;'

View File

@ -27,6 +27,7 @@
"vue-gettext": "^2.1.0",
"vue-lazyload": "^1.2.6",
"vue-masonry": "^0.11.5",
"vue-plyr": "^5.0.4",
"vue-router": "^3.0.1",
"vue-upload-component": "^2.8.11",
"vuedraggable": "^2.16.0",

20
front/public/embed.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.png">
<title>Funkwhale Widget</title>
</head>
<body>
<noscript>
<strong>We're sorry but this widget doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

567
front/src/Embed.vue Normal file
View File

@ -0,0 +1,567 @@
<template>
<main :class="[theme]">
<!-- SVG from https://cdn.plyr.io/3.4.7/plyr.svg -->
<svg aria-hidden="true" style="display: none" xmlns="http://www.w3.org/2000/svg">
<symbol id="plyr-download"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zM2 15h14v2H2z"/></symbol>
<symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"/></symbol>
<symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"/></symbol>
<symbol id="plyr-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z"/></symbol>
<symbol id="plyr-muted"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol>
<symbol id="plyr-pause"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zM12 1c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z"/></symbol>
<symbol id="plyr-pip"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z"/><path d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2v5z"/></symbol>
<symbol id="plyr-play"><path d="M15.562 8.1L3.87.225C3.052-.337 2 .225 2 1.125v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"/></symbol>
<symbol id="plyr-restart"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z"/></symbol>
<symbol id="plyr-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z"/></symbol>
<symbol id="plyr-settings"><path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol>
<symbol id="plyr-volume"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z"/><path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol></svg>
<!-- those ones are from fork-awesome -->
<symbol id="plyr-step-backward"><path d="M979 141c25-25 45-16 45 19v1472c0 35-20 44-45 19L269 941c-6-6-10-12-13-19v678c0 35-29 64-64 64H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h128c35 0 64 29 64 64v678c3-7 7-13 13-19z"/></symbol>
<symbol id="plyr-step-forward"><path d="M45 1651c-25 25-45 16-45-19V160c0-35 20-44 45-19l710 710c6 6 10 12 13 19V192c0-35 29-64 64-64h128c35 0 64 29 64 64v1408c0 35-29 64-64 64H832c-35 0-64-29-64-64V922c-3 7-7 13-13 19z"/></symbol>
</svg>
<article>
<aside class="cover main" v-if="currentTrack">
<img height="120" v-if="currentTrack.cover" :src="currentTrack.cover" alt="Cover" />
<img height="120" v-else src="./assets/embed/default-cover.jpeg" alt="Cover" />
</aside>
<div class="content" aria-label="Track information">
<header v-if="currentTrack">
<h3><a :href="fullUrl('/library/tracks/' + currentTrack.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.title }}</a></h3>
By <a :href="fullUrl('/library/artists/' + currentTrack.artist.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.artist.name }}</a>
</header>
<section v-if="!isLoading" class="controls" aria-label="Audio player">
<template v-if="currentTrack && currentTrack.sources.length > 0">
<div class="queue-controls plyr--audio" v-if="tracks.length > 1">
<div class="plyr__controls">
<button
@focus="setControlFocus($event, true)"
@blur="setControlFocus($event, false)"
@click="previous()"
type="button"
class="plyr__control"
aria-label="Play previous track">
<svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80">
<use xlink:href="#plyr-step-backward"></use>
</svg>
</button>
<button
@click="next()"
@focus="setControlFocus($event, true)"
@blur="setControlFocus($event, false)"
type="button"
class="plyr__control"
aria-label="Play next track">
<svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80">
<use xlink:href="#plyr-step-forward"></use>
</svg>
</button>
</div>
</div>
<vue-plyr
:key="currentIndex"
ref="player"
class="player"
:options="{loadSprite: false, controls: controls, duration: currentTrack.sources[0].duration}">
<audio preload="none">
<source v-for="source in currentTrack.sources" :src="source.src" :type="source.type"/>
</audio>
</vue-plyr>
</template>
<div v-else class="player">
<span v-if="error === 'invalid_type'" class="error">Widget improperly configured (bad resource type {{ type }}).</span>
<span v-else-if="error === 'invalid_id'" class="error">Widget improperly configured (missing resource id).</span>
<span v-else-if="error === 'server_not_found'" class="error">Track not found.</span>
<span v-else-if="error === 'server_requires_auth'" class="error">You need to login to access this resource.</span>
<span v-else-if="error === 'server_error'" class="error">A server error occured.</span>
<span v-else-if="error === 'server_error'" class="error">An unknown error occured while loading track data from server.</span>
<span v-else-if="currentTrack && currentTrack.sources.length === 0" class="error">This track is unavailable.</span>
<span v-else class="error">An unknown error occured while loading track data.</span>
</div>
<a title="Funkwhale" href="https://funkwhale.audio" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
<logo :fill="currentTheme.textColor" class="logo"></logo>
</a>
</section>
</div>
</article>
<div v-if="tracks.length > 1" class="queue-wrapper" id="queue">
<table class="queue">
<tbody>
<tr
:id="'queue-item-' + index"
role="button"
tabindex="0"
v-if="track.sources.length > 0"
:key="index"
:class="[{active: index === currentIndex}]"
@click="play(index)"
@keyup.enter="play(index)"
v-for="(track, index) in tracks">
<td class="position-cell" width="40">
<span class="position">
{{ index + 1 }}
</span>
</td>
<td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td>
<td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td>
<td class="album">
<div class="ellipsis " v-if="track.album" :title="track.album.title">{{ track.album.title }}</div>
</td>
<td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td>
</tr>
</tbody>
</table>
</div>
</main>
</template>
<script>
import axios from 'axios'
import Logo from "@/components/Logo"
import url from '@/utils/url'
import time from '@/utils/time'
function getURLParams () {
var urlParams
var match,
pl = /\+/g, // Regex for replacing addition symbol with a space
search = /([^&=]+)=?([^&]*)/g,
decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); },
query = window.location.search.substring(1);
urlParams = {};
while (match = search.exec(query))
urlParams[decode(match[1])] = decode(match[2]);
return urlParams
}
export default {
name: 'app',
components: {Logo},
data () {
return {
time,
supportedTypes: ['track', 'album'],
baseUrl: '',
error: null,
type: null,
id: null,
tracks: [],
url: null,
isLoading: true,
theme: 'dark',
currentIndex: -1,
themes: {
dark: {
textColor: 'white',
}
}
}
},
created () {
let params = getURLParams()
this.type = params.type
if (this.supportedTypes.indexOf(this.type) === -1) {
this.error = 'invalid_type'
}
this.id = params.id
if (!this.id) {
this.error = 'invalid_id'
}
if (this.error) {
this.isLoading = false
return
}
if (!!params.instance) {
this.baseUrl = params.instance
}
this.fetch(this.type, this.id)
},
mounted () {
var parser = document.createElement('a')
parser.href = this.baseUrl
this.url = parser
},
computed: {
currentTrack () {
if (this.tracks.length === 0) {
return null
}
return this.tracks[this.currentIndex]
},
currentTheme () {
return this.themes[this.theme]
},
controls () {
return [
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'mute', // Toggle mute
'volume', // Volume control
]
},
hasPrevious () {
return this.currentIndex > 0
},
hasNext () {
return this.currentIndex < this.tracks.length - 1
},
},
methods: {
next () {
if (this.hasNext) {
this.play(this.currentIndex + 1)
}
},
previous () {
if (this.hasPrevious) {
this.play(this.currentIndex - 1)
}
},
setControlFocus(event, enable) {
if (enable) {
event.target.classList.add("plyr__tab-focus");
} else {
event.target.classList.remove("plyr__tab-focus");
}
},
fetch (type, id) {
if (type === 'track') {
this.fetchTrack(id)
}
if (type === 'album') {
this.fetchTracks({album: id, playable: true})
}
},
play (index) {
this.currentIndex = index
let self = this
this.$nextTick(() => {
self.$refs.player.player.play()
})
},
fetchTrack (id) {
let self = this
let url = `${this.baseUrl}/api/v1/tracks/${id}/`
axios.get(url).then(response => {
self.tracks = self.parseTracks([response.data])
self.isLoading = false;
}).catch(error => {
if (error.response) {
console.log(error.response)
if (error.response.status === 404) {
self.error = 'server_not_found'
}
else if (error.response.status === 403) {
self.error = 'server_requires_auth'
}
else if (error.response.status === 500) {
self.error = 'server_error'
}
else {
self.error = 'server_unknown_error'
}
} else {
self.error = 'server_unknown_error'
}
self.isLoading = false;
})
},
fetchTracks (filters) {
let self = this
let url = `${this.baseUrl}/api/v1/tracks/`
axios.get(url, {params: filters}).then(response => {
self.tracks = self.parseTracks(response.data.results)
self.isLoading = false;
}).catch(error => {
if (error.response) {
console.log(error.response)
if (error.response.status === 404) {
self.error = 'server_not_found'
}
else if (error.response.status === 403) {
self.error = 'server_requires_auth'
}
else if (error.response.status === 500) {
self.error = 'server_error'
}
else {
self.error = 'server_unknown_error'
}
} else {
self.error = 'server_unknown_error'
}
self.isLoading = false;
})
},
parseTracks (tracks) {
let self = this
return tracks.map(t => {
return {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
cover: self.getCover(t.album.cover),
sources: self.getSources(t.uploads)
}
})
},
bindEvents () {
let self = this
this.$refs.player.player.on('ended', () => {
self.next()
})
},
fullUrl (path) {
if (path.startsWith('/')) {
return this.baseUrl + path
}
return path
},
getCover(albumCover) {
if (albumCover) {
return albumCover.medium_square_crop
}
},
getSources (uploads) {
let self = this;
let sources = uploads.map(u => {
return {
type: u.mimetype,
src: self.fullUrl(u.listen_url),
duration: u.duration
}
})
if (sources.length > 0) {
// We always add a transcoded MP3 src at the end
// because transcoding is expensive, but we want browsers that do
// not support other codecs to be able to play it :)
sources.push({
type: 'audio/mpeg',
src: url.updateQueryString(
self.fullUrl(sources[0].src),
'to',
'mp3'
)
})
}
return sources
}
},
watch: {
currentIndex (v) {
// we bind player events
let self = this
this.$nextTick(() => {
self.bindEvents()
if (self.tracks.length > 0) {
var topPos = document.getElementById(`queue-item-${v}`).offsetTop;
document.getElementById('queue').scrollTop = topPos-10;
}
})
},
tracks () {
this.currentIndex = 0
}
}
}
</script>
<style lang="scss">
html,
body,
main {
height: 100%;
}
body {
margin: 0;
font-family: sans-serif;
}
main {
display: flex;
flex-direction: column;
}
article {
display: flex;
position: relative;
aside {
padding: 0.5em;
}
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
section.controls {
display: flex;
}
.cover {
max-width: 120px;
max-height: 120px;
}
.player {
flex: 1;
align-self: flex-end;
}
article .content {
flex: 1;
display: flex;
flex-direction: column;
h3 {
margin: 0 0 0.5em;
}
header {
flex: 1;
padding: 1em;
}
}
.player,
.queue-controls {
padding: 0.25em 0;
margin-right: 0.25em;
align-self: center;
}
section .plyr--audio .plyr__controls {
padding: 0;
}
.error {
font-weight: bold;
display: block;
text-align: center;
}
.logo-wrapper {
height: 2em;
width: 2em;
padding: 0.25em;
margin-left: 0.5em;
display: block;
}
[role="button"] {
cursor: pointer;
}
.ellipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.queue-wrapper {
flex: 1;
overflow-y: auto;
padding: 0.5em;
}
.queue {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 0.5em;
td {
padding: 0.5em;
font-size: 90%;
img {
vertical-align: middle;
margin-right: 1em;
}
}
td:last-child {
text-align: right;
}
.position {
padding: 0.1em 0.3em;
display: inline-block;
}
}
@media screen and (max-width: 640px) {
.queue .album {
display: none;
}
.plyr__controls .plyr__time {
display: none;
}
}
@media screen and (max-width: 460px) {
article,
article .content {
display: block;
}
.cover.main {
float: right;
img {
height: 60px;
width: 60px;
}
}
}
@media screen and (max-width: 320px) {
.logo-wrapper,
.position-cell {
display: none;
}
}
// themes
.dark {
$primary-color: rgb(242, 113, 28);
$dark: rgb(27, 28, 29);
$lighter: rgb(47, 48, 48);
$clear: rgb(242, 242, 242);
// $primary-color: rgb(255, 88, 78);
.logo-wrapper {
background-color: $primary-color;
}
.plyr--audio .plyr__control.plyr__tab-focus,
.plyr--audio .plyr__control:hover,
.plyr--audio .plyr__control[aria-expanded="true"] {
background-color: $primary-color;
}
.plyr--audio .plyr__control.plyr__tab-focus,
.plyr--audio .plyr__control:hover,
.plyr--audio .plyr__control[aria-expanded="true"] {
background-color: $primary-color;
}
.plyr--full-ui input[type="range"] {
color: $primary-color;
}
article,
.player,
.plyr--audio .plyr__controls {
background-color: $dark;
}
.queue-wrapper {
background-color: $lighter;
}
article,
article a,
.player,
.queue tr,
.plyr--audio .plyr__controls {
color: white;
}
.plyr__control.plyr__tab-focus {
-webkit-box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5);
box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5);
outline: 0;
}
tr:hover,
tr:focus {
background-color: $dark;
}
tr.active {
background-color: $clear;
color: $dark;
}
tr.active {
.position {
background-color: $primary-color;
color: $clear;
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -3,16 +3,16 @@
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
<g>
<g>
<path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
<path :fill="fill" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
<path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
<path :fill="fill" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
/>
<path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
<path :fill="fill" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
</g>
<path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
<path :fill="fill" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
@ -24,10 +24,8 @@
<script>
export default {
props: {
fill: {type: String, default: '#222222'}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,80 @@
<template>
<div>
<div class="ui form">
<div class="two fields">
<div class="field">
<div class="field">
<label for="embed-width"><translate>Widget width</translate></label>
<p><translate>Leave empty for a responsive widget</translate></p>
<input id="embed-width" type="number" v-model.number="width" min="0" step="10" />
</div>
<template v-if="type != 'track'">
<br>
<div class="field">
<label for="embed-height"><translate>Widget height</translate></label>
<input id="embed-height" type="number" v-model="height" :min="minHeight" max="1000" step="10" />
</div>
</template>
</div>
<div class="field">
<button @click="copy" class="ui right floated button"><translate>Copy</translate></button>
<label for="embed-width"><translate>Embed code</translate></label>
<p><translate>Copy/paste this code in your website HTML</translate></p>
<div class="ui hidden divider"></div>
<textarea ref="textarea":value="embedCode" rows="3" readonly>
</textarea>
</div>
</div>
</div>
<div class="preview">
<h3><translate>Preview</translate></h3>
<iframe :width="frameWidth" :height="height" scrolling="no" frameborder="no" :src="iframeSrc"></iframe>
</div>
</div>
</template>
<script>
export default {
props: ['type', 'id'],
data () {
let d = {
width: null,
height: 150,
minHeight: 100
}
if (this.type === 'album') {
d.height = 330
d.minHeight = 250
}
return d
},
computed: {
iframeSrc () {
return this.$store.getters['instance/absoluteUrl'](
`/front/embed.html?&type=${this.type}&id=${this.id}`
)
},
frameWidth () {
if (this.width) {
return this.width
}
return '100%'
},
embedCode () {
let src = this.iframeSrc.replace(/&/g, '&amp;')
return `<iframe width="${this.frameWidth}" height="${this.height}" scrolling="no" frameborder="no" src="${src}"></iframe>`
}
},
methods: {
copy () {
this.$refs.textarea.select()
document.execCommand("Copy")
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -62,6 +62,7 @@ export default {
self.nextPage = response.data.next
self.isLoading = false
self.libraries = response.data.results
self.$emit('loaded', self.libraries)
}, error => {
self.isLoading = false
self.errors = error.backendErrors

View File

@ -37,6 +37,30 @@
<i class="external icon"></i>
<translate>View on MusicBrainz</translate>
</a>
<template v-if="publicLibraries.length > 0">
<button
@click="showEmbedModal = !showEmbedModal"
class="ui button">
<i class="code icon"></i>
<translate>Embed</translate>
</button>
<modal :show.sync="showEmbedModal">
<div class="header">
<translate>Embed this album on your website</translate>
</div>
<div class="content">
<div class="description">
<embed-wizard type="album" :id="album.id" />
</div>
</div>
<div class="actions">
<div class="ui deny button">
<translate>Cancel</translate>
</div>
</div>
</modal>
</template>
</div>
</section>
<template v-if="discs && discs.length > 1">
@ -64,7 +88,7 @@
<h2>
<translate>User libraries</translate>
</h2>
<library-widget :url="'albums/' + id + '/libraries/'">
<library-widget @loaded="libraries = $event" :url="'albums/' + id + '/libraries/'">
<translate slot="subtitle">This album is present in the following libraries:</translate>
</library-widget>
</section>
@ -79,6 +103,8 @@ import backend from "@/audio/backend"
import PlayButton from "@/components/audio/PlayButton"
import TrackTable from "@/components/audio/track/Table"
import LibraryWidget from "@/components/federation/LibraryWidget"
import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal'
const FETCH_URL = "albums/"
@ -98,13 +124,17 @@ export default {
components: {
PlayButton,
TrackTable,
LibraryWidget
LibraryWidget,
EmbedWizard,
Modal
},
data() {
return {
isLoading: true,
album: null,
discs: []
discs: [],
libraries: [],
showEmbedModal: false
}
},
created() {
@ -129,6 +159,11 @@ export default {
title: this.$gettext("Album")
}
},
publicLibraries () {
return this.libraries.filter(l => {
return l.privacy_level === 'everyone'
})
},
wikipediaUrl() {
return (
"https://en.wikipedia.org/w/index.php?search=" +

View File

@ -55,6 +55,30 @@
<i class="download icon"></i>
<translate>Download</translate>
</a>
<template v-if="publicLibraries.length > 0">
<button
@click="showEmbedModal = !showEmbedModal"
class="ui button">
<i class="code icon"></i>
<translate>Embed</translate>
</button>
<modal :show.sync="showEmbedModal">
<div class="header">
<translate>Embed this track on your website</translate>
</div>
<div class="content">
<div class="description">
<embed-wizard type="track" :id="track.id" />
</div>
</div>
<div class="actions">
<div class="ui deny button">
<translate>Cancel</translate>
</div>
</div>
</modal>
</template>
</div>
</section>
<section class="ui vertical stripe center aligned segment">
@ -144,7 +168,7 @@
<h2>
<translate>User libraries</translate>
</h2>
<library-widget :url="'tracks/' + id + '/libraries/'">
<library-widget @loaded="libraries = $event" :url="'tracks/' + id + '/libraries/'">
<translate slot="subtitle">This track is present in the following libraries:</translate>
</library-widget>
</section>
@ -162,6 +186,7 @@ import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
import LibraryWidget from "@/components/federation/LibraryWidget"
import Modal from '@/components/semantic/Modal'
import EmbedWizard from "@/components/audio/EmbedWizard"
const FETCH_URL = "tracks/"
@ -172,7 +197,8 @@ export default {
TrackPlaylistIcon,
TrackFavoriteIcon,
LibraryWidget,
Modal
Modal,
EmbedWizard
},
data() {
return {
@ -181,7 +207,9 @@ export default {
isLoadingLyrics: true,
track: null,
lyrics: null,
licenseData: null
licenseData: null,
libraries: [],
showEmbedModal: false
}
},
created() {
@ -224,6 +252,11 @@ export default {
}
},
computed: {
publicLibraries () {
return this.libraries.filter(l => {
return l.privacy_level === 'everyone'
})
},
labels() {
return {
title: this.$gettext("Track")

16
front/src/embed.js Normal file
View File

@ -0,0 +1,16 @@
import Vue from 'vue'
import Embed from './Embed'
import axios from 'axios'
import VuePlyr from 'vue-plyr'
Vue.use(VuePlyr)
Vue.config.productionTip = false
/* eslint-disable no-new */
new Vue({
el: '#app',
template: '<Embed/>',
components: { Embed }
})

View File

@ -12,5 +12,13 @@ export default {
min = Math.floor(sec / 60)
sec = sec - min * 60
return pad(min) + ':' + pad(sec)
},
durationFormatted (v) {
let duration = parseInt(v)
if (duration % 1 !== 0) {
return time.parse(0)
}
duration = Math.round(duration)
return this.parse(duration)
}
}

View File

@ -1,5 +1,21 @@
module.exports = {
baseUrl: '/front/',
pages: {
embed: {
entry: 'src/embed.js',
template: 'public/embed.html',
filename: 'embed.html',
},
index: {
entry: 'src/main.js',
template: 'public/index.html',
filename: 'index.html'
}
},
chainWebpack: config => {
config.optimization.delete('splitChunks')
},
configureWebpack: {
resolve: {
alias: {
@ -9,33 +25,7 @@ module.exports = {
},
devServer: {
disableHostCheck: true,
proxy: {
'^/rest': {
target: 'http://nginx:6001',
changeOrigin: true,
},
'^/staticfiles': {
target: 'http://nginx:6001',
changeOrigin: true,
},
'^/.well-known': {
target: 'http://nginx:6001',
changeOrigin: true,
},
'^/media': {
target: 'http://nginx:6001',
changeOrigin: true,
},
'^/federation': {
target: 'http://nginx:6001',
changeOrigin: true,
ws: true,
},
'^/api': {
target: 'http://nginx:6001',
changeOrigin: true,
ws: true,
},
}
// use https://node1.funkwhale.test/front-server/ if you use docker with federation
public: process.env.FRONT_DEVSERVER_URL || ('http://localhost:' + (process.env.VUE_PORT || '8080'))
}
}

View File

@ -2168,6 +2168,11 @@ core-js@^2.4.0, core-js@^2.5.3:
version "2.5.7"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
core-js@^2.5.7:
version "2.6.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.0.tgz#1e30793e9ee5782b307e37ffa22da0eacddd84d4"
integrity sha512-kLRC6ncVpuEW/1kwrOXYX6KQASCVtrh1gQr/UiaVgFlf9WE5Vp+lNe5+h3LuMr5PAucWnnEXwH0nQHRH/gpGtw==
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -2430,6 +2435,11 @@ currently-unhandled@^0.4.1:
dependencies:
array-find-index "^1.0.1"
custom-event-polyfill@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-1.0.6.tgz#6b026e81cd9f7bc896bd6b016a427407bb068db1"
integrity sha512-3FxpFlzGcHrDykwWu+xWVXZ8PfykM/9/bI3zXb953sh+AjInZWcQmrnmvPoZgiqNjmbtTm10PWvYqvRW527x6g==
cyclist@~0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
@ -4602,6 +4612,11 @@ loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
emojis-list "^2.0.0"
json5 "^0.5.0"
loadjs@^3.5.4:
version "3.5.5"
resolved "https://registry.yarnpkg.com/loadjs/-/loadjs-3.5.5.tgz#2fbaa981ffdd079e0f8786ea75aeed643483b368"
integrity sha512-qBuLnKt4C6+vctutozFqPHQ6s4SSa9tcE64NsvDJ92UZmUrFvqGI1oVOtnZz2xwpgOT+2niQtHtQIDP4e/wlTA==
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@ -5677,6 +5692,17 @@ pluralize@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
plyr@^3.4.5:
version "3.4.7"
resolved "https://registry.yarnpkg.com/plyr/-/plyr-3.4.7.tgz#7d92470fb27f8019422c6d4edfd3b172d902ef06"
integrity sha512-RxxT2WdC4/sEZQT7CBZqKx5ImVw96aWjT6kB6DM82jy9GcWDiBBnv04m/AeeaXg9S5ambPdiHhB6Pzfm2q84Gw==
dependencies:
core-js "^2.5.7"
custom-event-polyfill "^1.0.6"
loadjs "^3.5.4"
raven-js "^3.27.0"
url-polyfill "^1.1.0"
pn@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
@ -6246,6 +6272,11 @@ raven-js@^3.26.4:
version "3.26.4"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.26.4.tgz#32aae3a63a9314467a453c94c89a364ea43707be"
raven-js@^3.27.0:
version "3.27.0"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.27.0.tgz#9f47c03e17933ce756e189f3669d49c441c1ba6e"
integrity sha512-vChdOL+yzecfnGA+B5EhEZkJ3kY3KlMzxEhShKh6Vdtooyl0yZfYNFQfYzgMf2v4pyQa+OTZ5esTxxgOOZDHqw==
raw-body@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
@ -7558,6 +7589,11 @@ url-parse@^1.1.8, url-parse@^1.4.3:
querystringify "^2.0.0"
requires-port "^1.0.0"
url-polyfill@^1.1.0:
version "1.1.3"
resolved "https://registry.yarnpkg.com/url-polyfill/-/url-polyfill-1.1.3.tgz#ce0bdf2e923aa6f66bc198ab776323dfc5a91e62"
integrity sha512-xIAXc0DyXJCd767sSeRu4eqisyYhR0z0sohWArCn+WPwIatD39xGrc09l+tluIUi6jGkpGa8Gz8TKwkKYxMQvQ==
url@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@ -7682,6 +7718,13 @@ vue-masonry@^0.11.5:
masonry-layout "4.2.0"
vue "^2.0.0"
vue-plyr@^5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/vue-plyr/-/vue-plyr-5.0.4.tgz#13083b71a876d01200a3c93ebfd11585b671afda"
integrity sha512-zOLD7SZiYR/8DPYkZZR9zGTV+04GAc+fhnBymAWSRryncAG4889cYxXJSbIvlsNVGpdGRIOSIZ4p6pIupfmZ5w==
dependencies:
plyr "^3.4.5"
vue-router@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"