From 9539b33ea0cd986b06d89bc3a805b6fc09e8351d Mon Sep 17 00:00:00 2001 From: "Eugene K." Date: Sun, 25 Oct 2026 18:18:08 +0100 Subject: [PATCH 1/3] fix: remove a deleted video's leftover entry from a playlist YouTube rejects the playlistItems videoId filter with videoNotFound once a video is deleted, though the playlist keeps its entry. Removing then falls back to reading the whole playlist. Moving still fails fast. Co-Authored-By: Claude Opus 5.5 --- src/youtube_kit/core/ops/playlists.py | 35 +++++++++--- tests/ops/test_playlists.py | 53 +++++++++++++++++++ tests/snapshots/mcp/remove_from_playlist.json | 2 +- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/youtube_kit/core/ops/playlists.py b/src/youtube_kit/core/ops/playlists.py index 84f7d20..13e23bb 100644 --- a/src/youtube_kit/core/ops/playlists.py +++ b/src/youtube_kit/core/ops/playlists.py @@ -33,6 +33,7 @@ from .channels import copied_from_recent_uploads, my_channel_id, owner_or _PARTS = "snippet,status,contentDetails" +_MAX_PLAYLIST_LENGTH = 5000 # YouTube caps playlists here, so this limit reads a whole playlist. @operation(quota=1, read_only=True, idempotent=True, paginated=True) @@ -200,6 +201,8 @@ def add_video_to_playlist(playlist: str, video: str, *, position: int | None = N def remove_from_playlist(playlist: str, video: str) -> PlaylistRemoval: """Remove a video from one of your playlists, every time it appears. The video itself is not affected. + A deleted video's leftover entry is found by reading the whole playlist: 1 more unit per 50 entries. + Args: playlist: Playlist ID or URL. video: Video ID or URL. @@ -207,7 +210,8 @@ def remove_from_playlist(playlist: str, video: str) -> PlaylistRemoval: playlist_id, video_id = parse.playlist_id(playlist), parse.video_id(video) youtube = client.oauth() removed = sum( - delete_if_present(youtube.playlistItems().delete(id=item["id"])) for item in _occurrences(playlist_id, video_id) + delete_if_present(youtube.playlistItems().delete(id=item["id"])) + for item in _occurrences(playlist_id, video_id, deleted_video=True) ) return PlaylistRemoval(playlist_id=playlist_id, video_id=video_id, removed=removed) @@ -280,14 +284,29 @@ def set_playlist_thumbnail(playlist: str, image: Path) -> PlaylistImage: ) -def _occurrences(playlist_id: str, video_id: str) -> list[dict]: - """The playlist items holding a video; more than one if it was added repeatedly.""" - request = ( - client.oauth() - .playlistItems() - .list(part="id", playlistId=playlist_id, videoId=video_id, maxResults=API_PAGE_SIZE) +def _occurrences(playlist_id: str, video_id: str, *, deleted_video: bool = False) -> list[dict]: + """The playlist items holding a video; more than one if it was added repeatedly. + + With deleted_video, a video YouTube no longer knows is looked for by reading the whole playlist. + """ + items = client.oauth().playlistItems() + try: + return ( + items.list(part="id", playlistId=playlist_id, videoId=video_id, maxResults=API_PAGE_SIZE) + .execute() + .get("items", []) + ) + except HttpError as e: + if not deleted_video or http_reason(e) != "videoNotFound": + raise + # The videoId filter fails once a video is deleted, though the playlist keeps its entry: read the whole playlist. + page = collect( + lambda **page: items.list(part="snippet", playlistId=playlist_id, **page), + limit=_MAX_PLAYLIST_LENGTH, + page_token=None, + to_item=lambda item: item, ) - return request.execute().get("items", []) + return [item for item in page.items if item["snippet"]["resourceId"].get("videoId") == video_id] def _item_snippet(playlist_id: str, video_id: str, position: int | None) -> dict: diff --git a/tests/ops/test_playlists.py b/tests/ops/test_playlists.py index 207a59e..ea0ff5a 100644 --- a/tests/ops/test_playlists.py +++ b/tests/ops/test_playlists.py @@ -135,6 +135,59 @@ def test_remove_from_playlist_is_idempotent(api): assert playlists.remove_from_playlist("PL1", "dQw4w9WgXcQ").removed == 0 +def _entries(*video_ids, token=None): + items = [{"id": f"item-{v}", "snippet": {"resourceId": {"videoId": v}}} for v in video_ids] + return {"items": items, "nextPageToken": token} if token else {"items": items} + + +VIDEO_NOT_FOUND = http_error(404, "videoNotFound", "Video not found.") + + +def test_remove_from_playlist_finds_a_deleted_video_by_reading_the_playlist(api): + # YouTube rejects the videoId filter once the video is deleted, though the playlist still holds it. + api.oauth.playlistItems().list().execute.side_effect = [ + VIDEO_NOT_FOUND, + _entries("gKcKEGRfoAM", "7A22uuMVA6w", token="next"), + _entries("7A22uuMVA6w"), + ] + + result = playlists.remove_from_playlist("PL1", "7A22uuMVA6w") + + assert result.removed == 2 + deleted = [call.kwargs["id"] for call in api.oauth.playlistItems().delete.call_args_list if call.kwargs] + assert deleted == ["item-7A22uuMVA6w", "item-7A22uuMVA6w"] + scan = api.oauth.playlistItems().list.call_args.kwargs + assert (scan["part"], scan["pageToken"]) == ("snippet", "next") + assert "videoId" not in scan + + +def test_remove_from_playlist_of_a_video_in_no_entry_removes_nothing(api): + api.oauth.playlistItems().list().execute.side_effect = [VIDEO_NOT_FOUND, _entries("gKcKEGRfoAM")] + + assert playlists.remove_from_playlist("PL1", "7A22uuMVA6w").removed == 0 + api.oauth.playlistItems().delete.assert_not_called() + + +def test_remove_from_playlist_raises_other_errors_without_reading_the_playlist(api): + items = api.oauth.playlistItems() + items.list().execute.side_effect = http_error(404, "playlistNotFound", "Playlist not found.") + items.list.reset_mock() + + with pytest.raises(NotFound): + playlists.remove_from_playlist("PL1", "7A22uuMVA6w") + assert items.list.call_count == 1 + + +def test_move_in_playlist_does_not_read_the_playlist_for_a_deleted_video(api): + items = api.oauth.playlistItems() + items.list().execute.side_effect = VIDEO_NOT_FOUND + items.list.reset_mock() + + with pytest.raises(NotFound): + playlists.move_in_playlist("PL1", "7A22uuMVA6w", position=0) + assert items.list.call_count == 1 + + def test_move_in_playlist(api): api.oauth.playlistItems().list().execute.return_value = {"items": [{"id": "item1"}]} api.oauth.playlistItems().update().execute.return_value = playlist_item_resource(position=0) diff --git a/tests/snapshots/mcp/remove_from_playlist.json b/tests/snapshots/mcp/remove_from_playlist.json index 45e54d2..41345c4 100644 --- a/tests/snapshots/mcp/remove_from_playlist.json +++ b/tests/snapshots/mcp/remove_from_playlist.json @@ -6,7 +6,7 @@ "read_only_hint": false, "title": null }, - "description": "Remove a video from one of your playlists, every time it appears. The video itself is not affected.\n\nQuota: 51 units.", + "description": "Remove a video from one of your playlists, every time it appears. The video itself is not affected.\n\nA deleted video's leftover entry is found by reading the whole playlist: 1 more unit per 50 entries.\n\nQuota: 51 units.", "input_schema": { "additionalProperties": false, "properties": { From e52e2a409ce577f44529eeadaa15ceeb993f3a93 Mon Sep 17 00:00:00 2001 From: "Eugene K." Date: Sun, 25 Oct 2026 19:25:31 +0100 Subject: [PATCH 2/3] fix: say when a playlist is too long to search for a deleted video Reading the playlist now stops with an error at YouTube's 5,000-entry cap instead of reporting nothing removed, requests contentDetails rather than the larger snippet, and the help names unknown IDs as costing the same scan. Co-Authored-By: Claude Opus 5.5 --- src/youtube_kit/core/ops/playlists.py | 16 +++++++--- tests/ops/test_playlists.py | 32 +++++++++++++------ tests/snapshots/mcp/remove_from_playlist.json | 2 +- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/youtube_kit/core/ops/playlists.py b/src/youtube_kit/core/ops/playlists.py index 13e23bb..add9619 100644 --- a/src/youtube_kit/core/ops/playlists.py +++ b/src/youtube_kit/core/ops/playlists.py @@ -33,7 +33,7 @@ from .channels import copied_from_recent_uploads, my_channel_id, owner_or _PARTS = "snippet,status,contentDetails" -_MAX_PLAYLIST_LENGTH = 5000 # YouTube caps playlists here, so this limit reads a whole playlist. +_MAX_PLAYLIST_LENGTH = 5000 # YouTube's cap on a user playlist (uploads and Watch Later can be longer) @operation(quota=1, read_only=True, idempotent=True, paginated=True) @@ -201,7 +201,8 @@ def add_video_to_playlist(playlist: str, video: str, *, position: int | None = N def remove_from_playlist(playlist: str, video: str) -> PlaylistRemoval: """Remove a video from one of your playlists, every time it appears. The video itself is not affected. - A deleted video's leftover entry is found by reading the whole playlist: 1 more unit per 50 entries. + YouTube can't look up entries of a deleted video, so for a deleted or unknown video the whole playlist is read: + 1 more unit per 50 entries. Args: playlist: Playlist ID or URL. @@ -287,7 +288,7 @@ def set_playlist_thumbnail(playlist: str, image: Path) -> PlaylistImage: def _occurrences(playlist_id: str, video_id: str, *, deleted_video: bool = False) -> list[dict]: """The playlist items holding a video; more than one if it was added repeatedly. - With deleted_video, a video YouTube no longer knows is looked for by reading the whole playlist. + With deleted_video, falls back to reading the whole playlist when YouTube answers videoNotFound. """ items = client.oauth().playlistItems() try: @@ -301,12 +302,17 @@ def _occurrences(playlist_id: str, video_id: str, *, deleted_video: bool = False raise # The videoId filter fails once a video is deleted, though the playlist keeps its entry: read the whole playlist. page = collect( - lambda **page: items.list(part="snippet", playlistId=playlist_id, **page), + lambda **page: items.list(part="contentDetails", playlistId=playlist_id, **page), limit=_MAX_PLAYLIST_LENGTH, page_token=None, to_item=lambda item: item, ) - return [item for item in page.items if item["snippet"]["resourceId"].get("videoId") == video_id] + if page.next_page_token: + raise YouTubeError( + f"Playlist {playlist_id} has more than {_MAX_PLAYLIST_LENGTH} entries, " + f"too many to search for video {video_id}." + ) + return [item for item in page.items if item.get("contentDetails", {}).get("videoId") == video_id] def _item_snippet(playlist_id: str, video_id: str, position: int | None) -> dict: diff --git a/tests/ops/test_playlists.py b/tests/ops/test_playlists.py index ea0ff5a..dff8a0f 100644 --- a/tests/ops/test_playlists.py +++ b/tests/ops/test_playlists.py @@ -136,7 +136,7 @@ def test_remove_from_playlist_is_idempotent(api): def _entries(*video_ids, token=None): - items = [{"id": f"item-{v}", "snippet": {"resourceId": {"videoId": v}}} for v in video_ids] + items = [{"id": f"item-{v}", "contentDetails": {"videoId": v}} for v in video_ids] return {"items": items, "nextPageToken": token} if token else {"items": items} @@ -145,20 +145,32 @@ def _entries(*video_ids, token=None): def test_remove_from_playlist_finds_a_deleted_video_by_reading_the_playlist(api): # YouTube rejects the videoId filter once the video is deleted, though the playlist still holds it. - api.oauth.playlistItems().list().execute.side_effect = [ - VIDEO_NOT_FOUND, - _entries("gKcKEGRfoAM", "7A22uuMVA6w", token="next"), - _entries("7A22uuMVA6w"), - ] + first_page = _entries("gKcKEGRfoAM", "7A22uuMVA6w", token="next") + first_page["items"].append({"id": "item-no-video", "contentDetails": {}}) + items = api.oauth.playlistItems() + items.list().execute.side_effect = [VIDEO_NOT_FOUND, first_page, _entries("7A22uuMVA6w")] + items.list.reset_mock() result = playlists.remove_from_playlist("PL1", "7A22uuMVA6w") assert result.removed == 2 - deleted = [call.kwargs["id"] for call in api.oauth.playlistItems().delete.call_args_list if call.kwargs] + deleted = [call.kwargs["id"] for call in items.delete.call_args_list if call.kwargs] assert deleted == ["item-7A22uuMVA6w", "item-7A22uuMVA6w"] - scan = api.oauth.playlistItems().list.call_args.kwargs - assert (scan["part"], scan["pageToken"]) == ("snippet", "next") - assert "videoId" not in scan + filtered, *scans = [call.kwargs for call in items.list.call_args_list] + assert filtered["videoId"] == "7A22uuMVA6w" + assert scans == [ + {"part": "contentDetails", "playlistId": "PL1", "maxResults": 50}, + {"part": "contentDetails", "playlistId": "PL1", "maxResults": 50, "pageToken": "next"}, + ] + + +def test_remove_from_playlist_refuses_a_scan_cut_off_at_the_playlist_cap(api, monkeypatch): + monkeypatch.setattr(playlists, "_MAX_PLAYLIST_LENGTH", 1) + api.oauth.playlistItems().list().execute.side_effect = [VIDEO_NOT_FOUND, _entries("gKcKEGRfoAM", token="next")] + + with pytest.raises(YouTubeError, match="more than 1 entries"): + playlists.remove_from_playlist("PL1", "7A22uuMVA6w") + api.oauth.playlistItems().delete.assert_not_called() def test_remove_from_playlist_of_a_video_in_no_entry_removes_nothing(api): diff --git a/tests/snapshots/mcp/remove_from_playlist.json b/tests/snapshots/mcp/remove_from_playlist.json index 41345c4..87d61cf 100644 --- a/tests/snapshots/mcp/remove_from_playlist.json +++ b/tests/snapshots/mcp/remove_from_playlist.json @@ -6,7 +6,7 @@ "read_only_hint": false, "title": null }, - "description": "Remove a video from one of your playlists, every time it appears. The video itself is not affected.\n\nA deleted video's leftover entry is found by reading the whole playlist: 1 more unit per 50 entries.\n\nQuota: 51 units.", + "description": "Remove a video from one of your playlists, every time it appears. The video itself is not affected.\n\nYouTube can't look up entries of a deleted video, so for a deleted or unknown video the whole playlist is read:\n1 more unit per 50 entries.\n\nQuota: 51 units.", "input_schema": { "additionalProperties": false, "properties": { From 560e7c47156d9d199fcdaa34d8f4ea3ec721d4d0 Mon Sep 17 00:00:00 2001 From: "Eugene K." Date: Sun, 25 Oct 2026 20:06:05 +0100 Subject: [PATCH 3/3] fix: point to YouTube Studio when a playlist is too long to search Co-Authored-By: Claude Opus 5.5 --- src/youtube_kit/core/ops/playlists.py | 13 +++++-------- tests/ops/test_playlists.py | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/youtube_kit/core/ops/playlists.py b/src/youtube_kit/core/ops/playlists.py index add9619..7727b7a 100644 --- a/src/youtube_kit/core/ops/playlists.py +++ b/src/youtube_kit/core/ops/playlists.py @@ -212,7 +212,7 @@ def remove_from_playlist(playlist: str, video: str) -> PlaylistRemoval: youtube = client.oauth() removed = sum( delete_if_present(youtube.playlistItems().delete(id=item["id"])) - for item in _occurrences(playlist_id, video_id, deleted_video=True) + for item in _occurrences(playlist_id, video_id, scan_if_deleted=True) ) return PlaylistRemoval(playlist_id=playlist_id, video_id=video_id, removed=removed) @@ -285,11 +285,8 @@ def set_playlist_thumbnail(playlist: str, image: Path) -> PlaylistImage: ) -def _occurrences(playlist_id: str, video_id: str, *, deleted_video: bool = False) -> list[dict]: - """The playlist items holding a video; more than one if it was added repeatedly. - - With deleted_video, falls back to reading the whole playlist when YouTube answers videoNotFound. - """ +def _occurrences(playlist_id: str, video_id: str, *, scan_if_deleted: bool = False) -> list[dict]: + """The playlist items holding a video; more than one if it was added repeatedly.""" items = client.oauth().playlistItems() try: return ( @@ -298,7 +295,7 @@ def _occurrences(playlist_id: str, video_id: str, *, deleted_video: bool = False .get("items", []) ) except HttpError as e: - if not deleted_video or http_reason(e) != "videoNotFound": + if not scan_if_deleted or http_reason(e) != "videoNotFound": raise # The videoId filter fails once a video is deleted, though the playlist keeps its entry: read the whole playlist. page = collect( @@ -310,7 +307,7 @@ def _occurrences(playlist_id: str, video_id: str, *, deleted_video: bool = False if page.next_page_token: raise YouTubeError( f"Playlist {playlist_id} has more than {_MAX_PLAYLIST_LENGTH} entries, " - f"too many to search for video {video_id}." + f"too many to search for video {video_id}. Remove it in YouTube Studio." ) return [item for item in page.items if item.get("contentDetails", {}).get("videoId") == video_id] diff --git a/tests/ops/test_playlists.py b/tests/ops/test_playlists.py index dff8a0f..520784d 100644 --- a/tests/ops/test_playlists.py +++ b/tests/ops/test_playlists.py @@ -168,7 +168,7 @@ def test_remove_from_playlist_refuses_a_scan_cut_off_at_the_playlist_cap(api, mo monkeypatch.setattr(playlists, "_MAX_PLAYLIST_LENGTH", 1) api.oauth.playlistItems().list().execute.side_effect = [VIDEO_NOT_FOUND, _entries("gKcKEGRfoAM", token="next")] - with pytest.raises(YouTubeError, match="more than 1 entries"): + with pytest.raises(YouTubeError, match=r"more than 1 entries.*YouTube Studio"): playlists.remove_from_playlist("PL1", "7A22uuMVA6w") api.oauth.playlistItems().delete.assert_not_called()