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 BACKEND_URL=http://localhost:6001
YOUTUBE_API_KEY= API_AUTHENTICATION_REQUIRED=True
API_AUTHENTICATION_REQUIRED=False CACHALOT_ENABLED=False

View File

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

View File

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

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 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),
] ]

View File

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

View File

@ -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('.')])

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &nbsp; Queue &nbsp;
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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