diff --git a/HISTORY.rst b/HISTORY.rst index aefdfde..7ea13ab 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,7 +4,15 @@ History ======= v0.7.7 ------ -* Feat.: Provide "Share Link", "Listen link" as an attribute to album/artist/media. Add relevant tests (Fixes #266) - tehkillerbee_ +* 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/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 5b0ae2a..ba8c8e0 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -25,6 +25,7 @@ import tidalapi from tidalapi.album import Album from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound +from tidalapi.media import AudioMode, Quality from .cover import verify_image_cover, verify_video_cover @@ -36,15 +37,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 == Quality.high_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" @@ -152,3 +160,37 @@ 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 == 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.high_lossless + album = session.album("355473696") # MAX (LOSSLESS, 16bit/48kHz) + assert album.audio_quality == Quality.high_lossless + assert album.audio_modes == [AudioMode.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 == Quality.high_lossless + ) # Expected HI_RES_LOSSLESS here. TIDAL bug perhaps? + 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_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 71a343e..2d73958 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, + Codec, + ManifestMimeType, + MimeType, + 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) @@ -223,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): @@ -246,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) @@ -262,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) @@ -276,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 @@ -287,11 +469,11 @@ 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.is_BTS == True - assert stream.is_MPD == False + assert stream.audio_quality == Quality.low_320k + 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 @@ -299,9 +481,9 @@ 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.is_BTS == False - assert stream.is_MPD == True + assert stream.audio_quality == Quality.hi_res_lossless + 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,9 +497,9 @@ 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.codecs == "MP4A" + assert manifest.is_bts == True + assert manifest.is_mpd == False + assert manifest.codecs == Codec.MP4A assert manifest.dash_info is None assert manifest.encryption_key is None assert manifest.encryption_type == "NONE" @@ -327,9 +509,9 @@ 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.codecs == "flac" + assert manifest.is_bts == False + assert manifest.is_mpd == True + assert manifest.codecs == Codec.FLAC assert manifest.dash_info is not None assert manifest.encryption_key is None assert manifest.encryption_type == "NONE" @@ -339,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 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/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) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 7c9e1a2..ef1cb19 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") @@ -97,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 diff --git a/tidalapi/album.py b/tidalapi/album.py index 7ca5a25..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 @@ -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 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..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 @@ -124,13 +126,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 +155,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, @@ -184,7 +186,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 @@ -437,14 +439,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 +455,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 +471,26 @@ def is_HiRes(self) -> bool: return False @property - def is_DolbyAtmos(self) -> bool: + 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: 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 +554,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,23 +580,34 @@ 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 - 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. 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. 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"] @@ -591,13 +620,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 +673,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 +699,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 diff --git a/tidalapi/mix.py b/tidalapi/mix.py index 8f1ebca..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. @@ -178,16 +182,18 @@ 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 + _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. 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": diff --git a/tidalapi/request.py b/tidalapi/request.py index 3cdaf60..4f38412 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -154,7 +154,14 @@ 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"]) + 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: 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, ):