fix: honor OUTPUT_TEMPLATE for channel downloads (closes #1024)

Detect YouTube channel tabs that yt-dlp reports as playlists so channel downloads use OUTPUT_TEMPLATE_CHANNEL and its empty fallback instead of OUTPUT_TEMPLATE_PLAYLIST.
This commit is contained in:
Alex Shnitman 2026-07-10 09:49:59 +03:00
parent c34a18de7a
commit e2c777842e
2 changed files with 118 additions and 1 deletions

View file

@ -222,6 +222,103 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
assert dq.pending.exists("https://example.com/watch?v=1")
@pytest.mark.asyncio
async def test_channel_download_uses_output_template_when_channel_template_empty(dq_env):
"""Channel tabs reported as playlists must honor OUTPUT_TEMPLATE when OUTPUT_TEMPLATE_CHANNEL is empty."""
notifier = AsyncMock()
dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s"
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
dq_env.OUTPUT_TEMPLATE_PLAYLIST = ""
channel_id = "UCabcd123"
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "playlist",
"id": channel_id,
"channel_id": channel_id,
"channel": "Odin",
"title": "Odin - Videos",
"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",
},
],
}
dq = DownloadQueue(dq_env, notifier)
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
result = await dq.add(
"https://www.youtube.com/@odin/videos",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
)
assert result["status"] == "ok"
url = "https://example.com/watch?v=1"
assert dq.pending.exists(url)
download = dq.pending.get(url)
assert download.output_template.startswith("Odin [YT]/")
assert "Odin - Videos" not in download.output_template
@pytest.mark.asyncio
async def test_playlist_download_not_treated_as_channel(dq_env):
"""Real playlists (id != channel_id) must not be promoted to channel downloads."""
notifier = AsyncMock()
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, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "playlist",
"id": "PLxyz789",
"channel_id": "UCabcd123",
"channel": "Odin",
"title": "My Playlist",
"entries": [
{
"id": "vid1",
"title": "Test Video",
"url": "https://example.com/watch?v=1",
"webpage_url": "https://example.com/watch?v=1",
},
],
}
dq = DownloadQueue(dq_env, notifier)
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
result = await dq.add(
"https://www.youtube.com/playlist?list=PLxyz789",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
)
assert result["status"] == "ok"
url = "https://example.com/watch?v=1"
assert dq.pending.exists(url)
download = dq.pending.get(url)
assert download.output_template.startswith("My Playlist/")
@pytest.mark.asyncio
async def test_add_merges_global_preset_and_override_options(dq_env):
notifier = AsyncMock()

View file

@ -799,6 +799,16 @@ class DownloadQueue:
self._add_generation += 1
log.info('Playlist add operation canceled by user')
@staticmethod
def __is_channel_extraction(entry):
"""Return True when yt-dlp reported a channel tab as a playlist.
YouTube channel tabs are extracted with ``_type: 'playlist'`` but set
``id`` equal to ``channel_id``; real playlists keep a distinct id.
"""
channel_id = entry.get('channel_id')
return bool(channel_id) and entry.get('id') == channel_id
async def __import_queue(self):
for k, v in self.queue.saved_items():
await self.__add_download(v, True)
@ -1161,6 +1171,8 @@ class DownloadQueue:
_add_gen,
)
elif etype == 'playlist' or etype == 'channel':
if etype == 'playlist' and self.__is_channel_extraction(entry):
etype = 'channel'
log.debug(f'Processing as a {etype}')
entries = entry['entries']
# Convert generator to list if needed (for len() and slicing operations)
@ -1180,7 +1192,15 @@ class DownloadQueue:
if "id" not in etr:
etr["id"] = _entry_id(etr)
etr["_type"] = "video"
etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
if etype == 'channel':
etr["channel"] = (
entry.get("channel")
or entry.get("uploader")
or entry.get("title")
or entry.get("id")
)
else:
etr["playlist"] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
etr[f"{etype}_count"] = total_entries
etr[f"{etype}_autonumber"] = index