From e15aff3339e9a6d689845e1fb8d7fe21ae53990a Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Mon, 17 Aug 2026 09:31:56 +0200 Subject: [PATCH] fix: detect channels addressed without a tab (closes #1024) __is_channel_extraction keyed on id == channel_id. That holds for a channel tab - /channel/UC..., and the videos, streams, shorts and playlists tabs of a /@handle URL - but not for a channel addressed on its own: yt-dlp reports the id in the form the channel was asked for, so a bare handle URL yields '@handle' and a legacy /c/ URL yields the vanity name. Neither matched, so both fell through to OUTPUT_TEMPLATE_PLAYLIST and the folder came out as the feed's title. That is why e2c7778 fixed the reporter's tab URLs while a bare channel URL - what you get copying the address bar - went on ignoring OUTPUT_TEMPLATE. Both forms match uploader_id, which is the handle either way, so compare against that as well, without case: a legacy vanity name and the handle it became need not agree on it. A real playlist carries its owner's channel_id and uploader_id but keeps an id of its own, so it still reads as a playlist; no playlist id can collide with a handle, since those are 'PL...', 'OLAK...' and the like. Verified against the live extractor for all six channel URL forms and a real playlist. Co-Authored-By: Claude Opus 5 --- app/tests/test_download_queue.py | 76 ++++++++++++++++++++++++++++++++ app/ytdl.py | 28 ++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index cf5b8af..8c85be2 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -625,6 +625,82 @@ async def test_playlist_download_not_treated_as_channel(dq_env): assert download.output_template.startswith("My Playlist/") +def _channel_extraction(entry_id, **extra): + """A channel yt-dlp reported as a playlist, addressed by *entry_id*.""" + return { + "_type": "playlist", + "id": entry_id, + "channel_id": "UCabcd123", + "channel": "Odin", + "title": "Odin", + **extra, + "entries": [ + { + "id": "vid1", + "title": "Salvia Plath - Pondering", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + "channel": "Odin", + "upload_date": "20130804", + }, + ], + } + + +async def _add_and_get_template(dq_env, extraction, url): + dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s" + dq_env.OUTPUT_TEMPLATE_CHANNEL = "" + dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s" + + def fake_extract(self, _url, *_args, **_kwargs): + return extraction + + dq = DownloadQueue(dq_env, AsyncMock()) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=False) + assert result["status"] == "ok" + return dq.pending.get("https://example.com/watch?v=1").output_template + + +@pytest.mark.asyncio +async def test_bare_handle_channel_url_is_treated_as_a_channel(dq_env): + """A channel addressed as /@handle reports its id as the handle, not the + channel id, and was falling through to OUTPUT_TEMPLATE_PLAYLIST.""" + template = await _add_and_get_template( + dq_env, + _channel_extraction("@odin", uploader_id="@odin"), + "https://www.youtube.com/@odin", + ) + + assert template.startswith("Odin [YT]/") + + +@pytest.mark.asyncio +async def test_legacy_vanity_channel_url_is_treated_as_a_channel(dq_env): + """A legacy /c/Name URL reports the vanity name as its id, while + uploader_id is still the handle.""" + template = await _add_and_get_template( + dq_env, + _channel_extraction("Odin", uploader_id="@odin"), + "https://www.youtube.com/c/Odin", + ) + + assert template.startswith("Odin [YT]/") + + +@pytest.mark.asyncio +async def test_playlist_with_owner_uploader_id_is_still_a_playlist(dq_env): + """A real playlist carries its owner's channel_id and uploader_id, but its + own id matches neither, so it must keep the playlist template.""" + template = await _add_and_get_template( + dq_env, + _channel_extraction("PLxyz789", uploader_id="@odin", title="My Playlist"), + "https://www.youtube.com/playlist?list=PLxyz789", + ) + + assert template.startswith("My Playlist/") + + @pytest.mark.asyncio async def test_add_merges_global_preset_and_override_options(dq_env): notifier = AsyncMock() diff --git a/app/ytdl.py b/app/ytdl.py index df26599..a33b94f 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -1063,13 +1063,33 @@ class DownloadQueue: @staticmethod def __is_channel_extraction(entry): - """Return True when yt-dlp reported a channel tab as a playlist. + """Return True when yt-dlp reported a channel as a playlist. - YouTube channel tabs are extracted with ``_type: 'playlist'`` but set - ``id`` equal to ``channel_id``; real playlists keep a distinct id. + A channel *tab* -- ``/channel/UC...``, ``/@handle/videos``, and the + streams, shorts and playlists tabs -- is extracted with ``id`` equal to + ``channel_id``. A channel addressed without a tab keeps the form it was + asked for instead: ``@handle`` for a handle URL and the vanity name for + a legacy ``/c/`` URL. Both of those match ``uploader_id``, which is the + handle either way, so compare against it as well. + + A real playlist has an id of its own and matches neither, even though + it also carries its owner's ``channel_id``. """ channel_id = entry.get('channel_id') - return bool(channel_id) and entry.get('id') == channel_id + entry_id = entry.get('id') + if not channel_id or not entry_id: + return False + if entry_id == channel_id: + return True + uploader_id = entry.get('uploader_id') + if not uploader_id: + return False + # Compared without case because a legacy vanity name and the handle it + # became need not agree on it. No playlist id can collide here: those + # are 'PL...', 'OLAK...' and the like, never a handle. + handle = uploader_id.casefold() + entry_id = entry_id.casefold() + return handle in (entry_id, f'@{entry_id}') async def __import_queue(self): for k, v in self.queue.saved_items():