From be0ca6eea60c05ea014ce8bb30ba92b3a69891cb Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Thu, 8 Oct 2026 00:03:36 +0200 Subject: [PATCH 1/2] Bugfix: Parse tracks and albums without artist information Media.parse() and Album.parse() read json["artists"] and fell back to artists[0] when there was no "artist" field. A track or album with an empty or missing artists list (seen with tracks that are no longer available) therefore failed with IndexError or KeyError, and a JSON null "artist" with TypeError. One such item made a whole playlist or album listing fail. An item without any artist information now parses with artist = None (the attribute is already Optional) and artists = []. A null "artist" is treated as missing. Album.parse() keeps the artist it was given when the JSON has no artist information; otherwise its precedence is unchanged. Track flags missing from the JSON (payToStream, premiumStreamingOnly, editable, upload, spotlighted, url) now get the False/"" defaults the class declares instead of None, and copyright falls back to "" so unavailable tracks no longer expose a value that contradicts its type. --- tests/test_parsing.py | 201 ++++++++++++++++++++++++++++++++++++++++++ tidalapi/album.py | 9 +- tidalapi/media.py | 24 ++--- 3 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 tests/test_parsing.py diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 00000000..ddbc9f30 --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,201 @@ +# Copyright (C) 2023- The Tidalapi Developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Offline tests for parsing incomplete track and album metadata.""" + +import pytest + +import tidalapi + + +@pytest.fixture +def session(): + return tidalapi.Session(tidalapi.Config()) + + +def artist_json(artist_id=1, name="Artist"): + return {"id": artist_id, "name": name, "type": "MAIN", "picture": None} + + +def track_json(**overrides): + json_obj = { + "id": 5, + "title": "Track", + "duration": 10, + "explicit": False, + "allowStreaming": False, + "streamReady": False, + "stemReady": False, + "djReady": False, + "adSupportedStreamReady": False, + "trackNumber": 1, + "volumeNumber": 1, + "popularity": 0, + "artists": [artist_json()], + "album": None, + } + json_obj.update(overrides) + return json_obj + + +def album_json(**overrides): + json_obj = { + "id": 1, + "title": "Album", + "cover": None, + "videoCover": None, + "artists": [artist_json()], + } + json_obj.update(overrides) + return json_obj + + +def without(json_obj, key): + return {k: v for k, v in json_obj.items() if k != key} + + +def test_track_artist_falls_back_to_first_artist(session): + track = session.track().parse_track(track_json()) + + assert track.artist.name == "Artist" + assert [a.name for a in track.artists] == ["Artist"] + + +def test_track_prefers_explicit_artist(session): + track = session.track().parse_track( + track_json(artist=artist_json(2, "Main"), artists=[artist_json(1, "Other")]) + ) + + assert track.artist.name == "Main" + + +@pytest.mark.parametrize( + "overrides", + [ + {"artists": []}, + {"artists": None}, + {"artists": [], "artist": None}, + ], +) +def test_track_without_any_artist_has_no_artist(session, overrides): + track = session.track().parse_track(track_json(**overrides)) + + assert track.artist is None + assert track.artists == [] + + +def test_track_without_artists_key_has_no_artist(session): + track = session.track().parse_track(without(track_json(), "artists")) + + assert track.artist is None + assert track.artists == [] + + +def test_track_with_null_artist_falls_back_to_first_artist(session): + track = session.track().parse_track(track_json(artist=None)) + + assert track.artist.name == "Artist" + + +def test_unavailable_track_defaults_match_annotations(session): + track = session.track().parse_track(track_json()) + + assert track.available is False + assert track.pay_to_stream is False + assert track.premium_streaming_only is False + assert track.editable is False + assert track.upload is False + assert track.spotlighted is False + assert track.url == "" + # Only set for available tracks, so this is the class default + assert track.copyright == "" + + +def test_available_track_without_copyright_has_empty_copyright(session): + track = session.track().parse_track(track_json(allowStreaming=True)) + + assert track.copyright == "" + + +def test_track_flags_are_read_when_present(session): + track = session.track().parse_track( + track_json(payToStream=True, editable=True, url="https://tidal.com/t") + ) + + assert track.pay_to_stream is True + assert track.editable is True + assert track.url == "https://tidal.com/t" + + +def test_album_artist_falls_back_to_first_artist(session): + album = session.album().parse(album_json()) + + assert album.artist.name == "Artist" + + +@pytest.mark.parametrize("overrides", [{"artists": []}, {"artists": None}]) +def test_album_without_any_artist_has_no_artist(session, overrides): + album = session.album().parse(album_json(**overrides)) + + assert album.artist is None + assert album.artists == [] + + +def test_album_without_artists_key_has_no_artist(session): + album = session.album().parse(without(album_json(), "artists")) + + assert album.artist is None + assert album.artists == [] + + +def test_album_keeps_given_artist_when_json_has_no_artist_info(session): + given = session.artist().parse_artist(artist_json(9, "Given")) + + album = session.album().parse(album_json(artists=[]), given) + + assert album.artist.name == "Given" + + +def test_album_keeps_given_artist_over_json_artist(session): + given = session.artist().parse_artist(artist_json(9, "Given")) + + album = session.album().parse(album_json(artist=artist_json(2, "FromJson")), given) + + assert album.artist is given + + +def test_album_parses_json_artist_when_none_given(session): + album = session.album().parse(album_json(artist=artist_json(2, "FromJson"))) + + assert album.artist.name == "FromJson" + + +def test_album_json_without_artist_prefers_first_artist_over_given(session): + # Existing behavior: the artist field is missing on some albums, and the first + # artist of the album is used then. + given = session.artist().parse_artist(artist_json(9, "Given")) + + album = session.album().parse(album_json(), given) + + assert album.artist.name == "Artist" + + +def test_track_with_album_parses_album_artist(session): + track = session.track().parse_track( + track_json(album=album_json(artists=[]), artists=[]) + ) + + assert track.album.artist is None + assert track.artist is None diff --git a/tidalapi/album.py b/tidalapi/album.py index 3fdc75c5..ee64ae17 100644 --- a/tidalapi/album.py +++ b/tidalapi/album.py @@ -103,11 +103,14 @@ def parse( artists: Optional[List["Artist"]] = None, ) -> "Album": if artists is None: - artists = self.session.parse_artists(json_obj["artists"]) + # The artists can be missing or empty, e.g. for removed albums + artists_json = json_obj.get("artists") + artists = self.session.parse_artists(artists_json) if artists_json else [] # Sometimes the artist field is not filled, an example is 140196345 - if "artist" not in json_obj: - artist = artists[0] + if json_obj.get("artist") is None: + # Without any artist information, keep the artist we were given (if any) + artist = artists[0] if artists else artist elif artist is None: artist = self.session.parse_artist(json_obj["artist"]) diff --git a/tidalapi/media.py b/tidalapi/media.py index e04d382a..06572807 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -236,13 +236,17 @@ def parse(self, json_obj: JsonObj, album: Optional[Album] = None) -> None: :param album: The (optional) album to use, instead of parsing the JSON object :return: """ - artists = self.session.parse_artists(json_obj["artists"]) + # The artists can be missing or empty, e.g. for tracks that were removed + artists_json = json_obj.get("artists") + artists = self.session.parse_artists(artists_json) if artists_json else [] # Sometimes the artist field is not filled, example: 62300893 - if "artist" in json_obj: + if json_obj.get("artist") is not None: artist = self.session.parse_artist(json_obj["artist"]) - else: + elif artists: artist = artists[0] + else: + artist = None if album is None and json_obj["album"]: album = self.session.album().parse(json_obj["album"], artist, artists) @@ -338,14 +342,14 @@ class Track(Media): def parse_track(self, json_obj: JsonObj, album: Optional[Album] = None) -> Track: Media.parse(self, json_obj, album) - self.pay_to_stream = json_obj.get("payToStream") - self.premium_streaming_only = json_obj.get("premiumStreamingOnly") - self.editable = json_obj.get("editable") - self.upload = json_obj.get("upload") - self.spotlighted = json_obj.get("spotlighted") + self.pay_to_stream = json_obj.get("payToStream", False) + self.premium_streaming_only = json_obj.get("premiumStreamingOnly", False) + self.editable = json_obj.get("editable", False) + self.upload = json_obj.get("upload", False) + self.spotlighted = json_obj.get("spotlighted", False) # Generate share URLs from track ID and album (if it exists) - self.url = json_obj.get("url") + self.url = json_obj.get("url", "") if self.album: self.listen_url = f"{self.session.config.listen_base_url}/album/{self.album.id}/track/{self.id}" else: @@ -368,7 +372,7 @@ def parse_track(self, json_obj: JsonObj, album: Optional[Album] = None) -> Track self.date_added = self.user_date_added self.description = json_obj.get("description") self.version = json_obj.get("version") if json_obj.get("version") else None - self.copyright = json_obj.get("copyright") + self.copyright = json_obj.get("copyright", "") self.bpm = json_obj.get("bpm") self.key = json_obj.get("key") From 64beaf9b49427f64fb265a023ae86a6c835cf995 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Thu, 8 Oct 2026 00:03:36 +0200 Subject: [PATCH 2/2] Update changelog for artist parsing fix --- HISTORY.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 0c91f68d..62cf9d34 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -15,6 +15,8 @@ v0.9.0 * Added `AbuseDetected`, raised when TIDAL answers with an anti-abuse HTTP 403 - tehkillerbee_ * Added `Config(backoff=...)`, enabled by default: stop sending requests after a rate limit or abuse response and raise `TidalBackoffError` until it has passed. Pass `backoff=False` to disable - tehkillerbee_ * Bugfix: Non-JSON error responses and `Retry-After` given as a date no longer break error handling - tehkillerbee_ +* Bugfix: Parsing tracks and albums no longer fails when TIDAL returns no or empty artist information - tehkillerbee_ +* Parsed `Track` flags (`pay_to_stream`, `premium_streaming_only`, `editable`, `upload`, `spotlighted`) are now `False` instead of `None` when TIDAL omits them, and `url` is an empty string - tehkillerbee_ v0.8.11 --------