Merge branch 'release/0.2'
This commit is contained in:
commit
30d6195e84
6
.env.dev
6
.env.dev
|
@ -1,3 +1,3 @@
|
||||||
BACKEND_URL=http://localhost:12081
|
BACKEND_URL=http://localhost:6001
|
||||||
YOUTUBE_API_KEY=
|
API_AUTHENTICATION_REQUIRED=True
|
||||||
API_AUTHENTICATION_REQUIRED=False
|
CACHALOT_ENABLED=False
|
||||||
|
|
|
@ -70,6 +70,7 @@ docker_develop:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
before_script:
|
before_script:
|
||||||
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
||||||
|
- cp -r front/dist api/frontend
|
||||||
- cd api
|
- cd api
|
||||||
script:
|
script:
|
||||||
- docker build -t $IMAGE .
|
- docker build -t $IMAGE .
|
||||||
|
@ -83,6 +84,7 @@ docker_release:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
before_script:
|
before_script:
|
||||||
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
||||||
|
- cp -r front/dist api/frontend
|
||||||
- cd api
|
- cd api
|
||||||
script:
|
script:
|
||||||
- docker build -t $IMAGE -t $IMAGE_LATEST .
|
- docker build -t $IMAGE -t $IMAGE_LATEST .
|
||||||
|
|
|
@ -9,10 +9,15 @@ export REDIS_URL=redis://redis:6379/0
|
||||||
# the official postgres image uses 'postgres' as default user if not set explictly.
|
# the official postgres image uses 'postgres' as default user if not set explictly.
|
||||||
if [ -z "$POSTGRES_ENV_POSTGRES_USER" ]; then
|
if [ -z "$POSTGRES_ENV_POSTGRES_USER" ]; then
|
||||||
export POSTGRES_ENV_POSTGRES_USER=postgres
|
export POSTGRES_ENV_POSTGRES_USER=postgres
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export DATABASE_URL=postgres://$POSTGRES_ENV_POSTGRES_USER:$POSTGRES_ENV_POSTGRES_PASSWORD@postgres:5432/$POSTGRES_ENV_POSTGRES_USER
|
export DATABASE_URL=postgres://$POSTGRES_ENV_POSTGRES_USER:$POSTGRES_ENV_POSTGRES_PASSWORD@postgres:5432/$POSTGRES_ENV_POSTGRES_USER
|
||||||
|
|
||||||
export CELERY_BROKER_URL=$REDIS_URL
|
export CELERY_BROKER_URL=$REDIS_URL
|
||||||
|
|
||||||
exec "$@"
|
# we copy the frontend files, if any so we can serve them from the outside
|
||||||
|
if [ -d "frontend" ]; then
|
||||||
|
mkdir -p /frontend
|
||||||
|
cp -r frontend/* /frontend/
|
||||||
|
fi
|
||||||
|
exec "$@"
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
FROM nginx:latest
|
|
||||||
ADD nginx.conf /etc/nginx/nginx.conf
|
|
|
@ -4,26 +4,40 @@ from funkwhale_api.music import views
|
||||||
from funkwhale_api.playlists import views as playlists_views
|
from funkwhale_api.playlists import views as playlists_views
|
||||||
from rest_framework_jwt import views as jwt_views
|
from rest_framework_jwt import views as jwt_views
|
||||||
|
|
||||||
|
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
|
||||||
|
from dynamic_preferences.users.viewsets import UserPreferencesViewSet
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
|
router.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
|
||||||
router.register(r'tags', views.TagViewSet, 'tags')
|
router.register(r'tags', views.TagViewSet, 'tags')
|
||||||
router.register(r'tracks', views.TrackViewSet, 'tracks')
|
router.register(r'tracks', views.TrackViewSet, 'tracks')
|
||||||
|
router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
|
||||||
router.register(r'artists', views.ArtistViewSet, 'artists')
|
router.register(r'artists', views.ArtistViewSet, 'artists')
|
||||||
router.register(r'albums', views.AlbumViewSet, 'albums')
|
router.register(r'albums', views.AlbumViewSet, 'albums')
|
||||||
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
|
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
|
||||||
router.register(r'submit', views.SubmitViewSet, 'submit')
|
router.register(r'submit', views.SubmitViewSet, 'submit')
|
||||||
router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists')
|
router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists')
|
||||||
router.register(r'playlist-tracks', playlists_views.PlaylistTrackViewSet, 'playlist-tracks')
|
router.register(
|
||||||
|
r'playlist-tracks',
|
||||||
|
playlists_views.PlaylistTrackViewSet,
|
||||||
|
'playlist-tracks')
|
||||||
v1_patterns = router.urls
|
v1_patterns = router.urls
|
||||||
|
|
||||||
v1_patterns += [
|
v1_patterns += [
|
||||||
url(r'^providers/', include('funkwhale_api.providers.urls', namespace='providers')),
|
url(r'^providers/',
|
||||||
url(r'^favorites/', include('funkwhale_api.favorites.urls', namespace='favorites')),
|
include('funkwhale_api.providers.urls', namespace='providers')),
|
||||||
url(r'^search$', views.Search.as_view(), name='search'),
|
url(r'^favorites/',
|
||||||
url(r'^radios/', include('funkwhale_api.radios.urls', namespace='radios')),
|
include('funkwhale_api.favorites.urls', namespace='favorites')),
|
||||||
url(r'^history/', include('funkwhale_api.history.urls', namespace='history')),
|
url(r'^search$',
|
||||||
url(r'^users/', include('funkwhale_api.users.api_urls', namespace='users')),
|
views.Search.as_view(), name='search'),
|
||||||
url(r'^token/', jwt_views.obtain_jwt_token),
|
url(r'^radios/',
|
||||||
|
include('funkwhale_api.radios.urls', namespace='radios')),
|
||||||
|
url(r'^history/',
|
||||||
|
include('funkwhale_api.history.urls', namespace='history')),
|
||||||
|
url(r'^users/',
|
||||||
|
include('funkwhale_api.users.api_urls', namespace='users')),
|
||||||
|
url(r'^token/',
|
||||||
|
jwt_views.obtain_jwt_token),
|
||||||
url(r'^token/refresh/', jwt_views.refresh_jwt_token),
|
url(r'^token/refresh/', jwt_views.refresh_jwt_token),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,7 @@ THIRD_PARTY_APPS = (
|
||||||
'rest_auth',
|
'rest_auth',
|
||||||
'rest_auth.registration',
|
'rest_auth.registration',
|
||||||
'mptt',
|
'mptt',
|
||||||
|
'dynamic_preferences',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apps specific for this project go here.
|
# Apps specific for this project go here.
|
||||||
|
@ -65,6 +66,7 @@ LOCAL_APPS = (
|
||||||
'funkwhale_api.history',
|
'funkwhale_api.history',
|
||||||
'funkwhale_api.playlists',
|
'funkwhale_api.playlists',
|
||||||
'funkwhale_api.providers.audiofile',
|
'funkwhale_api.providers.audiofile',
|
||||||
|
'funkwhale_api.providers.youtube',
|
||||||
)
|
)
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
|
@ -217,7 +219,6 @@ STATICFILES_FINDERS = (
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
|
||||||
MEDIA_ROOT = str(APPS_DIR('media'))
|
MEDIA_ROOT = str(APPS_DIR('media'))
|
||||||
|
|
||||||
USE_SAMPLE_TRACK = env.bool("USE_SAMPLE_TRACK", False)
|
|
||||||
|
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
||||||
|
@ -261,7 +262,6 @@ BROKER_URL = env("CELERY_BROKER_URL", default='django://')
|
||||||
|
|
||||||
# Location of root django.contrib.admin URL, use {% url 'admin:index' %}
|
# Location of root django.contrib.admin URL, use {% url 'admin:index' %}
|
||||||
ADMIN_URL = r'^admin/'
|
ADMIN_URL = r'^admin/'
|
||||||
SESSION_SAVE_EVERY_REQUEST = True
|
|
||||||
# Your common stuff: Below this line define 3rd party library settings
|
# Your common stuff: Below this line define 3rd party library settings
|
||||||
CELERY_DEFAULT_RATE_LIMIT = 1
|
CELERY_DEFAULT_RATE_LIMIT = 1
|
||||||
CELERYD_TASK_TIME_LIMIT = 300
|
CELERYD_TASK_TIME_LIMIT = 300
|
||||||
|
@ -290,6 +290,7 @@ REST_FRAMEWORK = {
|
||||||
'PAGE_SIZE': 25,
|
'PAGE_SIZE': 25,
|
||||||
|
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
|
||||||
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
|
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
'rest_framework.authentication.BasicAuthentication',
|
||||||
|
@ -299,9 +300,24 @@ REST_FRAMEWORK = {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
FUNKWHALE_PROVIDERS = {
|
|
||||||
'youtube': {
|
|
||||||
'api_key': env('YOUTUBE_API_KEY', default='REPLACE_ME')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ATOMIC_REQUESTS = False
|
ATOMIC_REQUESTS = False
|
||||||
|
|
||||||
|
# Wether we should check user permission before serving audio files (meaning
|
||||||
|
# return an obfuscated url)
|
||||||
|
# This require a special configuration on the reverse proxy side
|
||||||
|
# See https://wellfire.co/learn/nginx-django-x-accel-redirects/ for example
|
||||||
|
PROTECT_AUDIO_FILES = env.bool('PROTECT_AUDIO_FILES', default=True)
|
||||||
|
|
||||||
|
# Which path will be used to process the internal redirection
|
||||||
|
# **DO NOT** put a slash at the end
|
||||||
|
PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected')
|
||||||
|
|
||||||
|
|
||||||
|
# use this setting to tweak for how long you want to cache
|
||||||
|
# musicbrainz results. (value is in seconds)
|
||||||
|
MUSICBRAINZ_CACHE_DURATION = env.int(
|
||||||
|
'MUSICBRAINZ_CACHE_DURATION',
|
||||||
|
default=300
|
||||||
|
)
|
||||||
|
|
||||||
|
CACHALOT_ENABLED = env.bool('CACHALOT_ENABLED', default=True)
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
__version__ = '0.1.0'
|
__version__ = '0.2.0'
|
||||||
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
from rest_framework import exceptions
|
||||||
|
from rest_framework_jwt import authentication
|
||||||
|
from rest_framework_jwt.settings import api_settings
|
||||||
|
|
||||||
|
|
||||||
|
class JSONWebTokenAuthenticationQS(
|
||||||
|
authentication.BaseJSONWebTokenAuthentication):
|
||||||
|
|
||||||
|
www_authenticate_realm = 'api'
|
||||||
|
|
||||||
|
def get_jwt_value(self, request):
|
||||||
|
token = request.query_params.get('jwt')
|
||||||
|
if 'jwt' in request.query_params and not token:
|
||||||
|
msg = _('Invalid Authorization header. No credentials provided.')
|
||||||
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return '{0} realm="{1}"'.format(
|
||||||
|
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm)
|
|
@ -0,0 +1,32 @@
|
||||||
|
from test_plus.test import TestCase
|
||||||
|
from rest_framework_jwt.settings import api_settings
|
||||||
|
|
||||||
|
from funkwhale_api.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
|
||||||
|
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
|
||||||
|
|
||||||
|
|
||||||
|
class TestJWTQueryString(TestCase):
|
||||||
|
www_authenticate_realm = 'api'
|
||||||
|
|
||||||
|
def test_can_authenticate_using_token_param_in_url(self):
|
||||||
|
user = User.objects.create_superuser(
|
||||||
|
username='test', email='test@test.com', password='test')
|
||||||
|
|
||||||
|
url = self.reverse('api:v1:tracks-list')
|
||||||
|
with self.settings(API_AUTHENTICATION_REQUIRED=True):
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
payload = jwt_payload_handler(user)
|
||||||
|
token = jwt_encode_handler(payload)
|
||||||
|
print(payload, token)
|
||||||
|
with self.settings(API_AUTHENTICATION_REQUIRED=True):
|
||||||
|
response = self.client.get(url, data={
|
||||||
|
'jwt': token
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
|
@ -37,13 +37,26 @@ def get_mp3_recording_id(f, k):
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise TagNotFound(k)
|
raise TagNotFound(k)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_track_number(v):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except ValueError:
|
||||||
|
# maybe the position is of the form "1/4"
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(v.split('/')[0])
|
||||||
|
except (ValueError, AttributeError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
CONF = {
|
CONF = {
|
||||||
'OggVorbis': {
|
'OggVorbis': {
|
||||||
'getter': lambda f, k: f[k][0],
|
'getter': lambda f, k: f[k][0],
|
||||||
'fields': {
|
'fields': {
|
||||||
'track_number': {
|
'track_number': {
|
||||||
'field': 'TRACKNUMBER',
|
'field': 'TRACKNUMBER',
|
||||||
'to_application': int
|
'to_application': convert_track_number
|
||||||
},
|
},
|
||||||
'title': {
|
'title': {
|
||||||
'field': 'title'
|
'field': 'title'
|
||||||
|
@ -74,7 +87,7 @@ CONF = {
|
||||||
'fields': {
|
'fields': {
|
||||||
'track_number': {
|
'track_number': {
|
||||||
'field': 'TPOS',
|
'field': 'TPOS',
|
||||||
'to_application': lambda v: int(v.split('/')[0])
|
'to_application': convert_track_number
|
||||||
},
|
},
|
||||||
'title': {
|
'title': {
|
||||||
'field': 'TIT2'
|
'field': 'TIT2'
|
||||||
|
|
|
@ -8,7 +8,6 @@ import markdown
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
|
@ -354,10 +353,12 @@ class TrackFile(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
if settings.USE_SAMPLE_TRACK:
|
if settings.PROTECT_AUDIO_FILES:
|
||||||
return static('music/sample1.ogg')
|
return reverse(
|
||||||
|
'api:v1:trackfiles-serve', kwargs={'pk': self.pk})
|
||||||
return self.audio_file.url
|
return self.audio_file.url
|
||||||
|
|
||||||
|
|
||||||
class ImportBatch(models.Model):
|
class ImportBatch(models.Model):
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
submitted_by = models.ForeignKey('users.User', related_name='imports')
|
submitted_by = models.ForeignKey('users.User', related_name='imports')
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import factory
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistFactory(factory.django.DjangoModelFactory):
|
||||||
|
name = factory.Sequence(lambda n: 'artist-{0}'.format(n))
|
||||||
|
mbid = factory.Faker('uuid4')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'music.Artist'
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumFactory(factory.django.DjangoModelFactory):
|
||||||
|
title = factory.Sequence(lambda n: 'album-{0}'.format(n))
|
||||||
|
mbid = factory.Faker('uuid4')
|
||||||
|
release_date = factory.Faker('date')
|
||||||
|
cover = factory.django.ImageField()
|
||||||
|
artist = factory.SubFactory(ArtistFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'music.Album'
|
||||||
|
|
||||||
|
|
||||||
|
class TrackFactory(factory.django.DjangoModelFactory):
|
||||||
|
title = factory.Sequence(lambda n: 'track-{0}'.format(n))
|
||||||
|
mbid = factory.Faker('uuid4')
|
||||||
|
album = factory.SubFactory(AlbumFactory)
|
||||||
|
artist = factory.SelfAttribute('album.artist')
|
||||||
|
position = 1
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'music.Track'
|
||||||
|
|
||||||
|
|
||||||
|
class TrackFileFactory(factory.django.DjangoModelFactory):
|
||||||
|
track = factory.SubFactory(TrackFactory)
|
||||||
|
audio_file = factory.django.FileField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'music.TrackFile'
|
|
@ -10,6 +10,8 @@ from funkwhale_api.music import serializers
|
||||||
from funkwhale_api.users.models import User
|
from funkwhale_api.users.models import User
|
||||||
|
|
||||||
from . import data as api_data
|
from . import data as api_data
|
||||||
|
from . import factories
|
||||||
|
|
||||||
|
|
||||||
class TestAPI(TMPDirTestCaseMixin, TestCase):
|
class TestAPI(TMPDirTestCaseMixin, TestCase):
|
||||||
|
|
||||||
|
@ -214,3 +216,26 @@ class TestAPI(TMPDirTestCaseMixin, TestCase):
|
||||||
with self.settings(API_AUTHENTICATION_REQUIRED=False):
|
with self.settings(API_AUTHENTICATION_REQUIRED=False):
|
||||||
response = getattr(self.client, method)(url)
|
response = getattr(self.client, method)(url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_track_file_url_is_restricted_to_authenticated_users(self):
|
||||||
|
f = factories.TrackFileFactory()
|
||||||
|
self.assertNotEqual(f.audio_file, None)
|
||||||
|
url = f.path
|
||||||
|
|
||||||
|
with self.settings(API_AUTHENTICATION_REQUIRED=True):
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
user = User.objects.create_superuser(
|
||||||
|
username='test', email='test@test.com', password='test')
|
||||||
|
self.client.login(username=user.username, password='test')
|
||||||
|
with self.settings(API_AUTHENTICATION_REQUIRED=True):
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
response['X-Accel-Redirect'],
|
||||||
|
'/_protected{}'.format(f.audio_file.url)
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import unicodedata
|
||||||
|
import urllib
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models.functions import Length
|
from django.db.models.functions import Length
|
||||||
|
from django.conf import settings
|
||||||
from rest_framework import viewsets, views
|
from rest_framework import viewsets, views
|
||||||
from rest_framework.decorators import detail_route, list_route
|
from rest_framework.decorators import detail_route, list_route
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
@ -51,6 +54,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
search_fields = ['name']
|
search_fields = ['name']
|
||||||
ordering_fields = ('creation_date',)
|
ordering_fields = ('creation_date',)
|
||||||
|
|
||||||
|
|
||||||
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (
|
queryset = (
|
||||||
models.Album.objects.all()
|
models.Album.objects.all()
|
||||||
|
@ -63,6 +67,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
search_fields = ['title']
|
search_fields = ['title']
|
||||||
ordering_fields = ('creation_date',)
|
ordering_fields = ('creation_date',)
|
||||||
|
|
||||||
|
|
||||||
class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
|
class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = models.ImportBatch.objects.all().order_by('-creation_date')
|
queryset = models.ImportBatch.objects.all().order_by('-creation_date')
|
||||||
serializer_class = serializers.ImportBatchSerializer
|
serializer_class = serializers.ImportBatchSerializer
|
||||||
|
@ -70,6 +75,7 @@ class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset().filter(submitted_by=self.request.user)
|
return super().get_queryset().filter(submitted_by=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
A simple ViewSet for viewing and editing accounts.
|
A simple ViewSet for viewing and editing accounts.
|
||||||
|
@ -120,6 +126,29 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
||||||
|
serializer_class = serializers.TrackFileSerializer
|
||||||
|
permission_classes = [ConditionalAuthentication]
|
||||||
|
|
||||||
|
@detail_route(methods=['get'])
|
||||||
|
def serve(self, request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
f = models.TrackFile.objects.get(pk=kwargs['pk'])
|
||||||
|
except models.TrackFile.DoesNotExist:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
response = Response()
|
||||||
|
filename = "filename*=UTF-8''{}{}".format(
|
||||||
|
urllib.parse.quote(f.track.full_name),
|
||||||
|
os.path.splitext(f.audio_file.name)[-1])
|
||||||
|
response["Content-Disposition"] = "attachment; {}".format(filename)
|
||||||
|
response['X-Accel-Redirect'] = "{}{}".format(
|
||||||
|
settings.PROTECT_FILES_PATH,
|
||||||
|
f.audio_file.url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class TagViewSet(viewsets.ReadOnlyModelViewSet):
|
class TagViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = Tag.objects.all().order_by('name')
|
queryset = Tag.objects.all().order_by('name')
|
||||||
serializer_class = serializers.TagSerializer
|
serializer_class = serializers.TagSerializer
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
|
import memoize.djangocache
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from funkwhale_api import __version__
|
from funkwhale_api import __version__
|
||||||
|
|
||||||
_api = musicbrainzngs
|
_api = musicbrainzngs
|
||||||
_api.set_useragent('funkwhale', str(__version__), 'contact@eliotberriot.com')
|
_api.set_useragent('funkwhale', str(__version__), 'contact@eliotberriot.com')
|
||||||
|
|
||||||
|
|
||||||
|
store = memoize.djangocache.Cache('default')
|
||||||
|
memo = memoize.Memoizer(store, namespace='memoize:musicbrainz')
|
||||||
|
|
||||||
|
|
||||||
def clean_artist_search(query, **kwargs):
|
def clean_artist_search(query, **kwargs):
|
||||||
cleaned_kwargs = {}
|
cleaned_kwargs = {}
|
||||||
if kwargs.get('name'):
|
if kwargs.get('name'):
|
||||||
|
@ -17,30 +23,55 @@ class API(object):
|
||||||
_api = _api
|
_api = _api
|
||||||
|
|
||||||
class artists(object):
|
class artists(object):
|
||||||
search = clean_artist_search
|
search = memo(
|
||||||
get = _api.get_artist_by_id
|
clean_artist_search, max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
get = memo(
|
||||||
|
_api.get_artist_by_id,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
|
||||||
class images(object):
|
class images(object):
|
||||||
get_front = _api.get_image_front
|
get_front = memo(
|
||||||
|
_api.get_image_front,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
|
||||||
class recordings(object):
|
class recordings(object):
|
||||||
search = _api.search_recordings
|
search = memo(
|
||||||
get = _api.get_recording_by_id
|
_api.search_recordings,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
get = memo(
|
||||||
|
_api.get_recording_by_id,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
|
||||||
class works(object):
|
class works(object):
|
||||||
search = _api.search_works
|
search = memo(
|
||||||
get = _api.get_work_by_id
|
_api.search_works,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
get = memo(
|
||||||
|
_api.get_work_by_id,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
|
||||||
class releases(object):
|
class releases(object):
|
||||||
search = _api.search_releases
|
search = memo(
|
||||||
get = _api.get_release_by_id
|
_api.search_releases,
|
||||||
browse = _api.browse_releases
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
get = memo(
|
||||||
|
_api.get_release_by_id,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
browse = memo(
|
||||||
|
_api.browse_releases,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
# get_image_front = _api.get_image_front
|
# get_image_front = _api.get_image_front
|
||||||
|
|
||||||
class release_groups(object):
|
class release_groups(object):
|
||||||
search = _api.search_release_groups
|
search = memo(
|
||||||
get = _api.get_release_group_by_id
|
_api.search_release_groups,
|
||||||
browse = _api.browse_release_groups
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
get = memo(
|
||||||
|
_api.get_release_group_by_id,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
|
browse = memo(
|
||||||
|
_api.browse_release_groups,
|
||||||
|
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
|
||||||
# get_image_front = _api.get_image_front
|
# get_image_front = _api.get_image_front
|
||||||
|
|
||||||
api = API()
|
api = API()
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import unittest
|
||||||
|
from test_plus.test import TestCase
|
||||||
|
|
||||||
|
from funkwhale_api.musicbrainz import client
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPI(TestCase):
|
||||||
|
def test_can_search_recording_in_musicbrainz_api(self, *mocks):
|
||||||
|
r = {'hello': 'world'}
|
||||||
|
mocked = 'funkwhale_api.musicbrainz.client._api.search_artists'
|
||||||
|
with unittest.mock.patch(mocked, return_value=r) as m:
|
||||||
|
self.assertEqual(client.api.artists.search('test'), r)
|
||||||
|
# now call from cache
|
||||||
|
self.assertEqual(client.api.artists.search('test'), r)
|
||||||
|
self.assertEqual(client.api.artists.search('test'), r)
|
||||||
|
|
||||||
|
self.assertEqual(m.call_count, 1)
|
|
@ -2,6 +2,7 @@ from rest_framework import viewsets
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import list_route
|
from rest_framework.decorators import list_route
|
||||||
|
import musicbrainzngs
|
||||||
|
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ class ReleaseBrowse(APIView):
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
result = api.releases.browse(
|
result = api.releases.browse(
|
||||||
release_group=kwargs['release_group_uuid'],
|
release_group=kwargs['release_group_uuid'],
|
||||||
includes=['recordings'])
|
includes=['recordings', 'artist-credits'])
|
||||||
return Response(result)
|
return Response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@ -54,17 +55,18 @@ class SearchViewSet(viewsets.ViewSet):
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=['get'])
|
||||||
def recordings(self, request, *args, **kwargs):
|
def recordings(self, request, *args, **kwargs):
|
||||||
query = request.GET['query']
|
query = request.GET['query']
|
||||||
results = api.recordings.search(query, artist=query)
|
results = api.recordings.search(query)
|
||||||
return Response(results)
|
return Response(results)
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=['get'])
|
||||||
def releases(self, request, *args, **kwargs):
|
def releases(self, request, *args, **kwargs):
|
||||||
query = request.GET['query']
|
query = request.GET['query']
|
||||||
results = api.releases.search(query, artist=query)
|
results = api.releases.search(query)
|
||||||
return Response(results)
|
return Response(results)
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=['get'])
|
||||||
def artists(self, request, *args, **kwargs):
|
def artists(self, request, *args, **kwargs):
|
||||||
query = request.GET['query']
|
query = request.GET['query']
|
||||||
results = api.artists.search(query)
|
results = api.artists.search(query)
|
||||||
|
# results = musicbrainzngs.search_artists(query)
|
||||||
return Response(results)
|
return Response(results)
|
||||||
|
|
|
@ -4,21 +4,20 @@ from apiclient.discovery import build
|
||||||
from apiclient.errors import HttpError
|
from apiclient.errors import HttpError
|
||||||
from oauth2client.tools import argparser
|
from oauth2client.tools import argparser
|
||||||
|
|
||||||
from django.conf import settings
|
from dynamic_preferences.registries import (
|
||||||
|
global_preferences_registry as registry)
|
||||||
|
|
||||||
# Set DEVELOPER_KEY to the API key value from the APIs & auth > Registered apps
|
|
||||||
# tab of
|
|
||||||
# https://cloud.google.com/console
|
|
||||||
# Please ensure that you have enabled the YouTube Data API for your project.
|
|
||||||
DEVELOPER_KEY = settings.FUNKWHALE_PROVIDERS['youtube']['api_key']
|
|
||||||
YOUTUBE_API_SERVICE_NAME = "youtube"
|
YOUTUBE_API_SERVICE_NAME = "youtube"
|
||||||
YOUTUBE_API_VERSION = "v3"
|
YOUTUBE_API_VERSION = "v3"
|
||||||
VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}'
|
VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}'
|
||||||
|
|
||||||
|
|
||||||
def _do_search(query):
|
def _do_search(query):
|
||||||
youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
|
manager = registry.manager()
|
||||||
developerKey=DEVELOPER_KEY)
|
youtube = build(
|
||||||
|
YOUTUBE_API_SERVICE_NAME,
|
||||||
|
YOUTUBE_API_VERSION,
|
||||||
|
developerKey=manager['providers_youtube__api_key'])
|
||||||
|
|
||||||
return youtube.search().list(
|
return youtube.search().list(
|
||||||
q=query,
|
q=query,
|
||||||
|
@ -55,4 +54,33 @@ class Client(object):
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def to_funkwhale(self, result):
|
||||||
|
"""
|
||||||
|
We convert youtube results to something more generic.
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "video id",
|
||||||
|
"type": "youtube#video",
|
||||||
|
"url": "https://www.youtube.com/watch?v=id",
|
||||||
|
"description": "description",
|
||||||
|
"channelId": "Channel id",
|
||||||
|
"title": "Title",
|
||||||
|
"channelTitle": "channel Title",
|
||||||
|
"publishedAt": "2012-08-22T18:41:03.000Z",
|
||||||
|
"cover": "http://coverurl"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': result['id']['videoId'],
|
||||||
|
'url': 'https://www.youtube.com/watch?v={}'.format(
|
||||||
|
result['id']['videoId']),
|
||||||
|
'type': result['id']['kind'],
|
||||||
|
'title': result['snippet']['title'],
|
||||||
|
'description': result['snippet']['description'],
|
||||||
|
'channelId': result['snippet']['channelId'],
|
||||||
|
'channelTitle': result['snippet']['channelTitle'],
|
||||||
|
'publishedAt': result['snippet']['publishedAt'],
|
||||||
|
'cover': result['snippet']['thumbnails']['high']['url'],
|
||||||
|
}
|
||||||
|
|
||||||
client = Client()
|
client = Client()
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
from dynamic_preferences.types import StringPreference, Section
|
||||||
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
|
youtube = Section('providers_youtube')
|
||||||
|
|
||||||
|
|
||||||
|
@global_preferences_registry.register
|
||||||
|
class APIKey(StringPreference):
|
||||||
|
section = youtube
|
||||||
|
name = 'api_key'
|
||||||
|
default = 'CHANGEME'
|
||||||
|
verbose_name = 'YouTube API key'
|
||||||
|
help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.'
|
|
@ -8,7 +8,7 @@ from funkwhale_api.providers.youtube.client import client
|
||||||
from . import data as api_data
|
from . import data as api_data
|
||||||
|
|
||||||
class TestAPI(TestCase):
|
class TestAPI(TestCase):
|
||||||
|
maxDiff = None
|
||||||
@unittest.mock.patch(
|
@unittest.mock.patch(
|
||||||
'funkwhale_api.providers.youtube.client._do_search',
|
'funkwhale_api.providers.youtube.client._do_search',
|
||||||
return_value=api_data.search['8 bit adventure'])
|
return_value=api_data.search['8 bit adventure'])
|
||||||
|
@ -25,11 +25,23 @@ class TestAPI(TestCase):
|
||||||
return_value=api_data.search['8 bit adventure'])
|
return_value=api_data.search['8 bit adventure'])
|
||||||
def test_can_get_search_results_from_funkwhale(self, *mocks):
|
def test_can_get_search_results_from_funkwhale(self, *mocks):
|
||||||
query = '8 bit adventure'
|
query = '8 bit adventure'
|
||||||
expected = json.dumps(client.search(query))
|
|
||||||
url = self.reverse('api:v1:providers:youtube:search')
|
url = self.reverse('api:v1:providers:youtube:search')
|
||||||
response = self.client.get(url + '?query={0}'.format(query))
|
response = self.client.get(url + '?query={0}'.format(query))
|
||||||
|
# we should cast the youtube result to something more generic
|
||||||
|
expected = {
|
||||||
|
"id": "0HxZn6CzOIo",
|
||||||
|
"url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
|
||||||
|
"type": "youtube#video",
|
||||||
|
"description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
|
||||||
|
"channelId": "UCps63j3krzAG4OyXeEyuhFw",
|
||||||
|
"title": "AdhesiveWombat - 8 Bit Adventure",
|
||||||
|
"channelTitle": "AdhesiveWombat",
|
||||||
|
"publishedAt": "2012-08-22T18:41:03.000Z",
|
||||||
|
"cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
|
self.assertEqual(
|
||||||
|
json.loads(response.content.decode('utf-8'))[0], expected)
|
||||||
|
|
||||||
@unittest.mock.patch(
|
@unittest.mock.patch(
|
||||||
'funkwhale_api.providers.youtube.client._do_search',
|
'funkwhale_api.providers.youtube.client._do_search',
|
||||||
|
@ -66,9 +78,22 @@ class TestAPI(TestCase):
|
||||||
'q': '8 bit adventure',
|
'q': '8 bit adventure',
|
||||||
}
|
}
|
||||||
|
|
||||||
expected = json.dumps(client.search_multiple(queries))
|
expected = {
|
||||||
|
"id": "0HxZn6CzOIo",
|
||||||
|
"url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
|
||||||
|
"type": "youtube#video",
|
||||||
|
"description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
|
||||||
|
"channelId": "UCps63j3krzAG4OyXeEyuhFw",
|
||||||
|
"title": "AdhesiveWombat - 8 Bit Adventure",
|
||||||
|
"channelTitle": "AdhesiveWombat",
|
||||||
|
"publishedAt": "2012-08-22T18:41:03.000Z",
|
||||||
|
"cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
url = self.reverse('api:v1:providers:youtube:searchs')
|
url = self.reverse('api:v1:providers:youtube:searchs')
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
url, json.dumps(queries), content_type='application/json')
|
url, json.dumps(queries), content_type='application/json')
|
||||||
|
|
||||||
self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
|
self.assertEqual(
|
||||||
|
expected,
|
||||||
|
json.loads(response.content.decode('utf-8'))['1'][0])
|
||||||
|
|
|
@ -10,7 +10,10 @@ class APISearch(APIView):
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
results = client.search(request.GET['query'])
|
results = client.search(request.GET['query'])
|
||||||
return Response(results)
|
return Response([
|
||||||
|
client.to_funkwhale(result)
|
||||||
|
for result in results
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
class APISearchs(APIView):
|
class APISearchs(APIView):
|
||||||
|
@ -18,4 +21,10 @@ class APISearchs(APIView):
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
results = client.search_multiple(request.data)
|
results = client.search_multiple(request.data)
|
||||||
return Response(results)
|
return Response({
|
||||||
|
key: [
|
||||||
|
client.to_funkwhale(result)
|
||||||
|
for result in group
|
||||||
|
]
|
||||||
|
for key, group in results.items()
|
||||||
|
})
|
||||||
|
|
|
@ -19,8 +19,11 @@ class User(AbstractUser):
|
||||||
relevant_permissions = {
|
relevant_permissions = {
|
||||||
# internal_codename : {external_codename}
|
# internal_codename : {external_codename}
|
||||||
'music.add_importbatch': {
|
'music.add_importbatch': {
|
||||||
'external_codename': 'import.launch'
|
'external_codename': 'import.launch',
|
||||||
}
|
},
|
||||||
|
'dynamic_preferences.change_globalpreferencemodel': {
|
||||||
|
'external_codename': 'settings.change',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
@ -47,7 +47,13 @@ class UserTestCase(TestCase):
|
||||||
# login required
|
# login required
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
user = UserFactory(is_staff=True, perms=['music.add_importbatch'])
|
user = UserFactory(
|
||||||
|
is_staff=True,
|
||||||
|
perms=[
|
||||||
|
'music.add_importbatch',
|
||||||
|
'dynamic_preferences.change_globalpreferencemodel',
|
||||||
|
]
|
||||||
|
)
|
||||||
self.assertTrue(user.has_perm('music.add_importbatch'))
|
self.assertTrue(user.has_perm('music.add_importbatch'))
|
||||||
self.login(user)
|
self.login(user)
|
||||||
|
|
||||||
|
@ -63,3 +69,5 @@ class UserTestCase(TestCase):
|
||||||
self.assertEqual(payload['name'], user.name)
|
self.assertEqual(payload['name'], user.name)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
payload['permissions']['import.launch']['status'], True)
|
payload['permissions']['import.launch']['status'], True)
|
||||||
|
self.assertEqual(
|
||||||
|
payload['permissions']['settings.change']['status'], True)
|
||||||
|
|
|
@ -50,3 +50,9 @@ beautifulsoup4==4.6.0
|
||||||
Markdown==2.6.8
|
Markdown==2.6.8
|
||||||
ipython==6.1.0
|
ipython==6.1.0
|
||||||
mutagen==1.38
|
mutagen==1.38
|
||||||
|
|
||||||
|
|
||||||
|
# Until this is merged
|
||||||
|
git+https://github.com/EliotBerriot/PyMemoize.git@django
|
||||||
|
|
||||||
|
django-dynamic-preferences>=1.2,<1.3
|
||||||
|
|
|
@ -6,13 +6,15 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
image: postgres:9.4
|
image: postgres:9.4
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
image: redis:3
|
image: redis:3
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data/redis:/data
|
||||||
|
|
||||||
celeryworker:
|
celeryworker:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
@ -46,6 +48,7 @@ services:
|
||||||
- ./data/music:/music:ro
|
- ./data/music:/music:ro
|
||||||
- ./data/media:/app/funkwhale_api/media
|
- ./data/media:/app/funkwhale_api/media
|
||||||
- ./data/static:/app/staticfiles
|
- ./data/static:/app/staticfiles
|
||||||
|
- ./front/dist:/frontend
|
||||||
ports:
|
ports:
|
||||||
- "${FUNKWHALE_API_IP:-127.0.0.1}:${FUNKWHALE_API_PORT:-5000}:5000"
|
- "${FUNKWHALE_API_IP:-127.0.0.1}:${FUNKWHALE_API_PORT:-5000}:5000"
|
||||||
links:
|
links:
|
||||||
|
|
|
@ -47,7 +47,17 @@ server {
|
||||||
location /media/ {
|
location /media/ {
|
||||||
alias /srv/funkwhale/data/media/;
|
alias /srv/funkwhale/data/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 /srv/funkwhale/data/media;
|
||||||
|
}
|
||||||
|
|
||||||
location /staticfiles/ {
|
location /staticfiles/ {
|
||||||
|
# django static files
|
||||||
alias /srv/funkwhale/data/static/;
|
alias /srv/funkwhale/data/static/;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
22
dev.yml
22
dev.yml
|
@ -27,7 +27,7 @@ services:
|
||||||
env_file: .env.dev
|
env_file: .env.dev
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
dockerfile: docker/Dockerfile.local
|
dockerfile: docker/Dockerfile.test
|
||||||
links:
|
links:
|
||||||
- postgres
|
- postgres
|
||||||
- redis
|
- redis
|
||||||
|
@ -53,12 +53,14 @@ services:
|
||||||
- redis
|
- redis
|
||||||
- celeryworker
|
- celeryworker
|
||||||
|
|
||||||
# nginx:
|
nginx:
|
||||||
# env_file: .env.dev
|
env_file: .env.dev
|
||||||
# build: ./api/compose/nginx
|
image: nginx
|
||||||
# links:
|
links:
|
||||||
# - api
|
- api
|
||||||
# volumes:
|
- front
|
||||||
# - ./api/funkwhale_api/media:/staticfiles/media
|
volumes:
|
||||||
# ports:
|
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
|
||||||
# - "0.0.0.0:6001:80"
|
- ./api/funkwhale_api/media:/protected/media
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:6001:80"
|
||||||
|
|
|
@ -27,27 +27,21 @@ http {
|
||||||
|
|
||||||
#gzip on;
|
#gzip on;
|
||||||
|
|
||||||
upstream app {
|
|
||||||
server django:12081;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
root /staticfiles;
|
location /_protected/media {
|
||||||
|
internal;
|
||||||
|
alias /protected/media;
|
||||||
|
}
|
||||||
location / {
|
location / {
|
||||||
# checks for static file, if not found proxy to app
|
proxy_set_header Host $host;
|
||||||
try_files $uri @proxy_to_app;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
|
||||||
|
|
||||||
location @proxy_to_app {
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
proxy_pass http://api:12081/;
|
||||||
proxy_pass http://app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,6 +1,24 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
0.2
|
||||||
|
-------
|
||||||
|
|
||||||
|
2017-07-09
|
||||||
|
|
||||||
|
* [feature] can now import artist and releases from youtube and musicbrainz.
|
||||||
|
This requires a YouTube API key for the search
|
||||||
|
* [breaking] we now check for user permission before serving audio files, which requires
|
||||||
|
a specific configuration block in your reverse proxy configuration:
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
|
||||||
|
location /_protected/media {
|
||||||
|
internal;
|
||||||
|
alias /srv/funkwhale/data/media;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
0.1
|
0.1
|
||||||
-------
|
-------
|
||||||
|
|
|
@ -46,7 +46,7 @@ Then launch the whole thing:
|
||||||
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
Now, you just need to setup the :ref:`frontend files <frontend-setup>`, and configure your :ref:`reverse-proxy <reverse-proxy-setup>`. Don't worry, it's quite easy.
|
Now, you just need to configure your :ref:`reverse-proxy <reverse-proxy-setup>`. Don't worry, it's quite easy.
|
||||||
|
|
||||||
About music acquisition
|
About music acquisition
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
|
@ -26,6 +26,11 @@ Available installation methods
|
||||||
Frontend setup
|
Frontend setup
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
You do not need to do this if you are deploying using Docker, as frontend files
|
||||||
|
are already included in the funkwhale docker image.
|
||||||
|
|
||||||
Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver:
|
Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
|
@ -19,7 +19,8 @@
|
||||||
"semantic-ui-css": "^2.2.10",
|
"semantic-ui-css": "^2.2.10",
|
||||||
"vue": "^2.3.3",
|
"vue": "^2.3.3",
|
||||||
"vue-resource": "^1.3.4",
|
"vue-resource": "^1.3.4",
|
||||||
"vue-router": "^2.3.1"
|
"vue-router": "^2.3.1",
|
||||||
|
"vuedraggable": "^2.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^6.7.2",
|
"autoprefixer": "^6.7.2",
|
||||||
|
|
|
@ -2,6 +2,26 @@
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<sidebar></sidebar>
|
<sidebar></sidebar>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
<div id="footer" class="ui vertical footer segment">
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="ui stackable equal height stackable grid">
|
||||||
|
<div class="three wide column">
|
||||||
|
<h4 class="ui header">Links</h4>
|
||||||
|
<div class="ui link list">
|
||||||
|
<a href="https://funkwhale.audio" class="item" target="_blank">Official website</a>
|
||||||
|
<a href="https://docs.funkwhale.audio" class="item" target="_blank">Documentation</a>
|
||||||
|
<a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">Source code</a>
|
||||||
|
<a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">Issue tracker</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ten wide column">
|
||||||
|
<h4 class="ui header">About funkwhale</h4>
|
||||||
|
<p>Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -27,12 +47,16 @@ export default {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
.main.pusher {
|
.main.pusher, .footer {
|
||||||
margin-left: 350px !important;
|
margin-left: 350px !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
|
}
|
||||||
|
.main-pusher {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
#footer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
.ui.stripe.segment {
|
.ui.stripe.segment {
|
||||||
padding: 4em;
|
padding: 4em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,5 @@
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
|
import time from '@/utils/time'
|
||||||
const pad = (val) => {
|
|
||||||
val = Math.floor(val)
|
|
||||||
if (val < 10) {
|
|
||||||
return '0' + val
|
|
||||||
}
|
|
||||||
return val + ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const Cov = {
|
const Cov = {
|
||||||
on (el, type, func) {
|
on (el, type, func) {
|
||||||
|
@ -108,7 +101,7 @@ class Audio {
|
||||||
})
|
})
|
||||||
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
||||||
this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100
|
this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100
|
||||||
this.state.durationTimerFormat = this.timeParse(this.state.duration)
|
this.state.durationTimerFormat = time.parse(this.state.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePlayState (e) {
|
updatePlayState (e) {
|
||||||
|
@ -116,9 +109,9 @@ class Audio {
|
||||||
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
||||||
this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100
|
this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100
|
||||||
|
|
||||||
this.state.durationTimerFormat = this.timeParse(this.state.duration)
|
this.state.durationTimerFormat = time.parse(this.state.duration)
|
||||||
this.state.currentTimeFormat = this.timeParse(this.state.currentTime)
|
this.state.currentTimeFormat = time.parse(this.state.currentTime)
|
||||||
this.state.lastTimeFormat = this.timeParse(this.state.duration - this.state.currentTime)
|
this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime)
|
||||||
|
|
||||||
this.hook.playState.forEach(func => {
|
this.hook.playState.forEach(func => {
|
||||||
func(this.state)
|
func(this.state)
|
||||||
|
@ -181,14 +174,6 @@ class Audio {
|
||||||
}
|
}
|
||||||
this.$Audio.currentTime = time
|
this.$Audio.currentTime = time
|
||||||
}
|
}
|
||||||
|
|
||||||
timeParse (sec) {
|
|
||||||
let min = 0
|
|
||||||
min = Math.floor(sec / 60)
|
|
||||||
sec = sec - min * 60
|
|
||||||
return pad(min) + ':' + pad(sec)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Audio
|
export default Audio
|
||||||
|
|
|
@ -5,6 +5,8 @@ import Audio from '@/audio'
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
import radios from '@/radios'
|
import radios from '@/radios'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import url from '@/utils/url'
|
||||||
|
import auth from '@/auth'
|
||||||
|
|
||||||
class Queue {
|
class Queue {
|
||||||
constructor (options = {}) {
|
constructor (options = {}) {
|
||||||
|
@ -92,6 +94,24 @@ class Queue {
|
||||||
}
|
}
|
||||||
cache.set('volume', newValue)
|
cache.set('volume', newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reorder (oldIndex, newIndex) {
|
||||||
|
// called when the user uses drag / drop to reorder
|
||||||
|
// tracks in queue
|
||||||
|
if (oldIndex === this.currentIndex) {
|
||||||
|
this.currentIndex = newIndex
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (oldIndex < this.currentIndex && newIndex >= this.currentIndex) {
|
||||||
|
// item before was moved after
|
||||||
|
this.currentIndex -= 1
|
||||||
|
}
|
||||||
|
if (oldIndex > this.currentIndex && newIndex <= this.currentIndex) {
|
||||||
|
// item after was moved before
|
||||||
|
this.currentIndex += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
append (track, index) {
|
append (track, index) {
|
||||||
this.previousQueue = null
|
this.previousQueue = null
|
||||||
index = index || this.tracks.length
|
index = index || this.tracks.length
|
||||||
|
@ -163,7 +183,17 @@ class Queue {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return this.next()
|
return this.next()
|
||||||
}
|
}
|
||||||
this.audio = new Audio(backend.absoluteUrl(file.path), {
|
let path = backend.absoluteUrl(file.path)
|
||||||
|
|
||||||
|
if (auth.user.authenticated) {
|
||||||
|
// we need to send the token directly in url
|
||||||
|
// so authentication can be checked by the backend
|
||||||
|
// because for audio files we cannot use the regular Authentication
|
||||||
|
// header
|
||||||
|
path = url.updateQueryString(path, 'jwt', auth.getAuthToken())
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audio = new Audio(path, {
|
||||||
preload: true,
|
preload: true,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
rate: 1,
|
rate: 1,
|
||||||
|
|
|
@ -10,14 +10,13 @@ const LOGIN_URL = config.API_URL + 'token/'
|
||||||
const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
|
const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
|
||||||
// const SIGNUP_URL = API_URL + 'users/'
|
// const SIGNUP_URL = API_URL + 'users/'
|
||||||
|
|
||||||
export default {
|
let userData = {
|
||||||
|
authenticated: false,
|
||||||
// User object will let us check authentication status
|
username: '',
|
||||||
user: {
|
availablePermissions: {},
|
||||||
authenticated: false,
|
profile: {}
|
||||||
username: '',
|
}
|
||||||
profile: null
|
let auth = {
|
||||||
},
|
|
||||||
|
|
||||||
// Send a request to the login URL and save the returned JWT
|
// Send a request to the login URL and save the returned JWT
|
||||||
login (context, creds, redirect, onError) {
|
login (context, creds, redirect, onError) {
|
||||||
|
@ -50,7 +49,7 @@ export default {
|
||||||
|
|
||||||
checkAuth () {
|
checkAuth () {
|
||||||
logger.default.info('Checking authentication...')
|
logger.default.info('Checking authentication...')
|
||||||
var jwt = cache.get('token')
|
var jwt = this.getAuthToken()
|
||||||
var username = cache.get('username')
|
var username = cache.get('username')
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
this.user.authenticated = true
|
this.user.authenticated = true
|
||||||
|
@ -63,9 +62,13 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAuthToken () {
|
||||||
|
return cache.get('token')
|
||||||
|
},
|
||||||
|
|
||||||
// The object to be passed as a header for authenticated requests
|
// The object to be passed as a header for authenticated requests
|
||||||
getAuthHeader () {
|
getAuthHeader () {
|
||||||
return 'JWT ' + cache.get('token')
|
return 'JWT ' + this.getAuthToken()
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchProfile () {
|
fetchProfile () {
|
||||||
|
@ -83,7 +86,14 @@ export default {
|
||||||
let self = this
|
let self = this
|
||||||
this.fetchProfile().then(data => {
|
this.fetchProfile().then(data => {
|
||||||
Vue.set(self.user, 'profile', data)
|
Vue.set(self.user, 'profile', data)
|
||||||
|
Object.keys(data.permissions).forEach(function (key) {
|
||||||
|
// this makes it easier to check for permissions in templates
|
||||||
|
Vue.set(self.user.availablePermissions, key, data.permissions[String(key)].status)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
favoriteTracks.fetch()
|
favoriteTracks.fetch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Vue.set(auth, 'user', userData)
|
||||||
|
export default auth
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
Welcome on funkwhale
|
Welcome on funkwhale
|
||||||
</h1>
|
</h1>
|
||||||
<p>We think listening music should be simple.</p>
|
<p>We think listening music should be simple.</p>
|
||||||
<router-link class="ui icon teal button" to="/browse">
|
<router-link class="ui icon teal button" to="/library">
|
||||||
Get me to the library
|
Get me to the library
|
||||||
<i class="right arrow icon"></i>
|
<i class="right arrow icon"></i>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
Clean library
|
Clean library
|
||||||
</h2>
|
</h2>
|
||||||
<p>Funkwhale takes care of fealing your music.</p>
|
<p>Funkwhale takes care of handling your music.</p>
|
||||||
<div class="ui list">
|
<div class="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="download icon"></i>
|
<i class="download icon"></i>
|
||||||
|
@ -90,9 +90,9 @@
|
||||||
<p>Funkwhale is dead simple to use.</p>
|
<p>Funkwhale is dead simple to use.</p>
|
||||||
<div class="ui list">
|
<div class="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="browser icon"></i>
|
<i class="libraryr icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
No add-ons, no plugins : you only need a web browser
|
No add-ons, no plugins : you only need a web libraryr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
<div class="menu-area">
|
<div class="menu-area">
|
||||||
<div class="ui compact fluid two item inverted menu">
|
<div class="ui compact fluid two item inverted menu">
|
||||||
<a class="active item" data-tab="browse">Browse</a>
|
<a class="active item" data-tab="library">Browse</a>
|
||||||
<a class="item" data-tab="queue">
|
<a class="item" data-tab="queue">
|
||||||
Queue
|
Queue
|
||||||
<template v-if="queue.tracks.length === 0">
|
<template v-if="queue.tracks.length === 0">
|
||||||
|
@ -26,12 +26,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="ui bottom attached active tab" data-tab="browse">
|
<div class="ui bottom attached active tab" data-tab="library">
|
||||||
<div class="ui inverted vertical fluid menu">
|
<div class="ui inverted vertical fluid menu">
|
||||||
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link>
|
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link>
|
||||||
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
|
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
|
||||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||||
<router-link class="item" :to="{path: '/browse'}"><i class="sound icon"> </i>Browse library</router-link>
|
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -50,27 +50,27 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui bottom attached tab" data-tab="queue">
|
<div class="ui bottom attached tab" data-tab="queue">
|
||||||
<table class="ui compact inverted very basic fixed single line table">
|
<table class="ui compact inverted very basic fixed single line table">
|
||||||
<tbody>
|
<draggable v-model="queue.tracks" element="tbody" @update="reorder">
|
||||||
<tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :class="[{'active': index === queue.currentIndex}]">
|
<tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
|
||||||
<td class="right aligned">{{ index + 1}}</td>
|
<td class="right aligned">{{ index + 1}}</td>
|
||||||
<td class="center aligned">
|
<td class="center aligned">
|
||||||
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
|
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
|
||||||
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<strong>{{ track.title }}</strong><br />
|
<strong>{{ track.title }}</strong><br />
|
||||||
{{ track.artist.name }}
|
{{ track.artist.name }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="favoriteTracks.objects[track.id]">
|
<template v-if="favoriteTracks.objects[track.id]">
|
||||||
<i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
|
<i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
|
||||||
</template
|
</template
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
|
<i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</draggable>
|
||||||
</table>
|
</table>
|
||||||
<div v-if="radios.running" class="ui black message">
|
<div v-if="radios.running" class="ui black message">
|
||||||
|
|
||||||
|
@ -98,6 +98,7 @@ import SearchBar from '@/components/audio/SearchBar'
|
||||||
import auth from '@/auth'
|
import auth from '@/auth'
|
||||||
import queue from '@/audio/queue'
|
import queue from '@/audio/queue'
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
import radios from '@/radios'
|
import radios from '@/radios'
|
||||||
|
|
||||||
import $ from 'jquery'
|
import $ from 'jquery'
|
||||||
|
@ -107,7 +108,8 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
Player,
|
Player,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
Logo
|
Logo,
|
||||||
|
draggable
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
@ -120,6 +122,11 @@ export default {
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
$(this.$el).find('.menu .item').tab()
|
$(this.$el).find('.menu .item').tab()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
reorder (e) {
|
||||||
|
this.queue.reorder(e.oldIndex, e.newIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,14 +7,14 @@
|
||||||
<img v-else src="../../assets/audio/default-cover.png">
|
<img v-else src="../../assets/audio/default-cover.png">
|
||||||
</div>
|
</div>
|
||||||
<div class="middle aligned content">
|
<div class="middle aligned content">
|
||||||
<router-link class="small header discrete link track" :to="{name: 'browse.track', params: {id: queue.currentTrack.id }}">
|
<router-link class="small header discrete link track" :to="{name: 'library.track', params: {id: queue.currentTrack.id }}">
|
||||||
{{ queue.currentTrack.title }}
|
{{ queue.currentTrack.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<router-link class="artist" :to="{name: 'browse.artist', params: {id: queue.currentTrack.artist.id }}">
|
<router-link class="artist" :to="{name: 'library.artist', params: {id: queue.currentTrack.artist.id }}">
|
||||||
{{ queue.currentTrack.artist.name }}
|
{{ queue.currentTrack.artist.name }}
|
||||||
</router-link> /
|
</router-link> /
|
||||||
<router-link class="album" :to="{name: 'browse.album', params: {id: queue.currentTrack.album.id }}">
|
<router-link class="album" :to="{name: 'library.album', params: {id: queue.currentTrack.album.id }}">
|
||||||
{{ queue.currentTrack.album.title }}
|
{{ queue.currentTrack.album.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default {
|
||||||
let categories = [
|
let categories = [
|
||||||
{
|
{
|
||||||
code: 'artists',
|
code: 'artists',
|
||||||
route: 'browse.artist',
|
route: 'library.artist',
|
||||||
name: 'Artist',
|
name: 'Artist',
|
||||||
getTitle (r) {
|
getTitle (r) {
|
||||||
return r.name
|
return r.name
|
||||||
|
@ -46,7 +46,7 @@ export default {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'albums',
|
code: 'albums',
|
||||||
route: 'browse.album',
|
route: 'library.album',
|
||||||
name: 'Album',
|
name: 'Album',
|
||||||
getTitle (r) {
|
getTitle (r) {
|
||||||
return r.title
|
return r.title
|
||||||
|
@ -57,7 +57,7 @@ export default {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'tracks',
|
code: 'tracks',
|
||||||
route: 'browse.track',
|
route: 'library.track',
|
||||||
name: 'Track',
|
name: 'Track',
|
||||||
getTitle (r) {
|
getTitle (r) {
|
||||||
return r.title
|
return r.title
|
||||||
|
|
|
@ -6,10 +6,10 @@
|
||||||
<img v-else src="../../../assets/audio/default-cover.png">
|
<img v-else src="../../../assets/audio/default-cover.png">
|
||||||
</div>
|
</div>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<router-link class="discrete link" :to="{name: 'browse.album', params: {id: album.id }}">{{ album.title }}</router-link>
|
<router-link class="discrete link" :to="{name: 'library.album', params: {id: album.id }}">{{ album.title }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
By <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
By <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}">
|
||||||
{{ album.artist.name }}
|
{{ album.artist.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,7 +21,7 @@
|
||||||
<play-button class="basic icon" :track="track" :discrete="true"></play-button>
|
<play-button class="basic icon" :track="track" :discrete="true"></play-button>
|
||||||
</td>
|
</td>
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
<router-link class="track discrete link" :to="{name: 'browse.track', params: {id: track.id }}">
|
<router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}">
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="ui card">
|
<div class="ui card">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<router-link class="discrete link" :to="{name: 'browse.artist', params: {id: artist.id }}">
|
<router-link class="discrete link" :to="{name: 'library.artist', params: {id: artist.id }}">
|
||||||
{{ artist.name }}
|
{{ artist.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<router-link class="discrete link":to="{name: 'browse.album', params: {id: album.id }}">
|
<router-link class="discrete link":to="{name: 'library.album', params: {id: album.id }}">
|
||||||
<strong>{{ album.title }}</strong>
|
<strong>{{ album.title }}</strong>
|
||||||
</router-link><br />
|
</router-link><br />
|
||||||
{{ album.tracks.length }} tracks
|
{{ album.tracks.length }} tracks
|
||||||
|
|
|
@ -20,17 +20,17 @@
|
||||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
<router-link class="track" :to="{name: 'browse.track', params: {id: track.id }}">
|
<router-link class="track" :to="{name: 'library.track', params: {id: track.id }}">
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
<router-link class="artist discrete link" :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
<router-link class="artist discrete link" :to="{name: 'library.artist', params: {id: track.artist.id }}">
|
||||||
{{ track.artist.name }}
|
{{ track.artist.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
<router-link class="album discrete link" :to="{name: 'browse.album', params: {id: track.album.id }}">
|
<router-link class="album discrete link" :to="{name: 'library.album', params: {id: track.album.id }}">
|
||||||
{{ track.album.title }}
|
{{ track.album.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -69,7 +69,7 @@ export default {
|
||||||
}
|
}
|
||||||
// We need to pass the component's this context
|
// We need to pass the component's this context
|
||||||
// to properly make use of http in the auth service
|
// to properly make use of http in the auth service
|
||||||
auth.login(this, credentials, {path: '/browse'}, function (response) {
|
auth.login(this, credentials, {path: '/library'}, function (response) {
|
||||||
// error callback
|
// error callback
|
||||||
if (response.status === 400) {
|
if (response.status === 400) {
|
||||||
self.error = 'invalid_credentials'
|
self.error = 'invalid_credentials'
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
{{ album.title }}
|
{{ album.title }}
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
Album containing {{ album.tracks.length }} tracks,
|
Album containing {{ album.tracks.length }} tracks,
|
||||||
by <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
by <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}">
|
||||||
{{ album.artist.name }}
|
{{ album.artist.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
|
@ -34,7 +34,7 @@ import RadioCard from '@/components/radios/Card'
|
||||||
const ARTISTS_URL = config.API_URL + 'artists/'
|
const ARTISTS_URL = config.API_URL + 'artists/'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'browse',
|
name: 'library',
|
||||||
components: {
|
components: {
|
||||||
Search,
|
Search,
|
||||||
ArtistCard,
|
ArtistCard,
|
|
@ -1,7 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="main browse pusher">
|
<div class="main library pusher">
|
||||||
<div class="ui secondary pointing menu">
|
<div class="ui secondary pointing menu">
|
||||||
<router-link class="ui item" to="/browse">Browse</router-link>
|
<router-link class="ui item" to="/library" exact>Browse</router-link>
|
||||||
|
<router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
|
||||||
|
<router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
|
||||||
</div>
|
</div>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,18 +11,25 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
import auth from '@/auth'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'browse'
|
name: 'library',
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
auth
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.browse.pusher > .ui.secondary.menu {
|
.library.pusher > .ui.secondary.menu {
|
||||||
margin: 0 2.5rem;
|
margin: 0 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browse {
|
.library {
|
||||||
.ui.segment.head {
|
.ui.segment.head {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
|
@ -12,10 +12,10 @@
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
From album
|
From album
|
||||||
<router-link :to="{name: 'browse.album', params: {id: track.album.id }}">
|
<router-link :to="{name: 'library.album', params: {id: track.album.id }}">
|
||||||
{{ track.album.title }}
|
{{ track.album.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
by <router-link :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
by <router-link :to="{name: 'library.artist', params: {id: track.artist.id }}">
|
||||||
{{ track.artist.name }}
|
{{ track.artist.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,6 +61,8 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
import auth from '@/auth'
|
||||||
|
import url from '@/utils/url'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
|
@ -121,7 +123,11 @@ export default {
|
||||||
},
|
},
|
||||||
downloadUrl () {
|
downloadUrl () {
|
||||||
if (this.track.files.length > 0) {
|
if (this.track.files.length > 0) {
|
||||||
return backend.absoluteUrl(this.track.files[0].path)
|
let u = backend.absoluteUrl(this.track.files[0].path)
|
||||||
|
if (auth.user.authenticated) {
|
||||||
|
u = url.updateQueryString(u, 'jwt', auth.getAuthToken())
|
||||||
|
}
|
||||||
|
return u
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lyricsSearchUrl () {
|
lyricsSearchUrl () {
|
|
@ -0,0 +1,153 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="ui dividing block header">
|
||||||
|
<a :href="getMusicbrainzUrl('artist', metadata.id)" target="_blank" title="View on MusicBrainz">{{ metadata.name }}</a>
|
||||||
|
</h3>
|
||||||
|
<form class="ui form" @submit.prevent="">
|
||||||
|
<h6 class="ui header">Filter album types</h6>
|
||||||
|
<div class="inline fields">
|
||||||
|
<div class="field" v-for="t in availableReleaseTypes">
|
||||||
|
<div class="ui checkbox">
|
||||||
|
<input type="checkbox" :value="t" v-model="releaseTypes" />
|
||||||
|
<label>{{ t }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<template
|
||||||
|
v-for="release in releases">
|
||||||
|
<release-import
|
||||||
|
:key="release.id"
|
||||||
|
:metadata="release"
|
||||||
|
:backends="backends"
|
||||||
|
:defaultEnabled="false"
|
||||||
|
:default-backend-id="defaultBackendId"
|
||||||
|
@import-data-changed="recordReleaseData"
|
||||||
|
@enabled="recordReleaseEnabled"
|
||||||
|
></release-import>
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue'
|
||||||
|
import logger from '@/logging'
|
||||||
|
import config from '@/config'
|
||||||
|
|
||||||
|
import ImportMixin from './ImportMixin'
|
||||||
|
import ReleaseImport from './ReleaseImport'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
mixins: [ImportMixin],
|
||||||
|
components: {
|
||||||
|
ReleaseImport
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
releaseImportData: [],
|
||||||
|
releaseGroupsData: {},
|
||||||
|
releases: [],
|
||||||
|
releaseTypes: ['Album'],
|
||||||
|
availableReleaseTypes: ['Album', 'Live', 'Compilation', 'EP', 'Single', 'Other']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchReleaseGroupsData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
recordReleaseData (release) {
|
||||||
|
let existing = this.releaseImportData.filter(r => {
|
||||||
|
return r.releaseId === release.releaseId
|
||||||
|
})[0]
|
||||||
|
if (existing) {
|
||||||
|
existing.tracks = release.tracks
|
||||||
|
} else {
|
||||||
|
this.releaseImportData.push({
|
||||||
|
releaseId: release.releaseId,
|
||||||
|
enabled: true,
|
||||||
|
tracks: release.tracks
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recordReleaseEnabled (release, enabled) {
|
||||||
|
let existing = this.releaseImportData.filter(r => {
|
||||||
|
return r.releaseId === release.releaseId
|
||||||
|
})[0]
|
||||||
|
if (existing) {
|
||||||
|
existing.enabled = enabled
|
||||||
|
} else {
|
||||||
|
this.releaseImportData.push({
|
||||||
|
releaseId: release.releaseId,
|
||||||
|
enabled: enabled,
|
||||||
|
tracks: release.tracks
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchReleaseGroupsData () {
|
||||||
|
let self = this
|
||||||
|
this.releaseGroups.forEach(group => {
|
||||||
|
let url = config.API_URL + 'providers/musicbrainz/releases/browse/' + group.id + '/'
|
||||||
|
let resource = Vue.resource(url)
|
||||||
|
resource.get({}).then((response) => {
|
||||||
|
logger.default.info('successfully fetched release group', group.id)
|
||||||
|
let release = response.data['release-list'].filter(r => {
|
||||||
|
return r.status === 'Official'
|
||||||
|
})[0]
|
||||||
|
self.releaseGroupsData[group.id] = release
|
||||||
|
self.releases = self.computeReleaseData()
|
||||||
|
}, (response) => {
|
||||||
|
logger.default.error('error while fetching release group', group.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
computeReleaseData () {
|
||||||
|
let self = this
|
||||||
|
let releases = []
|
||||||
|
this.releaseGroups.forEach(group => {
|
||||||
|
let data = self.releaseGroupsData[group.id]
|
||||||
|
if (data) {
|
||||||
|
releases.push(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return releases
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
type () {
|
||||||
|
return 'artist'
|
||||||
|
},
|
||||||
|
releaseGroups () {
|
||||||
|
let self = this
|
||||||
|
return this.metadata['release-group-list'].filter(r => {
|
||||||
|
return self.releaseTypes.indexOf(r.type) !== -1
|
||||||
|
}).sort(function (a, b) {
|
||||||
|
if (a['first-release-date'] < b['first-release-date']) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (a['first-release-date'] > b['first-release-date']) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
},
|
||||||
|
importData () {
|
||||||
|
let releases = this.releaseImportData.filter(r => {
|
||||||
|
return r.enabled
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
artistId: this.metadata.id,
|
||||||
|
count: releases.reduce(function (a, b) {
|
||||||
|
return a + b.tracks.length
|
||||||
|
}, 0),
|
||||||
|
albums: releases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
releaseTypes (newValue) {
|
||||||
|
this.fetchReleaseGroupsData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,106 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="isLoading && !batch" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="batch" class="ui vertical stripe segment">
|
||||||
|
<div :class="
|
||||||
|
['ui',
|
||||||
|
{'active': batch.status === 'pending'},
|
||||||
|
{'warning': batch.status === 'pending'},
|
||||||
|
{'success': batch.status === 'finished'},
|
||||||
|
'progress']">
|
||||||
|
<div class="bar" :style="progressBarStyle">
|
||||||
|
<div class="progress"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div>
|
||||||
|
<div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div>
|
||||||
|
</div>
|
||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job ID</th>
|
||||||
|
<th>Recording MusicBrainz ID</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="job in batch.jobs">
|
||||||
|
<td>{{ job.id }}</th>
|
||||||
|
<td>
|
||||||
|
<a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a :href="job.source" target="_blank">{{ job.source }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
:class="['ui', {'yellow': job.status === 'pending'}, {'green': job.status === 'finished'}, 'label']">{{ job.status }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import logger from '@/logging'
|
||||||
|
import config from '@/config'
|
||||||
|
|
||||||
|
const FETCH_URL = config.API_URL + 'import-batches/'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['id'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isLoading: true,
|
||||||
|
batch: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData () {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
let url = FETCH_URL + this.id + '/'
|
||||||
|
logger.default.debug('Fetching batch "' + this.id + '"')
|
||||||
|
this.$http.get(url).then((response) => {
|
||||||
|
self.batch = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
if (self.batch.status === 'pending') {
|
||||||
|
setTimeout(
|
||||||
|
self.fetchData,
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
progress () {
|
||||||
|
return this.batch.jobs.filter(j => {
|
||||||
|
return j.status === 'finished'
|
||||||
|
}).length * 100 / this.batch.jobs.length
|
||||||
|
},
|
||||||
|
progressBarStyle () {
|
||||||
|
return 'width: ' + parseInt(this.progress) + '%'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
id () {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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 vertical stripe segment">
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<button class="ui left floated labeled icon button" @click="fetchData(previousLink)" :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button>
|
||||||
|
<button class="ui right floated right labeled icon button" @click="fetchData(nextLink)" :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
|
||||||
|
<div class="ui hidden clearing divider"></div>
|
||||||
|
<div class="ui hidden clearing divider"></div>
|
||||||
|
<table v-if="results.length > 0" class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Launch date</th>
|
||||||
|
<th>Jobs</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="result in results">
|
||||||
|
<td>{{ result.id }}</th>
|
||||||
|
<td>
|
||||||
|
<router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}">
|
||||||
|
{{ result.creation_date }}
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
<td>{{ result.jobs.length }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
:class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import logger from '@/logging'
|
||||||
|
import config from '@/config'
|
||||||
|
|
||||||
|
const BATCHES_URL = config.API_URL + 'import-batches/'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
isLoading: false,
|
||||||
|
nextLink: null,
|
||||||
|
previousLink: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData(BATCHES_URL)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData (url) {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
logger.default.time('Loading import batches')
|
||||||
|
this.$http.get(url, {}).then((response) => {
|
||||||
|
self.results = response.data.results
|
||||||
|
self.nextLink = response.data.next
|
||||||
|
self.previousLink = response.data.previous
|
||||||
|
logger.default.timeEnd('Loading import batches')
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,81 @@
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import logger from '@/logging'
|
||||||
|
import config from '@/config'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
metadata: {type: Object, required: true},
|
||||||
|
defaultEnabled: {type: Boolean, default: true},
|
||||||
|
backends: {type: Array},
|
||||||
|
defaultBackendId: {type: String}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
currentBackendId: this.defaultBackendId,
|
||||||
|
isImporting: false,
|
||||||
|
enabled: this.defaultEnabled
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getMusicbrainzUrl (type, id) {
|
||||||
|
return 'https://musicbrainz.org/' + type + '/' + id
|
||||||
|
},
|
||||||
|
launchImport () {
|
||||||
|
let self = this
|
||||||
|
this.isImporting = true
|
||||||
|
let url = config.API_URL + 'submit/' + self.importType + '/'
|
||||||
|
let payload = self.importData
|
||||||
|
let resource = Vue.resource(url)
|
||||||
|
resource.save({}, payload).then((response) => {
|
||||||
|
logger.default.info('launched import for', self.type, self.metadata.id)
|
||||||
|
self.isImporting = false
|
||||||
|
router.push({
|
||||||
|
name: 'library.import.batches.detail',
|
||||||
|
params: {
|
||||||
|
id: response.data.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, (response) => {
|
||||||
|
logger.default.error('error while launching import for', self.type, self.metadata.id)
|
||||||
|
self.isImporting = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
importType () {
|
||||||
|
return this.type
|
||||||
|
},
|
||||||
|
currentBackend () {
|
||||||
|
let self = this
|
||||||
|
return this.backends.filter(b => {
|
||||||
|
return b.id === self.currentBackendId
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isImporting (newValue) {
|
||||||
|
this.$emit('import-state-changed', newValue)
|
||||||
|
},
|
||||||
|
importData: {
|
||||||
|
handler (newValue) {
|
||||||
|
this.$emit('import-data-changed', newValue)
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
},
|
||||||
|
enabled (newValue) {
|
||||||
|
this.$emit('enabled', this.importData, newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,231 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<div class="ui top three attached ordered steps">
|
||||||
|
<a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]">
|
||||||
|
<div class="content">
|
||||||
|
<div class="title">Import source</div>
|
||||||
|
<div class="description">
|
||||||
|
Uploaded files or external source
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a @click="currentStep = 1" :class="['step', {'active': currentStep === 1}, {'completed': currentStep > 1}]">
|
||||||
|
<div class="content">
|
||||||
|
<div class="title">Metadata</div>
|
||||||
|
<div class="description">Grab corresponding metadata</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a @click="currentStep = 2" :class="['step', {'active': currentStep === 2}, {'completed': currentStep > 2}]">
|
||||||
|
<div class="content">
|
||||||
|
<div class="title">Music</div>
|
||||||
|
<div class="description">Select relevant sources or files for import</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<template v-if="currentStep === 0">
|
||||||
|
<p>First, choose where you want to import the music from :</p>
|
||||||
|
<form class="ui form">
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui radio checkbox">
|
||||||
|
<input type="radio" id="external" value="external" v-model="currentSource">
|
||||||
|
<label for="external">External source. Supported backends:
|
||||||
|
<div v-for="backend in backends" class="ui basic label">
|
||||||
|
<i v-if="backend.icon" :class="[backend.icon, 'icon']"></i>
|
||||||
|
{{ backend.label }}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui disabled radio checkbox">
|
||||||
|
<input type="radio" id="upload" value="upload" v-model="currentSource" disabled>
|
||||||
|
<label for="upload">File upload</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<div v-if="currentStep === 1" class="ui stackable two column grid">
|
||||||
|
<div class="column">
|
||||||
|
<form class="ui form" @submit.prevent="">
|
||||||
|
<div class="field">
|
||||||
|
<label>Search an entity you want to import:</label>
|
||||||
|
<metadata-search
|
||||||
|
:mb-type="mbType"
|
||||||
|
:mb-id="mbId"
|
||||||
|
@id-changed="updateId"
|
||||||
|
@type-changed="updateType"></metadata-search>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="ui horizontal divider">
|
||||||
|
Or
|
||||||
|
</div>
|
||||||
|
<form class="ui form" @submit.prevent="">
|
||||||
|
<div class="field">
|
||||||
|
<label>Input a MusicBrainz ID manually:</label>
|
||||||
|
<input type="text" v-model="currentId" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<template v-if="currentType && currentId">
|
||||||
|
<h4 class="ui header">You will import:</h4>
|
||||||
|
<component
|
||||||
|
:mbId="currentId"
|
||||||
|
:is="metadataComponent"
|
||||||
|
@metadata-changed="this.updateMetadata"
|
||||||
|
></component>
|
||||||
|
</template>
|
||||||
|
<p>You can also skip this step and enter metadata manually.</p>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<h5 class="ui header">What is metadata?</h5>
|
||||||
|
<p>Metadata is the data related to the music you want to import. This includes all the information about the artists, albums and tracks. In order to have a high quality library, it is recommended to grab data from the <a href="http://musicbrainz.org/" target="_blank">MusicBrainz project</a>, which you can think about as the Wikipedia of music.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentStep === 2">
|
||||||
|
<component
|
||||||
|
ref="import"
|
||||||
|
:metadata="metadata"
|
||||||
|
:is="importComponent"
|
||||||
|
:backends="backends"
|
||||||
|
:default-backend-id="backends[0].id"
|
||||||
|
@import-data-changed="updateImportData"
|
||||||
|
@import-state-changed="updateImportState"
|
||||||
|
></component>
|
||||||
|
</div>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div class="ui buttons">
|
||||||
|
<button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i> Previous step</button>
|
||||||
|
<button @click="currentStep += 1" v-if="currentStep < 2" class="ui icon button">Next step <i class="right arrow icon"></i></button>
|
||||||
|
<button
|
||||||
|
@click="$refs.import.launchImport()"
|
||||||
|
v-if="currentStep === 2"
|
||||||
|
:class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']"
|
||||||
|
:disabled="isImporting || importData.count === 0"
|
||||||
|
>Import {{ importData.count }} tracks <i class="check icon"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import MetadataSearch from '@/components/metadata/Search'
|
||||||
|
import ReleaseCard from '@/components/metadata/ReleaseCard'
|
||||||
|
import ArtistCard from '@/components/metadata/ArtistCard'
|
||||||
|
import ReleaseImport from './ReleaseImport'
|
||||||
|
import ArtistImport from './ArtistImport'
|
||||||
|
|
||||||
|
import router from '@/router'
|
||||||
|
import $ from 'jquery'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
MetadataSearch,
|
||||||
|
ArtistCard,
|
||||||
|
ReleaseCard,
|
||||||
|
ArtistImport,
|
||||||
|
ReleaseImport
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
mbType: {type: String, required: false},
|
||||||
|
source: {type: String, required: false},
|
||||||
|
mbId: {type: String, required: false}
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
currentType: this.mbType || 'artist',
|
||||||
|
currentId: this.mbId,
|
||||||
|
currentStep: 0,
|
||||||
|
currentSource: this.source || 'external',
|
||||||
|
metadata: {},
|
||||||
|
isImporting: false,
|
||||||
|
importData: {
|
||||||
|
tracks: []
|
||||||
|
},
|
||||||
|
backends: [
|
||||||
|
{
|
||||||
|
id: 'youtube',
|
||||||
|
label: 'YouTube',
|
||||||
|
icon: 'youtube'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
if (this.currentSource) {
|
||||||
|
this.currentStep = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted: function () {
|
||||||
|
$(this.$el).find('.ui.checkbox').checkbox()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateRoute () {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
source: this.currentSource,
|
||||||
|
type: this.currentType,
|
||||||
|
id: this.currentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateImportData (newValue) {
|
||||||
|
this.importData = newValue
|
||||||
|
},
|
||||||
|
updateImportState (newValue) {
|
||||||
|
this.isImporting = newValue
|
||||||
|
},
|
||||||
|
updateMetadata (newValue) {
|
||||||
|
this.metadata = newValue
|
||||||
|
},
|
||||||
|
updateType (newValue) {
|
||||||
|
this.currentType = newValue
|
||||||
|
},
|
||||||
|
updateId (newValue) {
|
||||||
|
this.currentId = newValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
metadataComponent () {
|
||||||
|
if (this.currentType === 'artist') {
|
||||||
|
return 'ArtistCard'
|
||||||
|
}
|
||||||
|
if (this.currentType === 'release') {
|
||||||
|
return 'ReleaseCard'
|
||||||
|
}
|
||||||
|
if (this.currentType === 'recording') {
|
||||||
|
return 'RecordingCard'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
importComponent () {
|
||||||
|
if (this.currentType === 'artist') {
|
||||||
|
return 'ArtistImport'
|
||||||
|
}
|
||||||
|
if (this.currentType === 'release') {
|
||||||
|
return 'ReleaseImport'
|
||||||
|
}
|
||||||
|
if (this.currentType === 'recording') {
|
||||||
|
return 'RecordingImport'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
currentType (newValue) {
|
||||||
|
this.currentId = ''
|
||||||
|
this.updateRoute()
|
||||||
|
},
|
||||||
|
currentId (newValue) {
|
||||||
|
this.updateRoute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,113 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="ui dividing block header">
|
||||||
|
Album <a :href="getMusicbrainzUrl('release', metadata.id)" target="_blank" title="View on MusicBrainz">{{ metadata.title }}</a> ({{ tracks.length}} tracks) by
|
||||||
|
<a :href="getMusicbrainzUrl('artist', metadata['artist-credit'][0]['artist']['id'])" target="_blank" title="View on MusicBrainz">{{ metadata['artist-credit-phrase'] }}</a>
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
<div class="sub header">
|
||||||
|
<div class="ui toggle checkbox">
|
||||||
|
<input type="checkbox" v-model="enabled" />
|
||||||
|
<label>Import this release</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
<template
|
||||||
|
v-if="enabled"
|
||||||
|
v-for="track in tracks">
|
||||||
|
<track-import
|
||||||
|
:key="track.recording.id"
|
||||||
|
:metadata="track"
|
||||||
|
:release-metadata="metadata"
|
||||||
|
:backends="backends"
|
||||||
|
:default-backend-id="defaultBackendId"
|
||||||
|
@import-data-changed="recordTrackData"
|
||||||
|
@enabled="recordTrackEnabled"
|
||||||
|
></track-import>
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue'
|
||||||
|
import ImportMixin from './ImportMixin'
|
||||||
|
import TrackImport from './TrackImport'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
mixins: [ImportMixin],
|
||||||
|
components: {
|
||||||
|
TrackImport
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
trackImportData: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
recordTrackData (track) {
|
||||||
|
let existing = this.trackImportData.filter(t => {
|
||||||
|
return t.mbid === track.mbid
|
||||||
|
})[0]
|
||||||
|
if (existing) {
|
||||||
|
existing.source = track.source
|
||||||
|
} else {
|
||||||
|
this.trackImportData.push({
|
||||||
|
mbid: track.mbid,
|
||||||
|
enabled: true,
|
||||||
|
source: track.source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recordTrackEnabled (track, enabled) {
|
||||||
|
let existing = this.trackImportData.filter(t => {
|
||||||
|
return t.mbid === track.mbid
|
||||||
|
})[0]
|
||||||
|
if (existing) {
|
||||||
|
existing.enabled = enabled
|
||||||
|
} else {
|
||||||
|
this.trackImportData.push({
|
||||||
|
mbid: track.mbid,
|
||||||
|
enabled: enabled,
|
||||||
|
source: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
type () {
|
||||||
|
return 'release'
|
||||||
|
},
|
||||||
|
importType () {
|
||||||
|
return 'album'
|
||||||
|
},
|
||||||
|
tracks () {
|
||||||
|
return this.metadata['medium-list'][0]['track-list']
|
||||||
|
},
|
||||||
|
importData () {
|
||||||
|
let tracks = this.trackImportData.filter(t => {
|
||||||
|
return t.enabled
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
releaseId: this.metadata.id,
|
||||||
|
count: tracks.length,
|
||||||
|
tracks: tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
importData: {
|
||||||
|
handler (newValue) {
|
||||||
|
this.$emit('import-data-changed', newValue)
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ui.card {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,188 @@
|
||||||
|
<template>
|
||||||
|
<div class="ui stackable grid">
|
||||||
|
<div class="three wide column">
|
||||||
|
<h5 class="ui header">
|
||||||
|
{{ metadata.position }}. {{ metadata.recording.title }}
|
||||||
|
<div class="sub header">
|
||||||
|
{{ time.parse(parseInt(metadata.length) / 1000) }}
|
||||||
|
</div>
|
||||||
|
</h5>
|
||||||
|
<div class="ui toggle checkbox">
|
||||||
|
<input type="checkbox" v-model="enabled" />
|
||||||
|
<label>Import this track</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="three wide column" v-if="enabled">
|
||||||
|
<form class="ui mini form" @submit.prevent="">
|
||||||
|
<div class="field">
|
||||||
|
<label>Source</label>
|
||||||
|
<select v-model="currentBackendId">
|
||||||
|
<option v-for="backend in backends" :value="backend.id">
|
||||||
|
{{ backend.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<template v-if="currentResult">
|
||||||
|
<button @click="currentResultIndex -= 1" class="ui basic tiny icon button" :disabled="currentResultIndex === 0">
|
||||||
|
<i class="left arrow icon"></i>
|
||||||
|
</button>
|
||||||
|
Result {{ currentResultIndex + 1 }}/{{ results.length }}
|
||||||
|
<button @click="currentResultIndex += 1" class="ui basic tiny icon button" :disabled="currentResultIndex + 1 === results.length">
|
||||||
|
<i class="right arrow icon"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="four wide column" v-if="enabled">
|
||||||
|
<form class="ui mini form" @submit.prevent="">
|
||||||
|
<div class="field">
|
||||||
|
<label>Search query</label>
|
||||||
|
<input type="text" v-model="query" />
|
||||||
|
<label>Imported URL</label>
|
||||||
|
<input type="text" v-model="importedUrl" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="six wide column" v-if="enabled">
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isLoading && currentResult" class="ui items">
|
||||||
|
<div class="item">
|
||||||
|
<div class="ui small image">
|
||||||
|
<img :src="currentResult.cover" />
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<a
|
||||||
|
:href="currentResult.url"
|
||||||
|
target="_blank"
|
||||||
|
class="description"
|
||||||
|
v-html="$options.filters.highlight(currentResult.title, warnings)"></a>
|
||||||
|
<div v-if="currentResult.channelTitle" class="meta">
|
||||||
|
{{ currentResult.channelTitle}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue'
|
||||||
|
import time from '@/utils/time'
|
||||||
|
import config from '@/config'
|
||||||
|
import logger from '@/logging'
|
||||||
|
import ImportMixin from './ImportMixin'
|
||||||
|
|
||||||
|
import $ from 'jquery'
|
||||||
|
|
||||||
|
Vue.filter('highlight', function (words, query) {
|
||||||
|
query.forEach(w => {
|
||||||
|
let re = new RegExp('(' + w + ')', 'gi')
|
||||||
|
words = words.replace(re, '<span class=\'highlight\'>$1</span>')
|
||||||
|
})
|
||||||
|
return words
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
mixins: [ImportMixin],
|
||||||
|
props: {
|
||||||
|
releaseMetadata: {type: Object, required: true}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
let queryParts = [
|
||||||
|
this.releaseMetadata['artist-credit'][0]['artist']['name'],
|
||||||
|
this.releaseMetadata['title'],
|
||||||
|
this.metadata['recording']['title']
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
query: queryParts.join(' '),
|
||||||
|
isLoading: false,
|
||||||
|
results: [],
|
||||||
|
currentResultIndex: 0,
|
||||||
|
importedUrl: '',
|
||||||
|
warnings: [
|
||||||
|
'live',
|
||||||
|
'full',
|
||||||
|
'cover'
|
||||||
|
],
|
||||||
|
time
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.search()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
$('.ui.checkbox').checkbox()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
search () {
|
||||||
|
let self = this
|
||||||
|
this.isLoading = true
|
||||||
|
let url = config.API_URL + 'providers/' + this.currentBackendId + '/search/'
|
||||||
|
let resource = Vue.resource(url)
|
||||||
|
|
||||||
|
resource.get({query: this.query}).then((response) => {
|
||||||
|
logger.default.debug('searching', self.query, 'on', self.currentBackendId)
|
||||||
|
self.results = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
}, (response) => {
|
||||||
|
logger.default.error('error while searching', self.query, 'on', self.currentBackendId)
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
type () {
|
||||||
|
return 'track'
|
||||||
|
},
|
||||||
|
currentResult () {
|
||||||
|
if (this.results) {
|
||||||
|
return this.results[this.currentResultIndex]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
importData () {
|
||||||
|
return {
|
||||||
|
count: 1,
|
||||||
|
mbid: this.metadata.recording.id,
|
||||||
|
source: this.importedUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
query () {
|
||||||
|
this.search()
|
||||||
|
},
|
||||||
|
currentResult (newValue) {
|
||||||
|
if (newValue) {
|
||||||
|
this.importedUrl = newValue.url
|
||||||
|
}
|
||||||
|
},
|
||||||
|
importedUrl (newValue) {
|
||||||
|
this.$emit('url-changed', this.importData, this.importedUrl)
|
||||||
|
},
|
||||||
|
enabled (newValue) {
|
||||||
|
if (newValue && this.results.length === 0) {
|
||||||
|
this.search()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ui.card {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style lang="scss">
|
||||||
|
.highlight {
|
||||||
|
font-weight: bold !important;
|
||||||
|
background-color: yellow !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,64 @@
|
||||||
|
<template>
|
||||||
|
<div class="ui card">
|
||||||
|
<div class="content">
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<template v-if="data.id">
|
||||||
|
<div class="header">
|
||||||
|
<a :href="getMusicbrainzUrl('artist', data.id)" target="_blank" title="View on MusicBrainz">{{ data.name }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<table class="ui very basic fixed single line compact table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="group in releasesGroups">
|
||||||
|
<td>
|
||||||
|
{{ group['first-release-date'] }}
|
||||||
|
</td>
|
||||||
|
<td colspan="3">
|
||||||
|
<a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" title="View on MusicBrainz">
|
||||||
|
{{ group.title }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue'
|
||||||
|
import CardMixin from './CardMixin'
|
||||||
|
import time from '@/utils/time'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
mixins: [CardMixin],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
time
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
type () {
|
||||||
|
return 'artist'
|
||||||
|
},
|
||||||
|
releasesGroups () {
|
||||||
|
return this.data['release-group-list'].filter(r => {
|
||||||
|
return r.type === 'Album'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ui.card {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
import config from '@/config'
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
mbId: {type: String, required: true}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
isLoading: false,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData () {
|
||||||
|
let self = this
|
||||||
|
this.isLoading = true
|
||||||
|
let url = config.API_URL + 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/'
|
||||||
|
let resource = Vue.resource(url)
|
||||||
|
resource.get({}).then((response) => {
|
||||||
|
logger.default.info('successfully fetched', self.type, self.mbId)
|
||||||
|
self.data = response.data[self.type]
|
||||||
|
this.$emit('metadata-changed', self.data)
|
||||||
|
self.isLoading = false
|
||||||
|
}, (response) => {
|
||||||
|
logger.default.error('error while fetching', self.type, self.mbId)
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getMusicbrainzUrl (type, id) {
|
||||||
|
return 'https://musicbrainz.org/' + type + '/' + id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<div class="ui card">
|
||||||
|
<div class="content">
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<template v-if="data.id">
|
||||||
|
<div class="header">
|
||||||
|
<a :href="getMusicbrainzUrl('release', data.id)" target="_blank" title="View on MusicBrainz">{{ data.title }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<a :href="getMusicbrainzUrl('artist', data['artist-credit'][0]['artist']['id'])" target="_blank" title="View on MusicBrainz">{{ data['artist-credit-phrase'] }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<table class="ui very basic fixed single line compact table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="track in tracks">
|
||||||
|
<td>
|
||||||
|
{{ track.position }}
|
||||||
|
</td>
|
||||||
|
<td colspan="3">
|
||||||
|
<a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" title="View on MusicBrainz">
|
||||||
|
{{ track.recording.title }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ time.parse(parseInt(track.length) / 1000) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue'
|
||||||
|
import CardMixin from './CardMixin'
|
||||||
|
import time from '@/utils/time'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
mixins: [CardMixin],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
time
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
type () {
|
||||||
|
return 'release'
|
||||||
|
},
|
||||||
|
tracks () {
|
||||||
|
return this.data['medium-list'][0]['track-list']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ui.card {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,153 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ui form">
|
||||||
|
<div class="inline fields">
|
||||||
|
<div v-for="type in types" class="field">
|
||||||
|
<div class="ui radio checkbox">
|
||||||
|
<input type="radio" :value="type.value" v-model="currentType">
|
||||||
|
<label >{{ type.label }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui fluid search">
|
||||||
|
<div class="ui icon input">
|
||||||
|
<input class="prompt" placeholder="Enter your search query..." type="text">
|
||||||
|
<i class="search icon"></i>
|
||||||
|
</div>
|
||||||
|
<div class="results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import jQuery from 'jquery'
|
||||||
|
import config from '@/config'
|
||||||
|
import auth from '@/auth'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
mbType: {type: String, required: false},
|
||||||
|
mbId: {type: String, required: false}
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
currentType: this.mbType || 'artist',
|
||||||
|
currentId: this.mbId || '',
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
value: 'artist',
|
||||||
|
label: 'Artist'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'release',
|
||||||
|
label: 'Album'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'recording',
|
||||||
|
label: 'Track'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted: function () {
|
||||||
|
jQuery(this.$el).find('.ui.checkbox').checkbox()
|
||||||
|
this.setUpSearch()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
setUpSearch () {
|
||||||
|
var self = this
|
||||||
|
jQuery(this.$el).search({
|
||||||
|
minCharacters: 3,
|
||||||
|
onSelect (result, response) {
|
||||||
|
self.currentId = result.id
|
||||||
|
},
|
||||||
|
apiSettings: {
|
||||||
|
beforeXHR: function (xhrObject, s) {
|
||||||
|
xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
|
||||||
|
return xhrObject
|
||||||
|
},
|
||||||
|
onResponse: function (initialResponse) {
|
||||||
|
let category = self.currentTypeObject.value
|
||||||
|
let results = initialResponse[category + '-list'].map(r => {
|
||||||
|
let description = []
|
||||||
|
if (category === 'artist') {
|
||||||
|
if (r.type) {
|
||||||
|
description.push(r.type)
|
||||||
|
}
|
||||||
|
if (r.area) {
|
||||||
|
description.push(r.area.name)
|
||||||
|
} else if (r['begin-area']) {
|
||||||
|
description.push(r['begin-area'].name)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: r.name,
|
||||||
|
id: r.id,
|
||||||
|
description: description.join(' - ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (category === 'release') {
|
||||||
|
if (r['medium-track-count']) {
|
||||||
|
description.push(
|
||||||
|
r['medium-track-count'] + ' tracks'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (r['artist-credit-phrase']) {
|
||||||
|
description.push(r['artist-credit-phrase'])
|
||||||
|
}
|
||||||
|
if (r['date']) {
|
||||||
|
description.push(r['date'])
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: r.title,
|
||||||
|
id: r.id,
|
||||||
|
description: description.join(' - ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (category === 'recording') {
|
||||||
|
if (r['artist-credit-phrase']) {
|
||||||
|
description.push(r['artist-credit-phrase'])
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: r.title,
|
||||||
|
id: r.id,
|
||||||
|
description: description.join(' - ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {results: results}
|
||||||
|
},
|
||||||
|
url: this.searchUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentTypeObject: function () {
|
||||||
|
let self = this
|
||||||
|
return this.types.filter(t => {
|
||||||
|
return t.value === self.currentType
|
||||||
|
})[0]
|
||||||
|
},
|
||||||
|
searchUrl: function () {
|
||||||
|
return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
currentType (newValue) {
|
||||||
|
this.setUpSearch()
|
||||||
|
this.$emit('type-changed', newValue)
|
||||||
|
},
|
||||||
|
currentId (newValue) {
|
||||||
|
this.$emit('id-changed', newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -4,11 +4,15 @@ import Home from '@/components/Home'
|
||||||
import Login from '@/components/auth/Login'
|
import Login from '@/components/auth/Login'
|
||||||
import Profile from '@/components/auth/Profile'
|
import Profile from '@/components/auth/Profile'
|
||||||
import Logout from '@/components/auth/Logout'
|
import Logout from '@/components/auth/Logout'
|
||||||
import Browse from '@/components/browse/Browse'
|
import Library from '@/components/library/Library'
|
||||||
import BrowseHome from '@/components/browse/Home'
|
import LibraryHome from '@/components/library/Home'
|
||||||
import BrowseArtist from '@/components/browse/Artist'
|
import LibraryArtist from '@/components/library/Artist'
|
||||||
import BrowseAlbum from '@/components/browse/Album'
|
import LibraryAlbum from '@/components/library/Album'
|
||||||
import BrowseTrack from '@/components/browse/Track'
|
import LibraryTrack from '@/components/library/Track'
|
||||||
|
import LibraryImport from '@/components/library/import/Main'
|
||||||
|
import BatchList from '@/components/library/import/BatchList'
|
||||||
|
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||||
|
|
||||||
import Favorites from '@/components/favorites/List'
|
import Favorites from '@/components/favorites/List'
|
||||||
|
|
||||||
Vue.use(Router)
|
Vue.use(Router)
|
||||||
|
@ -43,13 +47,27 @@ export default new Router({
|
||||||
component: Favorites
|
component: Favorites
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/browse',
|
path: '/library',
|
||||||
component: Browse,
|
component: Library,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: BrowseHome },
|
{ path: '', component: LibraryHome },
|
||||||
{ path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true },
|
{ path: 'artist/:id', name: 'library.artist', component: LibraryArtist, props: true },
|
||||||
{ path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true },
|
{ path: 'album/:id', name: 'library.album', component: LibraryAlbum, props: true },
|
||||||
{ path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true }
|
{ path: 'track/:id', name: 'library.track', component: LibraryTrack, props: true },
|
||||||
|
{
|
||||||
|
path: 'import/launch',
|
||||||
|
name: 'library.import.launch',
|
||||||
|
component: LibraryImport,
|
||||||
|
props: (route) => ({ mbType: route.query.type, mbId: route.query.id })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'import/batches',
|
||||||
|
name: 'library.import.batches',
|
||||||
|
component: BatchList,
|
||||||
|
children: [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
function pad (val) {
|
||||||
|
val = Math.floor(val)
|
||||||
|
if (val < 10) {
|
||||||
|
return '0' + val
|
||||||
|
}
|
||||||
|
return val + ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
parse: function (sec) {
|
||||||
|
let min = 0
|
||||||
|
min = Math.floor(sec / 60)
|
||||||
|
sec = sec - min * 60
|
||||||
|
return pad(min) + ':' + pad(sec)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default {
|
||||||
|
updateQueryString (uri, key, value) {
|
||||||
|
var re = new RegExp('([?&])' + key + '=.*?(&|$)', 'i')
|
||||||
|
var separator = uri.indexOf('?') !== -1 ? '&' : '?'
|
||||||
|
if (uri.match(re)) {
|
||||||
|
return uri.replace(re, '$1' + key + '=' + value + '$2')
|
||||||
|
} else {
|
||||||
|
return uri + separator + key + '=' + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue