From dd9ad1a574c405f398149c2d496f55eb19a03036 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 10 Sep 2024 12:01:41 +0200 Subject: [PATCH 01/18] Bugfix: Fix linting for audio_modes. Update tests (Fixes #261) --- tests/test_album.py | 7 +++++++ tidalapi/album.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_album.py b/tests/test_album.py index 5b0ae2a..2d6ed9f 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -36,15 +36,22 @@ def test_album(session): assert album.type == "ALBUM" assert album.duration == 6712 assert album.available + assert album.ad_supported_ready + assert album.allow_streaming + assert album.dj_ready + assert album.audio_modes == ["STEREO"] + assert album.audio_quality == "LOSSLESS" assert album.num_tracks == 22 assert album.num_videos == 0 assert album.num_volumes == 2 assert album.release_date == datetime.datetime(2011, 9, 22) + assert album.available_release_date == datetime.datetime(2011, 9, 22) assert album.copyright == "Sinuz Recordings (a division of HITT bv)" assert album.version == "Deluxe" assert album.cover == "30d83a8c-1db6-439d-84b4-dbfb6f03c44c" assert album.video_cover is None assert album.explicit is False + assert album.premium_streaming_only is False assert album.universal_product_number == "3610151683488" assert 0 < album.popularity < 100 assert album.artist.name == "Lasgo" diff --git a/tidalapi/album.py b/tidalapi/album.py index 7ca5a25..22a1cff 100644 --- a/tidalapi/album.py +++ b/tidalapi/album.py @@ -67,7 +67,7 @@ class Album: popularity: Optional[int] = -1 user_date_added: Optional[datetime] = None audio_quality: Optional[str] = "" - audio_modes: Optional[str] = "" + audio_modes: Optional[List[str]] = [""] media_metadata_tags: Optional[List[str]] = [""] artist: Optional["Artist"] = None From 77fec144e6eedadd90e822c253d0362138b64cd9 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 10 Sep 2024 12:02:34 +0200 Subject: [PATCH 02/18] Update changelog --- HISTORY.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/HISTORY.rst b/HISTORY.rst index aefdfde..82a7eb4 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,7 @@ History ======= v0.7.7 ------ +* Bugfix: Fix linting for audio_modes. Update tests (Fixes #261) - tehkillerbee_ * Feat.: Provide "Share Link", "Listen link" as an attribute to album/artist/media. Add relevant tests (Fixes #266) - tehkillerbee_ * Allow switching authentication method oauth/pkce for tests. Default: oauth - tehkillerbee_ * Tests: Added track stream tests (BTS, MPD) - tehkillerbee_ From 63e579ed98d5824633b04e29b611c3e84fd7fb9c Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 10 Sep 2024 12:35:46 +0200 Subject: [PATCH 03/18] Bugfix: Use correct internal type int for relevant IDs (Fixes #260) --- tidalapi/album.py | 2 +- tidalapi/artist.py | 2 +- tidalapi/media.py | 2 +- tidalapi/mix.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tidalapi/album.py b/tidalapi/album.py index 22a1cff..d25b41f 100644 --- a/tidalapi/album.py +++ b/tidalapi/album.py @@ -43,7 +43,7 @@ class Album: name, cover and video cover. TIDAL does this to reduce the network load. """ - id: Optional[str] = None + id: Optional[int] = -1 name: Optional[str] = None cover = None video_cover = None diff --git a/tidalapi/artist.py b/tidalapi/artist.py index 8c5f2c0..57d69c0 100644 --- a/tidalapi/artist.py +++ b/tidalapi/artist.py @@ -40,7 +40,7 @@ class Artist: - id: Optional[str] = None + id: Optional[int] = -1 name: Optional[str] = None roles: Optional[List["Role"]] = None role: Optional["Role"] = None diff --git a/tidalapi/media.py b/tidalapi/media.py index d5525fa..47e8335 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -184,7 +184,7 @@ class Media: actual media, use the release date of the album. """ - id: Optional[str] = None + id: Optional[int] = -1 name: Optional[str] = None duration: Optional[int] = -1 available: bool = True diff --git a/tidalapi/mix.py b/tidalapi/mix.py index 8f1ebca..55f4645 100644 --- a/tidalapi/mix.py +++ b/tidalapi/mix.py @@ -178,15 +178,15 @@ class MixV2: # tehkillerbee: TODO Doesn't look like this is using the v2 endpoint anyways!? date_added: Optional[datetime] = None - title: str = "" - id: str = "" + title: Optional[str] = None + id: Optional[str] = None mix_type: Optional[MixType] = None images: Optional[ImageResponse] = None detail_images: Optional[ImageResponse] = None master = False title_text_info: Optional[TextInfo] = None sub_title_text_info: Optional[TextInfo] = None - sub_title: str = "" + sub_title: Optional[str] = None updated: Optional[datetime] = None def __init__(self, session: Session, mix_id: str): From 8cd57b3e19166368f8cea590a979d0d147ea5140 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 10 Sep 2024 12:54:40 +0200 Subject: [PATCH 04/18] Fix naming of getters to align with python naming convention and avoid confusion (Fixes #255) --- tests/test_media.py | 16 ++++++++-------- tidalapi/media.py | 34 +++++++++++++++++++--------------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/tests/test_media.py b/tests/test_media.py index 71a343e..9cb5838 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -290,8 +290,8 @@ def validate_stream(stream, is_hi_res_lossless: bool = False): assert stream.audio_mode == "STEREO" if not is_hi_res_lossless: assert stream.audio_quality == "HIGH" - assert stream.is_BTS == True - assert stream.is_MPD == False + assert stream.is_bts == True + assert stream.is_mpd == False assert stream.bit_depth == 16 assert stream.sample_rate == 44100 assert stream.manifest_mime_type == ManifestMimeType.BTS @@ -300,8 +300,8 @@ def validate_stream(stream, is_hi_res_lossless: bool = False): assert audio_resolution[1] == 44100 else: assert stream.audio_quality == "HI_RES_LOSSLESS" - assert stream.is_BTS == False - assert stream.is_MPD == True + assert stream.is_bts == False + assert stream.is_mpd == True assert stream.bit_depth == 24 assert stream.sample_rate == 192000 # HI_RES_LOSSLESS: 24bit/192kHz assert stream.manifest_mime_type == ManifestMimeType.MPD @@ -315,8 +315,8 @@ def validate_stream(stream, is_hi_res_lossless: bool = False): def validate_stream_manifest(manifest, is_hi_res_lossless: bool = False): if not is_hi_res_lossless: - assert manifest.is_BTS == True - assert manifest.is_MPD == False + assert manifest.is_bts == True + assert manifest.is_mpd == False assert manifest.codecs == "MP4A" assert manifest.dash_info is None assert manifest.encryption_key is None @@ -327,8 +327,8 @@ def validate_stream_manifest(manifest, is_hi_res_lossless: bool = False): assert manifest.mime_type == MimeType.audio_mp4 assert manifest.sample_rate == 44100 else: - assert manifest.is_BTS == False - assert manifest.is_MPD == True + assert manifest.is_bts == False + assert manifest.is_mpd == True assert manifest.codecs == "flac" assert manifest.dash_info is not None assert manifest.encryption_key is None diff --git a/tidalapi/media.py b/tidalapi/media.py index 47e8335..6b2a800 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -437,14 +437,14 @@ def get_stream(self) -> "Stream": return cast("Stream", stream) @property - def is_Mqa(self) -> bool: + def is_mqa(self) -> bool: try: if self.media_metadata_tags: return ( True if MediaMetadataTags.mqa in self.media_metadata_tags - and not self.is_Sony360RA - and not self.is_DolbyAtmos + and not self.is_sony360 + and not self.is_dolby_atmos else False ) except: @@ -453,7 +453,11 @@ def is_Mqa(self) -> bool: return True if self.audio_quality == Quality.hi_res else False @property - def is_HiRes(self) -> bool: + def is_hi_res_mqa(self) -> bool: + return self.is_mqa + + @property + def is_hi_res_lossless(self) -> bool: try: if ( self.media_metadata_tags @@ -465,14 +469,14 @@ def is_HiRes(self) -> bool: return False @property - def is_DolbyAtmos(self) -> bool: + def is_dolby_atmos(self) -> bool: try: return True if AudioMode.dolby_atmos in self.audio_modes else False except: return False @property - def is_Sony360RA(self) -> bool: + def is_sony360(self) -> bool: try: return True if AudioMode.sony_360 in self.audio_modes else False except: @@ -536,11 +540,11 @@ def get_manifest_data(self) -> str: raise ManifestDecodeError @property - def is_MPD(self) -> bool: + def is_mpd(self) -> bool: return True if ManifestMimeType.MPD in self.manifest_mime_type else False @property - def is_BTS(self) -> bool: + def is_bts(self) -> bool: return True if ManifestMimeType.BTS in self.manifest_mime_type else False @@ -562,7 +566,7 @@ class StreamManifest: def __init__(self, stream: Stream): self.manifest = stream.manifest self.manifest_mime_type = stream.manifest_mime_type - if stream.is_MPD: + if stream.is_mpd: # See https://ottverse.com/structure-of-an-mpeg-dash-mpd/ for more details self.dash_info = DashInfo.from_mpd(stream.get_manifest_data()) self.urls = self.dash_info.urls @@ -572,7 +576,7 @@ def __init__(self, stream: Stream): # TODO: Handle encryption key. self.encryption_type = "NONE" self.encryption_key = None - elif stream.is_BTS: + elif stream.is_bts: # Stream Manifest is base64 encoded. self.manifest_parsed = stream.get_manifest_data() # JSON string to object. @@ -591,13 +595,13 @@ def __init__(self, stream: Stream): self.file_extension = self.get_file_extension(self.urls[0], self.codecs) def get_urls(self) -> [str]: - if self.is_MPD: + if self.is_mpd: return self.urls else: return self.urls[0] def get_hls(self) -> str: - if self.is_MPD: + if self.is_mpd: return self.dash_info.get_hls() else: raise MPDNotAvailableError("HLS stream requires MPD MetaData") @@ -644,11 +648,11 @@ def is_encrypted(self) -> bool: return True if self.encryption_key else False @property - def is_MPD(self) -> bool: + def is_mpd(self) -> bool: return True if ManifestMimeType.MPD in self.manifest_mime_type else False @property - def is_BTS(self) -> bool: + def is_bts(self) -> bool: return True if ManifestMimeType.BTS in self.manifest_mime_type else False @@ -670,7 +674,7 @@ class DashInfo: @staticmethod def from_stream(stream) -> "DashInfo": try: - if stream.is_MPD and not stream.is_encrypted: + if stream.is_mpd and not stream.is_encrypted: return DashInfo(stream.get_manifest_data()) except: raise ManifestDecodeError From 23aee70347002c128c6c89278322c5e3696dbcb9 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:16:25 +0200 Subject: [PATCH 05/18] Bugfix: Ensure manifest.codecs always uses a Codec type for both MPD and BTS. --- tidalapi/media.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tidalapi/media.py b/tidalapi/media.py index 6b2a800..f4616da 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -124,13 +124,13 @@ def __str__(self) -> str: class Codec(str, Enum): MP3: str = "MP3" AAC: str = "AAC" - M4A: str = "MP4A" + MP4A: str = "MP4A" FLAC: str = "FLAC" MQA: str = "MQA" Atmos: str = "EAC3" AC4: str = "AC4" SONY360RA: str = "MHA1" - LowResCodecs: [str] = [MP3, AAC, M4A] + LowResCodecs: [str] = [MP3, AAC, MP4A] PremiumCodecs: [str] = [MQA, Atmos, AC4] HQCodecs: [str] = PremiumCodecs + [FLAC] @@ -153,7 +153,7 @@ class MimeType(str, Enum): audio_map = { Codec.MP3: audio_mp3, Codec.AAC: audio_m4a, - Codec.M4A: audio_m4a, + Codec.MP4A: audio_m4a, Codec.FLAC: audio_xflac, Codec.MQA: audio_xflac, Codec.Atmos: audio_eac3, @@ -468,6 +468,18 @@ def is_hi_res_lossless(self) -> bool: pass return False + @property + def is_lossless(self) -> bool: + try: + if ( + self.media_metadata_tags + and MediaMetadataTags.lossless in self.media_metadata_tags + ): + return True + except: + pass + return False + @property def is_dolby_atmos(self) -> bool: try: @@ -570,7 +582,17 @@ def __init__(self, stream: Stream): # See https://ottverse.com/structure-of-an-mpeg-dash-mpd/ for more details self.dash_info = DashInfo.from_mpd(stream.get_manifest_data()) self.urls = self.dash_info.urls - self.codecs = self.dash_info.codecs + # MPD reports mp4a codecs slightly differently when compared to BTS. Both will be interpreted as MP4A + if "flac" in self.dash_info.codecs: + self.codecs = Codec.FLAC + elif "mp4a.40.5" in self.dash_info.codecs: + # LOW 96K: "mp4a.40.5" + self.codecs = Codec.MP4A + elif "mp4a.40.2" in self.dash_info.codecs: + # LOW 320k "mp4a.40.2" + self.codecs = Codec.MP4A + else: + self.codecs = self.dash_info.codecs self.mime_type = self.dash_info.mime_type self.sample_rate = self.dash_info.audio_sampling_rate # TODO: Handle encryption key. @@ -583,6 +605,7 @@ def __init__(self, stream: Stream): stream_manifest = json.loads(self.manifest_parsed) # TODO: Handle more than one download URL self.urls = stream_manifest["urls"] + # Codecs can be interpreted directly when using BTS self.codecs = stream_manifest["codecs"].upper().split(".")[0] self.mime_type = stream_manifest["mimeType"] self.encryption_type = stream_manifest["encryptionType"] From 2fb626cac9e14e0ec0d5256725ec87a9a41dddc1 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:17:44 +0200 Subject: [PATCH 06/18] Make sure request response contains the detailed error message --- tidalapi/request.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tidalapi/request.py b/tidalapi/request.py index 3cdaf60..7287b8c 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -154,7 +154,11 @@ def request( self.latest_err_response = request if request.content: resp = request.json() - log.debug("Request response: '%s'", resp["errors"][0]["detail"]) + # Make sure request response contains the detailed error message + if "errors" in resp: + log.debug("Request response: '%s'", resp["errors"][0]["detail"]) + else: + log.debug("Request response: '%s'", resp["userMessage"]) if request.status_code and request.status_code == 404: raise ObjectNotFound elif request.status_code and request.status_code == 429: From 4402f826c0cf62802041b0bc4441e673c02bfb99 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:18:07 +0200 Subject: [PATCH 07/18] Added additional tests to verify stream formats (Relates to #252) --- tests/test_album.py | 30 +++++++ tests/test_media.py | 186 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index 2d6ed9f..6df53a0 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -24,6 +24,7 @@ import tidalapi from tidalapi.album import Album +from tidalapi.media import Quality from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound from .cover import verify_image_cover, verify_video_cover @@ -159,3 +160,32 @@ def test_album_type_single(session): def test_album_type_ep(session): album = session.album(289261563) assert album.type == "EP" + + +def test_album_quality_atmos(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + album = session.album("355472560") # DOLBY_ATMOS + assert album.audio_quality == "LOW" + assert album.audio_modes == ["DOLBY_ATMOS"] + assert "DOLBY_ATMOS" in album.media_metadata_tags + + +def test_album_quality_max(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + album = session.album("355473696") # MAX (LOSSLESS, 16bit/48kHz) + assert album.audio_quality == "LOSSLESS" + assert album.audio_modes == ["STEREO"] + assert "LOSSLESS" in album.media_metadata_tags + + +def test_album_quality_max_lossless(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + album = session.album("355473675") # MAX (HI_RES_LOSSLESS, 24bit/192kHz) + assert ( + album.audio_quality == "LOSSLESS" + ) # Expected HI_RES_LOSSLESS here. TIDAL bug perhaps? + assert album.audio_modes == ["STEREO"] + assert "HIRES_LOSSLESS" in album.media_metadata_tags diff --git a/tests/test_media.py b/tests/test_media.py index 9cb5838..ca93952 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -23,8 +23,15 @@ from dateutil import tz import tidalapi -from tidalapi.exceptions import MetadataNotAvailable -from tidalapi.media import AudioExtensions, AudioMode, ManifestMimeType, MimeType +from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound +from tidalapi.media import ( + AudioExtensions, + AudioMode, + ManifestMimeType, + MimeType, + Codec, + Quality, +) from .cover import verify_image_resolution, verify_video_resolution @@ -110,6 +117,181 @@ def test_track_streaming(session): ) # i.e. the default quality for the current session +@pytest.mark.skip(reason="SONY360 support has been removed") +def test_track_quality_sony360(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + # Alice In Chains / We Die Young (Max quality: HI_RES MHA1 SONY360; Album has now been removed) + with pytest.raises(ObjectNotFound): + session.album("249593867") + + +@pytest.mark.skip(reason="Atmos appears to fallback to HI_RES_LOSSLESS") +def test_track_quality_atmos(session): + # Session should allow highest possible quality (but should fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + album = session.album("355472560") # DOLBY_ATMOS, will fallback to HI_RES_LOSSLESS + track = album.tracks()[0] + assert track.audio_quality == "LOW" + assert track.audio_modes == ["DOLBY_ATMOS"] + assert track.is_dolby_atmos + stream = track.get_stream() + assert stream.is_mpd and not stream.is_bts + assert stream.audio_quality == "HIGH" # Expected this to be LOW? + assert stream.audio_mode == "STEREO" # Expected this to be DOLBY_ATMOS? + assert stream.bit_depth == 24 # Correct bit depth for atmos?? + assert stream.sample_rate == 192000 # Correct sample rate for atmos?? + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.Atmos + assert manifest.mime_type == MimeType.audio_eac3 + + +@pytest.mark.skip(reason="MQA albums appears to fallback to LOSSLESS") +def test_track_quality_mqa(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + # U2 / Achtung Baby (Max quality: HI_RES MQA, 16bit/44100Hz) + album = session.album("77640617") + track = album.tracks()[0] + assert track.audio_quality == "LOSSLESS" + assert track.audio_modes == ["STEREO"] + # assert track.is_mqa # for an MQA album, this value is expected to be true + stream = track.get_stream() + assert stream.is_mpd and not stream.is_bts + assert stream.audio_quality == "LOSSLESS" + assert stream.audio_mode == "STEREO" + assert stream.bit_depth == 16 + assert stream.sample_rate == 44100 + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.FLAC + assert manifest.mime_type == MimeType.audio_mp4 + + +def test_track_quality_low96k(session): + # Album is available in LOSSLESS, but we will explicitly request low 320k quality instead + session.audio_quality = Quality.low_96k + # D-A-D / A Prayer for the Loud (Max quality: LOSSLESS FLAC, 16bit/44.1kHz) + album = session.album("172358622") + track = album.tracks()[0] + assert track.audio_quality == "LOSSLESS" # Available in LOSSLESS (or below) + assert track.audio_modes == ["STEREO"] + # Only LOSSLESS (or below) is available for this album + assert not track.is_hi_res_lossless + assert track.is_lossless + stream = track.get_stream() + assert ( + not stream.is_mpd and stream.is_bts + ) # LOW/HIGH/LOSSLESS streams will use BTS, if OAuth authentication is used. + assert stream.audio_quality == "LOW" + assert stream.audio_mode == "STEREO" + assert stream.bit_depth == 16 + assert stream.sample_rate == 44100 + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.MP4A + assert ( + manifest.mime_type == MimeType.audio_mp4 + ) # All MPEG-DASH based streams use an 'audio_mp4' container + + +def test_track_quality_low320k(session): + # Album is available in LOSSLESS, but we will explicitly request low 320k quality instead + session.audio_quality = Quality.low_320k + # D-A-D / A Prayer for the Loud (Max quality: LOSSLESS FLAC, 16bit/44.1kHz) + album = session.album("172358622") + track = album.tracks()[0] + assert track.audio_quality == "LOSSLESS" # Available in LOSSLESS (or below) + assert track.audio_modes == ["STEREO"] + # Only LOSSLESS (or below) is available for this album + assert not track.is_hi_res_lossless + assert track.is_lossless + stream = track.get_stream() + assert not stream.is_mpd and stream.is_bts + assert stream.audio_quality == "HIGH" + assert stream.audio_mode == "STEREO" + assert stream.bit_depth == 16 + assert stream.sample_rate == 44100 + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.MP4A + assert ( + manifest.mime_type == MimeType.audio_mp4 + ) # All BTS (LOW/HIGH) based streams use an 'audio_mp4' container + + +def test_track_quality_lossless(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + # D-A-D / A Prayer for the Loud (Max quality: LOSSLESS FLAC, 16bit/44.1kHz) + album = session.album("172358622") + track = album.tracks()[0] + assert track.audio_quality == "LOSSLESS" + assert track.audio_modes == ["STEREO"] + # Only LOSSLESS is available for this album + assert not track.is_hi_res_lossless + assert track.is_lossless + stream = track.get_stream() + assert ( + not stream.is_mpd and stream.is_bts + ) # LOW/HIGH/LOSSLESS streams will use BTS, if OAuth authentication is used. + assert stream.audio_quality == "LOSSLESS" + assert stream.audio_mode == "STEREO" + assert stream.bit_depth == 16 + assert stream.sample_rate == 44100 + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.FLAC + assert ( + manifest.mime_type == MimeType.audio_flac + ) # All BTS (LOSSLESS) based streams use an 'audio_mp4' container + + +def test_track_quality_max(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + # Mark Knopfler, One Deep River: Reported as MAX (HI_RES_LOSSLESS, 16bit/48kHz) + album = session.album("355473696") + track = album.tracks()[0] + assert track.audio_quality == "LOSSLESS" + assert track.audio_modes == ["STEREO"] + # Both HI_RES_LOSSLESS and LOSSLESS is available for this album + assert track.is_hi_res_lossless + assert track.is_lossless + stream = track.get_stream() + assert ( + stream.is_mpd and not stream.is_bts + ) # HI_RES_LOSSLESS streams will use MPD, if OAuth authentication is used. + assert stream.audio_quality == "HI_RES_LOSSLESS" + assert stream.audio_mode == "STEREO" + assert stream.bit_depth == 16 + assert stream.sample_rate == 48000 + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.FLAC + assert ( + manifest.mime_type == MimeType.audio_mp4 + ) # All MPEG-DASH based streams use an 'audio_mp4' container + + +def test_track_quality_max_lossless(session): + # Session should allow highest possible quality (but will fallback to highest available album quality) + session.audio_quality = Quality.hi_res_lossless + album = session.album("355473675") # MAX (HI_RES_LOSSLESS, 24bit/192kHz) + track = album.tracks()[0] + # Both HI_RES_LOSSLESS and LOSSLESS is available for this album + assert track.is_hi_res_lossless + assert track.is_lossless + stream = track.get_stream() + assert ( + stream.is_mpd and not stream.is_bts + ) # HI_RES_LOSSLESS streams will use MPD, if OAuth authentication is used. + assert stream.audio_quality == "HI_RES_LOSSLESS" + assert stream.audio_mode == "STEREO" + assert stream.bit_depth == 24 + assert stream.sample_rate == 192000 + manifest = stream.get_stream_manifest() + assert manifest.codecs == Codec.FLAC + assert ( + manifest.mime_type == MimeType.audio_mp4 + ) # All MPEG-DASH based streams use an 'audio_mp4' container + + def test_video(session): video = session.video(125506698) From b28b959f0368c78fee6369a07353af8a2b944cec Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:18:15 +0200 Subject: [PATCH 08/18] Update changelog --- HISTORY.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 82a7eb4..ac8380d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,10 @@ History ======= v0.7.7 ------ +* Bugfix: Ensure manifest.codecs always uses a Codec type for both MPD and BTS. - tehkillerbee_ +* Added additional tests to verify stream formats (Relates to #252) - tehkillerbee_ +* BREAKING: Fix naming of getters to align with python naming convention and avoid confusion (Fixes #255) - tehkillerbee_ +* Bugfix: Use correct internal type int for relevant IDs (Fixes #260) - tehkillerbee_ * Bugfix: Fix linting for audio_modes. Update tests (Fixes #261) - tehkillerbee_ * Feat.: Provide "Share Link", "Listen link" as an attribute to album/artist/media. Add relevant tests (Fixes #266) - tehkillerbee_ * Allow switching authentication method oauth/pkce for tests. Default: oauth - tehkillerbee_ From bae3414c5b0de439154ab1ee23eec964f3eb1367 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:18:58 +0200 Subject: [PATCH 09/18] Formatting --- tests/test_album.py | 2 +- tests/test_media.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index 6df53a0..b747857 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -24,8 +24,8 @@ import tidalapi from tidalapi.album import Album -from tidalapi.media import Quality from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound +from tidalapi.media import Quality from .cover import verify_image_cover, verify_video_cover diff --git a/tests/test_media.py b/tests/test_media.py index ca93952..25465a5 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -27,9 +27,9 @@ from tidalapi.media import ( AudioExtensions, AudioMode, + Codec, ManifestMimeType, MimeType, - Codec, Quality, ) From 3ac7f560d2fcbe723d56873de5d52af5fae2dee5 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:59:07 +0200 Subject: [PATCH 10/18] Bugfix: Recent TIDAL changes resulted in Mix NotFound not resulting in ObjectNotFound exception. --- tests/test_mix.py | 10 ++++++++++ tidalapi/mix.py | 25 +++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/tests/test_mix.py b/tests/test_mix.py index ba0f934..799d455 100644 --- a/tests/test_mix.py +++ b/tests/test_mix.py @@ -45,3 +45,13 @@ def test_mix_unavailable(session): def test_mixv2_unavailable(session): with pytest.raises(ObjectNotFound): mix = session.mixv2("12345678") + + +@pytest.mark.skip(reason="Cannot test against user specific mixes") +def test_mix_available(session): + mix = session.mix("016edb91bc504e618de6918b11b25b") + + +@pytest.mark.skip(reason="Cannot test against user specific mixes") +def test_mixv2_available(session): + mix = session.mixv2("016edb91bc504e618de6918b11b25b") diff --git a/tidalapi/mix.py b/tidalapi/mix.py index 55f4645..9605e4c 100644 --- a/tidalapi/mix.py +++ b/tidalapi/mix.py @@ -104,10 +104,14 @@ def get(self, mix_id: Optional[str] = None) -> "Mix": else: result = self.session.parse_page(request.json()) assert not isinstance(result, list) - self._retrieved = True - self.__dict__.update(result.categories[0].__dict__) - self._items = result.categories[1].items - return self + if len(result.categories) <= 1: + # An empty page with no mixes was returned. Assume that the selected mix was not available + raise ObjectNotFound("Mix not found") + else: + self._retrieved = True + self.__dict__.update(result.categories[0].__dict__) + self._items = result.categories[1].items + return self def parse(self, json_obj: JsonObj) -> "Mix": """Parse a mix into a :class:`Mix`, replaces the calling object. @@ -188,6 +192,8 @@ class MixV2: sub_title_text_info: Optional[TextInfo] = None sub_title: Optional[str] = None updated: Optional[datetime] = None + _retrieved = False + _items: Optional[List[Union["Video", "Track"]]] = None def __init__(self, session: Session, mix_id: str): self.session = session @@ -215,8 +221,15 @@ def get(self, mix_id: Optional[str] = None) -> "MixV2": else: result = self.session.parse_page(request.json()) assert not isinstance(result, list) - self.__dict__.update(result.categories[0].__dict__) - return self + + if len(result.categories) <= 1: + # An empty page with no mixes was returned. Assume that the selected mix was not available + raise ObjectNotFound("Mix not found") + else: + self._retrieved = True + self.__dict__.update(result.categories[0].__dict__) + self._items = result.categories[1].items + return self def parse(self, json_obj: JsonObj) -> "MixV2": """Parse a mix into a :class:`MixV2`, replaces the calling object. From 9edf632b7b472143eeb3d3951fdeb88587a1aaa4 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 12:59:32 +0200 Subject: [PATCH 11/18] userMessage may not be available. Use raw json dump instead. --- tidalapi/request.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tidalapi/request.py b/tidalapi/request.py index 7287b8c..4f38412 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -157,8 +157,11 @@ def request( # Make sure request response contains the detailed error message if "errors" in resp: log.debug("Request response: '%s'", resp["errors"][0]["detail"]) - else: + elif "userMessage" in resp: log.debug("Request response: '%s'", resp["userMessage"]) + else: + log.debug("Request response: '%s'", json.dumps(resp)) + if request.status_code and request.status_code == 404: raise ObjectNotFound elif request.status_code and request.status_code == 429: From 78f7848081fb081d75b29878b7cc9ce7b21b4545 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:31:47 +0200 Subject: [PATCH 12/18] Use enum to specify default audio / video quality --- tidalapi/media.py | 2 ++ tidalapi/session.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tidalapi/media.py b/tidalapi/media.py index f4616da..46d3942 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -59,6 +59,7 @@ class Quality(str, Enum): high_lossless: str = "LOSSLESS" hi_res: str = "HI_RES" hi_res_lossless: str = "HI_RES_LOSSLESS" + default: str = low_320k def __str__(self) -> str: return self.value @@ -69,6 +70,7 @@ class VideoQuality(str, Enum): medium: str = "MEDIUM" low: str = "LOW" audio_only: str = "AUDIO_ONLY" + default: str = high def __str__(self) -> str: return self.value diff --git a/tidalapi/session.py b/tidalapi/session.py index 777c636..9aa3df1 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -126,8 +126,8 @@ class Config: @no_type_check def __init__( self, - quality: str = media.Quality.low_320k, - video_quality: str = media.VideoQuality.high, + quality: str = media.Quality.default, + video_quality: str = media.VideoQuality.default, item_limit: int = 1000, alac: bool = True, ): From 166e0540478069a61874b5b7266c4de6abd6385a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:32:32 +0200 Subject: [PATCH 13/18] Rewrite page tests to make sure they correspond to TIDAL layout changes --- tests/test_page.py | 46 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/test_page.py b/tests/test_page.py index b9e6606..2521c24 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -32,20 +32,38 @@ def test_explore(session): def test_get_explore_items(session): explore = session.explore() - iterator = iter(explore) - playlist = next(iterator).get() - assert playlist.name + assert explore.title == "Explore" + # First page usually contains Genres + assert explore.categories[0].title == "Genres" + assert explore.categories[1].title == "Moods, Activities & Events" + assert explore.categories[2].title == "" # Usually empty + + # Genre_decades items + genre_decades = explore.categories[0].items[0] + genre_decades_page_items = iter(genre_decades.get()) + first_item = next(genre_decades_page_items).get() + assert isinstance(first_item, tidalapi.Page) + assert first_item.title == "1950s" + assert first_item.categories[0].title == "Playlists" + assert first_item.categories[1].title == "Milestone Year Albums" + assert first_item.categories[2].title == "Albums Of The Decade" + playlist = first_item.categories[0].items[0] + assert playlist.name # == 'Remember...the 1950s' assert playlist.num_tracks > 1 + assert playlist.num_videos == 0 - genre = explore.categories[1].items[0] - genre_page_items = iter(genre.get()) - assert isinstance(next(genre_page_items).get(), tidalapi.Playlist) + genre_genres = explore.categories[0].items[1] + genre_genres_page_items = iter(genre_genres.get()) + playlist = next(genre_genres_page_items) # Usually a playlist + assert playlist.name # == 'Remember...the 1950s' + assert playlist.num_tracks > 1 + assert playlist.num_videos == 0 - genres = explore.categories[1].show_more() - iterator = iter(genres) + genres_more = explore.categories[0].show_more() + iterator = iter(genres_more) next(iterator) - assert next(iterator).title == "Blues" assert next(iterator).title == "Classical" + assert next(iterator).title == "Country" def test_for_you(session): @@ -56,11 +74,11 @@ def test_for_you(session): def test_show_more(session): videos = session.videos() originals = next( - iter(filter(lambda x: x.title == "TIDAL Originals", videos.categories)) + iter(filter(lambda x: x.title == "Custom mixes", videos.categories)) ) more = originals.show_more() assert len(more.categories[0].items) > 0 - assert isinstance(next(iter(more)), tidalapi.Artist) + assert isinstance(next(iter(more)), tidalapi.Mix) def test_page_iterator(session): @@ -73,8 +91,8 @@ def test_page_iterator(session): elif isinstance(item, tidalapi.Video): videos += 1 - assert playlists > 20 - assert videos > 20 + assert playlists == 20 + assert videos == 30 def test_get_video_items(session): @@ -88,7 +106,7 @@ def test_get_video_items(session): def test_page_links(session): explore = session.explore() - for item in explore.categories[3].items: + for item in explore.categories[2].items: page = item.get() if item.title == "TIDAL Rising": assert isinstance(page.categories[1].text, str) From 328e38be99e699bd4a712c95a7eec75e14495840 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:33:45 +0200 Subject: [PATCH 14/18] Add playlist share URL --- tests/test_playlist.py | 9 +++++++++ tidalapi/playlist.py | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 7c9e1a2..0325f28 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -57,6 +57,15 @@ def test_playlist(session): assert creator.name == "JAY Z" assert isinstance(creator, tidalapi.Artist) + assert ( + playlist.listen_url + == "https://listen.tidal.com/playlist/7eafb342-141a-4092-91eb-da0012da3a19" + ) + assert ( + playlist.share_url + == "https://tidal.com/browse/playlist/7eafb342-141a-4092-91eb-da0012da3a19" + ) + def test_updated_playlist(session): playlist = session.playlist("944dd087-f65c-4954-a9a3-042a574e86e3") diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index f7c36ec..84548c5 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -60,6 +60,11 @@ class Playlist: user_date_added: Optional[datetime] = None _etag: Optional[str] = None + # Direct URL to https://listen.tidal.com/playlist/ + listen_url: str = "" + # Direct URL to https://tidal.com/browse/playlist/ + share_url: str = "" + def __init__(self, session: "Session", playlist_id: Optional[str]): self.id = playlist_id self.session = session @@ -126,6 +131,9 @@ def parse(self, json_obj: JsonObj) -> "Playlist": else: self.creator = self.session.parse_user(creator) if creator else None + self.listen_url = f"{self.session.config.listen_base_url}/playlist/{self.id}" + self.share_url = f"{self.session.config.share_base_url}/playlist/{self.id}" + return copy.copy(self) def factory(self) -> "Playlist": From 3bae6ea467aa6b0bc6c4fa471c6a5e2dc3c726a5 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:35:10 +0200 Subject: [PATCH 15/18] Use Quality, AudioMode enum directly --- tests/test_album.py | 23 ++++++++++++++--------- tests/test_media.py | 18 +++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index b747857..f3f1166 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -25,7 +25,7 @@ import tidalapi from tidalapi.album import Album from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound -from tidalapi.media import Quality +from tidalapi.media import Quality, AudioMode from .cover import verify_image_cover, verify_video_cover @@ -41,7 +41,7 @@ def test_album(session): assert album.allow_streaming assert album.dj_ready assert album.audio_modes == ["STEREO"] - assert album.audio_quality == "LOSSLESS" + assert album.audio_quality == Quality.high_lossless assert album.num_tracks == 22 assert album.num_videos == 0 assert album.num_volumes == 2 @@ -166,17 +166,17 @@ def test_album_quality_atmos(session): # Session should allow highest possible quality (but will fallback to highest available album quality) session.audio_quality = Quality.hi_res_lossless album = session.album("355472560") # DOLBY_ATMOS - assert album.audio_quality == "LOW" - assert album.audio_modes == ["DOLBY_ATMOS"] + assert album.audio_quality == Quality.low_96k + assert album.audio_modes == [AudioMode.dolby_atmos] assert "DOLBY_ATMOS" in album.media_metadata_tags def test_album_quality_max(session): # Session should allow highest possible quality (but will fallback to highest available album quality) - session.audio_quality = Quality.hi_res_lossless + session.audio_quality = Quality.high_lossless album = session.album("355473696") # MAX (LOSSLESS, 16bit/48kHz) - assert album.audio_quality == "LOSSLESS" - assert album.audio_modes == ["STEREO"] + assert album.audio_quality == Quality.high_lossless + assert album.audio_modes == [AudioMode.stereo] assert "LOSSLESS" in album.media_metadata_tags @@ -185,7 +185,12 @@ def test_album_quality_max_lossless(session): session.audio_quality = Quality.hi_res_lossless album = session.album("355473675") # MAX (HI_RES_LOSSLESS, 24bit/192kHz) assert ( - album.audio_quality == "LOSSLESS" + album.audio_quality == Quality.high_lossless ) # Expected HI_RES_LOSSLESS here. TIDAL bug perhaps? - assert album.audio_modes == ["STEREO"] + assert album.audio_modes == [AudioMode.stereo] assert "HIRES_LOSSLESS" in album.media_metadata_tags + + +def test_reset_session_quality(session): + # HACK: Make sure to reset audio quality to default value for remaining tests + session.audio_quality = Quality.default diff --git a/tests/test_media.py b/tests/test_media.py index 25465a5..974e25b 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -428,8 +428,8 @@ def test_get_track_radio_limit_100(session): def test_get_stream_bts(session): track = session.track(77646170) # Beck: Sea Change, Track: The Golden Age - # Set session as BTS type (i.e. HIGH Quality) - session.audio_quality = "HIGH" + # Set session as BTS type (i.e. low_320k/HIGH Quality) + session.audio_quality = Quality.low_320k # Attempt to get stream and validate stream = track.get_stream() validate_stream(stream, False) @@ -444,7 +444,7 @@ def test_get_stream_bts(session): def test_get_stream_mpd(session): track = session.track(77646170) # Set session as MPD/DASH type (i.e. HI_RES_LOSSLESS Quality). - session.audio_quality = "HI_RES_LOSSLESS" + session.audio_quality = Quality.hi_res_lossless # Attempt to get stream and validate stream = track.get_stream() validate_stream(stream, True) @@ -458,7 +458,7 @@ def test_manifest_element_count(session): # and must be handled slightly differently when parsing the stream manifest DashInfo track = session.track(281047832) # Set session as MPD/DASH type (i.e. HI_RES_LOSSLESS Quality). - session.audio_quality = "HI_RES_LOSSLESS" + session.audio_quality = Quality.hi_res_lossless # Attempt to get stream stream = track.get_stream() # Get parsed stream manifest @@ -469,9 +469,9 @@ def validate_stream(stream, is_hi_res_lossless: bool = False): assert stream.album_peak_amplitude == 1.0 assert stream.album_replay_gain == -11.8 assert stream.asset_presentation == "FULL" - assert stream.audio_mode == "STEREO" + assert stream.audio_mode == AudioMode.stereo if not is_hi_res_lossless: - assert stream.audio_quality == "HIGH" + assert stream.audio_quality == Quality.low_320k assert stream.is_bts == True assert stream.is_mpd == False assert stream.bit_depth == 16 @@ -481,7 +481,7 @@ def validate_stream(stream, is_hi_res_lossless: bool = False): assert audio_resolution[0] == 16 assert audio_resolution[1] == 44100 else: - assert stream.audio_quality == "HI_RES_LOSSLESS" + assert stream.audio_quality == Quality.hi_res_lossless assert stream.is_bts == False assert stream.is_mpd == True assert stream.bit_depth == 24 @@ -499,7 +499,7 @@ def validate_stream_manifest(manifest, is_hi_res_lossless: bool = False): if not is_hi_res_lossless: assert manifest.is_bts == True assert manifest.is_mpd == False - assert manifest.codecs == "MP4A" + assert manifest.codecs == Codec.MP4A assert manifest.dash_info is None assert manifest.encryption_key is None assert manifest.encryption_type == "NONE" @@ -511,7 +511,7 @@ def validate_stream_manifest(manifest, is_hi_res_lossless: bool = False): else: assert manifest.is_bts == False assert manifest.is_mpd == True - assert manifest.codecs == "flac" + assert manifest.codecs == Codec.FLAC assert manifest.dash_info is not None assert manifest.encryption_key is None assert manifest.encryption_type == "NONE" From b0d8edbfcece59452cbe2495cc0cdb73d56b4f39 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:35:44 +0200 Subject: [PATCH 16/18] Make sure audio_quality is reset to default before tests continue --- tests/test_media.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_media.py b/tests/test_media.py index 974e25b..ac1fbb9 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -521,3 +521,8 @@ def validate_stream_manifest(manifest, is_hi_res_lossless: bool = False): assert manifest.mime_type == MimeType.audio_mp4 assert manifest.sample_rate == 192000 # TODO Validate stream URL contents + + +def test_reset_session_quality(session): + # HACK: Make sure to reset audio quality to default value for remaining tests + session.audio_quality = Quality.default From 9f2aa4c122c52444939dc10e3eebb1ec21c2c603 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:35:55 +0200 Subject: [PATCH 17/18] Fix misc broken tests --- tests/test_artist.py | 2 -- tests/test_media.py | 2 +- tests/test_playlist.py | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 8c06f95..0fa2f98 100644 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -56,7 +56,6 @@ def test_get_albums(session): session.album(17927863), session.album(36292296), session.album(17925106), - session.album(17782044), session.album(17926279), ] @@ -93,7 +92,6 @@ def test_get_top_tracks(session): session.track(17927865), session.track(17927867), session.track(17926280), - session.track(17782052), session.track(17927869), ] diff --git a/tests/test_media.py b/tests/test_media.py index ac1fbb9..2d73958 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -405,7 +405,7 @@ def test_full_name_track_3(session): def test_track_media_metadata_tags(session): track = session.track(182912246) assert track.name == "All You Ever Wanted" - assert track.media_metadata_tags == ["LOSSLESS", "HIRES_LOSSLESS", "MQA"] + assert track.media_metadata_tags == ["LOSSLESS", "HIRES_LOSSLESS"] def test_get_track_radio_limit_default(session): diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 0325f28..ef1cb19 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -106,13 +106,13 @@ def test_video_playlist(session): ) assert playlist.duration == 1996 assert playlist.last_updated == datetime.datetime( - 2020, 3, 25, 8, 5, 33, 115000, tzinfo=tz.tzutc() + 2024, 8, 14, 16, 26, 58, 898000, tzinfo=tz.tzutc() ) assert playlist.created == datetime.datetime( 2017, 1, 23, 18, 34, 56, 930000, tzinfo=tz.tzutc() ) assert playlist.type == "EDITORIAL" - assert playlist.public is True + assert playlist.public is False assert playlist.promoted_artists[0].name == "Sundance Film Festival" creator = playlist.creator From ef12176f1de913fa60f477242b064b91b8c4b52a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 14:36:47 +0200 Subject: [PATCH 18/18] Update changelog, formatting --- HISTORY.rst | 5 ++++- tests/test_album.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index ac8380d..7ea13ab 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,12 +4,15 @@ History ======= v0.7.7 ------ +* Tests: Fix all tests that previously failed. - tehkillerbee_ +* Use enum to specify default audio / video quality - tehkillerbee_ +* Bugfix: Recent TIDAL changes resulted in missing Mix not causing a ObjectNotFound exception. - tehkillerbee_ * Bugfix: Ensure manifest.codecs always uses a Codec type for both MPD and BTS. - tehkillerbee_ * Added additional tests to verify stream formats (Relates to #252) - tehkillerbee_ * BREAKING: Fix naming of getters to align with python naming convention and avoid confusion (Fixes #255) - tehkillerbee_ * Bugfix: Use correct internal type int for relevant IDs (Fixes #260) - tehkillerbee_ * Bugfix: Fix linting for audio_modes. Update tests (Fixes #261) - tehkillerbee_ -* Feat.: Provide "Share Link", "Listen link" as an attribute to album/artist/media. Add relevant tests (Fixes #266) - tehkillerbee_ +* Feat.: Provide "Share Link", "Listen link" as an attribute to album/artist/media/playlist/. Update relevant tests (Fixes #266) - tehkillerbee_ * Allow switching authentication method oauth/pkce for tests. Default: oauth - tehkillerbee_ * Tests: Added track stream tests (BTS, MPD) - tehkillerbee_ * Bugfix: Always use last element in segment timeline. (Fixes #273) - tehkillerbee_ diff --git a/tests/test_album.py b/tests/test_album.py index f3f1166..ba8c8e0 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -25,7 +25,7 @@ import tidalapi from tidalapi.album import Album from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound -from tidalapi.media import Quality, AudioMode +from tidalapi.media import AudioMode, Quality from .cover import verify_image_cover, verify_video_cover