mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
8c2990e68a
A "Remove sponsor segments" switch next to "Split by chapters" queues the download with the same postprocessor pair the CLI's --sponsorblock-remove sponsor builds (SponsorBlock + ModifyChapters). The flag persists as a cookie like the other form options, survives in the queue records, and is carried into retries. The pair is registered above the chapter-splitting block: yt-dlp runs same-stage postprocessors in list order, so ModifyChapters has to rewrite the chapter list before FFmpegSplitChapters cuts the file up, matching what the CLI builds for --sponsorblock-remove sponsor --split-chapters. With both toggles on the other way around the chapter files keep the sponsor segments and the removal desyncs the remaining chapter timings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1487 lines
49 KiB
Python
1487 lines
49 KiB
Python
"""Tests for ``DownloadQueue`` with mocked yt-dlp extraction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
import time
|
|
|
|
from ytdl import Download, DownloadInfo, DownloadQueue
|
|
|
|
|
|
@pytest.fixture
|
|
def dq_env():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
dl = os.path.join(tmp, "downloads")
|
|
st = os.path.join(tmp, "state")
|
|
os.makedirs(dl, exist_ok=True)
|
|
os.makedirs(st, exist_ok=True)
|
|
cfg = MagicMock()
|
|
cfg.STATE_DIR = st
|
|
cfg.DOWNLOAD_DIR = dl
|
|
cfg.AUDIO_DOWNLOAD_DIR = dl
|
|
cfg.TEMP_DIR = dl
|
|
cfg.MAX_CONCURRENT_DOWNLOADS = "3"
|
|
cfg.YTDL_OPTIONS = {}
|
|
cfg.YTDL_OPTIONS_PRESETS = {}
|
|
cfg.CUSTOM_DIRS = True
|
|
cfg.CREATE_CUSTOM_DIRS = True
|
|
cfg.CLEAR_COMPLETED_AFTER = "0"
|
|
cfg.DELETE_FILE_ON_TRASHCAN = False
|
|
cfg.OUTPUT_TEMPLATE = "%(title)s.%(ext)s"
|
|
cfg.OUTPUT_TEMPLATE_CHAPTER = "%(title)s.%(ext)s"
|
|
cfg.OUTPUT_TEMPLATE_PLAYLIST = ""
|
|
cfg.OUTPUT_TEMPLATE_CHANNEL = ""
|
|
yield cfg
|
|
|
|
|
|
def test_cancel_add_increments_generation(dq_env):
|
|
notifier = MagicMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
before = dq._add_generation
|
|
dq.cancel_add()
|
|
assert dq._add_generation == before + 1
|
|
|
|
|
|
def test_download_queue_has_dedicated_executor_sized_from_config(dq_env):
|
|
notifier = MagicMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
assert dq._download_executor is not None
|
|
assert dq._download_executor._max_workers == 2 * int(dq_env.MAX_CONCURRENT_DOWNLOADS) + 2
|
|
dq.close()
|
|
|
|
|
|
def test_close_cancels_running_downloads_before_shutdown(dq_env):
|
|
notifier = MagicMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
|
|
running = MagicMock()
|
|
running.started.return_value = True
|
|
running.running.return_value = True
|
|
idle = MagicMock()
|
|
idle.started.return_value = False
|
|
idle.running.return_value = False
|
|
|
|
dq.queue.dict["u-running"] = running
|
|
dq.queue.dict["u-idle"] = idle
|
|
|
|
dq.close()
|
|
|
|
# The active download's subprocess group is killed; the not-started one is
|
|
# left alone. Executor is shut down afterwards.
|
|
running.cancel.assert_called_once()
|
|
idle.cancel.assert_not_called()
|
|
assert dq._download_executor._shutdown
|
|
|
|
|
|
def test_get_returns_tuple_of_lists(dq_env):
|
|
notifier = MagicMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
q, done = dq.get()
|
|
assert q == [] and done == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
|
notifier = AsyncMock()
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
|
result = await dq.add(
|
|
"https://example.com/watch?v=1",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert dq.pending.exists("https://example.com/watch?v=1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_unsupported_url_recorded_as_failed_entry(dq_env):
|
|
"""An unsupported/unextractable URL must show up as a red-cross entry in the
|
|
done list, not just a transient toast and a server log line."""
|
|
import ytdl
|
|
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/not-a-video"
|
|
|
|
def boom(self, url, *_args, **_kwargs):
|
|
raise ytdl.yt_dlp.utils.YoutubeDLError(f'Unsupported URL: {url}')
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
|
result = await dq.add(
|
|
url, "video", "auto", "any", "best", "", "", 0, auto_start=True,
|
|
)
|
|
assert result["status"] == "error"
|
|
assert dq.done.exists(url)
|
|
failed = dq.done.get(url)
|
|
assert failed.info.status == "error"
|
|
assert failed.info.error == result["msg"]
|
|
assert failed.info.url == url
|
|
# The full URL stays in .url/.error for the detail panel; the display
|
|
# title is shortened to the hostname so the Completed row stays readable.
|
|
assert failed.info.title == "example.com"
|
|
notifier.completed.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_ssrf_rejected_url_recorded_as_failed_entry(dq_env):
|
|
"""A URL rejected by the SSRF guard (before yt-dlp ever runs) must also
|
|
surface as a failed entry, not just an error status returned to the caller."""
|
|
notifier = AsyncMock()
|
|
url = "file:///etc/passwd"
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
result = await dq.add(
|
|
url, "video", "auto", "any", "best", "", "", 0, auto_start=True,
|
|
)
|
|
assert result["status"] == "error"
|
|
assert dq.done.exists(url)
|
|
failed = dq.done.get(url)
|
|
assert failed.info.status == "error"
|
|
assert failed.info.error == result["msg"]
|
|
notifier.completed.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_removes_from_pending(dq_env):
|
|
notifier = AsyncMock()
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
|
await dq.add(
|
|
"https://example.com/pending",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
)
|
|
url = "https://example.com/pending"
|
|
await dq.cancel([url])
|
|
assert not dq.pending.exists(url)
|
|
notifier.canceled.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_before_start_marks_download_canceled(dq_env):
|
|
"""Regression test for the race condition where cancel() arrives after the
|
|
download has been placed in the queue and ``__start_download`` has been
|
|
scheduled via ``asyncio.create_task`` but has not yet executed. Without the
|
|
fix, the pending task would run ``download.start()`` despite the user
|
|
cancelling, because its ``download.canceled`` guard was never flipped."""
|
|
notifier = AsyncMock()
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
url = "https://example.com/race"
|
|
start_mock = AsyncMock()
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq.add(
|
|
url,
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=True,
|
|
)
|
|
assert dq.queue.exists(url)
|
|
download = dq.queue.get(url)
|
|
assert download.canceled is False
|
|
await dq.cancel([url])
|
|
assert not dq.queue.exists(url)
|
|
assert download.canceled is True
|
|
notifier.canceled.assert_awaited_with(url)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_pending_moves_to_queue(dq_env):
|
|
notifier = AsyncMock()
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
|
await dq.add(
|
|
"https://example.com/startme",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
)
|
|
url = "https://example.com/startme"
|
|
# Starting will spawn real download — cancel immediately before worker runs much
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
|
await dq.start_pending([url])
|
|
assert not dq.pending.exists(url)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
entry = {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": "https://example.com/watch?v=1",
|
|
"webpage_url": "https://example.com/watch?v=1",
|
|
"playlist_index": "01",
|
|
"playlist_title": "Playlist",
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")):
|
|
result = await dq.add_entry(
|
|
entry,
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert dq.pending.exists("https://example.com/watch?v=1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_restores_playlist_output_context(dq_env):
|
|
notifier = AsyncMock()
|
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
url = "https://example.com/watch?v=1"
|
|
failed_info = DownloadInfo(
|
|
id="vid1",
|
|
title="Test Video",
|
|
url=url,
|
|
quality="best",
|
|
download_type="video",
|
|
codec="auto",
|
|
format="any",
|
|
folder="",
|
|
custom_name_prefix="",
|
|
error="temporary failure",
|
|
entry={
|
|
"playlist_index": "01",
|
|
"playlist_title": "My Playlist",
|
|
"playlist_count": 10,
|
|
},
|
|
playlist_item_limit=0,
|
|
split_by_chapters=False,
|
|
chapter_template="",
|
|
)
|
|
failed_info.status = "error"
|
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, failed_info))
|
|
|
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": extracted_url,
|
|
"webpage_url": extracted_url,
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
result = await dq.retry(url)
|
|
|
|
assert result["status"] == "ok"
|
|
queued = dq.queue.get(url)
|
|
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
|
assert queued.info.entry["playlist_index"] == "01"
|
|
assert queued.info.entry["playlist_title"] == "My Playlist"
|
|
|
|
|
|
def _failed_playlist_item(url, **overrides):
|
|
"""A done-list entry for a playlist item that failed mid-download."""
|
|
info = DownloadInfo(
|
|
id="vid1",
|
|
title="Test Video",
|
|
url=url,
|
|
quality="best",
|
|
download_type="video",
|
|
codec="auto",
|
|
format="any",
|
|
folder="",
|
|
custom_name_prefix="",
|
|
error="temporary failure",
|
|
entry={
|
|
"playlist_index": "01",
|
|
"playlist_title": "My Playlist",
|
|
"playlist_count": 10,
|
|
},
|
|
playlist_item_limit=0,
|
|
split_by_chapters=False,
|
|
chapter_template="",
|
|
**overrides,
|
|
)
|
|
info.status = "error"
|
|
return info
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_keeps_playlist_context_through_url_indirection(dq_env):
|
|
# extract_flat=True makes yt-dlp hand back url/url_transparent results
|
|
# unprocessed, so __add_entry recurses into add() a second time. The retry
|
|
# context has to survive that hop or the item lands in the root directory.
|
|
notifier = AsyncMock()
|
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
url = "https://example.com/watch?v=1"
|
|
resolved = "https://example.com/resolved?v=1"
|
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url)))
|
|
|
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
|
if extracted_url == url:
|
|
return {"_type": "url", "url": resolved, "id": "vid1"}
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": extracted_url,
|
|
"webpage_url": extracted_url,
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
result = await dq.retry(url)
|
|
|
|
assert result["status"] == "ok"
|
|
queued = dq.queue.get(resolved)
|
|
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
|
assert queued.info.entry["playlist_title"] == "My Playlist"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_reapplies_current_options_gates(dq_env):
|
|
# The stored options passed parse_download_options when first submitted, but
|
|
# the configuration can have changed since; retry must not resurrect
|
|
# overrides or presets the current configuration no longer allows.
|
|
notifier = AsyncMock()
|
|
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = False
|
|
dq_env.YTDL_OPTIONS_PRESETS = {"Still There": {"writesubtitles": True}}
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
url = "https://example.com/watch?v=1"
|
|
info = _failed_playlist_item(
|
|
url,
|
|
ytdl_options_presets=["Still There", "Removed Preset"],
|
|
ytdl_options_overrides={"paths": {"home": "/etc"}},
|
|
)
|
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
|
|
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": extracted_url,
|
|
"webpage_url": extracted_url,
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
result = await dq.retry(url)
|
|
|
|
assert result["status"] == "ok"
|
|
queued = dq.queue.get(url)
|
|
assert queued.info.ytdl_options_overrides == {}
|
|
assert queued.info.ytdl_options_presets == ["Still There"]
|
|
assert queued.ytdl_opts.get("paths", {}).get("home") != "/etc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_keeps_overrides_while_still_allowed(dq_env):
|
|
notifier = AsyncMock()
|
|
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = True
|
|
dq_env.YTDL_OPTIONS_PRESETS = {}
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
url = "https://example.com/watch?v=1"
|
|
info = _failed_playlist_item(url, ytdl_options_overrides={"writesubtitles": True})
|
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
|
|
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": extracted_url,
|
|
"webpage_url": extracted_url,
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
result = await dq.retry(url)
|
|
|
|
assert result["status"] == "ok"
|
|
assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_carries_the_sponsorblock_flag(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
url = "https://example.com/watch?v=1"
|
|
dq.done.put(
|
|
Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url, sponsorblock=True))
|
|
)
|
|
|
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": extracted_url,
|
|
"webpage_url": extracted_url,
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
result = await dq.retry(url)
|
|
|
|
assert result["status"] == "ok"
|
|
assert dq.queue.get(url).info.sponsorblock is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
entry = {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Original Title",
|
|
"url": "https://example.com/watch?v=1",
|
|
"webpage_url": "https://example.com/watch?v=1",
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")):
|
|
first = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=False)
|
|
assert first["status"] == "ok"
|
|
assert "msg" not in first
|
|
|
|
dupe_entry = {**entry, "title": "Different Title"}
|
|
second = await dq.add_entry(dupe_entry, "audio", "auto", "mp3", "best", "", "", 0, auto_start=False)
|
|
|
|
assert second["status"] == "ok"
|
|
assert "Already in queue" in second["msg"]
|
|
# The original pending download's options must survive untouched.
|
|
pending_dl = dq.pending.get("https://example.com/watch?v=1")
|
|
assert pending_dl.info.download_type == "video"
|
|
assert pending_dl.info.title == "Original Title"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_entry_duplicate_while_queued_is_skipped(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
entry = {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": "https://example.com/watch?v=1",
|
|
"webpage_url": "https://example.com/watch?v=1",
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
first = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
|
assert first["status"] == "ok"
|
|
assert dq.queue.exists("https://example.com/watch?v=1")
|
|
|
|
second = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
|
|
|
assert second["status"] == "ok"
|
|
assert "Already in queue" in second["msg"]
|
|
|
|
|
|
@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, *_args, **_kwargs):
|
|
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, *_args, **_kwargs):
|
|
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/")
|
|
|
|
|
|
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()
|
|
dq_env.YTDL_OPTIONS = {"writesubtitles": False, "cookiefile": "/tmp/global.txt"}
|
|
dq_env.YTDL_OPTIONS_PRESETS = {
|
|
"Preset A": {"writesubtitles": True, "proxy": "http://preset-a"},
|
|
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
|
|
}
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid2",
|
|
"title": "Preset Video",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
|
result = await dq.add(
|
|
"https://example.com/preset",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
ytdl_options_presets=["Preset A", "Preset B"],
|
|
ytdl_options_overrides={"proxy": "http://override", "embed_thumbnail": True},
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
queued = dq.pending.get("https://example.com/preset")
|
|
assert queued.ytdl_opts["cookiefile"] == "/tmp/global.txt"
|
|
assert queued.ytdl_opts["writesubtitles"] is False
|
|
assert queued.ytdl_opts["ratelimit"] == 1000
|
|
assert queued.ytdl_opts["proxy"] == "http://override"
|
|
assert queued.ytdl_opts["embed_thumbnail"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_info_preset_null_download_archive_overrides_global(dq_env):
|
|
"""Preset download_archive:null must apply during extract_info (global archive otherwise wins first)."""
|
|
dq_env.YTDL_OPTIONS = {"download_archive": "/tmp/archive.txt"}
|
|
dq_env.YTDL_OPTIONS_PRESETS = {"NoArchive": {"download_archive": None}}
|
|
|
|
captured_params: list = []
|
|
|
|
class FakeYoutubeDL:
|
|
def __init__(self, params=None):
|
|
captured_params.append(params)
|
|
|
|
def extract_info(self, url, download=False):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid-archive",
|
|
"title": "Archive Test",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
|
result = await dq.add(
|
|
"https://example.com/archive-test",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
ytdl_options_presets=["NoArchive"],
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert len(captured_params) == 1
|
|
extract_params = captured_params[0]
|
|
assert extract_params.get("download_archive") is None
|
|
assert extract_params["extract_flat"] is True
|
|
assert extract_params["noplaylist"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
|
|
"""MeTube's flat-extract settings must not be overridden by presets."""
|
|
dq_env.YTDL_OPTIONS = {}
|
|
dq_env.YTDL_OPTIONS_PRESETS = {
|
|
"TryOverride": {"extract_flat": False, "noplaylist": False},
|
|
}
|
|
|
|
captured_params: list = []
|
|
|
|
class FakeYoutubeDL:
|
|
def __init__(self, params=None):
|
|
captured_params.append(params)
|
|
|
|
def extract_info(self, url, download=False):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid-flat",
|
|
"title": "Flat Test",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
|
result = await dq.add(
|
|
"https://example.com/flat-test",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
ytdl_options_presets=["TryOverride"],
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert captured_params[0]["extract_flat"] is True
|
|
assert captured_params[0]["noplaylist"] is True
|
|
|
|
|
|
def _feed_extract(feed):
|
|
"""Patch for __extract_info that returns a playlist/channel feed dict."""
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return copy.deepcopy(feed)
|
|
|
|
return fake_extract
|
|
|
|
|
|
_CHANNEL_FEED = {
|
|
"_type": "playlist",
|
|
"id": "UC123",
|
|
"title": "Vanessa - Videos",
|
|
"channel": "Vanessa",
|
|
"channel_id": "UC123",
|
|
"uploader": "Vanessa",
|
|
"extractor": "youtube:tab",
|
|
"extractor_key": "YoutubeTab",
|
|
"webpage_url": "https://example.com/@vanessa/videos",
|
|
"entries": [
|
|
{"id": "v1", "title": "One", "url": "https://example.com/v1",
|
|
"webpage_url": "https://example.com/v1", "_type": "url"},
|
|
],
|
|
}
|
|
|
|
_PLAYLIST_FEED = {
|
|
"_type": "playlist",
|
|
"id": "PL123",
|
|
"title": "My Playlist",
|
|
"extractor": "generic",
|
|
"extractor_key": "Generic",
|
|
"webpage_url": "https://example.com/playlist?list=PL123",
|
|
"entries": [
|
|
{"id": "v1", "title": "One", "url": "https://example.com/v1",
|
|
"webpage_url": "https://example.com/v1", "_type": "url"},
|
|
],
|
|
}
|
|
|
|
|
|
def _written_files(root):
|
|
found = []
|
|
for dirpath, _dirs, files in os.walk(root):
|
|
for f in files:
|
|
found.append(os.path.relpath(os.path.join(dirpath, f), root))
|
|
return sorted(found)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_feed_metadata_lands_beside_its_items(dq_env):
|
|
"""Issues #660/#1040: the feed-level .info.json follows the same template
|
|
the items use, so it sits in the channel's own folder rather than in
|
|
DOWNLOAD_DIR under yt-dlp's pl_* default name."""
|
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
|
dq_env.OUTPUT_TEMPLATE_CHANNEL = "%(channel)s/%(title)s.%(ext)s"
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_CHANNEL_FEED)), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
result = await dq.add(
|
|
"https://example.com/@vanessa/videos", "video", "auto", "any", "best",
|
|
"", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
|
os.path.join("Vanessa", "Vanessa - Videos.info.json")
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_playlist_feed_metadata_uses_the_playlist_template(dq_env):
|
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
await dq.add(
|
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
|
"", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
|
os.path.join("My Playlist", "My Playlist.info.json")
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feed_metadata_honours_custom_folder(dq_env):
|
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
await dq.add(
|
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
|
"Music", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
|
os.path.join("Music", "My Playlist", "My Playlist.info.json")
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_feed_metadata_without_writeinfojson(dq_env):
|
|
"""Nothing new appears for users who never asked for these files."""
|
|
dq_env.YTDL_OPTIONS = {}
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
await dq.add(
|
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
|
"", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert _written_files(dq_env.DOWNLOAD_DIR) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feed_metadata_can_be_turned_off_by_the_user(dq_env):
|
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True, "allow_playlist_files": False}
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
|
await dq.add(
|
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
|
"", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert _written_files(dq_env.DOWNLOAD_DIR) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feed_metadata_failure_does_not_fail_the_add(dq_env):
|
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()), \
|
|
patch.object(
|
|
DownloadQueue, "_DownloadQueue__write_feed_metadata_sync",
|
|
side_effect=OSError("read-only filesystem"),
|
|
):
|
|
result = await dq.add(
|
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
|
"", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert dq.pending.exists("https://example.com/v1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extraction_pass_never_writes_feed_metadata(dq_env):
|
|
"""The classification pass must not produce files: it runs before the add is
|
|
known to succeed, and yt-dlp writes playlist files regardless of `download`."""
|
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True, "allow_playlist_files": True}
|
|
captured: list = []
|
|
|
|
class FakeYoutubeDL:
|
|
def __init__(self, params=None):
|
|
captured.append(params)
|
|
|
|
def extract_info(self, url, download=False):
|
|
return {"_type": "video", "id": "v", "title": "V", "url": url, "webpage_url": url}
|
|
|
|
dq = DownloadQueue(dq_env, AsyncMock())
|
|
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
|
await dq.add(
|
|
"https://example.com/watch?v=1", "video", "auto", "any", "best",
|
|
"", "", 0, auto_start=False,
|
|
)
|
|
|
|
assert captured[0]["allow_playlist_files"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
|
notifier = AsyncMock()
|
|
|
|
def fake_extract(self, url, *_args, **_kwargs):
|
|
return {
|
|
"_type": "video",
|
|
"id": "vid1",
|
|
"title": "Test Video",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
}
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
|
result = await dq.add(
|
|
"https://example.com/clip",
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=False,
|
|
clip_start=10.0,
|
|
clip_end=99.5,
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
download = dq.pending.get("https://example.com/clip")
|
|
assert download.info.clip_start == 10.0
|
|
assert download.info.clip_end == 99.5
|
|
|
|
|
|
def _upcoming_entry(url: str, *, release_timestamp: float | None = None) -> dict:
|
|
return {
|
|
"_type": "video",
|
|
"id": "live1",
|
|
"title": "Upcoming Stream",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
"live_status": "is_upcoming",
|
|
"release_timestamp": release_timestamp if release_timestamp is not None else time.time() + 3600,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_upcoming_stream_scheduled_without_starting(dq_env):
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/live-upcoming"
|
|
start_mock = AsyncMock()
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
result = await dq.add_entry(
|
|
_upcoming_entry(url),
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=True,
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert dq.queue.exists(url)
|
|
download = dq.queue.get(url)
|
|
assert download.info.status == "scheduled"
|
|
assert download.info.live_status == "is_upcoming"
|
|
assert download.info.live_release_timestamp is not None
|
|
start_mock.assert_not_called()
|
|
assert url in dq._scheduled_probe_at
|
|
# The "scheduled to start at ..." message must include a UTC offset
|
|
# (a naive datetime's %z would render as an empty string here).
|
|
assert re.search(r"[+-]\d{4}$", download.info.error)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_scheduled_starts_when_live(dq_env):
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/live-upcoming"
|
|
start_mock = AsyncMock()
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq.add_entry(
|
|
_upcoming_entry(url),
|
|
"video",
|
|
"auto",
|
|
"any",
|
|
"best",
|
|
"",
|
|
"",
|
|
0,
|
|
auto_start=True,
|
|
)
|
|
|
|
download = dq.queue.get(url)
|
|
|
|
def fake_probe_extract(self, probe_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
|
assert probe_url == url
|
|
return {
|
|
"_type": "video",
|
|
"id": "live1",
|
|
"title": "Live Now",
|
|
"url": url,
|
|
"webpage_url": url,
|
|
"live_status": "is_live",
|
|
"formats": [{"format_id": "22"}],
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_probe_extract), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq._probe_scheduled_download(download)
|
|
|
|
assert url not in dq._scheduled_probe_at
|
|
assert download.info.live_status == "is_live"
|
|
assert download.info.status == "pending"
|
|
start_mock.assert_called_once_with(download)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_scheduled_re_registers_monitor(dq_env):
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/live-restart"
|
|
release = time.time() + 7200
|
|
|
|
info = DownloadInfo(
|
|
id="live1",
|
|
title="Upcoming Stream",
|
|
url=url,
|
|
quality="best",
|
|
download_type="video",
|
|
codec="auto",
|
|
format="any",
|
|
folder="",
|
|
custom_name_prefix="",
|
|
error=None,
|
|
entry=None,
|
|
playlist_item_limit=0,
|
|
split_by_chapters=False,
|
|
chapter_template="",
|
|
live_status="is_upcoming",
|
|
live_release_timestamp=release,
|
|
)
|
|
info.status = "scheduled"
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
start_mock = AsyncMock()
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq._DownloadQueue__add_download(info, True)
|
|
|
|
assert dq.queue.exists(url)
|
|
assert dq.queue.get(url).info.status == "scheduled"
|
|
assert url in dq._scheduled_probe_at
|
|
start_mock.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_transient_error_retries_without_failing(dq_env):
|
|
"""A single probe failure must not abandon the scheduled stream."""
|
|
import ytdl
|
|
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/live-transient"
|
|
start_mock = AsyncMock()
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq.add_entry(
|
|
_upcoming_entry(url),
|
|
"video", "auto", "any", "best", "", "", 0,
|
|
auto_start=True,
|
|
)
|
|
download = dq.queue.get(url)
|
|
|
|
def boom(self, *args, **kwargs):
|
|
raise ytdl.yt_dlp.utils.YoutubeDLError("temporary network glitch")
|
|
|
|
before = time.time()
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
|
await dq._probe_scheduled_download(download)
|
|
|
|
# Still scheduled, still monitored, probe rescheduled into the future.
|
|
assert download.info.status == "scheduled"
|
|
assert url in dq._scheduled_probe_at
|
|
assert dq._scheduled_probe_at[url] >= before
|
|
assert dq._scheduled_probe_failures[url] == 1
|
|
notifier.completed.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_gives_up_after_max_failures(dq_env):
|
|
import ytdl
|
|
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/live-dead"
|
|
start_mock = AsyncMock()
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq.add_entry(
|
|
_upcoming_entry(url),
|
|
"video", "auto", "any", "best", "", "", 0,
|
|
auto_start=True,
|
|
)
|
|
download = dq.queue.get(url)
|
|
|
|
def boom(self, *args, **kwargs):
|
|
raise ytdl.yt_dlp.utils.YoutubeDLError("stream was deleted")
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
|
for _ in range(ytdl._LIVE_PROBE_MAX_FAILURES):
|
|
await dq._probe_scheduled_download(download)
|
|
|
|
assert url not in dq._scheduled_probe_at
|
|
assert not dq.queue.exists(url)
|
|
assert dq.done.exists(url)
|
|
assert download.info.status == "error"
|
|
notifier.completed.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_recovers_after_transient_then_starts(dq_env):
|
|
"""A transient failure followed by a successful live probe should start the download."""
|
|
import ytdl
|
|
|
|
notifier = AsyncMock()
|
|
url = "https://example.com/live-recover"
|
|
start_mock = AsyncMock()
|
|
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq.add_entry(
|
|
_upcoming_entry(url),
|
|
"video", "auto", "any", "best", "", "", 0,
|
|
auto_start=True,
|
|
)
|
|
download = dq.queue.get(url)
|
|
# The scheduling placeholder error is set on add.
|
|
assert download.info.error
|
|
|
|
def boom(self, *args, **kwargs):
|
|
raise ytdl.yt_dlp.utils.YoutubeDLError("temporary glitch")
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
|
await dq._probe_scheduled_download(download)
|
|
assert dq._scheduled_probe_failures[url] == 1
|
|
|
|
def live_now(self, *args, **kwargs):
|
|
return {
|
|
"_type": "video", "id": "live1", "title": "Live Now",
|
|
"url": url, "webpage_url": url, "live_status": "is_live",
|
|
"formats": [{"format_id": "22"}],
|
|
}
|
|
|
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", live_now), \
|
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
|
await dq._probe_scheduled_download(download)
|
|
|
|
assert url not in dq._scheduled_probe_at
|
|
assert url not in dq._scheduled_probe_failures
|
|
assert download.info.status == "pending"
|
|
# Placeholder error/msg cleared now that a real download is starting.
|
|
assert download.info.error is None
|
|
assert download.info.msg is None
|
|
start_mock.assert_called_once_with(download)
|
|
|
|
|
|
def test_seconds_until_next_probe_none_when_empty(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
assert dq._seconds_until_next_probe() is None
|
|
|
|
|
|
def test_calc_download_path_allows_subfolder(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
path, err = dq._DownloadQueue__calc_download_path("video", "sub/dir")
|
|
assert err is None
|
|
assert os.path.realpath(path) == os.path.join(os.path.realpath(dq_env.DOWNLOAD_DIR), "sub", "dir")
|
|
|
|
|
|
def test_calc_download_path_rejects_sibling_prefix_escape(dq_env):
|
|
"""A folder resolving to a sibling sharing a name prefix must be rejected.
|
|
|
|
Regression test: ``startswith`` would have accepted ``../downloads-secret``
|
|
when the base directory is ``.../downloads``.
|
|
"""
|
|
notifier = AsyncMock()
|
|
base = os.path.realpath(dq_env.DOWNLOAD_DIR)
|
|
sibling = base + "-secret"
|
|
os.makedirs(sibling, exist_ok=True)
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
escape_folder = os.path.join("..", os.path.basename(sibling), "x")
|
|
path, err = dq._DownloadQueue__calc_download_path("video", escape_folder)
|
|
assert path is None
|
|
assert err is not None and err["status"] == "error"
|
|
|
|
|
|
def test_calc_download_path_rejects_parent_escape(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
path, err = dq._DownloadQueue__calc_download_path("video", "../../etc")
|
|
assert path is None
|
|
assert err is not None and err["status"] == "error"
|
|
|
|
|
|
def test_download_info_to_public_dict_excludes_server_only_fields():
|
|
info = DownloadInfo(
|
|
id="vid1",
|
|
title="Test Video",
|
|
url="https://example.com/watch?v=1",
|
|
quality="best",
|
|
download_type="video",
|
|
codec="auto",
|
|
format="any",
|
|
folder="",
|
|
custom_name_prefix="",
|
|
error=None,
|
|
entry={"id": "vid1", "huge": "x" * 100000},
|
|
playlist_item_limit=0,
|
|
split_by_chapters=False,
|
|
chapter_template="",
|
|
)
|
|
info.subtitle_files = [{"filename": "a.srt", "size": 10}]
|
|
public = info.to_public_dict()
|
|
assert "entry" not in public
|
|
assert "subtitle_files" not in public
|
|
# Client-facing fields are still present.
|
|
assert public["url"] == "https://example.com/watch?v=1"
|
|
assert public["title"] == "Test Video"
|
|
assert public["status"] == "pending"
|
|
|
|
|
|
def _make_download(dq_env, *, download_type="video", status="downloading", filename=None):
|
|
info = DownloadInfo(
|
|
id="id1",
|
|
title="t",
|
|
url="http://example.com/v",
|
|
quality="best",
|
|
download_type=download_type,
|
|
codec="auto",
|
|
format="any",
|
|
folder="",
|
|
custom_name_prefix="",
|
|
error=None,
|
|
entry=None,
|
|
playlist_item_limit=0,
|
|
split_by_chapters=False,
|
|
chapter_template="",
|
|
)
|
|
info.status = status
|
|
info.filename = filename
|
|
info.size = 123 if filename else None
|
|
return Download(
|
|
dq_env.DOWNLOAD_DIR, dq_env.TEMP_DIR, "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info
|
|
)
|
|
|
|
|
|
def test_download_close_releases_status_queue(dq_env):
|
|
download = _make_download(dq_env)
|
|
status_queue = MagicMock()
|
|
proc = MagicMock()
|
|
download.status_queue = status_queue
|
|
download.proc = proc
|
|
|
|
download.close()
|
|
|
|
proc.close.assert_called_once()
|
|
assert download.status_queue is None
|
|
|
|
|
|
def test_download_close_releases_status_queue_without_process(dq_env):
|
|
download = _make_download(dq_env)
|
|
download.status_queue = MagicMock()
|
|
|
|
download.close()
|
|
|
|
assert download.status_queue is None
|
|
|
|
|
|
def test_download_close_releases_status_queue_when_process_close_fails(dq_env):
|
|
download = _make_download(dq_env)
|
|
download.status_queue = MagicMock()
|
|
download.proc = MagicMock()
|
|
download.proc.close.side_effect = RuntimeError('close failed')
|
|
|
|
with pytest.raises(RuntimeError, match='close failed'):
|
|
download.close()
|
|
|
|
assert download.status_queue is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_download_cleanup_clears_filename_on_error(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
download = _make_download(dq_env, status="downloading", filename="../tmp/partial.mp4")
|
|
dq.queue.put(download)
|
|
|
|
dq._post_download_cleanup(download)
|
|
|
|
assert download.info.status == "error"
|
|
assert download.info.filename is None
|
|
assert download.info.size is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_download_cleanup_keeps_captured_subtitles_on_error(dq_env):
|
|
notifier = AsyncMock()
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
download = _make_download(dq_env, download_type="captions", status="downloading", filename="en.srt")
|
|
download.info.subtitle_files = [{"filename": "en.srt", "size": 42}]
|
|
dq.queue.put(download)
|
|
|
|
dq._post_download_cleanup(download)
|
|
|
|
assert download.info.status == "error"
|
|
assert download.info.filename == "en.srt"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_skips_deletion_outside_download_directory(dq_env):
|
|
notifier = AsyncMock()
|
|
dq_env.DELETE_FILE_ON_TRASHCAN = True
|
|
dq = DownloadQueue(dq_env, notifier)
|
|
|
|
outside_dir = tempfile.mkdtemp()
|
|
outside_file = os.path.join(outside_dir, "outside.txt")
|
|
with open(outside_file, "w") as f:
|
|
f.write("do not delete me")
|
|
|
|
# A crafted/legacy relative filename that escapes DOWNLOAD_DIR via '..'.
|
|
escaping_filename = os.path.relpath(outside_file, dq_env.DOWNLOAD_DIR)
|
|
download = _make_download(dq_env, status="finished", filename=escaping_filename)
|
|
dq.done.put(download)
|
|
|
|
await dq.clear([download.info.url])
|
|
|
|
assert os.path.exists(outside_file)
|
|
assert not dq.done.exists(download.info.url)
|