Merge branch 'embed' into 'develop'
Fix #578: iframe embed Closes #578 See merge request funkwhale/funkwhale!496
This commit is contained in:
commit
24b8cb66df
|
@ -31,6 +31,7 @@ subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic"
|
||||||
|
|
||||||
|
|
||||||
v1_patterns += [
|
v1_patterns += [
|
||||||
|
url(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
|
||||||
url(
|
url(
|
||||||
r"^instance/",
|
r"^instance/",
|
||||||
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
|
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
|
||||||
|
|
|
@ -70,7 +70,16 @@ else:
|
||||||
FUNKWHALE_PROTOCOL = _parsed.scheme
|
FUNKWHALE_PROTOCOL = _parsed.scheme
|
||||||
|
|
||||||
FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
|
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
|
# XXX: deprecated, see #186
|
||||||
FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
|
FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
|
||||||
|
@ -159,7 +168,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
# MIDDLEWARE CONFIGURATION
|
# MIDDLEWARE CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
MIDDLEWARE = (
|
MIDDLEWARE = (
|
||||||
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
|
"funkwhale_api.common.middleware.SPAFallbackMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
@ -305,6 +314,7 @@ FILE_UPLOAD_PERMISSIONS = 0o644
|
||||||
# URL Configuration
|
# URL Configuration
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
|
SPA_URLCONF = "config.spa_urls"
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
ASGI_APPLICATION = "config.routing.application"
|
ASGI_APPLICATION = "config.routing.application"
|
||||||
|
@ -400,7 +410,13 @@ if AUTH_LDAP_ENABLED:
|
||||||
AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
|
AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
|
||||||
|
|
||||||
CACHE_DEFAULT = "redis://127.0.0.1:6379/0"
|
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"
|
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
|
@ -3,9 +3,12 @@ from django.utils.deconstruct import deconstructible
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django import urls
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
|
||||||
|
@ -107,3 +110,32 @@ def chunk_queryset(source_qs, chunk_size):
|
||||||
|
|
||||||
if nb_items < chunk_size:
|
if nb_items < chunk_size:
|
||||||
return
|
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]
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django import urls
|
||||||
|
from django.conf import settings
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
from versatileimagefield.serializers import VersatileImageFieldSerializer
|
from versatileimagefield.serializers import VersatileImageFieldSerializer
|
||||||
|
|
||||||
from funkwhale_api.activity import serializers as activity_serializers
|
from funkwhale_api.activity import serializers as activity_serializers
|
||||||
from funkwhale_api.common import serializers as common_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.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 . import filters, models, tasks
|
from . import filters, models, tasks
|
||||||
|
|
||||||
|
@ -380,3 +386,98 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
|
|
||||||
def get_type(self, obj):
|
def get_type(self, obj):
|
||||||
return "Audio"
|
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
|
||||||
|
|
|
@ -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
|
|
@ -508,3 +508,13 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
first_arg = [i.conf for i in instance_or_qs if i.conf]
|
first_arg = [i.conf for i in instance_or_qs if i.conf]
|
||||||
return super().get_serializer(*((first_arg,) + args[1:]), **kwargs)
|
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)
|
||||||
|
|
|
@ -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)
|
|
@ -13,7 +13,7 @@ import factory
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
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.core.files import uploadedfile
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.test import client
|
from django.test import client
|
||||||
|
@ -100,6 +100,12 @@ def cache():
|
||||||
django_cache.clear()
|
django_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def local_cache():
|
||||||
|
yield caches["local"]
|
||||||
|
caches["local"].clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def factories(db):
|
def factories(db):
|
||||||
"""
|
"""
|
||||||
|
@ -382,3 +388,15 @@ def temp_signal(mocker):
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def stdout():
|
def stdout():
|
||||||
yield io.StringIO()
|
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
|
||||||
|
|
|
@ -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
|
|
@ -6,8 +6,10 @@ import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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 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__))
|
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)
|
response = api_client.get(url)
|
||||||
|
|
||||||
assert response.data == expected
|
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
|
||||||
|
|
|
@ -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``.
|
|
@ -24,17 +24,14 @@ server {
|
||||||
root /frontend;
|
root /frontend;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ @rewrites;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @rewrites {
|
|
||||||
rewrite ^(.+)$ /index.html last;
|
|
||||||
}
|
|
||||||
location /api/ {
|
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
# this is needed if you have file import via upload enabled
|
# this is needed if you have file import via upload enabled
|
||||||
client_max_body_size ${NGINX_MAX_BODY_SIZE};
|
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/ {
|
location /federation/ {
|
||||||
|
|
|
@ -44,17 +44,14 @@ server {
|
||||||
root ${FUNKWHALE_FRONTEND_PATH};
|
root ${FUNKWHALE_FRONTEND_PATH};
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ @rewrites;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @rewrites {
|
|
||||||
rewrite ^(.+)$ /index.html last;
|
|
||||||
}
|
|
||||||
location /api/ {
|
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
# this is needed if you have file import via upload enabled
|
# this is needed if you have file import via upload enabled
|
||||||
client_max_body_size ${NGINX_MAX_BODY_SIZE};
|
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/ {
|
location /federation/ {
|
||||||
|
|
42
dev.yml
42
dev.yml
|
@ -10,20 +10,13 @@ services:
|
||||||
- "HOST=0.0.0.0"
|
- "HOST=0.0.0.0"
|
||||||
- "VUE_PORT=${VUE_PORT-8080}"
|
- "VUE_PORT=${VUE_PORT-8080}"
|
||||||
ports:
|
ports:
|
||||||
- "${VUE_PORT-8080}:${VUE_PORT-8080}"
|
- "${VUE_PORT-8080}"
|
||||||
volumes:
|
volumes:
|
||||||
- "./front:/app"
|
- "./front:/app"
|
||||||
- "/app/node_modules"
|
- "/app/node_modules"
|
||||||
- "./po:/po"
|
- "./po:/po"
|
||||||
networks:
|
networks:
|
||||||
- federation
|
|
||||||
- internal
|
- 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:
|
postgres:
|
||||||
env_file:
|
env_file:
|
||||||
|
@ -66,7 +59,7 @@ services:
|
||||||
- "CACHE_URL=redis://redis:6379/0"
|
- "CACHE_URL=redis://redis:6379/0"
|
||||||
volumes:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
- "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
api:
|
api:
|
||||||
|
@ -76,10 +69,10 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
dockerfile: docker/Dockerfile.test
|
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:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
- "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
|
||||||
environment:
|
environment:
|
||||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||||
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
||||||
|
@ -99,22 +92,35 @@ services:
|
||||||
- .env
|
- .env
|
||||||
image: nginx
|
image: nginx
|
||||||
environment:
|
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- }"
|
- "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME- }"
|
||||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||||
links:
|
links:
|
||||||
- api
|
- api
|
||||||
- front
|
- front
|
||||||
volumes:
|
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
|
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
|
||||||
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
- "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
|
||||||
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
|
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
|
||||||
- ./api/funkwhale_api/media:/protected/media
|
- "${MEDIA_ROOT-./api/funkwhale_api/media}:/protected/media:ro"
|
||||||
ports:
|
|
||||||
- "6001"
|
|
||||||
networks:
|
networks:
|
||||||
|
- federation
|
||||||
- internal
|
- 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:
|
docs:
|
||||||
build: docs
|
build: docs
|
||||||
command: python serve.py
|
command: python serve.py
|
||||||
|
|
|
@ -32,26 +32,57 @@ http {
|
||||||
'' close;
|
'' close;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream funkwhale-api {
|
||||||
|
server ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT};
|
||||||
|
}
|
||||||
|
upstream funkwhale-front {
|
||||||
|
server ${FUNKWHALE_FRONT_IP}:${FUNKWHALE_FRONT_PORT};
|
||||||
|
}
|
||||||
server {
|
server {
|
||||||
listen 6001;
|
listen 80;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
client_max_body_size 30M;
|
client_max_body_size 30M;
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
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 {
|
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;
|
internal;
|
||||||
alias /protected/media;
|
alias /protected/media;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /_protected/music {
|
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;
|
internal;
|
||||||
alias /music;
|
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/;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,8 @@
|
||||||
#!/bin/bash -eux
|
#!/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
|
envsubst "`env | awk -F = '{printf \" $$%s\", $$1}'`" \
|
||||||
nginx -g "daemon off;"
|
< /etc/nginx/nginx.conf.template \
|
||||||
|
> /etc/nginx/nginx.conf \
|
||||||
|
&& cat /etc/nginx/nginx.conf \
|
||||||
|
&& nginx-debug -g 'daemon off;'
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"vue-gettext": "^2.1.0",
|
"vue-gettext": "^2.1.0",
|
||||||
"vue-lazyload": "^1.2.6",
|
"vue-lazyload": "^1.2.6",
|
||||||
"vue-masonry": "^0.11.5",
|
"vue-masonry": "^0.11.5",
|
||||||
|
"vue-plyr": "^5.0.4",
|
||||||
"vue-router": "^3.0.1",
|
"vue-router": "^3.0.1",
|
||||||
"vue-upload-component": "^2.8.11",
|
"vue-upload-component": "^2.8.11",
|
||||||
"vuedraggable": "^2.16.0",
|
"vuedraggable": "^2.16.0",
|
||||||
|
|
|
@ -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>
|
|
@ -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 |
|
@ -3,16 +3,16 @@
|
||||||
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
|
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
|
||||||
<g>
|
<g>
|
||||||
<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"/>
|
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"
|
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
|
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"/>
|
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
|
||||||
</g>
|
</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
|
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
|
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"/>
|
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>
|
<script>
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
props: {
|
||||||
|
fill: {type: String, default: '#222222'}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped lang="scss">
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -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, '&')
|
||||||
|
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>
|
|
@ -62,6 +62,7 @@ export default {
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.libraries = response.data.results
|
self.libraries = response.data.results
|
||||||
|
self.$emit('loaded', self.libraries)
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.errors = error.backendErrors
|
self.errors = error.backendErrors
|
||||||
|
|
|
@ -37,6 +37,30 @@
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate>View on MusicBrainz</translate>
|
<translate>View on MusicBrainz</translate>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<template v-if="discs && discs.length > 1">
|
<template v-if="discs && discs.length > 1">
|
||||||
|
@ -64,7 +88,7 @@
|
||||||
<h2>
|
<h2>
|
||||||
<translate>User libraries</translate>
|
<translate>User libraries</translate>
|
||||||
</h2>
|
</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>
|
<translate slot="subtitle">This album is present in the following libraries:</translate>
|
||||||
</library-widget>
|
</library-widget>
|
||||||
</section>
|
</section>
|
||||||
|
@ -79,6 +103,8 @@ import backend from "@/audio/backend"
|
||||||
import PlayButton from "@/components/audio/PlayButton"
|
import PlayButton from "@/components/audio/PlayButton"
|
||||||
import TrackTable from "@/components/audio/track/Table"
|
import TrackTable from "@/components/audio/track/Table"
|
||||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||||
|
import EmbedWizard from "@/components/audio/EmbedWizard"
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
const FETCH_URL = "albums/"
|
const FETCH_URL = "albums/"
|
||||||
|
|
||||||
|
@ -98,13 +124,17 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
PlayButton,
|
PlayButton,
|
||||||
TrackTable,
|
TrackTable,
|
||||||
LibraryWidget
|
LibraryWidget,
|
||||||
|
EmbedWizard,
|
||||||
|
Modal
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
album: null,
|
album: null,
|
||||||
discs: []
|
discs: [],
|
||||||
|
libraries: [],
|
||||||
|
showEmbedModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
@ -129,6 +159,11 @@ export default {
|
||||||
title: this.$gettext("Album")
|
title: this.$gettext("Album")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
publicLibraries () {
|
||||||
|
return this.libraries.filter(l => {
|
||||||
|
return l.privacy_level === 'everyone'
|
||||||
|
})
|
||||||
|
},
|
||||||
wikipediaUrl() {
|
wikipediaUrl() {
|
||||||
return (
|
return (
|
||||||
"https://en.wikipedia.org/w/index.php?search=" +
|
"https://en.wikipedia.org/w/index.php?search=" +
|
||||||
|
|
|
@ -55,6 +55,30 @@
|
||||||
<i class="download icon"></i>
|
<i class="download icon"></i>
|
||||||
<translate>Download</translate>
|
<translate>Download</translate>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="ui vertical stripe center aligned segment">
|
<section class="ui vertical stripe center aligned segment">
|
||||||
|
@ -144,7 +168,7 @@
|
||||||
<h2>
|
<h2>
|
||||||
<translate>User libraries</translate>
|
<translate>User libraries</translate>
|
||||||
</h2>
|
</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>
|
<translate slot="subtitle">This track is present in the following libraries:</translate>
|
||||||
</library-widget>
|
</library-widget>
|
||||||
</section>
|
</section>
|
||||||
|
@ -162,6 +186,7 @@ import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
||||||
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
||||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||||
import Modal from '@/components/semantic/Modal'
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
import EmbedWizard from "@/components/audio/EmbedWizard"
|
||||||
|
|
||||||
const FETCH_URL = "tracks/"
|
const FETCH_URL = "tracks/"
|
||||||
|
|
||||||
|
@ -172,7 +197,8 @@ export default {
|
||||||
TrackPlaylistIcon,
|
TrackPlaylistIcon,
|
||||||
TrackFavoriteIcon,
|
TrackFavoriteIcon,
|
||||||
LibraryWidget,
|
LibraryWidget,
|
||||||
Modal
|
Modal,
|
||||||
|
EmbedWizard
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -181,7 +207,9 @@ export default {
|
||||||
isLoadingLyrics: true,
|
isLoadingLyrics: true,
|
||||||
track: null,
|
track: null,
|
||||||
lyrics: null,
|
lyrics: null,
|
||||||
licenseData: null
|
licenseData: null,
|
||||||
|
libraries: [],
|
||||||
|
showEmbedModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
@ -224,6 +252,11 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
publicLibraries () {
|
||||||
|
return this.libraries.filter(l => {
|
||||||
|
return l.privacy_level === 'everyone'
|
||||||
|
})
|
||||||
|
},
|
||||||
labels() {
|
labels() {
|
||||||
return {
|
return {
|
||||||
title: this.$gettext("Track")
|
title: this.$gettext("Track")
|
||||||
|
|
|
@ -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 }
|
||||||
|
})
|
|
@ -12,5 +12,13 @@ export default {
|
||||||
min = Math.floor(sec / 60)
|
min = Math.floor(sec / 60)
|
||||||
sec = sec - min * 60
|
sec = sec - min * 60
|
||||||
return pad(min) + ':' + pad(sec)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,21 @@
|
||||||
|
|
||||||
module.exports = {
|
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: {
|
configureWebpack: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
@ -9,33 +25,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
disableHostCheck: true,
|
disableHostCheck: true,
|
||||||
proxy: {
|
// use https://node1.funkwhale.test/front-server/ if you use docker with federation
|
||||||
'^/rest': {
|
public: process.env.FRONT_DEVSERVER_URL || ('http://localhost:' + (process.env.VUE_PORT || '8080'))
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2168,6 +2168,11 @@ core-js@^2.4.0, core-js@^2.5.3:
|
||||||
version "2.5.7"
|
version "2.5.7"
|
||||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
|
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:
|
core-util-is@1.0.2, core-util-is@~1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
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:
|
dependencies:
|
||||||
array-find-index "^1.0.1"
|
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:
|
cyclist@~0.2.2:
|
||||||
version "0.2.2"
|
version "0.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
|
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"
|
emojis-list "^2.0.0"
|
||||||
json5 "^0.5.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:
|
locate-path@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
|
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"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
|
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:
|
pn@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
|
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"
|
version "3.26.4"
|
||||||
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.26.4.tgz#32aae3a63a9314467a453c94c89a364ea43707be"
|
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:
|
raw-body@2.3.2:
|
||||||
version "2.3.2"
|
version "2.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
|
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"
|
querystringify "^2.0.0"
|
||||||
requires-port "^1.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:
|
url@^0.11.0:
|
||||||
version "0.11.0"
|
version "0.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
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"
|
masonry-layout "4.2.0"
|
||||||
vue "^2.0.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:
|
vue-router@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
|
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
|
||||||
|
|
Loading…
Reference in New Issue