diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index 695788f..2c0dceb 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -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() diff --git a/app/ytdl.py b/app/ytdl.py index 78acc82..207d35e 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -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