Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------
Expand Down
201 changes: 201 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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
9 changes: 6 additions & 3 deletions tidalapi/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
24 changes: 14 additions & 10 deletions tidalapi/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
Loading