Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-ytmusicapi for openSUSE:Factory checked in at 2026-09-18 22:06:05 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-ytmusicapi (Old) and /work/SRC/openSUSE:Factory/.python-ytmusicapi.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-ytmusicapi" Fri Sep 18 22:06:05 2026 rev:17 rq:1378684 version:1.12.3 Changes: -------- --- /work/SRC/openSUSE:Factory/python-ytmusicapi/python-ytmusicapi.changes 2026-08-11 17:12:08.749361487 +0200 +++ /work/SRC/openSUSE:Factory/.python-ytmusicapi.new.383539/python-ytmusicapi.changes 2026-09-18 22:06:21.313132468 +0200 @@ -1,0 +2,12 @@ +Thu Sep 17 19:21:22 UTC 2026 - Christophe Marin <[email protected]> + +- Update to 1.12.3: + * fix: tolerate missing thumbnails in all parsers + * fix(parsers): tolerate playlist headers without secondSubtitle + * Parse isAvailable for song/video search results + * fix: resolve flaky search, explore, and podcast tests + * Handle untimed mobile lyrics payloads + * fix(browsing): skip unavailable homepage cards + * fix(search): mark unavailable episode search results with isAvailable + +------------------------------------------------------------------- Old: ---- ytmusicapi-1.12.2.tar.gz New: ---- ytmusicapi-1.12.3.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-ytmusicapi.spec ++++++ --- /var/tmp/diff_new_pack.javTjI/_old 2026-09-18 22:06:22.175168595 +0200 +++ /var/tmp/diff_new_pack.javTjI/_new 2026-09-18 22:06:22.177168679 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-ytmusicapi -Version: 1.12.2 +Version: 1.12.3 Release: 0 Summary: Unofficial API for YouTube Music License: MIT ++++++ ytmusicapi-1.12.2.tar.gz -> ytmusicapi-1.12.3.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/PKG-INFO new/ytmusicapi-1.12.3/PKG-INFO --- old/ytmusicapi-1.12.2/PKG-INFO 2026-08-08 20:30:17.594722000 +0200 +++ new/ytmusicapi-1.12.3/PKG-INFO 2026-09-16 17:04:30.573035000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: ytmusicapi -Version: 1.12.2 +Version: 1.12.3 Summary: Unofficial API for YouTube Music Author-email: sigma67 <[email protected]> License-Expression: MIT diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/mixins/test_browsing.py new/ytmusicapi-1.12.3/tests/mixins/test_browsing.py --- old/ytmusicapi-1.12.2/tests/mixins/test_browsing.py 2026-08-08 20:30:12.160535300 +0200 +++ new/ytmusicapi-1.12.3/tests/mixins/test_browsing.py 2026-09-16 17:04:23.243380000 +0200 @@ -308,6 +308,86 @@ assert song.start_time <= song.end_time assert isinstance(song.id, int) + @pytest.mark.parametrize( + ("timed_lyrics_data", "expected_lyrics", "has_timestamps"), + [ + ( + [ + { + "lyricLine": "First line", + "cueRange": { + "startTimeMilliseconds": "1000", + "endTimeMilliseconds": "2000", + "metadata": {"id": "1"}, + }, + }, + { + "lyricLine": "Second line", + "cueRange": { + "startTimeMilliseconds": "2000", + "endTimeMilliseconds": "3000", + "metadata": {"id": "2"}, + }, + }, + ], + [ + LyricLine(text="First line", start_time=1000, end_time=2000, id=1), + LyricLine(text="Second line", start_time=2000, end_time=3000, id=2), + ], + True, + ), + ( + [{"lyricLine": "First line"}, {"lyricLine": "Second line"}], + "First line\nSecond line", + False, + ), + ( + [ + { + "lyricLine": "First line", + "cueRange": { + "startTimeMilliseconds": "1000", + "endTimeMilliseconds": "2000", + "metadata": {"id": "1"}, + }, + }, + {"lyricLine": "Second line"}, + ], + "First line\nSecond line", + False, + ), + ], + ) + def test_get_lyrics_timestamp_shapes(self, yt, timed_lyrics_data, expected_lyrics, has_timestamps): + mock_response = { + "contents": { + "elementRenderer": { + "newElement": { + "type": { + "componentType": { + "model": { + "timedLyricsModel": { + "lyricsData": { + "timedLyricsData": timed_lyrics_data, + "sourceMessage": "Source: Test", + } + } + } + } + } + } + } + } + } + + with mock.patch("ytmusicapi.YTMusic._send_request", return_value=mock_response): + lyrics_song = yt.get_lyrics("MPLYtest", timestamps=True) + + assert lyrics_song is not None + assert lyrics_song["lyrics"] == expected_lyrics + assert lyrics_song["source"] == "Source: Test" + assert lyrics_song["hasTimestamps"] is has_timestamps + def test_get_signatureTimestamp(self, yt): signature_timestamp = yt.get_signatureTimestamp() assert signature_timestamp is not None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/mixins/test_explore.py new/ytmusicapi-1.12.3/tests/mixins/test_explore.py --- old/ytmusicapi-1.12.2/tests/mixins/test_explore.py 2026-08-08 20:30:12.160535300 +0200 +++ new/ytmusicapi-1.12.3/tests/mixins/test_explore.py 2026-09-16 17:04:23.243380000 +0200 @@ -19,7 +19,11 @@ for item in explore.get("top_songs", {"items": []})["items"]: assert item["videoId"], item assert item["videoType"], item - assert item.get("views") or item.get("album"), item + if podcast := item.get("podcast", None): + assert podcast["id"], item + assert podcast["name"], item + else: + assert item.get("views") or item.get("album"), item for item in explore["trending"]["items"]: assert item["videoId"], item diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/mixins/test_playlists.py new/ytmusicapi-1.12.3/tests/mixins/test_playlists.py --- old/ytmusicapi-1.12.2/tests/mixins/test_playlists.py 2026-08-08 20:30:12.160535300 +0200 +++ new/ytmusicapi-1.12.3/tests/mixins/test_playlists.py 2026-09-16 17:04:23.243380000 +0200 @@ -108,7 +108,7 @@ "playlist_id", [ "OLAK5uy_nT1mL8aZvxqfIRFN9L8FgIzfvk6HUkd0I", # Show - "OLAK5uy_ksLYkcnrOSKYl62uxB3ga2zfBZfCuvnJ4", # Audiobook + "OLAK5uy_kIzblCjlA2EThP40umbI81GfLrDaOhp5s", # Audiobook ], ) def test_get_playlist_audiobook(self, yt, playlist_id): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/mixins/test_podcasts.py new/ytmusicapi-1.12.3/tests/mixins/test_podcasts.py --- old/ytmusicapi-1.12.2/tests/mixins/test_podcasts.py 2026-08-08 20:30:12.160535300 +0200 +++ new/ytmusicapi-1.12.3/tests/mixins/test_podcasts.py 2026-09-16 17:04:23.243380000 +0200 @@ -49,6 +49,8 @@ results = yt.search("europe", filter="episodes") episodes = [] for result in results: + if not result["isAvailable"]: + continue # search can surface episodes that are no longer available try: episode = yt.get_episode(result["videoId"]) except YTMusicServerError: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/mixins/test_search.py new/ytmusicapi-1.12.3/tests/mixins/test_search.py --- old/ytmusicapi-1.12.2/tests/mixins/test_search.py 2026-08-08 20:30:12.160535300 +0200 +++ new/ytmusicapi-1.12.3/tests/mixins/test_search.py 2026-09-16 17:04:23.243380000 +0200 @@ -25,7 +25,7 @@ results = yt.search(query) assert all(album["playlistId"] is not None for album in results if album["resultType"] == "album") assert ["resultType" in r for r in results] == [True] * len(results) - assert len(results) >= 5 + assert len(results) >= 3 assert not any( artist["name"].lower() in API_RESULT_TYPES for result in results diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/parsers/test_browsing.py new/ytmusicapi-1.12.3/tests/parsers/test_browsing.py --- old/ytmusicapi-1.12.2/tests/parsers/test_browsing.py 2026-08-08 20:30:12.160910400 +0200 +++ new/ytmusicapi-1.12.3/tests/parsers/test_browsing.py 2026-09-16 17:04:23.243965100 +0200 @@ -4,7 +4,7 @@ from tests.parsers.data import OWNED_PLAYLIST from ytmusicapi.navigation import MRLIR, MTRIR -from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist +from ytmusicapi.parsers.browsing import parse_content_list, parse_mixed_content, parse_playlist def test_parse_playlist_marks_playlist_with_editor_endpoint_as_owned(): @@ -81,3 +81,57 @@ assert parse_content_list(results, lambda item: item["id"], MTRIR) == [1, 3] assert parse_content_list(results, lambda item: item["id"], MRLIR) == [2] + + +class TestParseMixedContent: + @pytest.mark.parametrize("include_unavailable", [False, True]) + def test_unavailable_upload_does_not_hide_other_recommendations(self, include_unavailable): + artist_endpoint = { + "browseEndpoint": { + "browseId": "UCartist", + "browseEndpointContextSupportedConfigs": { + "browseEndpointContextMusicConfig": {"pageType": "MUSIC_PAGE_TYPE_ARTIST"} + }, + } + } + artist = { + "title": {"runs": [{"text": "Artist", "navigationEndpoint": artist_endpoint}]}, + "subtitle": {}, + "navigationEndpoint": artist_endpoint, + } + song = { + "title": {"runs": [{"text": "Song"}]}, + "subtitle": {"runs": [{"text": "Artist"}]}, + "navigationEndpoint": {"watchEndpoint": {"videoId": "video-id"}}, + } + watch_playlist = { + "title": {"runs": [{"text": "Radio"}]}, + "navigationEndpoint": {"watchPlaylistEndpoint": {"playlistId": "RDplaylist"}}, + } + contents = [{MTRIR: artist}, {MTRIR: song}, {MTRIR: watch_playlist}] + if include_unavailable: + # #668: a deleted upload still appears in Listen again, but its + # title has no navigation and its outer browse endpoint has no ID. + unavailable = deepcopy(artist) + unavailable["title"] = {"runs": [{"text": "Anthony M"}]} + del unavailable["navigationEndpoint"]["browseEndpoint"]["browseId"] + contents.insert(1, {MTRIR: unavailable}) + + parsed = parse_mixed_content([{"musicCarouselShelfRenderer": {"contents": contents}}]) + + assert parsed == [ + { + "title": None, + "contents": [ + {"title": "Artist", "browseId": "UCartist", "subscribers": None, "thumbnails": None}, + { + "title": "Song", + "videoId": "video-id", + "playlistId": None, + "thumbnails": None, + "artists": [{"name": "Artist", "id": None}], + }, + {"title": "Radio", "playlistId": "RDplaylist", "thumbnails": None}, + ], + } + ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/parsers/test_playlists.py new/ytmusicapi-1.12.3/tests/parsers/test_playlists.py --- old/ytmusicapi-1.12.2/tests/parsers/test_playlists.py 1970-01-01 01:00:00.000000000 +0100 +++ new/ytmusicapi-1.12.3/tests/parsers/test_playlists.py 2026-09-16 17:04:23.243965100 +0200 @@ -0,0 +1,31 @@ +from ytmusicapi.parsers.playlists import parse_playlist_header_meta + + +def _header(**extra: dict) -> dict: + header = { + "title": {"runs": [{"text": "My Playlist"}]}, + "thumbnail": { + "musicThumbnailRenderer": { + "thumbnail": {"thumbnails": [{"url": "https://example.com/t.jpg", "width": 60, "height": 60}]} + } + }, + } + header.update(extra) + return header + + +class TestParsePlaylistHeaderMeta: + def test_missing_second_subtitle(self): + # some playlists are served without a secondSubtitle at all + parsed = parse_playlist_header_meta(_header()) + assert parsed["title"] == "My Playlist" + assert parsed["views"] is None + assert parsed["duration"] is None + assert parsed["trackCount"] is None + + def test_second_subtitle_track_count(self): + parsed = parse_playlist_header_meta( + _header(secondSubtitle={"runs": [{"text": "101 songs"}, {"text": " • "}, {"text": "6+ hours"}]}) + ) + assert parsed["trackCount"] == 101 + assert parsed["duration"] == "6+ hours" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/parsers/test_search.py new/ytmusicapi-1.12.3/tests/parsers/test_search.py --- old/ytmusicapi-1.12.2/tests/parsers/test_search.py 1970-01-01 01:00:00.000000000 +0100 +++ new/ytmusicapi-1.12.3/tests/parsers/test_search.py 2026-09-16 17:04:23.243965100 +0200 @@ -0,0 +1,48 @@ +from ytmusicapi.parsers.search import parse_search_result + + +def _flex_result(**extra: str) -> dict: + result = { + "flexColumns": [ + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [{"text": "Song Title"}]}}}, + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [{"text": "Artist"}]}}}, + ], + } + result.update(extra) + return result + + +class TestParseSearchResultIsAvailable: + def test_defaults_to_available(self): + parsed = parse_search_result(_flex_result(), "song", "Songs") + assert parsed["isAvailable"] is True + + def test_grey_out_policy_marks_unavailable(self): + parsed = parse_search_result( + _flex_result(musicItemRendererDisplayPolicy="MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT"), + "video", + "Videos", + ) + assert parsed["isAvailable"] is False + + def test_other_policy_values_remain_available(self): + parsed = parse_search_result( + _flex_result(musicItemRendererDisplayPolicy="MUSIC_ITEM_RENDERER_DISPLAY_POLICY_DEFAULT"), + "song", + "Songs", + ) + assert parsed["isAvailable"] is True + + def test_other_result_types_are_unaffected(self): + # isAvailable is only meaningful for playable song/video/episode results + parsed = parse_search_result(_flex_result(), "album", "Albums") + assert "isAvailable" not in parsed + + def test_grey_out_policy_marks_episode_unavailable(self): + parsed = parse_search_result( + _flex_result(musicItemRendererDisplayPolicy="MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT"), + "episode", + "Episodes", + ) + assert parsed["isAvailable"] is False + assert parsed["videoId"] is None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/tests/parsers/test_thumbnails.py new/ytmusicapi-1.12.3/tests/parsers/test_thumbnails.py --- old/ytmusicapi-1.12.2/tests/parsers/test_thumbnails.py 1970-01-01 01:00:00.000000000 +0100 +++ new/ytmusicapi-1.12.3/tests/parsers/test_thumbnails.py 2026-09-16 17:04:23.243965100 +0200 @@ -0,0 +1,74 @@ +"""Parsers must tolerate items without thumbnail data (see #977).""" + +import pytest + +from ytmusicapi.parsers.browsing import ( + parse_album, + parse_related_artist, + parse_single, + parse_song, + parse_watch_playlist, +) +from ytmusicapi.parsers.explore import parse_chart_playlist +from ytmusicapi.parsers.library import parse_albums +from ytmusicapi.parsers.playlists import parse_playlist_header_meta +from ytmusicapi.parsers.podcasts import parse_base_header, parse_podcast +from ytmusicapi.parsers.watch import parse_watch_track + + +def _title(text: str = "Title", browse_id: str | None = None, video_id: str | None = None) -> dict: + run: dict = {"text": text} + if browse_id: + run["navigationEndpoint"] = {"browseEndpoint": {"browseId": browse_id}} + if video_id: + run["navigationEndpoint"] = {"watchEndpoint": {"videoId": video_id}} + return {"title": {"runs": [run]}} + + +def _subtitle(*texts: str) -> dict: + return {"subtitle": {"runs": [{"text": text} for text in texts]}} + + [email protected]( + ("parse_func", "item"), + [ + (parse_album, {**_title(browse_id="MPREb_1"), **_subtitle("Album", " • ", "2024")}), + (parse_single, {**_title(browse_id="MPREb_1"), **_subtitle("2024")}), + ( + parse_song, + { + **_title(), + **_subtitle("Artist"), + "navigationEndpoint": {"watchEndpoint": {"videoId": "videoId1"}}, + }, + ), + (parse_related_artist, {**_title(browse_id="UC_1"), **_subtitle("1M subscribers")}), + ( + parse_watch_playlist, + {**_title(), "navigationEndpoint": {"watchPlaylistEndpoint": {"playlistId": "RD1"}}}, + ), + (parse_chart_playlist, _title(browse_id="VLPL1")), + (parse_podcast, {**_title(browse_id="MPSP1"), **_subtitle("Channel")}), + (parse_base_header, {**_title(), "straplineTextOne": {}}), + (parse_playlist_header_meta, {**_title(), "secondSubtitle": {}}), + ], +) +def test_missing_thumbnails(parse_func, item): + assert parse_func(item)["thumbnails"] is None + + +def test_missing_thumbnails_library_albums(): + item = {"musicTwoRowItemRenderer": {**_title(browse_id="MPREb_1"), "subtitle": {}}} + + assert parse_albums([item])[0]["thumbnails"] is None + + +def test_missing_thumbnail_watch_track(): + track = { + "videoId": "videoId1", + **_title(), + "menu": {"menuRenderer": {"items": []}}, + "longBylineText": {"runs": [{"text": "Artist"}]}, + } + + assert parse_watch_track(track)["thumbnail"] is None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/mixins/browsing.py new/ytmusicapi-1.12.3/ytmusicapi/mixins/browsing.py --- old/ytmusicapi-1.12.2/ytmusicapi/mixins/browsing.py 2026-08-08 20:30:12.163473400 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/mixins/browsing.py 2026-09-16 17:04:23.248292400 +0200 @@ -1033,11 +1033,19 @@ if "timedLyricsData" not in data: # pragma: no cover return None - lyrics = TimedLyrics( - lyrics=list(map(LyricLine.from_raw, data["timedLyricsData"])), - source=data.get("sourceMessage"), - hasTimestamps=True, - ) + raw_lyrics = data["timedLyricsData"] + if all("cueRange" in raw_lyric for raw_lyric in raw_lyrics): + lyrics = TimedLyrics( + lyrics=list(map(LyricLine.from_raw, raw_lyrics)), + source=data.get("sourceMessage"), + hasTimestamps=True, + ) + else: + lyrics = Lyrics( + lyrics="\n".join(raw_lyric["lyricLine"] for raw_lyric in raw_lyrics), + source=data.get("sourceMessage"), + hasTimestamps=False, + ) else: lyrics_str = nav( response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *DESCRIPTION], True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/mixins/podcasts.py new/ytmusicapi-1.12.3/ytmusicapi/mixins/podcasts.py --- old/ytmusicapi-1.12.2/ytmusicapi/mixins/podcasts.py 2026-08-08 20:30:12.163473400 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/mixins/podcasts.py 2026-09-16 17:04:23.248292400 +0200 @@ -71,7 +71,7 @@ channel = { "title": nav(response, [*HEADER_MUSIC_VISUAL, *TITLE_TEXT]), - "thumbnails": nav(response, [*HEADER_MUSIC_VISUAL, *THUMBNAILS]), + "thumbnails": nav(response, [*HEADER_MUSIC_VISUAL, *THUMBNAILS], True), } results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/mixins/search.py new/ytmusicapi-1.12.3/ytmusicapi/mixins/search.py --- old/ytmusicapi-1.12.2/ytmusicapi/mixins/search.py 2026-08-08 20:30:12.163473400 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/mixins/search.py 2026-09-16 17:04:23.248292400 +0200 @@ -91,7 +91,8 @@ "id": "MPREb_9nqEki4ZDpp" }, "duration": "4:19", - "duration_seconds": 259 + "duration_seconds": 259, + "isAvailable": true, "isExplicit": false, "inLibrary": false, "feedbackTokens": { @@ -138,7 +139,8 @@ ], "views": "386M", "duration": "4:38", - "duration_seconds": 278 + "duration_seconds": 278, + "isAvailable": true }, { "category": "Artists", @@ -249,6 +251,8 @@ elif "musicShelfRenderer" in res: shelf_contents = res["musicShelfRenderer"]["contents"] + if MRLIR not in shelf_contents[0]: + continue category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) elif "itemSectionRenderer" in res: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/albums.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/albums.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/albums.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/albums.py 2026-09-16 17:04:23.249659000 +0200 @@ -11,7 +11,7 @@ album = { "title": nav(header, TITLE_TEXT), "type": nav(header, SUBTITLE), - "thumbnails": nav(header, THUMBNAIL_CROPPED), + "thumbnails": nav(header, THUMBNAIL_CROPPED, True), "isExplicit": nav(header, SUBTITLE_BADGE_LABEL, True) is not None, } @@ -45,7 +45,7 @@ album = { "title": nav(header, TITLE_TEXT), "type": nav(header, SUBTITLE), - "thumbnails": nav(header, THUMBNAILS), + "thumbnails": nav(header, THUMBNAILS, True), "isExplicit": nav(header, SUBTITLE_BADGE_LABEL, True) is not None, } description, description_runs = parse_description_runs( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/browsing.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/browsing.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/browsing.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/browsing.py 2026-09-16 17:04:23.249659000 +0200 @@ -34,8 +34,12 @@ if page_type is None: # song or watch_playlist if nav(data, NAVIGATION_WATCH_PLAYLIST_ID, True) is not None: content = parse_watch_playlist(data) - else: + elif nav(data, NAVIGATION_VIDEO_ID, True) is not None: content = parse_song(data) + else: + # Deleted uploads can still appear in Listen again + # without a usable browse or playback destination. + continue elif page_type in ["MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_AUDIOBOOK"]: content = parse_album(data) elif page_type in ["MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL"]: @@ -91,7 +95,7 @@ ], "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), "audioPlaylistId": parse_album_playlistid_if_exists(nav(result, THUMBNAIL_OVERLAY_NAVIGATION, True)), - "thumbnails": nav(result, THUMBNAIL_RENDERER), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), "isExplicit": nav(result, SUBTITLE_BADGE_LABEL, True) is not None, } @@ -102,7 +106,7 @@ single = { "title": nav(result, TITLE_TEXT), "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), - "thumbnails": nav(result, THUMBNAIL_RENDERER), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), } return _parse_album_single_subtitle(result, single) @@ -113,7 +117,7 @@ "title": nav(result, TITLE_TEXT), "videoId": nav(result, NAVIGATION_VIDEO_ID), "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), - "thumbnails": nav(result, THUMBNAIL_RENDERER), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), } song.update(parse_song_runs(nav(result, SUBTITLE_RUNS), skip_type_spec=True)) return song @@ -121,11 +125,16 @@ def parse_song_flat(data: JsonDict, with_playlist_id: bool = False) -> JsonDict: columns = [get_flex_column_item(data, i) for i in range(len(data["flexColumns"]))] + # podcast episodes link their title to the episode page instead of a watchEndpoint, + # so the playable videoId must come from the play button instead + video_id = nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True) or nav( + data, [*PLAY_BUTTON, "playNavigationEndpoint", *WATCH_VIDEO_ID], True + ) song = { "title": nav(columns[0], TEXT_RUN_TEXT), - "videoId": nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "videoId": video_id, "videoType": nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *NAVIGATION_VIDEO_TYPE], True), - "thumbnails": nav(data, THUMBNAILS), + "thumbnails": nav(data, THUMBNAILS, True), "isExplicit": nav(data, BADGE_LABEL, True) is not None, } @@ -208,7 +217,7 @@ "title": nav(data, TITLE_TEXT), "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), "subscribers": subscribers, - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } @@ -216,5 +225,5 @@ return { "title": nav(data, TITLE_TEXT), "playlistId": nav(data, NAVIGATION_WATCH_PLAYLIST_ID), - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/explore.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/explore.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/explore.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/explore.py 2026-09-16 17:04:23.249659000 +0200 @@ -9,7 +9,12 @@ def parse_chart_song(data: JsonDict) -> JsonDict: - parsed = parse_song_flat(data, with_playlist_id=True) + video_type = nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *NAVIGATION_VIDEO_TYPE], True) + parsed = ( + parse_episode_flat(data) + if video_type == "MUSIC_VIDEO_TYPE_PODCAST_EPISODE" + else parse_song_flat(data, with_playlist_id=True) + ) parsed.update(parse_ranking(data, none_if_absent=False)) return parsed @@ -26,7 +31,7 @@ return { "title": nav(data, TITLE_TEXT), "playlistId": nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } @@ -47,7 +52,7 @@ "title": nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), "browseId": nav(data, NAVIGATION_BROWSE_ID), "subscribers": subscribers, - "thumbnails": nav(data, THUMBNAILS), + "thumbnails": nav(data, THUMBNAILS, True), } parsed.update(parse_ranking(data, none_if_absent=True)) return parsed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/library.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/library.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/library.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/library.py 2026-09-16 17:04:23.249659000 +0200 @@ -57,7 +57,7 @@ album["browseId"] = nav(data, TITLE + NAVIGATION_BROWSE_ID) album["playlistId"] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) album["title"] = nav(data, TITLE_TEXT) - album["thumbnails"] = nav(data, THUMBNAIL_RENDERER) + album["thumbnails"] = nav(data, THUMBNAIL_RENDERER, True) if "runs" in data["subtitle"]: album["type"] = nav(data, SUBTITLE) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/playlists.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/playlists.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/playlists.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/playlists.py 2026-09-16 17:04:23.249659000 +0200 @@ -39,7 +39,7 @@ "duration": None, "trackCount": None, "title": "".join([run["text"] for run in header.get("title", {}).get("runs", [])]), - "thumbnails": nav(header, THUMBNAILS), + "thumbnails": nav(header, THUMBNAILS, True), } if "facepile" in header: avatar_renderer = nav(header, ["facepile", "avatarStackViewModel", "rendererContext"]) @@ -75,7 +75,7 @@ True, ), } - if "runs" in header["secondSubtitle"]: + if "runs" in header.get("secondSubtitle", {}): second_subtitle_runs = header["secondSubtitle"]["runs"] has_views = (len(second_subtitle_runs) > 3) * 2 playlist_meta["views"] = None if not has_views else to_int(second_subtitle_runs[0]["text"]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/podcasts.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/podcasts.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/podcasts.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/podcasts.py 2026-09-16 17:04:23.249659000 +0200 @@ -24,7 +24,7 @@ @dataclass class Timestamp(DescriptionElement): - seconds: int + seconds: int | None @dataclass @@ -54,7 +54,7 @@ elif "watchEndpoint" in navigationEndpoint: element = Timestamp( text=run["text"], - seconds=nav(navigationEndpoint, ["watchEndpoint", "startTimeSeconds"]), + seconds=nav(navigationEndpoint, ["watchEndpoint", "startTimeSeconds"], True), ) else: element = DescriptionElement(text=nav(run, ["text"], True)) # type: ignore @@ -76,7 +76,7 @@ return { "author": author if author["name"] else None, "title": nav(header, TITLE_TEXT), - "thumbnails": nav(header, THUMBNAILS), + "thumbnails": nav(header, THUMBNAILS, True), } @@ -107,7 +107,7 @@ def parse_episode(data: JsonDict) -> JsonDict: """Parses a single episode under "Episodes" on a channel page or on a podcast page""" - thumbnails = nav(data, THUMBNAILS) + thumbnails = nav(data, THUMBNAILS, True) date = nav(data, SUBTITLE, True) duration = nav(data, ["playbackProgress", *PROGRESS_RENDERER, *DURATION_TEXT], True) title = nav(data, TITLE_TEXT) @@ -138,7 +138,7 @@ "playlistId": nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *WATCH_PLAYLIST_ID]), "videoType": nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *NAVIGATION_VIDEO_TYPE]), "date": nav(get_flex_column_item(data, 2), TEXT_RUN_TEXT), - "thumbnails": nav(data, THUMBNAILS), + "thumbnails": nav(data, THUMBNAILS, True), } @@ -149,5 +149,5 @@ "channel": parse_id_name(nav(data, [*SUBTITLE_RUNS, 0], True)), "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), "podcastId": nav(data, THUMBNAIL_OVERLAY, True), - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/search.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/search.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/search.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/search.py 2026-09-16 17:04:23.249659000 +0200 @@ -182,6 +182,14 @@ ) search_result["videoType"] = video_type + if result_type in ["song", "video", "episode"]: + isAvailable = True + if "musicItemRendererDisplayPolicy" in data: + isAvailable = ( + data["musicItemRendererDisplayPolicy"] != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT" + ) + search_result["isAvailable"] = isAvailable + if result_type in ["song", "video", "album"]: search_result["duration"] = None search_result["year"] = None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/uploads.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/uploads.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/uploads.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/uploads.py 2026-09-16 17:04:23.249659000 +0200 @@ -31,7 +31,7 @@ title = get_item_text(data, 0) like = nav(data, MENU_LIKE_STATUS) - thumbnails = nav(data, THUMBNAILS) if "thumbnail" in data else None + thumbnails = nav(data, THUMBNAILS, True) duration = None if "fixedColumns" in data: duration = nav(get_fixed_column_item(data, 0), TEXT_RUN_TEXT) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi/parsers/watch.py new/ytmusicapi-1.12.3/ytmusicapi/parsers/watch.py --- old/ytmusicapi-1.12.2/ytmusicapi/parsers/watch.py 2026-08-08 20:30:12.164529000 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi/parsers/watch.py 2026-09-16 17:04:23.249659000 +0200 @@ -38,7 +38,7 @@ "videoId": data["videoId"], "title": nav(data, TITLE_TEXT), "length": nav(data, ["lengthText", "runs", 0, "text"], True), - "thumbnail": nav(data, THUMBNAIL), + "thumbnail": nav(data, THUMBNAIL, True), "likeStatus": like_status, "videoType": nav(data, ["navigationEndpoint", *NAVIGATION_VIDEO_TYPE], True), } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi.egg-info/PKG-INFO new/ytmusicapi-1.12.3/ytmusicapi.egg-info/PKG-INFO --- old/ytmusicapi-1.12.2/ytmusicapi.egg-info/PKG-INFO 2026-08-08 20:30:17.541373300 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi.egg-info/PKG-INFO 2026-09-16 17:04:30.490104400 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: ytmusicapi -Version: 1.12.2 +Version: 1.12.3 Summary: Unofficial API for YouTube Music Author-email: sigma67 <[email protected]> License-Expression: MIT diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi.egg-info/SOURCES.txt new/ytmusicapi-1.12.3/ytmusicapi.egg-info/SOURCES.txt --- old/ytmusicapi-1.12.2/ytmusicapi.egg-info/SOURCES.txt 2026-08-08 20:30:17.563423400 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi.egg-info/SOURCES.txt 2026-09-16 17:04:30.524861300 +0200 @@ -88,7 +88,10 @@ tests/parsers/__init__.py tests/parsers/data.py tests/parsers/test_browsing.py +tests/parsers/test_playlists.py +tests/parsers/test_search.py tests/parsers/test_songs.py +tests/parsers/test_thumbnails.py tests/setup/__init__.py tests/setup/setup_account.py ytmusicapi/__init__.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi.egg-info/scm_file_list.json new/ytmusicapi-1.12.3/ytmusicapi.egg-info/scm_file_list.json --- old/ytmusicapi-1.12.2/ytmusicapi.egg-info/scm_file_list.json 2026-08-08 20:30:17.540401700 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi.egg-info/scm_file_list.json 2026-09-16 17:04:30.488495000 +0200 @@ -86,7 +86,10 @@ "tests/parsers/__init__.py", "tests/parsers/data.py", "tests/parsers/test_browsing.py", + "tests/parsers/test_playlists.py", + "tests/parsers/test_search.py", "tests/parsers/test_songs.py", + "tests/parsers/test_thumbnails.py", "tests/setup/__init__.py", "tests/setup/setup_account.py", "tests/test.example.cfg", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ytmusicapi-1.12.2/ytmusicapi.egg-info/scm_version.json new/ytmusicapi-1.12.3/ytmusicapi.egg-info/scm_version.json --- old/ytmusicapi-1.12.2/ytmusicapi.egg-info/scm_version.json 2026-08-08 20:30:17.530676100 +0200 +++ new/ytmusicapi-1.12.3/ytmusicapi.egg-info/scm_version.json 2026-09-16 17:04:30.464919600 +0200 @@ -1,8 +1,8 @@ { - "tag": "1.12.2", + "tag": "1.12.3", "distance": 0, - "node": "g8d43d072b93961b1a3863c6009b583115fb79823", + "node": "g206d470bd857dde9e4c588170a23188037e03ad5", "dirty": false, "branch": "HEAD", - "node_date": "2026-08-08" + "node_date": "2026-09-16" }
