Merge branch 'release/0.2'

This commit is contained in:
Eliot Berriot 2017-07-09 11:37:52 +02:00
commit 30d6195e84
63 changed files with 1957 additions and 187 deletions

View File

@ -1,3 +1,3 @@
BACKEND_URL=http://localhost:12081
YOUTUBE_API_KEY=
API_AUTHENTICATION_REQUIRED=False
BACKEND_URL=http://localhost:6001
API_AUTHENTICATION_REQUIRED=True
CACHALOT_ENABLED=False

View File

@ -70,6 +70,7 @@ docker_develop:
stage: deploy
before_script:
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
- cp -r front/dist api/frontend
- cd api
script:
- docker build -t $IMAGE .
@ -83,6 +84,7 @@ docker_release:
stage: deploy
before_script:
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
- cp -r front/dist api/frontend
- cd api
script:
- docker build -t $IMAGE -t $IMAGE_LATEST .

View File

@ -15,4 +15,9 @@ export DATABASE_URL=postgres://$POSTGRES_ENV_POSTGRES_USER:$POSTGRES_ENV_POSTGRE
export CELERY_BROKER_URL=$REDIS_URL
# 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 "$@"

View File

@ -1,2 +0,0 @@
FROM nginx:latest
ADD nginx.conf /etc/nginx/nginx.conf

View File

@ -4,26 +4,40 @@ from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_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.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
router.register(r'tags', views.TagViewSet, 'tags')
router.register(r'tracks', views.TrackViewSet, 'tracks')
router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
router.register(r'artists', views.ArtistViewSet, 'artists')
router.register(r'albums', views.AlbumViewSet, 'albums')
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
router.register(r'submit', views.SubmitViewSet, 'submit')
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 += [
url(r'^providers/', include('funkwhale_api.providers.urls', namespace='providers')),
url(r'^favorites/', include('funkwhale_api.favorites.urls', namespace='favorites')),
url(r'^search$', views.Search.as_view(), name='search'),
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'^providers/',
include('funkwhale_api.providers.urls', namespace='providers')),
url(r'^favorites/',
include('funkwhale_api.favorites.urls', namespace='favorites')),
url(r'^search$',
views.Search.as_view(), name='search'),
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),
]

View File

@ -53,6 +53,7 @@ THIRD_PARTY_APPS = (
'rest_auth',
'rest_auth.registration',
'mptt',
'dynamic_preferences',
)
# Apps specific for this project go here.
@ -65,6 +66,7 @@ LOCAL_APPS = (
'funkwhale_api.history',
'funkwhale_api.playlists',
'funkwhale_api.providers.audiofile',
'funkwhale_api.providers.youtube',
)
# 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
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
@ -261,7 +262,6 @@ BROKER_URL = env("CELERY_BROKER_URL", default='django://')
# Location of root django.contrib.admin URL, use {% url 'admin:index' %}
ADMIN_URL = r'^admin/'
SESSION_SAVE_EVERY_REQUEST = True
# Your common stuff: Below this line define 3rd party library settings
CELERY_DEFAULT_RATE_LIMIT = 1
CELERYD_TASK_TIME_LIMIT = 300
@ -290,6 +290,7 @@ REST_FRAMEWORK = {
'PAGE_SIZE': 25,
'DEFAULT_AUTHENTICATION_CLASSES': (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'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
# 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)

View File

@ -1,3 +1,3 @@
# -*- 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('.')])

View File

@ -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)

View File

@ -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)

View File

@ -37,13 +37,26 @@ def get_mp3_recording_id(f, k):
except IndexError:
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 = {
'OggVorbis': {
'getter': lambda f, k: f[k][0],
'fields': {
'track_number': {
'field': 'TRACKNUMBER',
'to_application': int
'to_application': convert_track_number
},
'title': {
'field': 'title'
@ -74,7 +87,7 @@ CONF = {
'fields': {
'track_number': {
'field': 'TPOS',
'to_application': lambda v: int(v.split('/')[0])
'to_application': convert_track_number
},
'title': {
'field': 'TIT2'

View File

@ -8,7 +8,6 @@ import markdown
from django.conf import settings
from django.db import models
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.core.files.base import ContentFile
from django.core.files import File
from django.core.urlresolvers import reverse
@ -354,10 +353,12 @@ class TrackFile(models.Model):
@property
def path(self):
if settings.USE_SAMPLE_TRACK:
return static('music/sample1.ogg')
if settings.PROTECT_AUDIO_FILES:
return reverse(
'api:v1:trackfiles-serve', kwargs={'pk': self.pk})
return self.audio_file.url
class ImportBatch(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
submitted_by = models.ForeignKey('users.User', related_name='imports')

View File

@ -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'

View File

@ -10,6 +10,8 @@ from funkwhale_api.music import serializers
from funkwhale_api.users.models import User
from . import data as api_data
from . import factories
class TestAPI(TMPDirTestCaseMixin, TestCase):
@ -214,3 +216,26 @@ class TestAPI(TMPDirTestCaseMixin, TestCase):
with self.settings(API_AUTHENTICATION_REQUIRED=False):
response = getattr(self.client, method)(url)
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)
)

View File

@ -1,8 +1,11 @@
import os
import json
import unicodedata
import urllib
from django.core.urlresolvers import reverse
from django.db import models, transaction
from django.db.models.functions import Length
from django.conf import settings
from rest_framework import viewsets, views
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response
@ -51,6 +54,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
search_fields = ['name']
ordering_fields = ('creation_date',)
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Album.objects.all()
@ -63,6 +67,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
search_fields = ['title']
ordering_fields = ('creation_date',)
class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
queryset = models.ImportBatch.objects.all().order_by('-creation_date')
serializer_class = serializers.ImportBatchSerializer
@ -70,6 +75,7 @@ class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return super().get_queryset().filter(submitted_by=self.request.user)
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
"""
A simple ViewSet for viewing and editing accounts.
@ -120,6 +126,29 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
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):
queryset = Tag.objects.all().order_by('name')
serializer_class = serializers.TagSerializer

View File

@ -1,11 +1,17 @@
import musicbrainzngs
import memoize.djangocache
from django.conf import settings
from funkwhale_api import __version__
_api = musicbrainzngs
_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):
cleaned_kwargs = {}
if kwargs.get('name'):
@ -17,30 +23,55 @@ class API(object):
_api = _api
class artists(object):
search = clean_artist_search
get = _api.get_artist_by_id
search = memo(
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):
get_front = _api.get_image_front
get_front = memo(
_api.get_image_front,
max_age=settings.MUSICBRAINZ_CACHE_DURATION)
class recordings(object):
search = _api.search_recordings
get = _api.get_recording_by_id
search = memo(
_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):
search = _api.search_works
get = _api.get_work_by_id
search = memo(
_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):
search = _api.search_releases
get = _api.get_release_by_id
browse = _api.browse_releases
search = memo(
_api.search_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
class release_groups(object):
search = _api.search_release_groups
get = _api.get_release_group_by_id
browse = _api.browse_release_groups
search = memo(
_api.search_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
api = API()

View File

@ -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)

View File

@ -2,6 +2,7 @@ from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.decorators import list_route
import musicbrainzngs
from funkwhale_api.common.permissions import ConditionalAuthentication
@ -44,7 +45,7 @@ class ReleaseBrowse(APIView):
def get(self, request, *args, **kwargs):
result = api.releases.browse(
release_group=kwargs['release_group_uuid'],
includes=['recordings'])
includes=['recordings', 'artist-credits'])
return Response(result)
@ -54,17 +55,18 @@ class SearchViewSet(viewsets.ViewSet):
@list_route(methods=['get'])
def recordings(self, request, *args, **kwargs):
query = request.GET['query']
results = api.recordings.search(query, artist=query)
results = api.recordings.search(query)
return Response(results)
@list_route(methods=['get'])
def releases(self, request, *args, **kwargs):
query = request.GET['query']
results = api.releases.search(query, artist=query)
results = api.releases.search(query)
return Response(results)
@list_route(methods=['get'])
def artists(self, request, *args, **kwargs):
query = request.GET['query']
results = api.artists.search(query)
# results = musicbrainzngs.search_artists(query)
return Response(results)

View File

@ -4,21 +4,20 @@ from apiclient.discovery import build
from apiclient.errors import HttpError
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_VERSION = "v3"
VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}'
def _do_search(query):
youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
developerKey=DEVELOPER_KEY)
manager = registry.manager()
youtube = build(
YOUTUBE_API_SERVICE_NAME,
YOUTUBE_API_VERSION,
developerKey=manager['providers_youtube__api_key'])
return youtube.search().list(
q=query,
@ -55,4 +54,33 @@ class Client(object):
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()

View File

@ -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/.'

View File

@ -8,7 +8,7 @@ from funkwhale_api.providers.youtube.client import client
from . import data as api_data
class TestAPI(TestCase):
maxDiff = None
@unittest.mock.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
@ -25,11 +25,23 @@ class TestAPI(TestCase):
return_value=api_data.search['8 bit adventure'])
def test_can_get_search_results_from_funkwhale(self, *mocks):
query = '8 bit adventure'
expected = json.dumps(client.search(query))
url = self.reverse('api:v1:providers:youtube:search')
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(
'funkwhale_api.providers.youtube.client._do_search',
@ -66,9 +78,22 @@ class TestAPI(TestCase):
'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')
response = self.client.post(
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])

View File

@ -10,7 +10,10 @@ class APISearch(APIView):
def get(self, request, *args, **kwargs):
results = client.search(request.GET['query'])
return Response(results)
return Response([
client.to_funkwhale(result)
for result in results
])
class APISearchs(APIView):
@ -18,4 +21,10 @@ class APISearchs(APIView):
def post(self, request, *args, **kwargs):
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()
})

View File

@ -19,8 +19,11 @@ class User(AbstractUser):
relevant_permissions = {
# internal_codename : {external_codename}
'music.add_importbatch': {
'external_codename': 'import.launch'
}
'external_codename': 'import.launch',
},
'dynamic_preferences.change_globalpreferencemodel': {
'external_codename': 'settings.change',
},
}
def __str__(self):

View File

@ -47,7 +47,13 @@ class UserTestCase(TestCase):
# login required
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.login(user)
@ -63,3 +69,5 @@ class UserTestCase(TestCase):
self.assertEqual(payload['name'], user.name)
self.assertEqual(
payload['permissions']['import.launch']['status'], True)
self.assertEqual(
payload['permissions']['settings.change']['status'], True)

View File

@ -50,3 +50,9 @@ beautifulsoup4==4.6.0
Markdown==2.6.8
ipython==6.1.0
mutagen==1.38
# Until this is merged
git+https://github.com/EliotBerriot/PyMemoize.git@django
django-dynamic-preferences>=1.2,<1.3

View File

@ -6,13 +6,15 @@ services:
restart: unless-stopped
env_file: .env
image: postgres:9.4
volumes:
- ./data/postgres:/var/lib/postgresql/data
redis:
restart: unless-stopped
env_file: .env
image: redis:3
volumes:
- ./data:/data
- ./data/redis:/data
celeryworker:
restart: unless-stopped
@ -46,6 +48,7 @@ services:
- ./data/music:/music:ro
- ./data/media:/app/funkwhale_api/media
- ./data/static:/app/staticfiles
- ./front/dist:/frontend
ports:
- "${FUNKWHALE_API_IP:-127.0.0.1}:${FUNKWHALE_API_PORT:-5000}:5000"
links:

View File

@ -47,7 +47,17 @@ server {
location /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/ {
# django static files
alias /srv/funkwhale/data/static/;
}
}

22
dev.yml
View File

@ -27,7 +27,7 @@ services:
env_file: .env.dev
build:
context: ./api
dockerfile: docker/Dockerfile.local
dockerfile: docker/Dockerfile.test
links:
- postgres
- redis
@ -53,12 +53,14 @@ services:
- redis
- celeryworker
# nginx:
# env_file: .env.dev
# build: ./api/compose/nginx
# links:
# - api
# volumes:
# - ./api/funkwhale_api/media:/staticfiles/media
# ports:
# - "0.0.0.0:6001:80"
nginx:
env_file: .env.dev
image: nginx
links:
- api
- front
volumes:
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
- ./api/funkwhale_api/media:/protected/media
ports:
- "0.0.0.0:6001:80"

View File

@ -27,27 +27,21 @@ http {
#gzip on;
upstream app {
server django:12081;
}
server {
listen 80;
charset utf-8;
root /staticfiles;
location /_protected/media {
internal;
alias /protected/media;
}
location / {
# checks for static file, if not found proxy to app
try_files $uri @proxy_to_app;
}
location @proxy_to_app {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
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_pass http://app;
}
proxy_pass http://api:12081/;
}
}
}

View File

@ -1,6 +1,24 @@
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
-------

View File

@ -46,7 +46,7 @@ Then launch the whole thing:
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
-----------------------

View File

@ -26,6 +26,11 @@ Available installation methods
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:
.. code-block:: bash

View File

@ -19,7 +19,8 @@
"semantic-ui-css": "^2.2.10",
"vue": "^2.3.3",
"vue-resource": "^1.3.4",
"vue-router": "^2.3.1"
"vue-router": "^2.3.1",
"vuedraggable": "^2.14.1"
},
"devDependencies": {
"autoprefixer": "^6.7.2",

View File

@ -2,6 +2,26 @@
<div id="app">
<sidebar></sidebar>
<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>
</template>
@ -27,12 +47,16 @@ export default {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.main.pusher {
.main.pusher, .footer {
margin-left: 350px !important;
transform: none !important;
}
.main-pusher {
padding: 1.5rem 0;
}
#footer {
padding: 1.5rem;
}
.ui.stripe.segment {
padding: 4em;
}

View File

@ -1,12 +1,5 @@
import logger from '@/logging'
const pad = (val) => {
val = Math.floor(val)
if (val < 10) {
return '0' + val
}
return val + ''
}
import time from '@/utils/time'
const Cov = {
on (el, type, func) {
@ -108,7 +101,7 @@ class Audio {
})
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.durationTimerFormat = this.timeParse(this.state.duration)
this.state.durationTimerFormat = time.parse(this.state.duration)
}
updatePlayState (e) {
@ -116,9 +109,9 @@ class Audio {
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.durationTimerFormat = this.timeParse(this.state.duration)
this.state.currentTimeFormat = this.timeParse(this.state.currentTime)
this.state.lastTimeFormat = this.timeParse(this.state.duration - this.state.currentTime)
this.state.durationTimerFormat = time.parse(this.state.duration)
this.state.currentTimeFormat = time.parse(this.state.currentTime)
this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime)
this.hook.playState.forEach(func => {
func(this.state)
@ -181,14 +174,6 @@ class Audio {
}
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

View File

@ -5,6 +5,8 @@ import Audio from '@/audio'
import backend from '@/audio/backend'
import radios from '@/radios'
import Vue from 'vue'
import url from '@/utils/url'
import auth from '@/auth'
class Queue {
constructor (options = {}) {
@ -92,6 +94,24 @@ class Queue {
}
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) {
this.previousQueue = null
index = index || this.tracks.length
@ -163,7 +183,17 @@ class Queue {
if (!file) {
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,
autoplay: true,
rate: 1,

View File

@ -10,14 +10,13 @@ const LOGIN_URL = config.API_URL + 'token/'
const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
// const SIGNUP_URL = API_URL + 'users/'
export default {
// User object will let us check authentication status
user: {
let userData = {
authenticated: false,
username: '',
profile: null
},
availablePermissions: {},
profile: {}
}
let auth = {
// Send a request to the login URL and save the returned JWT
login (context, creds, redirect, onError) {
@ -50,7 +49,7 @@ export default {
checkAuth () {
logger.default.info('Checking authentication...')
var jwt = cache.get('token')
var jwt = this.getAuthToken()
var username = cache.get('username')
if (jwt) {
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
getAuthHeader () {
return 'JWT ' + cache.get('token')
return 'JWT ' + this.getAuthToken()
},
fetchProfile () {
@ -83,7 +86,14 @@ export default {
let self = this
this.fetchProfile().then(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()
}
}
Vue.set(auth, 'user', userData)
export default auth

View File

@ -6,7 +6,7 @@
Welcome on funkwhale
</h1>
<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
<i class="right arrow icon"></i>
</router-link>
@ -60,7 +60,7 @@
<h2 class="ui header">
Clean library
</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="item">
<i class="download icon"></i>
@ -90,9 +90,9 @@
<p>Funkwhale is dead simple to use.</p>
<div class="ui list">
<div class="item">
<i class="browser icon"></i>
<i class="libraryr icon"></i>
<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 class="item">

View File

@ -13,7 +13,7 @@
<div class="menu-area">
<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">
Queue &nbsp;
<template v-if="queue.tracks.length === 0">
@ -26,12 +26,12 @@
</div>
</div>
<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">
<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-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>
</div>
</div>
@ -50,8 +50,8 @@
</div>
<div class="ui bottom attached tab" data-tab="queue">
<table class="ui compact inverted very basic fixed single line table">
<tbody>
<tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :class="[{'active': index === queue.currentIndex}]">
<draggable v-model="queue.tracks" element="tbody" @update="reorder">
<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="center aligned">
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
@ -70,7 +70,7 @@
<i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
</td>
</tr>
</tbody>
</draggable>
</table>
<div v-if="radios.running" class="ui black message">
@ -98,6 +98,7 @@ import SearchBar from '@/components/audio/SearchBar'
import auth from '@/auth'
import queue from '@/audio/queue'
import backend from '@/audio/backend'
import draggable from 'vuedraggable'
import radios from '@/radios'
import $ from 'jquery'
@ -107,7 +108,8 @@ export default {
components: {
Player,
SearchBar,
Logo
Logo,
draggable
},
data () {
return {
@ -120,6 +122,11 @@ export default {
},
mounted () {
$(this.$el).find('.menu .item').tab()
},
methods: {
reorder (e) {
this.queue.reorder(e.oldIndex, e.newIndex)
}
}
}
</script>

View File

@ -7,14 +7,14 @@
<img v-else src="../../assets/audio/default-cover.png">
</div>
<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 }}
</router-link>
<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 }}
</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 }}
</router-link>
</div>

View File

@ -35,7 +35,7 @@ export default {
let categories = [
{
code: 'artists',
route: 'browse.artist',
route: 'library.artist',
name: 'Artist',
getTitle (r) {
return r.name
@ -46,7 +46,7 @@ export default {
},
{
code: 'albums',
route: 'browse.album',
route: 'library.album',
name: 'Album',
getTitle (r) {
return r.title
@ -57,7 +57,7 @@ export default {
},
{
code: 'tracks',
route: 'browse.track',
route: 'library.track',
name: 'Track',
getTitle (r) {
return r.title

View File

@ -6,10 +6,10 @@
<img v-else src="../../../assets/audio/default-cover.png">
</div>
<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 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 }}
</router-link>
</div>
@ -21,7 +21,7 @@
<play-button class="basic icon" :track="track" :discrete="true"></play-button>
</td>
<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 }}
</router-link>
</td>

View File

@ -2,7 +2,7 @@
<div class="ui card">
<div class="content">
<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 }}
</router-link>
</div>
@ -15,7 +15,7 @@
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
</td>
<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>
</router-link><br />
{{ album.tracks.length }} tracks

View File

@ -20,17 +20,17 @@
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
</td>
<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 }}
</router-link>
</td>
<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 }}
</router-link>
</td>
<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 }}
</router-link>
</td>

View File

@ -69,7 +69,7 @@ export default {
}
// We need to pass the component's this context
// 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
if (response.status === 400) {
self.error = 'invalid_credentials'

View File

@ -12,7 +12,7 @@
{{ album.title }}
<div class="sub header">
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 }}
</router-link>
</div>

View File

@ -34,7 +34,7 @@ import RadioCard from '@/components/radios/Card'
const ARTISTS_URL = config.API_URL + 'artists/'
export default {
name: 'browse',
name: 'library',
components: {
Search,
ArtistCard,

View File

@ -1,7 +1,9 @@
<template>
<div class="main browse pusher">
<div class="main library pusher">
<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>
<router-view></router-view>
</div>
@ -9,18 +11,25 @@
<script>
import auth from '@/auth'
export default {
name: 'browse'
name: 'library',
data: function () {
return {
auth
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss">
.browse.pusher > .ui.secondary.menu {
.library.pusher > .ui.secondary.menu {
margin: 0 2.5rem;
}
.browse {
.library {
.ui.segment.head {
background-size: cover;
background-position: center;

View File

@ -12,10 +12,10 @@
{{ track.title }}
<div class="sub header">
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 }}
</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 }}
</router-link>
</div>
@ -61,6 +61,8 @@
<script>
import auth from '@/auth'
import url from '@/utils/url'
import logger from '@/logging'
import backend from '@/audio/backend'
import PlayButton from '@/components/audio/PlayButton'
@ -121,7 +123,11 @@ export default {
},
downloadUrl () {
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 () {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -4,11 +4,15 @@ import Home from '@/components/Home'
import Login from '@/components/auth/Login'
import Profile from '@/components/auth/Profile'
import Logout from '@/components/auth/Logout'
import Browse from '@/components/browse/Browse'
import BrowseHome from '@/components/browse/Home'
import BrowseArtist from '@/components/browse/Artist'
import BrowseAlbum from '@/components/browse/Album'
import BrowseTrack from '@/components/browse/Track'
import Library from '@/components/library/Library'
import LibraryHome from '@/components/library/Home'
import LibraryArtist from '@/components/library/Artist'
import LibraryAlbum from '@/components/library/Album'
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'
Vue.use(Router)
@ -43,13 +47,27 @@ export default new Router({
component: Favorites
},
{
path: '/browse',
component: Browse,
path: '/library',
component: Library,
children: [
{ path: '', component: BrowseHome },
{ path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true },
{ path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true },
{ path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true }
{ path: '', component: LibraryHome },
{ path: 'artist/:id', name: 'library.artist', component: LibraryArtist, props: true },
{ path: 'album/:id', name: 'library.album', component: LibraryAlbum, 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 }
]
}

16
front/src/utils/time.js Normal file
View File

@ -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)
}
}

11
front/src/utils/url.js Normal file
View File

@ -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
}
}
}