diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 16f21d3465c0a683e5885063e07b8544e6712da9..e6eeff31dd1a5c59f149f5c3a9c20d391f50929f 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -31,6 +31,7 @@ subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic" v1_patterns += [ + url(r"^oembed/$", views.OembedView.as_view(), name="oembed"), url( r"^instance/", include(("funkwhale_api.instance.urls", "instance"), namespace="instance"), diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 6a9501c0a298cad81481d9a5e773481f5344eb55..16efef696211f4fecddd19f5cfd309548fd6c9b0 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -70,7 +70,16 @@ else: FUNKWHALE_PROTOCOL = _parsed.scheme FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) - +FUNKWHALE_SPA_HTML_ROOT = env( + "FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/" +) +FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int( + "FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15 +) +FUNKWHALE_EMBED_URL = env( + "FUNKWHALE_EMBED_URL", default=FUNKWHALE_SPA_HTML_ROOT + "embed.html" +) +APP_NAME = "Funkwhale" # XXX: deprecated, see #186 FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True) @@ -159,7 +168,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS # MIDDLEWARE CONFIGURATION # ------------------------------------------------------------------------------ MIDDLEWARE = ( - # Make sure djangosecure.middleware.SecurityMiddleware is listed first + "funkwhale_api.common.middleware.SPAFallbackMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", @@ -305,6 +314,7 @@ FILE_UPLOAD_PERMISSIONS = 0o644 # URL Configuration # ------------------------------------------------------------------------------ ROOT_URLCONF = "config.urls" +SPA_URLCONF = "config.spa_urls" # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application WSGI_APPLICATION = "config.wsgi.application" ASGI_APPLICATION = "config.routing.application" @@ -400,7 +410,13 @@ if AUTH_LDAP_ENABLED: AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify" CACHE_DEFAULT = "redis://127.0.0.1:6379/0" -CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)} +CACHES = { + "default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT), + "local": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "local-cache", + }, +} CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache" diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..071965b048b30ffb9aaf15c80d63cc5249389d10 --- /dev/null +++ b/api/config/spa_urls.py @@ -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", + ), +] diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..090d67c9e5c7a6370379649d1a0c3d4719d9c493 --- /dev/null +++ b/api/funkwhale_api/common/middleware.py @@ -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 diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index deda2f5900e721db633fc52ea70b8da87bf5d6bb..81bc5c02694b934469cb9d665d5d01cb978cd520 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -3,9 +3,12 @@ from django.utils.deconstruct import deconstructible import os import shutil import uuid +import xml.etree.ElementTree as ET from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit +from django.conf import settings +from django import urls from django.db import transaction @@ -107,3 +110,32 @@ def chunk_queryset(source_qs, chunk_size): if nb_items < chunk_size: return + + +def join_url(start, end): + if start.endswith("/") and end.startswith("/"): + return start + end[1:] + + if not start.endswith("/") and not end.startswith("/"): + return start + "/" + end + + return start + end + + +def spa_reverse(name, args=[], kwargs={}): + return urls.reverse(name, urlconf=settings.SPA_URLCONF, args=args, kwargs=kwargs) + + +def spa_resolve(path): + return urls.resolve(path, urlconf=settings.SPA_URLCONF) + + +def parse_meta(html): + # dirty but this is only for testing so we don't really care, + # we convert the html string to xml so it can be parsed as xml + html = '<?xml version="1.0"?>' + html + tree = ET.fromstring(html) + + meta = [elem for elem in tree.iter() if elem.tag in ["meta", "link"]] + + return [dict([("tag", elem.tag)] + list(elem.items())) for elem in meta] diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 6157c5a75d7fb55328762dbefb8446a022b6d997..42e05765426af64d89787d75af4130b14c0d217e 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,12 +1,18 @@ +import urllib.parse + from django.db import transaction +from django import urls +from django.conf import settings from rest_framework import serializers from taggit.models import Tag from versatileimagefield.serializers import VersatileImageFieldSerializer from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import routes +from funkwhale_api.federation import utils as federation_utils from . import filters, models, tasks @@ -380,3 +386,98 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): def get_type(self, obj): return "Audio" + + +class OembedSerializer(serializers.Serializer): + format = serializers.ChoiceField(choices=["json"]) + url = serializers.URLField() + maxheight = serializers.IntegerField(required=False) + maxwidth = serializers.IntegerField(required=False) + + def validate(self, validated_data): + try: + match = common_utils.spa_resolve( + urllib.parse.urlparse(validated_data["url"]).path + ) + except urls.exceptions.Resolver404: + raise serializers.ValidationError( + "Invalid URL {}".format(validated_data["url"]) + ) + data = { + "version": 1.0, + "type": "rich", + "provider_name": "{} - {}".format( + preferences.get("instance__name"), settings.APP_NAME + ), + "provider_url": settings.FUNKWHALE_URL, + "height": validated_data.get("maxheight") or 400, + "width": validated_data.get("maxwidth") or 600, + } + embed_id = None + embed_type = None + if match.url_name == "library_track": + qs = models.Track.objects.select_related("artist", "album__artist").filter( + pk=int(match.kwargs["pk"]) + ) + try: + track = qs.get() + except models.Track.DoesNotExist: + raise serializers.ValidationError( + "No track matching id {}".format(match.kwargs["pk"]) + ) + embed_type = "track" + embed_id = track.pk + data["title"] = "{} by {}".format(track.title, track.artist.name) + if track.album.cover: + data["thumbnail_url"] = federation_utils.full_url( + track.album.cover.crop["400x400"].url + ) + data["description"] = track.full_name + data["author_name"] = track.artist.name + data["height"] = 150 + data["author_url"] = federation_utils.full_url( + common_utils.spa_reverse( + "library_artist", kwargs={"pk": track.artist.pk} + ) + ) + elif match.url_name == "library_album": + qs = models.Album.objects.select_related("artist").filter( + pk=int(match.kwargs["pk"]) + ) + try: + album = qs.get() + except models.Album.DoesNotExist: + raise serializers.ValidationError( + "No album matching id {}".format(match.kwargs["pk"]) + ) + embed_type = "album" + embed_id = album.pk + if album.cover: + data["thumbnail_url"] = federation_utils.full_url( + album.cover.crop["400x400"].url + ) + data["title"] = "{} by {}".format(album.title, album.artist.name) + data["description"] = "{} by {}".format(album.title, album.artist.name) + data["author_name"] = album.artist.name + data["height"] = 400 + data["author_url"] = federation_utils.full_url( + common_utils.spa_reverse( + "library_artist", kwargs={"pk": album.artist.pk} + ) + ) + else: + raise serializers.ValidationError( + "Unsupported url: {}".format(validated_data["url"]) + ) + data[ + "html" + ] = '<iframe width="{}" height="{}" scrolling="no" frameborder="no" src="{}"></iframe>'.format( + data["width"], + data["height"], + settings.FUNKWHALE_EMBED_URL + + "?type={}&id={}".format(embed_type, embed_id), + ) + return data + + def create(self, data): + return data diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py new file mode 100644 index 0000000000000000000000000000000000000000..c1476e8a93c70d94134e02c93a9824677885f2d8 --- /dev/null +++ b/api/funkwhale_api/music/spa_views.py @@ -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 diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 19a00884bbd32de166756fd9aa7760aa1baa8bdb..1fcd782fbb8602f882ec67fc3ffc2d3334b17df8 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -508,3 +508,13 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet): except AttributeError: first_arg = [i.conf for i in instance_or_qs if i.conf] return super().get_serializer(*((first_arg,) + args[1:]), **kwargs) + + +class OembedView(views.APIView): + permission_classes = [common_permissions.ConditionalAuthentication] + + def get(self, request, *args, **kwargs): + serializer = serializers.OembedSerializer(data=request.GET) + serializer.is_valid(raise_exception=True) + embed_data = serializer.save() + return Response(embed_data) diff --git a/api/tests/common/test_middleware.py b/api/tests/common/test_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..2ed875a53100fb216d09ade2af6fceea3bee4ca1 --- /dev/null +++ b/api/tests/common/test_middleware.py @@ -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) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index d880c8d6faa96c7f007f81721f607fe439040dbd..99317303c86aa8f9980e7dda2196fcf3e48906f8 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -13,7 +13,7 @@ import factory import pytest from django.contrib.auth.models import AnonymousUser -from django.core.cache import cache as django_cache +from django.core.cache import cache as django_cache, caches from django.core.files import uploadedfile from django.utils import timezone from django.test import client @@ -100,6 +100,12 @@ def cache(): django_cache.clear() +@pytest.fixture(autouse=True) +def local_cache(): + yield caches["local"] + caches["local"].clear() + + @pytest.fixture def factories(db): """ @@ -382,3 +388,15 @@ def temp_signal(mocker): @pytest.fixture() def stdout(): yield io.StringIO() + + +@pytest.fixture +def spa_html(r_mock, settings): + yield r_mock.get( + settings.FUNKWHALE_SPA_HTML_ROOT + "index.html", text="<head></head>" + ) + + +@pytest.fixture +def no_api_auth(preferences): + preferences["common__api_authentication_required"] = False diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py new file mode 100644 index 0000000000000000000000000000000000000000..a60da50dffb3e9bb7f023e8212a1b3c045dd92bc --- /dev/null +++ b/api/tests/music/test_spa_views.py @@ -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 diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 263b7878472dcd9f53ed94737e3f05ecd79ce7d2..d954787b0e529acd1019af55b00b30abc5fd7e66 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -6,8 +6,10 @@ import pytest from django.urls import reverse from django.utils import timezone -from funkwhale_api.music import licenses, models, serializers, tasks, views +from funkwhale_api.common import utils from funkwhale_api.federation import api_serializers as federation_api_serializers +from funkwhale_api.federation import utils as federation_utils +from funkwhale_api.music import licenses, models, serializers, tasks, views DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -570,3 +572,74 @@ def test_detail_license(api_client, preferences): response = api_client.get(url) assert response.data == expected + + +def test_oembed_track(factories, no_api_auth, api_client, settings, preferences): + settings.FUNKWHALE_URL = "http://test" + settings.FUNKWHALE_EMBED_URL = "http://embed" + preferences["instance__name"] = "Hello" + track = factories["music.Track"]() + url = reverse("api:v1:oembed") + track_url = "https://test.com/library/tracks/{}".format(track.pk) + iframe_src = "http://embed?type=track&id={}".format(track.pk) + expected = { + "version": 1.0, + "type": "rich", + "provider_name": "{} - {}".format( + preferences["instance__name"], settings.APP_NAME + ), + "provider_url": settings.FUNKWHALE_URL, + "height": 150, + "width": 600, + "title": "{} by {}".format(track.title, track.artist.name), + "description": track.full_name, + "thumbnail_url": federation_utils.full_url( + track.album.cover.crop["400x400"].url + ), + "html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format( + iframe_src + ), + "author_name": track.artist.name, + "author_url": federation_utils.full_url( + utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}) + ), + } + + response = api_client.get(url, {"url": track_url, "format": "json"}) + + assert response.data == expected + + +def test_oembed_album(factories, no_api_auth, api_client, settings, preferences): + settings.FUNKWHALE_URL = "http://test" + settings.FUNKWHALE_EMBED_URL = "http://embed" + preferences["instance__name"] = "Hello" + track = factories["music.Track"]() + album = track.album + url = reverse("api:v1:oembed") + album_url = "https://test.com/library/albums/{}".format(album.pk) + iframe_src = "http://embed?type=album&id={}".format(album.pk) + expected = { + "version": 1.0, + "type": "rich", + "provider_name": "{} - {}".format( + preferences["instance__name"], settings.APP_NAME + ), + "provider_url": settings.FUNKWHALE_URL, + "height": 400, + "width": 600, + "title": "{} by {}".format(album.title, album.artist.name), + "description": "{} by {}".format(album.title, album.artist.name), + "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url), + "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format( + iframe_src + ), + "author_name": album.artist.name, + "author_url": federation_utils.full_url( + utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}) + ), + } + + response = api_client.get(url, {"url": album_url, "format": "json"}) + + assert response.data == expected diff --git a/changes/changelog.d/578.feature b/changes/changelog.d/578.feature new file mode 100644 index 0000000000000000000000000000000000000000..9ab4fcf67000c05199d6c817cdab1fbd2fff2cd9 --- /dev/null +++ b/changes/changelog.d/578.feature @@ -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``. diff --git a/deploy/docker.nginx.template b/deploy/docker.nginx.template index d73a1c4b6ebd176aa0ef761affb80ba1c667a2d8..1e2ab0014b74d54e2e7e55db1255aee3c66e6860 100644 --- a/deploy/docker.nginx.template +++ b/deploy/docker.nginx.template @@ -24,17 +24,14 @@ server { root /frontend; location / { - try_files $uri $uri/ @rewrites; - } - - location @rewrites { - rewrite ^(.+)$ /index.html last; - } - location /api/ { include /etc/nginx/funkwhale_proxy.conf; # this is needed if you have file import via upload enabled client_max_body_size ${NGINX_MAX_BODY_SIZE}; - proxy_pass http://funkwhale-api/api/; + proxy_pass http://funkwhale-api/; + } + + location /front/ { + alias /frontend; } location /federation/ { diff --git a/deploy/nginx.template b/deploy/nginx.template index 1eb011d4efc1ff11162863b1e69d21c847422d3a..f6bf610694946ccfa5193afe57acb723cc3452e2 100644 --- a/deploy/nginx.template +++ b/deploy/nginx.template @@ -44,17 +44,14 @@ server { root ${FUNKWHALE_FRONTEND_PATH}; location / { - try_files $uri $uri/ @rewrites; - } - - location @rewrites { - rewrite ^(.+)$ /index.html last; - } - location /api/ { include /etc/nginx/funkwhale_proxy.conf; # this is needed if you have file import via upload enabled client_max_body_size ${NGINX_MAX_BODY_SIZE}; - proxy_pass http://funkwhale-api/api/; + proxy_pass http://funkwhale-api/; + } + + location /front/ { + alias ${FUNKWHALE_FRONTEND_PATH}; } location /federation/ { diff --git a/dev.yml b/dev.yml index a77edb7db2c7987945a9e697a7462d0a71322a8f..39f891a3a88c223b64bd6ca17a5fcc5ed080cf98 100644 --- a/dev.yml +++ b/dev.yml @@ -10,20 +10,13 @@ services: - "HOST=0.0.0.0" - "VUE_PORT=${VUE_PORT-8080}" ports: - - "${VUE_PORT-8080}:${VUE_PORT-8080}" + - "${VUE_PORT-8080}" volumes: - "./front:/app" - "/app/node_modules" - "./po:/po" networks: - - federation - internal - labels: - traefik.backend: "${COMPOSE_PROJECT_NAME-node1}" - traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}" - traefik.enable: "true" - traefik.federation.protocol: "http" - traefik.federation.port: "${VUE_PORT-8080}" postgres: env_file: @@ -66,7 +59,7 @@ services: - "CACHE_URL=redis://redis:6379/0" volumes: - ./api:/app - - "${MUSIC_DIRECTORY-./data/music}:/music:ro" + - "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro" networks: - internal api: @@ -76,10 +69,10 @@ services: build: context: ./api dockerfile: docker/Dockerfile.test - command: python /app/manage.py runserver 0.0.0.0:12081 + command: python /app/manage.py runserver 0.0.0.0:${FUNKWHALE_API_PORT-5000} volumes: - ./api:/app - - "${MUSIC_DIRECTORY-./data/music}:/music:ro" + - "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro" environment: - "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}" - "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test" @@ -99,22 +92,35 @@ services: - .env image: nginx environment: - - "VUE_PORT=${VUE_PORT-8080}" + - "NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE-30M}" + - "FUNKWHALE_API_IP=${FUNKHALE_API_IP-api}" + - "FUNKWHALE_API_PORT=${FUNKWHALE_API_PORT-5000}" + - "FUNKWHALE_FRONT_IP=${FUNKHALE_FRONT_IP-front}" + - "FUNKWHALE_FRONT_PORT=${VUE_PORT-8080}" - "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME- }" - "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}" links: - api - front volumes: - - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf + - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf.template:ro - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro - - "${MUSIC_DIRECTORY-./data/music}:/music:ro" - - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro - - ./api/funkwhale_api/media:/protected/media - ports: - - "6001" + - "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro" + - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro + - "${MEDIA_ROOT-./api/funkwhale_api/media}:/protected/media:ro" networks: + - federation - internal + + labels: + traefik.backend: "${COMPOSE_PROJECT_NAME-node1}" + traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}" + traefik.enable: "true" + traefik.federation.protocol: "http" + traefik.federation.port: "80" + traefik.frontend.passHostHeader: true + traefik.docker.network: federation + docs: build: docs command: python serve.py diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 2ed1a97d540823ab6021df46db677718885c4529..297cfa509663121c2b4589e19b9aaaefb6d48775 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -32,26 +32,57 @@ http { '' close; } + upstream funkwhale-api { + server ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}; + } + upstream funkwhale-front { + server ${FUNKWHALE_FRONT_IP}:${FUNKWHALE_FRONT_PORT}; + } server { - listen 6001; + listen 80; charset utf-8; client_max_body_size 30M; include /etc/nginx/funkwhale_proxy.conf; - location /_protected/media { - internal; - alias /protected/media; + + location /front/ { + proxy_pass http://funkwhale-front/front/; } - location /_protected/music { - internal; - alias /music; + location /front-server/ { + proxy_pass http://funkwhale-front/; } + location / { include /etc/nginx/funkwhale_proxy.conf; - proxy_pass http://api:12081/; + # 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://api:12081/api/subsonic/rest/; + proxy_pass http://funkwhale-api/api/subsonic/rest/; + } + + location /media/ { + alias /protected/media/; + } + + location /_protected/media { + # this is an internal location that is used to serve + # audio files once correct permission / authentication + # has been checked on API side + internal; + alias /protected/media; + } + + location /_protected/music { + # this is an internal location that is used to serve + # audio files once correct permission / authentication + # has been checked on API side + # Set this to the same value as your MUSIC_DIRECTORY_PATH setting + internal; + alias /music; } } } diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh index f359f4da98fe8b9f98b64c318aebe6d334ebc686..7c36bcd2f6aa5094dd7da33f3cc0295968e1c755 100755 --- a/docker/nginx/entrypoint.sh +++ b/docker/nginx/entrypoint.sh @@ -1,18 +1,8 @@ #!/bin/bash -eux -FORWARDED_PORT="$VUE_PORT" -COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME// /}" -if [ -n "$COMPOSE_PROJECT_NAME" ]; then - echo - FUNKWHALE_HOSTNAME="$COMPOSE_PROJECT_NAME.funkwhale.test" - FORWARDED_PORT="443" -fi -echo "Copying template file..." -cp /etc/nginx/funkwhale_proxy.conf{.template,} -sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf -sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf -sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf -sed -i "s/proxy_set_header X-Forwarded-Proto \$scheme/proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO}/" /etc/nginx/funkwhale_proxy.conf -cat /etc/nginx/funkwhale_proxy.conf -nginx -g "daemon off;" +envsubst "`env | awk -F = '{printf \" $$%s\", $$1}'`" \ + < /etc/nginx/nginx.conf.template \ + > /etc/nginx/nginx.conf \ + && cat /etc/nginx/nginx.conf \ + && nginx-debug -g 'daemon off;' diff --git a/front/package.json b/front/package.json index 9c8cba9fee9d8382b4efd46ad54d44f8566946fd..23894600e85922197548453633fbff68f690fe9d 100644 --- a/front/package.json +++ b/front/package.json @@ -27,6 +27,7 @@ "vue-gettext": "^2.1.0", "vue-lazyload": "^1.2.6", "vue-masonry": "^0.11.5", + "vue-plyr": "^5.0.4", "vue-router": "^3.0.1", "vue-upload-component": "^2.8.11", "vuedraggable": "^2.16.0", diff --git a/front/public/embed.html b/front/public/embed.html new file mode 100644 index 0000000000000000000000000000000000000000..241e1cd8d9126885871c73854f76809687809997 --- /dev/null +++ b/front/public/embed.html @@ -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> diff --git a/front/src/Embed.vue b/front/src/Embed.vue new file mode 100644 index 0000000000000000000000000000000000000000..fdd3406faa00fe42fc78aeec5dac2d34de5c4b2f --- /dev/null +++ b/front/src/Embed.vue @@ -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> diff --git a/front/src/assets/embed/default-cover.jpeg b/front/src/assets/embed/default-cover.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..3eb05a40a2254469095d4ec177df445b951833b8 Binary files /dev/null and b/front/src/assets/embed/default-cover.jpeg differ diff --git a/front/src/components/Logo.vue b/front/src/components/Logo.vue index f63bd7ab6a476fb3623609b871ecd8a26955ba93..ff87dc299885acf39c57a67967d32ab3b3b87193 100644 --- a/front/src/components/Logo.vue +++ b/front/src/components/Logo.vue @@ -3,16 +3,16 @@ viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve"> <g> <g> - <path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11 + <path :fill="fill" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11 c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/> - <path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1 + <path :fill="fill" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1 c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z" /> - <path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1 + <path :fill="fill" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1 c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3 C132.2,64.3,131.7,63.8,131.1,63.8z"/> </g> - <path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2 + <path :fill="fill" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2 c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8 c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1 c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/> @@ -24,10 +24,8 @@ <script> export default { + props: { + fill: {type: String, default: '#222222'} + } } </script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> - -</style> diff --git a/front/src/components/audio/EmbedWizard.vue b/front/src/components/audio/EmbedWizard.vue new file mode 100644 index 0000000000000000000000000000000000000000..7a50ffa54223a189d40cd91fbbe9f0e41b0814ee --- /dev/null +++ b/front/src/components/audio/EmbedWizard.vue @@ -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> diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue index ff73bb7a892d77d09969100e54e7137b6d15e0c4..abe993e4662a01cda6feb10f6f97d19bcdb26ea4 100644 --- a/front/src/components/federation/LibraryWidget.vue +++ b/front/src/components/federation/LibraryWidget.vue @@ -62,6 +62,7 @@ export default { self.nextPage = response.data.next self.isLoading = false self.libraries = response.data.results + self.$emit('loaded', self.libraries) }, error => { self.isLoading = false self.errors = error.backendErrors diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 9a4adfafc1134d854e6267a68740e19f4c413649..6a3cfaa8ebf64fc87e51c36179b25b8bbc6b2ebd 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -37,6 +37,30 @@ <i class="external icon"></i> <translate>View on MusicBrainz</translate> </a> + <template v-if="publicLibraries.length > 0"> + <button + @click="showEmbedModal = !showEmbedModal" + class="ui button"> + <i class="code icon"></i> + <translate>Embed</translate> + </button> + <modal :show.sync="showEmbedModal"> + <div class="header"> + <translate>Embed this album on your website</translate> + </div> + <div class="content"> + <div class="description"> + <embed-wizard type="album" :id="album.id" /> + + </div> + </div> + <div class="actions"> + <div class="ui deny button"> + <translate>Cancel</translate> + </div> + </div> + </modal> + </template> </div> </section> <template v-if="discs && discs.length > 1"> @@ -64,7 +88,7 @@ <h2> <translate>User libraries</translate> </h2> - <library-widget :url="'albums/' + id + '/libraries/'"> + <library-widget @loaded="libraries = $event" :url="'albums/' + id + '/libraries/'"> <translate slot="subtitle">This album is present in the following libraries:</translate> </library-widget> </section> @@ -79,6 +103,8 @@ import backend from "@/audio/backend" import PlayButton from "@/components/audio/PlayButton" import TrackTable from "@/components/audio/track/Table" import LibraryWidget from "@/components/federation/LibraryWidget" +import EmbedWizard from "@/components/audio/EmbedWizard" +import Modal from '@/components/semantic/Modal' const FETCH_URL = "albums/" @@ -98,13 +124,17 @@ export default { components: { PlayButton, TrackTable, - LibraryWidget + LibraryWidget, + EmbedWizard, + Modal }, data() { return { isLoading: true, album: null, - discs: [] + discs: [], + libraries: [], + showEmbedModal: false } }, created() { @@ -129,6 +159,11 @@ export default { title: this.$gettext("Album") } }, + publicLibraries () { + return this.libraries.filter(l => { + return l.privacy_level === 'everyone' + }) + }, wikipediaUrl() { return ( "https://en.wikipedia.org/w/index.php?search=" + diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 4aeecdf39dbf5355ccadaa1053899403119d57d6..d4127de1d719a0dd800d24de93956d1d35551aa1 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -55,6 +55,30 @@ <i class="download icon"></i> <translate>Download</translate> </a> + <template v-if="publicLibraries.length > 0"> + <button + @click="showEmbedModal = !showEmbedModal" + class="ui button"> + <i class="code icon"></i> + <translate>Embed</translate> + </button> + <modal :show.sync="showEmbedModal"> + <div class="header"> + <translate>Embed this track on your website</translate> + </div> + <div class="content"> + <div class="description"> + <embed-wizard type="track" :id="track.id" /> + + </div> + </div> + <div class="actions"> + <div class="ui deny button"> + <translate>Cancel</translate> + </div> + </div> + </modal> + </template> </div> </section> <section class="ui vertical stripe center aligned segment"> @@ -144,7 +168,7 @@ <h2> <translate>User libraries</translate> </h2> - <library-widget :url="'tracks/' + id + '/libraries/'"> + <library-widget @loaded="libraries = $event" :url="'tracks/' + id + '/libraries/'"> <translate slot="subtitle">This track is present in the following libraries:</translate> </library-widget> </section> @@ -162,6 +186,7 @@ import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon" import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" import LibraryWidget from "@/components/federation/LibraryWidget" import Modal from '@/components/semantic/Modal' +import EmbedWizard from "@/components/audio/EmbedWizard" const FETCH_URL = "tracks/" @@ -172,7 +197,8 @@ export default { TrackPlaylistIcon, TrackFavoriteIcon, LibraryWidget, - Modal + Modal, + EmbedWizard }, data() { return { @@ -181,7 +207,9 @@ export default { isLoadingLyrics: true, track: null, lyrics: null, - licenseData: null + licenseData: null, + libraries: [], + showEmbedModal: false } }, created() { @@ -224,6 +252,11 @@ export default { } }, computed: { + publicLibraries () { + return this.libraries.filter(l => { + return l.privacy_level === 'everyone' + }) + }, labels() { return { title: this.$gettext("Track") diff --git a/front/src/embed.js b/front/src/embed.js new file mode 100644 index 0000000000000000000000000000000000000000..31ada5480b27f106f671f1b776885bbf99e3e019 --- /dev/null +++ b/front/src/embed.js @@ -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 } +}) diff --git a/front/src/utils/time.js b/front/src/utils/time.js index 022a365bf2a6421de20ad8e212112bfdae6484d2..028131cf47fabd180c149b1728b3705ff89064a1 100644 --- a/front/src/utils/time.js +++ b/front/src/utils/time.js @@ -12,5 +12,13 @@ export default { min = Math.floor(sec / 60) sec = sec - min * 60 return pad(min) + ':' + pad(sec) + }, + durationFormatted (v) { + let duration = parseInt(v) + if (duration % 1 !== 0) { + return time.parse(0) + } + duration = Math.round(duration) + return this.parse(duration) } } diff --git a/front/vue.config.js b/front/vue.config.js index 243c8bc0f571496ac871b837cd83ff01ce0e7245..c6893424650b3f3e2918699c6cdd958d92a26d5f 100644 --- a/front/vue.config.js +++ b/front/vue.config.js @@ -1,5 +1,21 @@ module.exports = { + baseUrl: '/front/', + pages: { + embed: { + entry: 'src/embed.js', + template: 'public/embed.html', + filename: 'embed.html', + }, + index: { + entry: 'src/main.js', + template: 'public/index.html', + filename: 'index.html' + } + }, + chainWebpack: config => { + config.optimization.delete('splitChunks') + }, configureWebpack: { resolve: { alias: { @@ -9,33 +25,7 @@ module.exports = { }, devServer: { disableHostCheck: true, - proxy: { - '^/rest': { - target: 'http://nginx:6001', - changeOrigin: true, - }, - '^/staticfiles': { - target: 'http://nginx:6001', - changeOrigin: true, - }, - '^/.well-known': { - target: 'http://nginx:6001', - changeOrigin: true, - }, - '^/media': { - target: 'http://nginx:6001', - changeOrigin: true, - }, - '^/federation': { - target: 'http://nginx:6001', - changeOrigin: true, - ws: true, - }, - '^/api': { - target: 'http://nginx:6001', - changeOrigin: true, - ws: true, - }, - } + // use https://node1.funkwhale.test/front-server/ if you use docker with federation + public: process.env.FRONT_DEVSERVER_URL || ('http://localhost:' + (process.env.VUE_PORT || '8080')) } } diff --git a/front/yarn.lock b/front/yarn.lock index b3d6a460b9a089424ed76e390f02445b5fcd6cc6..e1b637c7320ea87a6485d9fa840bd8d34276a006 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -2168,6 +2168,11 @@ core-js@^2.4.0, core-js@^2.5.3: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" +core-js@^2.5.7: + version "2.6.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.0.tgz#1e30793e9ee5782b307e37ffa22da0eacddd84d4" + integrity sha512-kLRC6ncVpuEW/1kwrOXYX6KQASCVtrh1gQr/UiaVgFlf9WE5Vp+lNe5+h3LuMr5PAucWnnEXwH0nQHRH/gpGtw== + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -2430,6 +2435,11 @@ currently-unhandled@^0.4.1: dependencies: array-find-index "^1.0.1" +custom-event-polyfill@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-1.0.6.tgz#6b026e81cd9f7bc896bd6b016a427407bb068db1" + integrity sha512-3FxpFlzGcHrDykwWu+xWVXZ8PfykM/9/bI3zXb953sh+AjInZWcQmrnmvPoZgiqNjmbtTm10PWvYqvRW527x6g== + cyclist@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" @@ -4602,6 +4612,11 @@ loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: emojis-list "^2.0.0" json5 "^0.5.0" +loadjs@^3.5.4: + version "3.5.5" + resolved "https://registry.yarnpkg.com/loadjs/-/loadjs-3.5.5.tgz#2fbaa981ffdd079e0f8786ea75aeed643483b368" + integrity sha512-qBuLnKt4C6+vctutozFqPHQ6s4SSa9tcE64NsvDJ92UZmUrFvqGI1oVOtnZz2xwpgOT+2niQtHtQIDP4e/wlTA== + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -5677,6 +5692,17 @@ pluralize@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" +plyr@^3.4.5: + version "3.4.7" + resolved "https://registry.yarnpkg.com/plyr/-/plyr-3.4.7.tgz#7d92470fb27f8019422c6d4edfd3b172d902ef06" + integrity sha512-RxxT2WdC4/sEZQT7CBZqKx5ImVw96aWjT6kB6DM82jy9GcWDiBBnv04m/AeeaXg9S5ambPdiHhB6Pzfm2q84Gw== + dependencies: + core-js "^2.5.7" + custom-event-polyfill "^1.0.6" + loadjs "^3.5.4" + raven-js "^3.27.0" + url-polyfill "^1.1.0" + pn@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" @@ -6246,6 +6272,11 @@ raven-js@^3.26.4: version "3.26.4" resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.26.4.tgz#32aae3a63a9314467a453c94c89a364ea43707be" +raven-js@^3.27.0: + version "3.27.0" + resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.27.0.tgz#9f47c03e17933ce756e189f3669d49c441c1ba6e" + integrity sha512-vChdOL+yzecfnGA+B5EhEZkJ3kY3KlMzxEhShKh6Vdtooyl0yZfYNFQfYzgMf2v4pyQa+OTZ5esTxxgOOZDHqw== + raw-body@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" @@ -7558,6 +7589,11 @@ url-parse@^1.1.8, url-parse@^1.4.3: querystringify "^2.0.0" requires-port "^1.0.0" +url-polyfill@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/url-polyfill/-/url-polyfill-1.1.3.tgz#ce0bdf2e923aa6f66bc198ab776323dfc5a91e62" + integrity sha512-xIAXc0DyXJCd767sSeRu4eqisyYhR0z0sohWArCn+WPwIatD39xGrc09l+tluIUi6jGkpGa8Gz8TKwkKYxMQvQ== + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -7682,6 +7718,13 @@ vue-masonry@^0.11.5: masonry-layout "4.2.0" vue "^2.0.0" +vue-plyr@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/vue-plyr/-/vue-plyr-5.0.4.tgz#13083b71a876d01200a3c93ebfd11585b671afda" + integrity sha512-zOLD7SZiYR/8DPYkZZR9zGTV+04GAc+fhnBymAWSRryncAG4889cYxXJSbIvlsNVGpdGRIOSIZ4p6pIupfmZ5w== + dependencies: + plyr "^3.4.5" + vue-router@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"