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"
 }

Reply via email to