From aa80bd15fa89a91f0cc943d20176549746b69ca3 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Thu, 29 Jun 2017 23:26:57 +0200 Subject: [PATCH] Fixed #4: can now import artists and releases with a clean interface :party: --- .env.dev | 2 +- api/config/api_urls.py | 29 ++- api/config/settings/common.py | 17 +- api/funkwhale_api/music/metadata.py | 17 +- api/funkwhale_api/musicbrainz/client.py | 57 ++++- .../musicbrainz/tests/test_cache.py | 17 ++ api/funkwhale_api/musicbrainz/views.py | 8 +- api/funkwhale_api/providers/youtube/client.py | 44 +++- .../youtube/dynamic_preferences_registry.py | 13 + .../providers/youtube/tests/test_youtube.py | 35 ++- api/funkwhale_api/providers/youtube/views.py | 13 +- api/funkwhale_api/users/models.py | 7 +- api/funkwhale_api/users/tests/test_views.py | 10 +- api/requirements/base.txt | 6 + dev.yml | 2 +- front/src/audio/index.js | 25 +- front/src/auth/index.js | 22 +- front/src/components/Home.vue | 6 +- front/src/components/Sidebar.vue | 6 +- front/src/components/audio/Player.vue | 6 +- front/src/components/audio/SearchBar.vue | 6 +- front/src/components/audio/album/Card.vue | 6 +- front/src/components/audio/artist/Card.vue | 4 +- front/src/components/audio/track/Table.vue | 6 +- front/src/components/auth/Login.vue | 2 +- .../components/{browse => library}/Album.vue | 2 +- .../components/{browse => library}/Artist.vue | 0 .../components/{browse => library}/Home.vue | 2 +- .../Browse.vue => library/Library.vue} | 19 +- .../components/{browse => library}/Track.vue | 4 +- .../library/import/ArtistImport.vue | 153 ++++++++++++ .../components/library/import/BatchDetail.vue | 106 ++++++++ .../components/library/import/BatchList.vue | 80 ++++++ .../components/library/import/ImportMixin.vue | 81 ++++++ front/src/components/library/import/Main.vue | 231 ++++++++++++++++++ .../library/import/ReleaseImport.vue | 113 +++++++++ .../components/library/import/TrackImport.vue | 188 ++++++++++++++ front/src/components/metadata/ArtistCard.vue | 64 +++++ front/src/components/metadata/CardMixin.vue | 50 ++++ front/src/components/metadata/ReleaseCard.vue | 66 +++++ front/src/components/metadata/Search.vue | 153 ++++++++++++ front/src/router/index.js | 40 ++- front/src/utils/time.js | 16 ++ 43 files changed, 1614 insertions(+), 120 deletions(-) create mode 100644 api/funkwhale_api/musicbrainz/tests/test_cache.py create mode 100644 api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py rename front/src/components/{browse => library}/Album.vue (96%) rename front/src/components/{browse => library}/Artist.vue (100%) rename front/src/components/{browse => library}/Home.vue (99%) rename front/src/components/{browse/Browse.vue => library/Library.vue} (55%) rename front/src/components/{browse => library}/Track.vue (96%) create mode 100644 front/src/components/library/import/ArtistImport.vue create mode 100644 front/src/components/library/import/BatchDetail.vue create mode 100644 front/src/components/library/import/BatchList.vue create mode 100644 front/src/components/library/import/ImportMixin.vue create mode 100644 front/src/components/library/import/Main.vue create mode 100644 front/src/components/library/import/ReleaseImport.vue create mode 100644 front/src/components/library/import/TrackImport.vue create mode 100644 front/src/components/metadata/ArtistCard.vue create mode 100644 front/src/components/metadata/CardMixin.vue create mode 100644 front/src/components/metadata/ReleaseCard.vue create mode 100644 front/src/components/metadata/Search.vue create mode 100644 front/src/utils/time.js diff --git a/.env.dev b/.env.dev index a7413b0ff1..bc2d667b1d 100644 --- a/.env.dev +++ b/.env.dev @@ -1,3 +1,3 @@ BACKEND_URL=http://localhost:6001 -YOUTUBE_API_KEY= API_AUTHENTICATION_REQUIRED=True +CACHALOT_ENABLED=False diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 9c612c94d9..5ed4cffdd5 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -4,8 +4,11 @@ from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views from rest_framework_jwt import views as jwt_views +from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet +from dynamic_preferences.users.viewsets import UserPreferencesViewSet router = routers.SimpleRouter() +router.register(r'settings', GlobalPreferencesViewSet, base_name='settings') router.register(r'tags', views.TagViewSet, 'tags') router.register(r'tracks', views.TrackViewSet, 'tracks') router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles') @@ -14,17 +17,27 @@ router.register(r'albums', views.AlbumViewSet, 'albums') router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches') router.register(r'submit', views.SubmitViewSet, 'submit') router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists') -router.register(r'playlist-tracks', playlists_views.PlaylistTrackViewSet, 'playlist-tracks') +router.register( + r'playlist-tracks', + playlists_views.PlaylistTrackViewSet, + 'playlist-tracks') v1_patterns = router.urls v1_patterns += [ - url(r'^providers/', include('funkwhale_api.providers.urls', namespace='providers')), - url(r'^favorites/', include('funkwhale_api.favorites.urls', namespace='favorites')), - url(r'^search$', views.Search.as_view(), name='search'), - url(r'^radios/', include('funkwhale_api.radios.urls', namespace='radios')), - url(r'^history/', include('funkwhale_api.history.urls', namespace='history')), - url(r'^users/', include('funkwhale_api.users.api_urls', namespace='users')), - url(r'^token/', jwt_views.obtain_jwt_token), + url(r'^providers/', + include('funkwhale_api.providers.urls', namespace='providers')), + url(r'^favorites/', + include('funkwhale_api.favorites.urls', namespace='favorites')), + url(r'^search$', + views.Search.as_view(), name='search'), + url(r'^radios/', + include('funkwhale_api.radios.urls', namespace='radios')), + url(r'^history/', + include('funkwhale_api.history.urls', namespace='history')), + url(r'^users/', + include('funkwhale_api.users.api_urls', namespace='users')), + url(r'^token/', + jwt_views.obtain_jwt_token), url(r'^token/refresh/', jwt_views.refresh_jwt_token), ] diff --git a/api/config/settings/common.py b/api/config/settings/common.py index b6e195ca2a..70804c3c9d 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -53,6 +53,7 @@ THIRD_PARTY_APPS = ( 'rest_auth', 'rest_auth.registration', 'mptt', + 'dynamic_preferences', ) # Apps specific for this project go here. @@ -65,6 +66,7 @@ LOCAL_APPS = ( 'funkwhale_api.history', 'funkwhale_api.playlists', 'funkwhale_api.providers.audiofile', + 'funkwhale_api.providers.youtube', ) # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -298,11 +300,6 @@ REST_FRAMEWORK = { ) } -FUNKWHALE_PROVIDERS = { - 'youtube': { - 'api_key': env('YOUTUBE_API_KEY', default='REPLACE_ME') - } -} ATOMIC_REQUESTS = False # Wether we should check user permission before serving audio files (meaning @@ -314,3 +311,13 @@ 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) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index d1be9a4e1f..31d13d4957 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -37,13 +37,26 @@ def get_mp3_recording_id(f, k): except IndexError: raise TagNotFound(k) + +def convert_track_number(v): + try: + return int(v) + except ValueError: + # maybe the position is of the form "1/4" + pass + + try: + return int(v.split('/')[0]) + except (ValueError, AttributeError, IndexError): + pass + CONF = { 'OggVorbis': { 'getter': lambda f, k: f[k][0], 'fields': { 'track_number': { 'field': 'TRACKNUMBER', - 'to_application': int + 'to_application': convert_track_number }, 'title': { 'field': 'title' @@ -74,7 +87,7 @@ CONF = { 'fields': { 'track_number': { 'field': 'TPOS', - 'to_application': lambda v: int(v.split('/')[0]) + 'to_application': convert_track_number }, 'title': { 'field': 'TIT2' diff --git a/api/funkwhale_api/musicbrainz/client.py b/api/funkwhale_api/musicbrainz/client.py index e281555b2e..049ed298c7 100644 --- a/api/funkwhale_api/musicbrainz/client.py +++ b/api/funkwhale_api/musicbrainz/client.py @@ -1,11 +1,17 @@ import musicbrainzngs +import memoize.djangocache from django.conf import settings from funkwhale_api import __version__ + _api = musicbrainzngs _api.set_useragent('funkwhale', str(__version__), 'contact@eliotberriot.com') +store = memoize.djangocache.Cache('default') +memo = memoize.Memoizer(store, namespace='memoize:musicbrainz') + + def clean_artist_search(query, **kwargs): cleaned_kwargs = {} if kwargs.get('name'): @@ -17,30 +23,55 @@ class API(object): _api = _api class artists(object): - search = clean_artist_search - get = _api.get_artist_by_id + search = memo( + clean_artist_search, max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo( + _api.get_artist_by_id, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) class images(object): - get_front = _api.get_image_front + get_front = memo( + _api.get_image_front, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) class recordings(object): - search = _api.search_recordings - get = _api.get_recording_by_id + search = memo( + _api.search_recordings, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo( + _api.get_recording_by_id, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) class works(object): - search = _api.search_works - get = _api.get_work_by_id + search = memo( + _api.search_works, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo( + _api.get_work_by_id, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) class releases(object): - search = _api.search_releases - get = _api.get_release_by_id - browse = _api.browse_releases + search = memo( + _api.search_releases, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo( + _api.get_release_by_id, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) + browse = memo( + _api.browse_releases, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) # get_image_front = _api.get_image_front class release_groups(object): - search = _api.search_release_groups - get = _api.get_release_group_by_id - browse = _api.browse_release_groups + search = memo( + _api.search_release_groups, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo( + _api.get_release_group_by_id, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) + browse = memo( + _api.browse_release_groups, + max_age=settings.MUSICBRAINZ_CACHE_DURATION) # get_image_front = _api.get_image_front api = API() diff --git a/api/funkwhale_api/musicbrainz/tests/test_cache.py b/api/funkwhale_api/musicbrainz/tests/test_cache.py new file mode 100644 index 0000000000..d2d1260ecb --- /dev/null +++ b/api/funkwhale_api/musicbrainz/tests/test_cache.py @@ -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) diff --git a/api/funkwhale_api/musicbrainz/views.py b/api/funkwhale_api/musicbrainz/views.py index fac8089b98..5c101b1619 100644 --- a/api/funkwhale_api/musicbrainz/views.py +++ b/api/funkwhale_api/musicbrainz/views.py @@ -2,6 +2,7 @@ from rest_framework import viewsets from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.decorators import list_route +import musicbrainzngs from funkwhale_api.common.permissions import ConditionalAuthentication @@ -44,7 +45,7 @@ class ReleaseBrowse(APIView): def get(self, request, *args, **kwargs): result = api.releases.browse( release_group=kwargs['release_group_uuid'], - includes=['recordings']) + includes=['recordings', 'artist-credits']) return Response(result) @@ -54,17 +55,18 @@ class SearchViewSet(viewsets.ViewSet): @list_route(methods=['get']) def recordings(self, request, *args, **kwargs): query = request.GET['query'] - results = api.recordings.search(query, artist=query) + results = api.recordings.search(query) return Response(results) @list_route(methods=['get']) def releases(self, request, *args, **kwargs): query = request.GET['query'] - results = api.releases.search(query, artist=query) + results = api.releases.search(query) return Response(results) @list_route(methods=['get']) def artists(self, request, *args, **kwargs): query = request.GET['query'] results = api.artists.search(query) + # results = musicbrainzngs.search_artists(query) return Response(results) diff --git a/api/funkwhale_api/providers/youtube/client.py b/api/funkwhale_api/providers/youtube/client.py index 7c0ea326c4..792e501d74 100644 --- a/api/funkwhale_api/providers/youtube/client.py +++ b/api/funkwhale_api/providers/youtube/client.py @@ -4,21 +4,20 @@ from apiclient.discovery import build from apiclient.errors import HttpError from oauth2client.tools import argparser -from django.conf import settings +from dynamic_preferences.registries import ( + global_preferences_registry as registry) -# Set DEVELOPER_KEY to the API key value from the APIs & auth > Registered apps -# tab of -# https://cloud.google.com/console -# Please ensure that you have enabled the YouTube Data API for your project. -DEVELOPER_KEY = settings.FUNKWHALE_PROVIDERS['youtube']['api_key'] YOUTUBE_API_SERVICE_NAME = "youtube" YOUTUBE_API_VERSION = "v3" VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}' def _do_search(query): - youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, - developerKey=DEVELOPER_KEY) + manager = registry.manager() + youtube = build( + YOUTUBE_API_SERVICE_NAME, + YOUTUBE_API_VERSION, + developerKey=manager['providers_youtube__api_key']) return youtube.search().list( q=query, @@ -55,4 +54,33 @@ class Client(object): return results + def to_funkwhale(self, result): + """ + We convert youtube results to something more generic. + + { + "id": "video id", + "type": "youtube#video", + "url": "https://www.youtube.com/watch?v=id", + "description": "description", + "channelId": "Channel id", + "title": "Title", + "channelTitle": "channel Title", + "publishedAt": "2012-08-22T18:41:03.000Z", + "cover": "http://coverurl" + } + """ + return { + 'id': result['id']['videoId'], + 'url': 'https://www.youtube.com/watch?v={}'.format( + result['id']['videoId']), + 'type': result['id']['kind'], + 'title': result['snippet']['title'], + 'description': result['snippet']['description'], + 'channelId': result['snippet']['channelId'], + 'channelTitle': result['snippet']['channelTitle'], + 'publishedAt': result['snippet']['publishedAt'], + 'cover': result['snippet']['thumbnails']['high']['url'], + } + client = Client() diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py new file mode 100644 index 0000000000..fc7f7d793e --- /dev/null +++ b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py @@ -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/.' diff --git a/api/funkwhale_api/providers/youtube/tests/test_youtube.py b/api/funkwhale_api/providers/youtube/tests/test_youtube.py index ca0a956285..db6bd8413f 100644 --- a/api/funkwhale_api/providers/youtube/tests/test_youtube.py +++ b/api/funkwhale_api/providers/youtube/tests/test_youtube.py @@ -8,7 +8,7 @@ from funkwhale_api.providers.youtube.client import client from . import data as api_data class TestAPI(TestCase): - + maxDiff = None @unittest.mock.patch( 'funkwhale_api.providers.youtube.client._do_search', return_value=api_data.search['8 bit adventure']) @@ -25,11 +25,23 @@ class TestAPI(TestCase): return_value=api_data.search['8 bit adventure']) def test_can_get_search_results_from_funkwhale(self, *mocks): query = '8 bit adventure' - expected = json.dumps(client.search(query)) url = self.reverse('api:v1:providers:youtube:search') response = self.client.get(url + '?query={0}'.format(query)) + # we should cast the youtube result to something more generic + expected = { + "id": "0HxZn6CzOIo", + "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", + "type": "youtube#video", + "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "channelId": "UCps63j3krzAG4OyXeEyuhFw", + "title": "AdhesiveWombat - 8 Bit Adventure", + "channelTitle": "AdhesiveWombat", + "publishedAt": "2012-08-22T18:41:03.000Z", + "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" + } - self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8'))) + self.assertEqual( + json.loads(response.content.decode('utf-8'))[0], expected) @unittest.mock.patch( 'funkwhale_api.providers.youtube.client._do_search', @@ -66,9 +78,22 @@ class TestAPI(TestCase): 'q': '8 bit adventure', } - expected = json.dumps(client.search_multiple(queries)) + expected = { + "id": "0HxZn6CzOIo", + "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", + "type": "youtube#video", + "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "channelId": "UCps63j3krzAG4OyXeEyuhFw", + "title": "AdhesiveWombat - 8 Bit Adventure", + "channelTitle": "AdhesiveWombat", + "publishedAt": "2012-08-22T18:41:03.000Z", + "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" + } + url = self.reverse('api:v1:providers:youtube:searchs') response = self.client.post( url, json.dumps(queries), content_type='application/json') - self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8'))) + self.assertEqual( + expected, + json.loads(response.content.decode('utf-8'))['1'][0]) diff --git a/api/funkwhale_api/providers/youtube/views.py b/api/funkwhale_api/providers/youtube/views.py index 7ad2c2c3df..989b33090c 100644 --- a/api/funkwhale_api/providers/youtube/views.py +++ b/api/funkwhale_api/providers/youtube/views.py @@ -10,7 +10,10 @@ class APISearch(APIView): def get(self, request, *args, **kwargs): results = client.search(request.GET['query']) - return Response(results) + return Response([ + client.to_funkwhale(result) + for result in results + ]) class APISearchs(APIView): @@ -18,4 +21,10 @@ class APISearchs(APIView): def post(self, request, *args, **kwargs): results = client.search_multiple(request.data) - return Response(results) + return Response({ + key: [ + client.to_funkwhale(result) + for result in group + ] + for key, group in results.items() + }) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 1abbbb51ff..c5ca896ab6 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -19,8 +19,11 @@ class User(AbstractUser): relevant_permissions = { # internal_codename : {external_codename} 'music.add_importbatch': { - 'external_codename': 'import.launch' - } + 'external_codename': 'import.launch', + }, + 'dynamic_preferences.change_globalpreferencemodel': { + 'external_codename': 'settings.change', + }, } def __str__(self): diff --git a/api/funkwhale_api/users/tests/test_views.py b/api/funkwhale_api/users/tests/test_views.py index 6250a7ca78..52826cfa4b 100644 --- a/api/funkwhale_api/users/tests/test_views.py +++ b/api/funkwhale_api/users/tests/test_views.py @@ -47,7 +47,13 @@ class UserTestCase(TestCase): # login required self.assertEqual(response.status_code, 401) - user = UserFactory(is_staff=True, perms=['music.add_importbatch']) + user = UserFactory( + is_staff=True, + perms=[ + 'music.add_importbatch', + 'dynamic_preferences.change_globalpreferencemodel', + ] + ) self.assertTrue(user.has_perm('music.add_importbatch')) self.login(user) @@ -63,3 +69,5 @@ class UserTestCase(TestCase): self.assertEqual(payload['name'], user.name) self.assertEqual( payload['permissions']['import.launch']['status'], True) + self.assertEqual( + payload['permissions']['settings.change']['status'], True) diff --git a/api/requirements/base.txt b/api/requirements/base.txt index ae851962a4..bdf17cf9a6 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -50,3 +50,9 @@ beautifulsoup4==4.6.0 Markdown==2.6.8 ipython==6.1.0 mutagen==1.38 + + +# Until this is merged +git+https://github.com/EliotBerriot/PyMemoize.git@django + +django-dynamic-preferences>=1.2,<1.3 diff --git a/dev.yml b/dev.yml index 7122884920..f0fc8845a0 100644 --- a/dev.yml +++ b/dev.yml @@ -27,7 +27,7 @@ services: env_file: .env.dev build: context: ./api - dockerfile: docker/Dockerfile.local + dockerfile: docker/Dockerfile.test links: - postgres - redis diff --git a/front/src/audio/index.js b/front/src/audio/index.js index 22cc85ad33..48f6104439 100644 --- a/front/src/audio/index.js +++ b/front/src/audio/index.js @@ -1,12 +1,5 @@ import logger from '@/logging' - -const pad = (val) => { - val = Math.floor(val) - if (val < 10) { - return '0' + val - } - return val + '' -} +import time from '@/utils/time' const Cov = { on (el, type, func) { @@ -108,7 +101,7 @@ class Audio { }) this.state.duration = Math.round(this.$Audio.duration * 100) / 100 this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100 - this.state.durationTimerFormat = this.timeParse(this.state.duration) + this.state.durationTimerFormat = time.parse(this.state.duration) } updatePlayState (e) { @@ -116,9 +109,9 @@ class Audio { this.state.duration = Math.round(this.$Audio.duration * 100) / 100 this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100 - this.state.durationTimerFormat = this.timeParse(this.state.duration) - this.state.currentTimeFormat = this.timeParse(this.state.currentTime) - this.state.lastTimeFormat = this.timeParse(this.state.duration - this.state.currentTime) + this.state.durationTimerFormat = time.parse(this.state.duration) + this.state.currentTimeFormat = time.parse(this.state.currentTime) + this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime) this.hook.playState.forEach(func => { func(this.state) @@ -181,14 +174,6 @@ class Audio { } this.$Audio.currentTime = time } - - timeParse (sec) { - let min = 0 - min = Math.floor(sec / 60) - sec = sec - min * 60 - return pad(min) + ':' + pad(sec) - } - } export default Audio diff --git a/front/src/auth/index.js b/front/src/auth/index.js index 219a1531f0..8023694285 100644 --- a/front/src/auth/index.js +++ b/front/src/auth/index.js @@ -10,14 +10,13 @@ const LOGIN_URL = config.API_URL + 'token/' const USER_PROFILE_URL = config.API_URL + 'users/users/me/' // const SIGNUP_URL = API_URL + 'users/' -export default { - - // User object will let us check authentication status - user: { - authenticated: false, - username: '', - profile: null - }, +let userData = { + authenticated: false, + username: '', + availablePermissions: {}, + profile: {} +} +let auth = { // Send a request to the login URL and save the returned JWT login (context, creds, redirect, onError) { @@ -87,7 +86,14 @@ export default { let self = this this.fetchProfile().then(data => { Vue.set(self.user, 'profile', data) + Object.keys(data.permissions).forEach(function (key) { + // this makes it easier to check for permissions in templates + Vue.set(self.user.availablePermissions, key, data.permissions[String(key)].status) + }) }) favoriteTracks.fetch() } } + +Vue.set(auth, 'user', userData) +export default auth diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index ea0f5edc07..dd324943fe 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -6,7 +6,7 @@ Welcome on funkwhale </h1> <p>We think listening music should be simple.</p> - <router-link class="ui icon teal button" to="/browse"> + <router-link class="ui icon teal button" to="/library"> Get me to the library <i class="right arrow icon"></i> </router-link> @@ -90,9 +90,9 @@ <p>Funkwhale is dead simple to use.</p> <div class="ui list"> <div class="item"> - <i class="browser icon"></i> + <i class="libraryr icon"></i> <div class="content"> - No add-ons, no plugins : you only need a web browser + No add-ons, no plugins : you only need a web libraryr </div> </div> <div class="item"> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 90e6d2d060..e39dd16b9c 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -13,7 +13,7 @@ <div class="menu-area"> <div class="ui compact fluid two item inverted menu"> - <a class="active item" data-tab="browse">Browse</a> + <a class="active item" data-tab="library">Browse</a> <a class="item" data-tab="queue"> Queue <template v-if="queue.tracks.length === 0"> @@ -26,12 +26,12 @@ </div> </div> <div class="tabs"> - <div class="ui bottom attached active tab" data-tab="browse"> + <div class="ui bottom attached active tab" data-tab="library"> <div class="ui inverted vertical fluid menu"> <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link> <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link> <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link> - <router-link class="item" :to="{path: '/browse'}"><i class="sound icon"> </i>Browse library</router-link> + <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link> <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link> </div> </div> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 2f41bbbf08..466ead0e83 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -7,14 +7,14 @@ <img v-else src="../../assets/audio/default-cover.png"> </div> <div class="middle aligned content"> - <router-link class="small header discrete link track" :to="{name: 'browse.track', params: {id: queue.currentTrack.id }}"> + <router-link class="small header discrete link track" :to="{name: 'library.track', params: {id: queue.currentTrack.id }}"> {{ queue.currentTrack.title }} </router-link> <div class="meta"> - <router-link class="artist" :to="{name: 'browse.artist', params: {id: queue.currentTrack.artist.id }}"> + <router-link class="artist" :to="{name: 'library.artist', params: {id: queue.currentTrack.artist.id }}"> {{ queue.currentTrack.artist.name }} </router-link> / - <router-link class="album" :to="{name: 'browse.album', params: {id: queue.currentTrack.album.id }}"> + <router-link class="album" :to="{name: 'library.album', params: {id: queue.currentTrack.album.id }}"> {{ queue.currentTrack.album.title }} </router-link> </div> diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 64bf3202f2..6f4629296d 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -35,7 +35,7 @@ export default { let categories = [ { code: 'artists', - route: 'browse.artist', + route: 'library.artist', name: 'Artist', getTitle (r) { return r.name @@ -46,7 +46,7 @@ export default { }, { code: 'albums', - route: 'browse.album', + route: 'library.album', name: 'Album', getTitle (r) { return r.title @@ -57,7 +57,7 @@ export default { }, { code: 'tracks', - route: 'browse.track', + route: 'library.track', name: 'Track', getTitle (r) { return r.title diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 79261ef7e8..fcdf1622d0 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -6,10 +6,10 @@ <img v-else src="../../../assets/audio/default-cover.png"> </div> <div class="header"> - <router-link class="discrete link" :to="{name: 'browse.album', params: {id: album.id }}">{{ album.title }}</router-link> + <router-link class="discrete link" :to="{name: 'library.album', params: {id: album.id }}">{{ album.title }}</router-link> </div> <div class="meta"> - By <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}"> + By <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}"> {{ album.artist.name }} </router-link> </div> @@ -21,7 +21,7 @@ <play-button class="basic icon" :track="track" :discrete="true"></play-button> </td> <td colspan="6"> - <router-link class="track discrete link" :to="{name: 'browse.track', params: {id: track.id }}"> + <router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}"> {{ track.title }} </router-link> </td> diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index 4cdd2969fd..a9701c07eb 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -2,7 +2,7 @@ <div class="ui card"> <div class="content"> <div class="header"> - <router-link class="discrete link" :to="{name: 'browse.artist', params: {id: artist.id }}"> + <router-link class="discrete link" :to="{name: 'library.artist', params: {id: artist.id }}"> {{ artist.name }} </router-link> </div> @@ -15,7 +15,7 @@ <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> </td> <td colspan="4"> - <router-link class="discrete link":to="{name: 'browse.album', params: {id: album.id }}"> + <router-link class="discrete link":to="{name: 'library.album', params: {id: album.id }}"> <strong>{{ album.title }}</strong> </router-link><br /> {{ album.tracks.length }} tracks diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index aa3d324ed8..6898353d89 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -20,17 +20,17 @@ <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png"> </td> <td colspan="6"> - <router-link class="track" :to="{name: 'browse.track', params: {id: track.id }}"> + <router-link class="track" :to="{name: 'library.track', params: {id: track.id }}"> {{ track.title }} </router-link> </td> <td colspan="6"> - <router-link class="artist discrete link" :to="{name: 'browse.artist', params: {id: track.artist.id }}"> + <router-link class="artist discrete link" :to="{name: 'library.artist', params: {id: track.artist.id }}"> {{ track.artist.name }} </router-link> </td> <td colspan="6"> - <router-link class="album discrete link" :to="{name: 'browse.album', params: {id: track.album.id }}"> + <router-link class="album discrete link" :to="{name: 'library.album', params: {id: track.album.id }}"> {{ track.album.title }} </router-link> </td> diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index af4936b3df..867738759f 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -69,7 +69,7 @@ export default { } // We need to pass the component's this context // to properly make use of http in the auth service - auth.login(this, credentials, {path: '/browse'}, function (response) { + auth.login(this, credentials, {path: '/library'}, function (response) { // error callback if (response.status === 400) { self.error = 'invalid_credentials' diff --git a/front/src/components/browse/Album.vue b/front/src/components/library/Album.vue similarity index 96% rename from front/src/components/browse/Album.vue rename to front/src/components/library/Album.vue index 35b1f3a3c7..5cc4d02715 100644 --- a/front/src/components/browse/Album.vue +++ b/front/src/components/library/Album.vue @@ -12,7 +12,7 @@ {{ album.title }} <div class="sub header"> Album containing {{ album.tracks.length }} tracks, - by <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}"> + by <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}"> {{ album.artist.name }} </router-link> </div> diff --git a/front/src/components/browse/Artist.vue b/front/src/components/library/Artist.vue similarity index 100% rename from front/src/components/browse/Artist.vue rename to front/src/components/library/Artist.vue diff --git a/front/src/components/browse/Home.vue b/front/src/components/library/Home.vue similarity index 99% rename from front/src/components/browse/Home.vue rename to front/src/components/library/Home.vue index 3ce8616a3b..651f7cb63b 100644 --- a/front/src/components/browse/Home.vue +++ b/front/src/components/library/Home.vue @@ -34,7 +34,7 @@ import RadioCard from '@/components/radios/Card' const ARTISTS_URL = config.API_URL + 'artists/' export default { - name: 'browse', + name: 'library', components: { Search, ArtistCard, diff --git a/front/src/components/browse/Browse.vue b/front/src/components/library/Library.vue similarity index 55% rename from front/src/components/browse/Browse.vue rename to front/src/components/library/Library.vue index d8f542a5e5..56b750a4af 100644 --- a/front/src/components/browse/Browse.vue +++ b/front/src/components/library/Library.vue @@ -1,7 +1,9 @@ <template> - <div class="main browse pusher"> + <div class="main library pusher"> <div class="ui secondary pointing menu"> - <router-link class="ui item" to="/browse">Browse</router-link> + <router-link class="ui item" to="/library" exact>Browse</router-link> + <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> + <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> </div> <router-view></router-view> </div> @@ -9,18 +11,25 @@ <script> +import auth from '@/auth' + export default { - name: 'browse' + name: 'library', + data: function () { + return { + auth + } + } } </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style lang="scss"> -.browse.pusher > .ui.secondary.menu { +.library.pusher > .ui.secondary.menu { margin: 0 2.5rem; } -.browse { +.library { .ui.segment.head { background-size: cover; background-position: center; diff --git a/front/src/components/browse/Track.vue b/front/src/components/library/Track.vue similarity index 96% rename from front/src/components/browse/Track.vue rename to front/src/components/library/Track.vue index 1e15687934..3c627c13c2 100644 --- a/front/src/components/browse/Track.vue +++ b/front/src/components/library/Track.vue @@ -12,10 +12,10 @@ {{ track.title }} <div class="sub header"> From album - <router-link :to="{name: 'browse.album', params: {id: track.album.id }}"> + <router-link :to="{name: 'library.album', params: {id: track.album.id }}"> {{ track.album.title }} </router-link> - by <router-link :to="{name: 'browse.artist', params: {id: track.artist.id }}"> + by <router-link :to="{name: 'library.artist', params: {id: track.artist.id }}"> {{ track.artist.name }} </router-link> </div> diff --git a/front/src/components/library/import/ArtistImport.vue b/front/src/components/library/import/ArtistImport.vue new file mode 100644 index 0000000000..4f049f52ea --- /dev/null +++ b/front/src/components/library/import/ArtistImport.vue @@ -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> diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue new file mode 100644 index 0000000000..57560fc04e --- /dev/null +++ b/front/src/components/library/import/BatchDetail.vue @@ -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> diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue new file mode 100644 index 0000000000..41b94bd4ec --- /dev/null +++ b/front/src/components/library/import/BatchList.vue @@ -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> diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue new file mode 100644 index 0000000000..f3fc6fca6a --- /dev/null +++ b/front/src/components/library/import/ImportMixin.vue @@ -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> diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue new file mode 100644 index 0000000000..10f6f352af --- /dev/null +++ b/front/src/components/library/import/Main.vue @@ -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> diff --git a/front/src/components/library/import/ReleaseImport.vue b/front/src/components/library/import/ReleaseImport.vue new file mode 100644 index 0000000000..9f8c1d347a --- /dev/null +++ b/front/src/components/library/import/ReleaseImport.vue @@ -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> diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue new file mode 100644 index 0000000000..3081091c59 --- /dev/null +++ b/front/src/components/library/import/TrackImport.vue @@ -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> diff --git a/front/src/components/metadata/ArtistCard.vue b/front/src/components/metadata/ArtistCard.vue new file mode 100644 index 0000000000..3a50a31552 --- /dev/null +++ b/front/src/components/metadata/ArtistCard.vue @@ -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> diff --git a/front/src/components/metadata/CardMixin.vue b/front/src/components/metadata/CardMixin.vue new file mode 100644 index 0000000000..78aae5e7e0 --- /dev/null +++ b/front/src/components/metadata/CardMixin.vue @@ -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> diff --git a/front/src/components/metadata/ReleaseCard.vue b/front/src/components/metadata/ReleaseCard.vue new file mode 100644 index 0000000000..201c3ab0c5 --- /dev/null +++ b/front/src/components/metadata/ReleaseCard.vue @@ -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> diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue new file mode 100644 index 0000000000..8a400cf7b0 --- /dev/null +++ b/front/src/components/metadata/Search.vue @@ -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> diff --git a/front/src/router/index.js b/front/src/router/index.js index bb92b5ae15..b3d90731f4 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -4,11 +4,15 @@ import Home from '@/components/Home' import Login from '@/components/auth/Login' import Profile from '@/components/auth/Profile' import Logout from '@/components/auth/Logout' -import Browse from '@/components/browse/Browse' -import BrowseHome from '@/components/browse/Home' -import BrowseArtist from '@/components/browse/Artist' -import BrowseAlbum from '@/components/browse/Album' -import BrowseTrack from '@/components/browse/Track' +import Library from '@/components/library/Library' +import LibraryHome from '@/components/library/Home' +import LibraryArtist from '@/components/library/Artist' +import LibraryAlbum from '@/components/library/Album' +import LibraryTrack from '@/components/library/Track' +import LibraryImport from '@/components/library/import/Main' +import BatchList from '@/components/library/import/BatchList' +import BatchDetail from '@/components/library/import/BatchDetail' + import Favorites from '@/components/favorites/List' Vue.use(Router) @@ -43,13 +47,27 @@ export default new Router({ component: Favorites }, { - path: '/browse', - component: Browse, + path: '/library', + component: Library, children: [ - { path: '', component: BrowseHome }, - { path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true }, - { path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true }, - { path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true } + { path: '', component: LibraryHome }, + { path: 'artist/:id', name: 'library.artist', component: LibraryArtist, props: true }, + { path: 'album/:id', name: 'library.album', component: LibraryAlbum, props: true }, + { path: 'track/:id', name: 'library.track', component: LibraryTrack, props: true }, + { + path: 'import/launch', + name: 'library.import.launch', + component: LibraryImport, + props: (route) => ({ mbType: route.query.type, mbId: route.query.id }) + }, + { + path: 'import/batches', + name: 'library.import.batches', + component: BatchList, + children: [ + ] + }, + { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true } ] } diff --git a/front/src/utils/time.js b/front/src/utils/time.js new file mode 100644 index 0000000000..022a365bf2 --- /dev/null +++ b/front/src/utils/time.js @@ -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) + } +} -- GitLab