Merge branch 'feature/4' into 'develop'

Fix #4: implement importing

Closes #4

See merge request !13
This commit is contained in:
Eliot Berriot 2017-07-09 09:35:48 +00:00
commit a433d3c149
43 changed files with 1614 additions and 120 deletions

View File

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

View File

@ -4,8 +4,11 @@ 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'trackfiles', views.TrackFileViewSet, 'trackfiles')
@ -14,17 +17,27 @@ 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
@ -298,11 +300,6 @@ 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 # Wether we should check user permission before serving audio files (meaning
@ -314,3 +311,13 @@ PROTECT_AUDIO_FILES = env.bool('PROTECT_AUDIO_FILES', default=True)
# Which path will be used to process the internal redirection # Which path will be used to process the internal redirection
# **DO NOT** put a slash at the end # **DO NOT** put a slash at the end
PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected') 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

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

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

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

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

@ -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 = {
// User object will let us check authentication status
user: {
authenticated: false, authenticated: false,
username: '', username: '',
profile: null availablePermissions: {},
}, profile: {}
}
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) {
@ -87,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>
@ -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>

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>

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