From 1d8018ef69af05c85b0ff7b544a25d7081991df6 Mon Sep 17 00:00:00 2001 From: laurent Date: Fri, 2 Apr 2021 12:24:45 +0200 Subject: [PATCH 1/2] New feature adding track images with Mopidy 3 compatibilty --- mopidy_soundcloud/actor.py | 4 +- mopidy_soundcloud/images.py | 107 +++++++ mopidy_soundcloud/library.py | 26 +- mopidy_soundcloud/soundcloud.py | 9 +- setup.cfg | 2 +- tests/fixtures/sc-images-playlist.yaml | 389 +++++++++++++++++++++++++ tests/fixtures/sc-images-track.yaml | 134 +++++++++ tests/test_api.py | 8 +- tests/test_library.py | 45 ++- 9 files changed, 709 insertions(+), 15 deletions(-) create mode 100644 mopidy_soundcloud/images.py create mode 100644 tests/fixtures/sc-images-playlist.yaml create mode 100644 tests/fixtures/sc-images-track.yaml diff --git a/mopidy_soundcloud/actor.py b/mopidy_soundcloud/actor.py index a142c56..274e04f 100644 --- a/mopidy_soundcloud/actor.py +++ b/mopidy_soundcloud/actor.py @@ -14,7 +14,7 @@ def __init__(self, config, audio): super().__init__() self.config = config self.remote = SoundCloudClient(config) - self.library = SoundCloudLibraryProvider(backend=self) + self.library = SoundCloudLibraryProvider(self.remote, backend=self) self.playback = SoundCloudPlaybackProvider(audio=audio, backend=self) self.uri_schemes = ["soundcloud", "sc"] @@ -28,7 +28,7 @@ def on_start(self): class SoundCloudPlaybackProvider(backend.PlaybackProvider): def translate_uri(self, uri): track_id = self.backend.remote.parse_track_uri(uri) - track = self.backend.remote.get_track(track_id, True) + track = self.backend.remote.get_parsed_track(track_id, True) if track is None: return None return track.uri diff --git a/mopidy_soundcloud/images.py b/mopidy_soundcloud/images.py new file mode 100644 index 0000000..dc4f818 --- /dev/null +++ b/mopidy_soundcloud/images.py @@ -0,0 +1,107 @@ +import itertools +import logging +import operator +import urllib.parse + +from mopidy import models +from mopidy_soundcloud.soundcloud import SoundCloudClient + +# NOTE: current file adapted from https://github.com/mopidy/mopidy-spotify +# - /mopidy-spotify/images.py + +logger = logging.getLogger(__name__) + + +class SoundCloudImageProvider: + _API_MAX_IDS_PER_REQUEST = 50 + + _cache = {} # (type, id) -> [Image(), ...] + + # For reference + _ARTWORK_MAP = { + "mini": 16, + "tiny": 20, + "small": 32, + "badge": 47, + "t67x67": 67, + "large": 100, + "t300x300": 300, + "crop": 400, + "t500x500": 500, + "original": 0, + } + + def __init__(self, web_client: SoundCloudClient): + self.web_client = web_client + + def get_images(self, uris): + result = {} + uri_type_getter = operator.itemgetter("type") + uris = sorted((self._parse_uri(u) for u in uris), key=uri_type_getter) + for uri_type, group in itertools.groupby(uris, uri_type_getter): + batch = [] + for uri in group: + if uri["key"] in self._cache: + result[uri["uri"]] = self._cache[uri["key"]] + elif uri_type == "playlist": + result.update(self._process_set(uri)) + else: + batch.append(uri) + if len(batch) >= self._API_MAX_IDS_PER_REQUEST: + result.update(self._process_uris(batch)) + batch = [] + result.update(self._process_uris(batch)) + return result + + def _parse_uri(self, uri): + parsed_uri = urllib.parse.urlparse(uri) + uri_type, uri_id = None, None + + if parsed_uri.scheme == "soundcloud": + uri_type, uri_id = parsed_uri.path.split("/")[:2] + elif parsed_uri.scheme in ("http", "https"): + if "soundcloud.com" in parsed_uri.netloc: + uri_type, uri_id = parsed_uri.path.split("/")[1:3] + + supported_types = ("song", "album", "artist", "playlist") + if uri_type and uri_type in supported_types and uri_id: + return { + "uri": uri, + "type": uri_type, + "id": self.web_client.parse_track_uri(uri_id), + "key": (uri_type, uri_id), + } + + raise ValueError(f"Could not parse {repr(uri)} as a SoundCloud URI") + + def _process_set(self, uri): + tracks = self.web_client.get_set(uri["id"]) + set_images = tuple() + for track in tracks: + set_images += (*self._process_track(track),) + + self._cache[uri["key"]] = set_images + + return {uri["uri"]: set_images} + + def _process_uris(self, uris): + result = {} + for uri in uris: + if uri["key"] not in self._cache: + track = self.web_client.get_track(uri["id"]) + self._cache[uri["key"]] = self._process_track(track) + + result.update({uri["uri"]: self._cache[uri["key"]]}) + return result + + @staticmethod + def _process_track(track): + images = [] + if track: + image_sources = track["artwork_url"], track["user"]["avatar_url"] + for image_url in image_sources: + if image_url is not None: + image_url = image_url.replace("large", "t500x500") + image = models.Image(uri=image_url, height=500, width=500) + images.append(image) + return tuple(images) diff --git a/mopidy_soundcloud/library.py b/mopidy_soundcloud/library.py index 79c38c3..d811eb3 100644 --- a/mopidy_soundcloud/library.py +++ b/mopidy_soundcloud/library.py @@ -6,6 +6,8 @@ from mopidy import backend, models from mopidy.models import SearchResult, Track +from mopidy_soundcloud.images import SoundCloudImageProvider + logger = logging.getLogger(__name__) @@ -38,7 +40,7 @@ class SoundCloudLibraryProvider(backend.LibraryProvider): uri="soundcloud:directory", name="SoundCloud" ) - def __init__(self, *args, **kwargs): + def __init__(self, web_client, *args, **kwargs): super().__init__(*args, **kwargs) self.vfs = {"soundcloud:directory": {}} self.add_to_vfs(new_folder("Following", ["following"])) @@ -46,6 +48,11 @@ def __init__(self, *args, **kwargs): self.add_to_vfs(new_folder("Sets", ["sets"])) self.add_to_vfs(new_folder("Stream", ["stream"])) + self.image_provider = SoundCloudImageProvider(web_client) + + def get_images(self, uris): + return self.image_provider.get_images(uris) + def add_to_vfs(self, _model): self.vfs["soundcloud:directory"][_model.uri] = _model @@ -144,9 +151,24 @@ def lookup(self, uri): uri = uri.replace("sc:", "") return self.backend.remote.resolve_url(uri) + if "directory" in uri: + return + + if "stream" in uri: + return list(self.backend.remote.get_user_stream()) + + if "liked" in uri: + return list(self.backend.remote.get_likes()) + + if "sets" in uri: + return list(self.backend.remote.get_sets()) + + if "following" in uri: + return list(self.backend.remote.get_followings()) + try: track_id = self.backend.remote.parse_track_uri(uri) - track = self.backend.remote.get_track(track_id) + track = self.backend.remote.get_parsed_track(track_id) if track is None: logger.info( f"Failed to lookup {uri}: SoundCloud track not found" diff --git a/mopidy_soundcloud/soundcloud.py b/mopidy_soundcloud/soundcloud.py index 2d06cdb..0b79451 100644 --- a/mopidy_soundcloud/soundcloud.py +++ b/mopidy_soundcloud/soundcloud.py @@ -235,13 +235,18 @@ def get_tracks(self, user_id=None): # Public @cache() - def get_track(self, track_id, streamable=False): + def get_track(self, track_id): logger.debug(f"Getting info for track with ID {track_id}") try: - return self.parse_track(self._get(f"tracks/{track_id}"), streamable) + return self._get(f"tracks/{track_id}") except Exception: return None + @cache() + def get_parsed_track(self, track_id, streamable=False): + track = self.get_track(track_id) + return self.parse_track(track, streamable) + @staticmethod def parse_track_uri(track): logger.debug(f"Parsing track {track}") diff --git a/setup.cfg b/setup.cfg index 0162752..b179c86 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = Mopidy-SoundCloud -version = 3.0.1 +version = 3.1.0 url = https://github.com/mopidy/mopidy-soundcloud author = Janez Troha author_email = dz0ny@ubuntu.si diff --git a/tests/fixtures/sc-images-playlist.yaml b/tests/fixtures/sc-images-playlist.yaml new file mode 100644 index 0000000..60ca052 --- /dev/null +++ b/tests/fixtures/sc-images-playlist.yaml @@ -0,0 +1,389 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + user-agent: + - Mopidy-SoundCloud/3.0.2 Mopidy/3.1.1 CPython/3.9.2 + method: GET + uri: https://api.soundcloud.com/me?client_id=93e33e327fd8a9b77becd179652272e2 + response: + body: + string: !!binary | + H4sIAAAAAAAAAHRRW27bQAy8SrC/da2VaiSODpC/nqAoFvTuSiXMfXQfCYQgdy+lyK5stIIgQEPO + cMh5F2hE//wk5ePT4SB34oyeAVGzTWInok0OCP15hb4+HmUnj89dy8UZ8OAs176HiGZijCAX5YLB + Ae2s08mubeSB34f2Wy87fh++SH5mfkLu+FVKzH3TQMR9DtUbTaGavQ6umQfk5mpu60fVRBvyP4g3 + VuEVCqQ7ErT7zCzjFw46GG1ujB2gUlErgyCNdh/9yCKah5Q0id5Xop0YMPGunwf4RJblt8DA3xUQ + LGBs1gljweAvHRrLVdBg1mHMNwpuyhG0vcHe7Cljuf9VBQtdwZJAn9XiWPQcaySYCNneXyh4PiQT + BqBslw52JV6StfOh64lQqwFeQ2LxvOENgSi8cTIXrL1g6MdtY66n675Z9D9+cuSRAhiVrQ7eZEV2 + mPnyKLn9dw0FRP8uKhtzPNSotX2trEbvNDhrs4z7v/YH75OQA+UjzXfZmrwULge6qzlIk7IOkBj3 + HLmbp5VU2QgFDbRGm2wMW/LHHwAAAP//AwC9tXUnWQMAAA== + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Length: + - '433' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 04 Apr 2021 19:36:29 GMT + ETag: + - '"e6b7820e97afc9216ad3430f26581c43"' + Referrer-Policy: + - no-referrer + Server: + - am/2 + Status: + - 200 OK + Vary: + - Origin + Via: + - 1.1 196da8dbede310a18cd917665afeaa22.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - fPJuZnSlfiAQzgN4pE3otNT0QDP8HTywBQDEOBITs4hGPKilSFYqjA== + X-Amz-Cf-Pop: + - AMS50-C1 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Robots-Tag: + - noindex + X-UA-Compatible: + - IE=Edge,chrome=1 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + user-agent: + - Mopidy-SoundCloud/3.0.2 Mopidy/3.1.1 CPython/3.9.2 + method: GET + uri: https://api.soundcloud.com/playlists/1129540288?client_id=93e33e327fd8a9b77becd179652272e2 + response: + body: + string: !!binary | + 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 + headers: + Cache-Control: + - private, max-age=0 + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Length: + - '15326' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 04 Apr 2021 19:36:30 GMT + Referrer-Policy: + - no-referrer + Server: + - am/2 + Vary: + - Origin + Via: + - 1.1 e7150584c93f85e64aa53364c55a16c7.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - Kdn7j0oyTg_UNTiFmpb3q93eR4edWU2xsaQmFtQr5ZED2wonG_ahkQ== + X-Amz-Cf-Pop: + - AMS50-C1 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Robots-Tag: + - noindex + status: + code: 200 + message: OK +version: 1 diff --git a/tests/fixtures/sc-images-track.yaml b/tests/fixtures/sc-images-track.yaml new file mode 100644 index 0000000..256f9d9 --- /dev/null +++ b/tests/fixtures/sc-images-track.yaml @@ -0,0 +1,134 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + user-agent: + - Mopidy-SoundCloud/3.0.2 Mopidy/3.1.1 CPython/3.9.2 + method: GET + uri: https://api.soundcloud.com/me?client_id=93e33e327fd8a9b77becd179652272e2 + response: + body: + string: !!binary | + H4sIAAAAAAAAAHRRW27bQAy8SrC/da2VaiSODpC/nqAoFvTuSiXMfXQfCYQgdy+lyK5stIIgQEPO + cMh5F2hE//wk5ePT4SB34oyeAVGzTWInok0OCP15hb4+HmUnj89dy8UZ8OAs176HiGZijCAX5YLB + Ae2s08mubeSB34f2Wy87fh++SH5mfkLu+FVKzH3TQMR9DtUbTaGavQ6umQfk5mpu60fVRBvyP4g3 + VuEVCqQ7ErT7zCzjFw46GG1ujB2gUlErgyCNdh/9yCKah5Q0id5Xop0YMPGunwf4RJblt8DA3xUQ + LGBs1gljweAvHRrLVdBg1mHMNwpuyhG0vcHe7Cljuf9VBQtdwZJAn9XiWPQcaySYCNneXyh4PiQT + BqBslw52JV6StfOh64lQqwFeQ2LxvOENgSi8cTIXrL1g6MdtY66n675Z9D9+cuSRAhiVrQ7eZEV2 + mPnyKLn9dw0FRP8uKhtzPNSotX2trEbvNDhrs4z7v/YH75OQA+UjzXfZmrwULge6qzlIk7IOkBj3 + HLmbp5VU2QgFDbRGm2wMW/LHHwAAAP//AwC9tXUnWQMAAA== + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Length: + - '433' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 04 Apr 2021 19:00:26 GMT + ETag: + - '"e6b7820e97afc9216ad3430f26581c43"' + Referrer-Policy: + - no-referrer + Server: + - am/2 + Status: + - 200 OK + Vary: + - Origin + Via: + - 1.1 d8c5e23736c47a3e5184b0a78042898f.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - 7bWSlEWdbbslZCIxRVya24TOidBubP1j9vhXrGQ5CG8VO5BQUTRKPw== + X-Amz-Cf-Pop: + - AMS50-C1 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Robots-Tag: + - noindex + X-UA-Compatible: + - IE=Edge,chrome=1 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + user-agent: + - Mopidy-SoundCloud/3.0.2 Mopidy/3.1.1 CPython/3.9.2 + method: GET + uri: https://api.soundcloud.com/tracks/13158665?client_id=93e33e327fd8a9b77becd179652272e2 + response: + body: + string: !!binary | + H4sIAAAAAAAAALxUTY+bMBD9K5GvDQECySZIPbRS1VNP7R1N8AS8MTayTXbTVf97x3yFZauueikX + 4Hk+nmfezAu7CMVZxpyB4sLWTNBPnMS7w36/W7PCIDjkOTgy2UZxHEZpGO1X8S5LHrI0WX2I6CG3 + 1qLJvW+yPx7jKF4z3hpwQisKd4ijI8XSdY3KwUkiy5xpcYLyQreKMjysmXWUj3KdhRK2Qk6htRGl + UCDJSjlvbcVPMiE26SFZMwnW5bXm4iyQ9ywPYZSE22gVP2TbNNseJ5a2AiNUSVZNe5KiIMhBmUth + /f0sseCF1C3P6NMU+FE0lVYYGCy08VQaNDVIoS5kXbeqqChYAC5wApQCG1Sa6uDzOKpbPb8p1ifk + 3CP56UbeIKWPR1kqsJi3RrJMtVLOMCec9+9RCSeUXYH7/xKVmQ4HS/ZtoLQCt/rRU1qNlDjawoim + 78g8poJ6CmRQIqWe4npN5O7W+OB9EXz11uyCN+pCqcC1dxbCmmL8vgqOen6rU1MvkuQ3BLPEampx + tQQ53EZo0sJZUyd80+oUiBD1EpUn7gsbkFHlLLWNRHntNNQaQWeVc43NwhAasbk3e0MqDLur2nBU + /qBolr2wV6IehqU7ey0HkPgcWIdX4kEl7v372rJPdLb6Pjv7g2ZpsvaUfrVNst0hS9P7ZL1H3Sey + 4UBxzqqv/+S6cHtDGK7gwCycRLyx5MVV79OZ2MBTi9L4mBx2h+BRnbcNDySYEjePTcl+Devg/zAP + /zaIYNyTNpe5EvvZXGR4XxFh7+cnST8pqYH/e4jRk4I8wRW9hhdBPDwv+Pm5bL88fk3hef85rzdN + N31c1yBULnVBcizteDFqjpDdhunWqbnRm6b+1bRPRUDa7C53+oJqgXVd65Gui2e40tT5pXwGaXFA + Gwm3k98Ow+qm2XgDPSTxflauEU7SNRuCevojfvAD32jrJiS6O/erdCAwgjY36CvRbXTP+NdvAAAA + //8DAHLrK0jOBgAA + headers: + Cache-Control: + - private, max-age=0 + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Length: + - '753' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 04 Apr 2021 19:00:30 GMT + Referrer-Policy: + - no-referrer + Server: + - am/2 + Vary: + - Origin + Via: + - 1.1 52102486f97ad6ff39f81538f01349ab.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - jDytazHi2g7VJdpCJ_FzVFXYXGCE7vHsoUzRKy5VhSoCv0uzq_ECkQ== + X-Amz-Cf-Pop: + - AMS50-C1 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Robots-Tag: + - noindex + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_api.py b/tests/test_api.py index 71f3525..e45a34d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -66,12 +66,12 @@ def test_resolves_object(self): @my_vcr.use_cassette("sc-resolve-track-none.yaml") def test_resolves_unknown_track_to_none(self): - track = self.api.get_track("s38720262") + track = self.api.get_parsed_track("s38720262") assert track is None @my_vcr.use_cassette("sc-resolve-track.yaml") def test_resolves_track(self): - track = self.api.get_track("13158665") + track = self.api.get_parsed_track("13158665") assert isinstance(track, Track) assert track.uri == "soundcloud:song/Munching at Tiannas house.13158665" @@ -176,7 +176,7 @@ def test_readeble_url(self): @my_vcr.use_cassette("sc-resolve-track-id.yaml") def test_resolves_stream_track(self): - track = self.api.get_track("13158665", True) + track = self.api.get_parsed_track("13158665", True) assert isinstance(track, Track) assert track.uri == ( "https://cf-media.sndcdn.com/fxguEjG4ax6B.128.mp3?Policy=" @@ -223,7 +223,7 @@ def test_resolves_app_client_id(self): @my_vcr.use_cassette("sc-resolve-track-id-invalid-client-id.yaml") def test_resolves_stream_track_invalid_id(self): self.api.public_client_id = "blahblahrubbosh" - track = self.api.get_track("13158665", True) + track = self.api.get_parsed_track("13158665", True) assert isinstance(track, Track) assert track.uri == ( "https://cf-media.sndcdn.com/fxguEjG4ax6B.128.mp3?Policy=" diff --git a/tests/test_library.py b/tests/test_library.py index a6a10bc..80118ed 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,4 +1,6 @@ +import os.path import unittest +import vcr import pykka @@ -11,16 +13,31 @@ ) from mopidy_soundcloud.soundcloud import safe_url +local_path = os.path.abspath(os.path.dirname(__file__)) +my_vcr = vcr.VCR( + serializer="yaml", + cassette_library_dir=local_path + "/fixtures", + record_mode="once", + match_on=["uri", "method"], + decode_compressed_response=False, + filter_headers=["Authorization"], +) + class ApiTest(unittest.TestCase): def setUp(self): config = Extension().get_config_schema() - config["auth_token"] = "1-35204-61921957-55796ebef403996" + config["auth_token"] = "3-35204-970067440-lVY4FovkEcKrEGw" # using this user http://maildrop.cc/inbox/mopidytestuser - self.backend = actor.SoundCloudBackend.start( - config={"soundcloud": config, "proxy": {}}, audio=None + config = {"soundcloud": config, "proxy": {}} + self.soundCloudBackend = actor.SoundCloudBackend(config, audio=None) + + self.backend = self.soundCloudBackend.start( + config=config, audio=None ).proxy() - self.library = SoundCloudLibraryProvider(backend=self.backend) + self.library = SoundCloudLibraryProvider( + self.soundCloudBackend.remote, backend=self.backend + ) def tearDown(self): pykka.ActorRegistry.stop_all() @@ -94,3 +111,23 @@ def test_default_folders(self): uri="soundcloud:directory:stream", ), ] + + @my_vcr.use_cassette("sc-images-track.yaml") + def test_track_images(self): + uri_str = "soundcloud:song/Munching at Tiannas house.13158665" + image_uri = ( + "https://i1.sndcdn.com/avatars-000004193858-jnf2pd-t500x500.jpg" + ) + images = self.library.image_provider.get_images([uri_str]) + assert len(images[uri_str]) == 1 + check_uri = images[uri_str][0]._uri + assert check_uri == image_uri + + @my_vcr.use_cassette("sc-images-playlist.yaml") + def test_playlist_images(self): + uri_str = "soundcloud:playlist/Old Songs Throwback.1129540288" + image_uri = "https://i1.sndcdn.com/artworks-aaArnHd1VBTE-0-t500x500.jpg" + images = self.library.image_provider.get_images([uri_str]) + assert len(images[uri_str]) == 127 + check_uri = images[uri_str][0]._uri + assert check_uri == image_uri From db28268ba43ecd603aad1593a3d01386dea4b659 Mon Sep 17 00:00:00 2001 From: laurent Date: Wed, 28 Apr 2021 11:33:37 +0200 Subject: [PATCH 2/2] Prioritize image url sources --- mopidy_soundcloud/images.py | 14 +++++++++++++- tests/test_library.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/mopidy_soundcloud/images.py b/mopidy_soundcloud/images.py index dc4f818..37604c6 100644 --- a/mopidy_soundcloud/images.py +++ b/mopidy_soundcloud/images.py @@ -98,10 +98,22 @@ def _process_uris(self, uris): def _process_track(track): images = [] if track: - image_sources = track["artwork_url"], track["user"]["avatar_url"] + image_sources = [ + track.get("artwork_url"), + track.get("calculated_artwork_url"), + ] + + # Only include avatar images if no other images found + if image_sources.count(None) == len(image_sources): + image_sources = [ + track.get("user", {}).get("avatar_url"), + track.get("avatar_url"), + ] + for image_url in image_sources: if image_url is not None: image_url = image_url.replace("large", "t500x500") image = models.Image(uri=image_url, height=500, width=500) images.append(image) + return tuple(images) diff --git a/tests/test_library.py b/tests/test_library.py index 80118ed..5ecf8f3 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -128,6 +128,6 @@ def test_playlist_images(self): uri_str = "soundcloud:playlist/Old Songs Throwback.1129540288" image_uri = "https://i1.sndcdn.com/artworks-aaArnHd1VBTE-0-t500x500.jpg" images = self.library.image_provider.get_images([uri_str]) - assert len(images[uri_str]) == 127 + assert len(images[uri_str]) == 64 check_uri = images[uri_str][0]._uri assert check_uri == image_uri