fixes for the refactored frontend
authormarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Thu, 31 Oct 2019 01:20:24 +0000 (02:20 +0100)
committermarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Thu, 31 Oct 2019 01:20:24 +0000 (02:20 +0100)
music_assistant.code-workspace
music_assistant/database.py
music_assistant/models/musicprovider.py
music_assistant/music_manager.py
music_assistant/musicproviders/qobuz.py
music_assistant/musicproviders/spotify.py
music_assistant/web.py
requirements.txt

index 362d7c25bb405a5cc76d0c7518cc240999a574f4..1f11899da44a70d06bf5f61e9f0524cfcaf99128 100644 (file)
@@ -2,6 +2,9 @@
        "folders": [
                {
                        "path": "."
+               },
+               {
+                       "path": "/Users/marcelvanderveldt/Workdir/test"
                }
        ]
 }
\ No newline at end of file
index ebff6bed81d82b7be9396714818728a01f035cd2..15782a506292c6100ad85a010cbdbcdf9b532a01 100755 (executable)
@@ -274,7 +274,7 @@ class Database():
                 artist.external_ids = await self.__get_external_ids(artist.item_id, MediaType.Artist, db)
                 artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db)
                 artist.tags = await self.__get_tags(artist.item_id, MediaType.Artist, db)
-                artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db, filter_key='image')
+                artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db)
             artists.append(artist)
         if should_close_db:
             await db.close()
@@ -354,8 +354,6 @@ class Database():
                     album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db)
                     album.tags = await self.__get_tags(album.item_id, MediaType.Album, db)
                     album.labels = await self.__get_album_labels(album.item_id, db)
-                else:
-                    album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db, filter_key='image')
                 albums.append(album)
         if should_close_db:
             await db.close()
@@ -512,6 +510,12 @@ class Database():
         sql_query = ' WHERE artist_id = %d' % artist_id
         return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby, fulldata=False)
 
+    async def album_tracks(self, album_id:int, limit=100000, offset=0, orderby='disc_number,track_number') -> List[Track]:
+        ''' get album tracks for the given album '''
+        sql_query = """SELECT * FROM tracks
+                    WHERE album_id=%s""" % album_id
+        return await self.tracks(sql_query, orderby=orderby, limit=limit, offset=offset, fulldata=False)
+
     async def playlist_tracks(self, playlist_id:int, limit=100000, offset=0, orderby='position') -> List[Track]:
         ''' get playlist tracks for the given playlist_id '''
         sql_query = """SELECT *, playlist_tracks.position FROM tracks
index 546a662621ba99eac504e9822c81fce62874c50e..e3484913f95b978e671090d9979fca9adc9e0c8f 100755 (executable)
@@ -221,11 +221,12 @@ class MusicProvider():
         ''' return top tracks for an artist '''
         items = []
         for prov_track in await self.get_artist_toptracks(prov_item_id):
-            db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
-            if db_id:
-                items.append( await self.mass.db.track(db_id) )
-            else:
-                items.append(prov_track)
+            if prov_track:
+                db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
+                if db_id:
+                    items.append( await self.mass.db.track(db_id) )
+                else:
+                    items.append(prov_track)
         return items
 
     async def artist_albums(self, prov_item_id) -> List[Track]:
@@ -245,10 +246,11 @@ class MusicProvider():
             searchstr = "%s - %s" %(searchartist.name, searchtrack.name)
             search_results = await self.search(searchstr, [MediaType.Track], limit=5)
             for item in search_results["tracks"]:
-                if item and item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
+                if (item and item.name == searchtrack.name and 
+                        item.version == searchtrack.version):
                     # double safety check - artist must match exactly !
                     for artist in item.artists:
-                        if artist.name == searchartist.name:
+                        if artist.name.lower() == searchartist.name.lower():
                             # just load this item in the database, it will be matched automagically ;-)
                             return await self.artist(artist.item_id, lazy=False)
 
@@ -257,7 +259,9 @@ class MusicProvider():
         searchstr = "%s - %s %s" %(searchalbum.artist.name, searchalbum.name, searchalbum.version)
         search_results = await self.search(searchstr, [MediaType.Album], limit=5)
         for item in search_results["albums"]:
-            if item and item.name == searchalbum.name and item.version == searchalbum.version and item.artist.name == searchalbum.artist.name:
+            if (item and item.name == searchalbum.name and 
+                    item.version == searchalbum.version and 
+                    item.artist.name == searchalbum.artist.name):
                 # just load this item in the database, it will be matched automagically ;-)
                 await self.album(item.item_id, lazy=False)
 
@@ -267,7 +271,10 @@ class MusicProvider():
         searchartists = [item.name for item in searchtrack.artists]
         search_results = await self.search(searchstr, [MediaType.Track], limit=5)
         for item in search_results["tracks"]:
-            if item and item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
+            if (item and item.name == searchtrack.name and 
+                    item.version == searchtrack.version and 
+                    item.album and item.album.name == searchtrack.album.name and
+                    item.album.version == searchtrack.album.version):
                 # double safety check - artist must match exactly !
                 for artist in item.artists:
                     if artist.name in searchartists:
index 0dab61b658d47964a7afb41ea99d5b4bdd263486..5305866666d88927ab1d90444206446ef05e8478 100755 (executable)
@@ -6,6 +6,9 @@ from typing import List
 import toolz
 import operator
 import os
+import base64
+from PIL import Image
+import aiohttp
 
 from .utils import run_periodic, LOGGER, load_provider_modules
 from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio
@@ -151,15 +154,18 @@ class MusicManager():
 
     async def album_tracks(self, album_id, provider='database') -> List[Track]:
         ''' get the album tracks for given album '''
-        items = []
         album = await self.album(album_id, provider)
-        for prov_mapping in album.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.album_tracks(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False)
+        if provider == 'database' and album.in_library:
+            # library albums are synced
+            items =  await self.mass.db.album_tracks(album_id)
+            if items:
+                return items
+        # collect the tracks from the first provider
+        for prov in album.provider_ids:
+            prov_obj = self.providers[prov['provider']]
+            items = await prov_obj.album_tracks(album_id)
+            if items:
+                break
         items = sorted(items, key=operator.attrgetter('track_number'), reverse=False)
         return items
 
@@ -339,8 +345,8 @@ class MusicManager():
             db_item = await music_provider.album(item.item_id, lazy=False)
             cur_db_ids.append(db_item.item_id)
             # precache album tracks...
-            for album_track in await music_provider.get_album_tracks(item.item_id):
-                await music_provider.track(album_track.item_id)
+            for album_track in await music_provider.album_tracks(item.item_id):
+                await self.track(album_track.item_id, album_track.provider)
             if not db_item.item_id in prev_db_ids:
                 await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id)
         # process deletions
@@ -390,6 +396,7 @@ class MusicManager():
 
     async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id):
         ''' sync library playlists tracks for given provider'''
+        playlist = await self.mass.db.playlist(db_playlist_id)
         music_provider = self.providers[prov_id]
         prev_items = await self.playlist_tracks(db_playlist_id)
         prev_db_ids = [item.item_id for item in prev_items]
@@ -407,12 +414,14 @@ class MusicManager():
                     # always add/update because position could be changed
                     # note: we ignore duplicate tracks in the same playlist
                     await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos)
+                else:
+                    LOGGER.warning("SKIP duplicate track in playlist %s: %s" %(playlist.name, db_item.name))
             pos += 1
         # process playlist track deletions
         for db_id in prev_db_ids:
             if db_id not in cur_db_ids:
                 await self.mass.db.remove_playlist_track(db_playlist_id, db_id)
-        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id))
+        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (playlist.name, prov_id))
 
     async def sync_radios(self, prov_id):
         ''' sync library radios for given provider'''
@@ -433,3 +442,50 @@ class MusicManager():
             if db_id not in cur_db_ids:
                 await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id)
         LOGGER.info("Finished syncing Radios for provider %s" % prov_id)
+
+    async def get_image_path(self, item_id, media_type:MediaType, provider, size=50, key='image'):
+        ''' get path to (resized) thumb image for given media item '''
+        cache_folder = os.path.join(self.mass.datapath, '.thumbs')
+        cache_id = f'{item_id}{media_type}{provider}{key}'
+        cache_id = base64.b64encode(cache_id.encode('utf-8')).decode('utf-8')
+        cache_file_org = os.path.join(cache_folder, f'{cache_id}0.png')
+        cache_file_sized = os.path.join(cache_folder, f'{cache_id}{size}.png')
+        if os.path.isfile(cache_file_sized):
+            # return file from cache
+            return cache_file_sized
+        # no file in cache so we should get it
+        img_url = ''
+        item = await self.item(item_id, media_type, provider)
+        if item and item.metadata.get(key):
+            img_url = item.metadata[key]
+        elif media_type == MediaType.Track:
+            # try album image instead for tracks
+            return await self.get_image_path(
+                    item.album.item_id, MediaType.Album, item.album.provider, size, key)
+        elif media_type == MediaType.Album:
+            # try artist image instead for albums
+            return await self.get_image_path(
+                    item.artist.item_id, MediaType.Artist, item.artist.provider, size, key)
+        if not img_url:
+            return None
+        # fetch image and store in cache
+        os.makedirs(cache_folder, exist_ok=True)
+        # download base image
+        async with aiohttp.ClientSession() as session:
+            async with session.get(img_url, verify_ssl=False) as response:
+                assert response.status == 200
+                img_data = await response.read()
+                with open(cache_file_org, 'wb') as f:
+                    f.write(img_data)
+        if not size:
+            # return base image
+            return cache_file_org
+        # save resized image
+        basewidth = size
+        img = Image.open(cache_file_org)
+        wpercent = (basewidth/float(img.size[0]))
+        hsize = int((float(img.size[1])*float(wpercent)))
+        img = img.resize((basewidth,hsize), Image.ANTIALIAS)
+        img.save(cache_file_sized)
+        # return file from cache
+        return cache_file_sized
index 576c3772ab3fa06c45f6cc72a6abf716cf07e9cc..c1213ae1aeeb32525cc0af5d5f5d19400f3618b6 100644 (file)
@@ -166,6 +166,8 @@ class QobuzProvider(MusicProvider):
             track = await self.__parse_track(track_obj)
             if track:
                 tracks.append(track)
+            else:
+                LOGGER.warning("Unavailable track found in album %s: %s" %(prov_album_id, track_obj['title']))
         return tracks
 
     async def get_playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
@@ -179,7 +181,9 @@ class QobuzProvider(MusicProvider):
             playlist_track = await self.__parse_track(track_obj)
             if playlist_track:
                 tracks.append(playlist_track)
-            # TODO: should we look for an alternative track version if the original is marked unavailable ?
+            else:
+                LOGGER.warning("Unavailable track found in playlist %s: %s" %(playlist_obj['name'], track_obj['title']))
+                # TODO: should we look for an alternative track version if the original is marked unavailable ?
         return tracks
 
     async def get_artist_albums(self, prov_artist_id, limit=100, offset=0) -> List[Album]:
@@ -197,14 +201,16 @@ class QobuzProvider(MusicProvider):
     async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
         ''' get a list of most popular tracks for the given artist '''
         # artist toptracks not supported on Qobuz, so use search instead
+        # assuming qobuz returns results sorted by popularity
         items = []
         artist = await self.get_artist(prov_artist_id)
-        params = {"query": artist.name, "limit": 10, "type": "tracks" }
+        params = {"query": artist.name, "limit": 25, "type": "tracks" }
         searchresult = await self.__get_data("catalog/search", params)
         for item in searchresult["tracks"]["items"]:
             if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id):
                 track = await self.__parse_track(item)
-                items.append(track)
+                if track:
+                    items.append(track)
         return items
     
     async def add_library(self, prov_item_id, media_type:MediaType):
@@ -336,9 +342,22 @@ class QobuzProvider(MusicProvider):
             return None
         album.item_id = album_obj['id']
         album.provider = self.prov_id
+        if album_obj['maximum_sampling_rate'] > 192:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
+        elif album_obj['maximum_sampling_rate'] > 96:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
+        elif album_obj['maximum_sampling_rate'] > 48:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
+        elif album_obj['maximum_bit_depth'] > 16:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1
+        elif album_obj.get('format_id',0) == 5:
+            quality = TrackQuality.LOSSY_AAC
+        else:
+            quality = TrackQuality.FLAC_LOSSLESS
         album.provider_ids.append({
             "provider": self.prov_id,
             "item_id": album_obj['id'],
+            "quality": quality,
             "details": "%skHz %sbit" %(album_obj['maximum_sampling_rate'], album_obj['maximum_bit_depth'])
         })
         album.name, album.version = parse_track_title(album_obj['title'])
index d51ab00ab508b72fe42158d1fe2d8fae50cafa4d..d2464d63b1e06b5ed411868adeccc37e6477450b 100644 (file)
@@ -162,7 +162,7 @@ class SpotifyProvider(MusicProvider):
 
     async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]:
         ''' get playlist tracks for given playlist id '''
-        playlist_obj = await self.__get_data("playlists/%s?fields=snapshot_id" % prov_playlist_id, ignore_cache=True)
+        playlist_obj = await self.__get_data("playlists/%s?fields=snapshot_id,name" % prov_playlist_id, ignore_cache=True)
         cache_checksum = playlist_obj["snapshot_id"]
         track_objs = await self.__get_all_items("playlists/%s/tracks" % prov_playlist_id, limit=limit, offset=offset, cache_checksum=cache_checksum)
         tracks = []
@@ -170,6 +170,8 @@ class SpotifyProvider(MusicProvider):
             playlist_track = await self.__parse_track(track_obj)
             if playlist_track:
                 tracks.append(playlist_track)
+            else:
+                LOGGER.warning("Unavailable track found in playlist %s: %s" %(playlist_obj['name'], track_obj['track']['name']))
         return tracks
 
     async def get_artist_albums(self, prov_artist_id) -> List[Album]:
@@ -342,7 +344,8 @@ class SpotifyProvider(MusicProvider):
             album.metadata['explicit'] = str(album_obj['explicit']).lower()
         album.provider_ids.append({
             "provider": self.prov_id,
-            "item_id": album_obj['id']
+            "item_id": album_obj['id'],
+            "quality": TrackQuality.LOSSY_OGG
         })
         return album
 
index b4153fd10cbb0bd94c2d222e0f91b387d6b5e5e1..121c5f077aaa8100ab9657afa58bc8404cea3f42 100755 (executable)
@@ -57,16 +57,15 @@ class Web():
                 LOGGER.warning( "SSL certificate key file not found: %s" % config['ssl_key'])
             self.https_port = config['https_port']
             self._enable_ssl = enable_ssl
-        
 
     async def setup(self):
         ''' perform async setup '''
-        app = web.Application()
+        app = web.Application(middlewares=[self.handle_cors])
         app.add_routes([web.get('/jsonrpc.js', self.json_rpc)])
         app.add_routes([web.post('/jsonrpc.js', self.json_rpc)])
         app.add_routes([web.get('/ws', self.websocket_handler)])
-        app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream)])
-        app.add_routes([web.get('/stream/{player_id}/{queue_item_id}', self.mass.http_streamer.stream)])
+        app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream, allow_head=False)])
+        app.add_routes([web.get('/stream/{player_id}/{queue_item_id}', self.mass.http_streamer.stream, allow_head=False)])
         app.add_routes([web.get('/api/search', self.search)])
         app.add_routes([web.get('/api/config', self.get_config)])
         app.add_routes([web.post('/api/config/{key}/{subkey}', self.save_config)])
@@ -82,6 +81,7 @@ class Web():
         app.add_routes([web.get('/api/artists/{artist_id}/toptracks', self.artist_toptracks)])
         app.add_routes([web.get('/api/artists/{artist_id}/albums', self.artist_albums)])
         app.add_routes([web.get('/api/albums/{album_id}/tracks', self.album_tracks)])
+        app.add_routes([web.get('/api/{media_type}/{media_id}/image', self.get_image)])
         app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)])
         app.add_routes([web.get('/api/{media_type}', self.get_items)])
         app.add_routes([web.get('/', self.index)])
@@ -99,6 +99,13 @@ class Web():
             await https_site.start()
             LOGGER.info("Started HTTPS webserver on port %s" % self.config['https_port'])
 
+    @web.middleware
+    async def handle_cors(self, request, handler):
+        ''' append CORS header to our API '''
+        response = await handler(request)
+        response.headers['Access-Control-Allow-Origin'] = '*'
+        return response
+
     async def get_items(self, request):
         ''' get multiple library items'''
         media_type_str = request.match_info.get('media_type')
@@ -128,17 +135,32 @@ class Web():
             result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy)
         return web.json_response(result, dumps=json_serializer)
 
+    async def get_image(self, request):
+        ''' get item image '''
+        media_type_str = request.match_info.get('media_type')
+        media_type = media_type_from_string(media_type_str)
+        media_id = request.match_info.get('media_id')
+        # optional params
+        provider = request.rel_url.query.get('provider', 'database')
+        size = int(request.rel_url.query.get('size', 0))
+        type_key = request.rel_url.query.get('type', 'image')
+        img_file = await self.mass.music.get_image_path(media_id, media_type, provider, size, type_key)
+        if not img_file or not os.path.isfile(img_file):
+            return web.Response(status=404)
+        headers = {'Cache-Control': 'max-age=86400, public', 'Pragma': 'public'}
+        return web.FileResponse(img_file, headers=headers)
+
     async def artist_toptracks(self, request):
         ''' get top tracks for given artist '''
         artist_id = request.match_info.get('artist_id')
-        provider = request.rel_url.query.get('provider')
+        provider = request.rel_url.query.get('provider', 'database')
         result = await self.mass.music.artist_toptracks(artist_id, provider)
         return web.json_response(result, dumps=json_serializer)
 
     async def artist_albums(self, request):
         ''' get (all) albums for given artist '''
         artist_id = request.match_info.get('artist_id')
-        provider = request.rel_url.query.get('provider')
+        provider = request.rel_url.query.get('provider', 'database')
         result = await self.mass.music.artist_albums(artist_id, provider)
         return web.json_response(result, dumps=json_serializer)
 
@@ -147,14 +169,14 @@ class Web():
         playlist_id = request.match_info.get('playlist_id')
         limit = int(request.query.get('limit', 50))
         offset = int(request.query.get('offset', 0))
-        provider = request.rel_url.query.get('provider')
+        provider = request.rel_url.query.get('provider', 'database')
         result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit)
         return web.json_response(result, dumps=json_serializer)
 
     async def album_tracks(self, request):
         ''' get album tracks from provider'''
         album_id = request.match_info.get('album_id')
-        provider = request.rel_url.query.get('provider')
+        provider = request.rel_url.query.get('provider','database')
         result = await self.mass.music.album_tracks(album_id, provider)
         return web.json_response(result, dumps=json_serializer)
 
@@ -332,6 +354,8 @@ class Web():
         cmds = params[1]
         cmd_str = " ".join(cmds)
         player = await self.mass.players.get_player(player_id)
+        if not player:
+            return web.Response(status=404)
         if cmd_str == 'play':
             await player.play()
         elif cmd_str == 'pause':
index cfe85c5e57943023715a77d2bd99b61db7a5d6e8..7e7384c2ba44860f6b81c6e57b6ab2da11f8b045 100755 (executable)
@@ -16,4 +16,5 @@ aiohttp
 pyloudnorm
 SoundFile
 aiorun
-soco
\ No newline at end of file
+soco
+pillow
\ No newline at end of file