mirror of
https://github.com/alexta69/metube.git
synced 2026-07-23 13:22:48 +00:00
fix: harden download lifecycle, subscriptions, and UI robustness
Addresses a full-project review. Backend correctness and availability: - ytdl: cancel() only SIGKILLs the child's process group when the child actually became its own group leader, so a race (or failed setpgrp) can no longer kill the whole server; kill the group on cancel and on shutdown to avoid orphaned ffmpeg children - ytdl: dedicated ThreadPoolExecutor for download supervision so active downloads can't starve extract_info / live probes on the default pool - ytdl/main/subscriptions: route fire-and-forget tasks through a bg_tasks helper that keeps a strong ref and logs failures - subscriptions: run flat-playlist extraction in an executor and check feeds with bounded concurrency so one slow feed can't block the loop; set last_checked on failure so broken feeds aren't retried every 60s - main: validate ids on /start & /delete and numeric env vars at startup; return 400 (not 500) on bad subscriptions/update input; serve /history from memory; move get_custom_dirs off the event loop; restrict t= stripping to YouTube hosts; drop double percent-decode in state guard - dl_formats/ytdl: enforce requested caption format via FFmpegSubtitlesConvertor and strip VTT header metadata only in the pre-cue region so real dialogue is preserved - ytdl: throttle progress events, dedup adds against pending, clear filename/size on error and reject out-of-dir trashcan deletes, pin fork start-method on Linux only Frontend: - retry deletes the done record only after a successful re-add - surface HTTP errors for delete/start and reset the deleting flag - ignore late 'updated' events for rows no longer in the queue - track table rows by map key; FileSizePipe uses base-1024 Also: HTTPS-aware Docker healthcheck, dead-code removal, and shared helpers for path-containment and yt-dlp option merging. Adds/updates unit tests throughout (250 backend tests passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+84
-3
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
@@ -20,6 +21,7 @@ def mock_dqueue(monkeypatch):
|
||||
d.initialize = AsyncMock(return_value=None)
|
||||
d.add = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||
d.clear = AsyncMock(return_value={"status": "ok"})
|
||||
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel_add = MagicMock()
|
||||
d.queue = MagicMock()
|
||||
@@ -28,6 +30,9 @@ def mock_dqueue(monkeypatch):
|
||||
d.queue.saved_items = MagicMock(return_value=[])
|
||||
d.done.saved_items = MagicMock(return_value=[])
|
||||
d.pending.saved_items = MagicMock(return_value=[])
|
||||
d.queue.items = MagicMock(return_value=[])
|
||||
d.done.items = MagicMock(return_value=[])
|
||||
d.pending.items = MagicMock(return_value=[])
|
||||
d.get = MagicMock(return_value=([], []))
|
||||
monkeypatch.setattr(main, "dqueue", d)
|
||||
return d
|
||||
@@ -215,11 +220,35 @@ async def test_start_pending(mock_dqueue):
|
||||
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("body", [{}, {"ids": "abc"}, {"ids": []}, {"ids": [1, 2]}])
|
||||
async def test_start_rejects_malformed_ids(mock_dqueue, body):
|
||||
req = _json_request(body)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.start(req)
|
||||
mock_dqueue.start_pending.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"where": "queue"},
|
||||
{"where": "queue", "ids": "abc"},
|
||||
{"where": "queue", "ids": []},
|
||||
{"where": "queue", "ids": [1, 2]},
|
||||
],
|
||||
)
|
||||
async def test_delete_rejects_malformed_ids(mock_dqueue, body):
|
||||
req = _json_request(body)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.delete(req)
|
||||
mock_dqueue.cancel.assert_not_awaited()
|
||||
mock_dqueue.clear.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shape(mock_dqueue):
|
||||
mock_dqueue.queue.saved_items.return_value = []
|
||||
mock_dqueue.done.saved_items.return_value = []
|
||||
mock_dqueue.pending.saved_items.return_value = []
|
||||
req = MagicMock(spec=web.Request)
|
||||
resp = await main.history(req)
|
||||
assert resp.status == 200
|
||||
@@ -227,6 +256,30 @@ async def test_history_shape(mock_dqueue):
|
||||
assert set(data.keys()) == {"done", "queue", "pending"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_reads_in_memory_queues_not_disk_state(mock_dqueue):
|
||||
fake_queue_dl = MagicMock()
|
||||
fake_queue_dl.info = {"id": "q1", "title": "Queued"}
|
||||
fake_done_dl = MagicMock()
|
||||
fake_done_dl.info = {"id": "d1", "title": "Done"}
|
||||
fake_pending_dl = MagicMock()
|
||||
fake_pending_dl.info = {"id": "p1", "title": "Pending"}
|
||||
mock_dqueue.queue.items.return_value = [("q1", fake_queue_dl)]
|
||||
mock_dqueue.done.items.return_value = [("d1", fake_done_dl)]
|
||||
mock_dqueue.pending.items.return_value = [("p1", fake_pending_dl)]
|
||||
|
||||
req = MagicMock(spec=web.Request)
|
||||
resp = await main.history(req)
|
||||
assert resp.status == 200
|
||||
data = json.loads(resp.text)
|
||||
assert [item["id"] for item in data["queue"]] == ["q1"]
|
||||
assert [item["id"] for item in data["done"]] == ["d1"]
|
||||
assert [item["id"] for item in data["pending"]] == ["p1"]
|
||||
mock_dqueue.queue.saved_items.assert_not_called()
|
||||
mock_dqueue.done.saved_items.assert_not_called()
|
||||
mock_dqueue.pending.saved_items.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_json(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
@@ -311,6 +364,24 @@ async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
||||
main.submgr.add_subscription.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriptions_update_invalid_enabled_returns_error_not_500(mock_dqueue):
|
||||
req = _json_request({"id": "nonexistent", "enabled": "maybe"})
|
||||
resp = await main.subscriptions_update(req)
|
||||
assert resp.status == 200
|
||||
body = json.loads(resp.text)
|
||||
assert body["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriptions_update_invalid_interval_returns_error_not_500(mock_dqueue):
|
||||
req = _json_request({"id": "nonexistent", "check_interval_minutes": "abc"})
|
||||
resp = await main.subscriptions_update(req)
|
||||
assert resp.status == 200
|
||||
body = json.loads(resp.text)
|
||||
assert body["status"] == "error"
|
||||
|
||||
|
||||
def test_is_within_state_dir_blocks_state_subtree():
|
||||
state_dir = main._STATE_DIR_REAL
|
||||
assert main._is_within_state_dir(state_dir)
|
||||
@@ -331,6 +402,11 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
|
||||
(download_dir / "video.mp4").write_bytes(b"video")
|
||||
# request.path is already percent-decoded by aiohttp; state_dir_guard must
|
||||
# not decode it a second time, or a filename containing a literal '%'
|
||||
# gets mangled into a false 404.
|
||||
percent_filename = "100% done.mp4"
|
||||
(download_dir / percent_filename).write_bytes(b"percent video")
|
||||
|
||||
monkeypatch.setattr(main.config, "STATE_DIR", str(state_dir))
|
||||
monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir)))
|
||||
@@ -343,7 +419,12 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
||||
allowed = await client.get("/download/video.mp4")
|
||||
assert allowed.status == 200
|
||||
assert await allowed.read() == b"video"
|
||||
|
||||
percent_resp = await client.get("/download/" + quote(percent_filename))
|
||||
assert percent_resp.status == 200
|
||||
assert await percent_resp.read() == b"percent video"
|
||||
finally:
|
||||
(state_dir / "cookies.txt").unlink(missing_ok=True)
|
||||
(download_dir / "video.mp4").unlink(missing_ok=True)
|
||||
(download_dir / percent_filename).unlink(missing_ok=True)
|
||||
state_dir.rmdir()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for the ``bg_tasks.create_task`` strong-reference/logging helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
import bg_tasks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_removes_itself_from_registry_on_success():
|
||||
async def _ok():
|
||||
return 42
|
||||
|
||||
task = bg_tasks.create_task(_ok(), name="ok_task")
|
||||
assert task in bg_tasks._TASKS
|
||||
result = await task
|
||||
assert result == 42
|
||||
assert task not in bg_tasks._TASKS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_logs_unhandled_exception(caplog):
|
||||
async def _boom():
|
||||
raise ValueError("kaboom")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="bg_tasks"):
|
||||
task = bg_tasks.create_task(_boom(), name="boom_task")
|
||||
with pytest.raises(ValueError):
|
||||
await task
|
||||
# Let the done-callback (scheduled via call_soon) run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task not in bg_tasks._TASKS
|
||||
assert any("boom_task" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_does_not_log_on_cancellation(caplog):
|
||||
async def _sleep_forever():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="bg_tasks"):
|
||||
task = bg_tasks.create_task(_sleep_forever(), name="cancel_task")
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task not in bg_tasks._TASKS
|
||||
assert not any("cancel_task" in record.message for record in caplog.records)
|
||||
@@ -159,6 +159,35 @@ class ConfigTests(unittest.TestCase):
|
||||
c = Config()
|
||||
self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0")
|
||||
|
||||
def test_invalid_default_option_playlist_item_limit_exits(self):
|
||||
for bad in ("-1", "many"):
|
||||
with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_default_option_playlist_item_limit_zero_allowed(self):
|
||||
with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT="0"), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT, "0")
|
||||
|
||||
def test_invalid_subscription_default_check_interval_exits(self):
|
||||
for bad in ("0", "-1", "often"):
|
||||
with patch.dict(os.environ, _base_env(SUBSCRIPTION_DEFAULT_CHECK_INTERVAL=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_subscription_scan_playlist_end_exits(self):
|
||||
for bad in ("0", "-1", "all"):
|
||||
with patch.dict(os.environ, _base_env(SUBSCRIPTION_SCAN_PLAYLIST_END=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_subscription_max_seen_ids_exits(self):
|
||||
for bad in ("0", "-1", "unlimited"):
|
||||
with patch.dict(os.environ, _base_env(SUBSCRIPTION_MAX_SEEN_IDS=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_runtime_override_roundtrip(self):
|
||||
with patch.dict(os.environ, _base_env(), clear=False):
|
||||
c = Config()
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.dl_formats import (
|
||||
_normalize_subtitle_language,
|
||||
get_format,
|
||||
get_opts,
|
||||
merge_ytdl_option_layers,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +119,31 @@ class DlFormatsTests(unittest.TestCase):
|
||||
|
||||
def test_get_opts_captions_txt_maps_to_srt_format(self):
|
||||
opts = get_opts("captions", "auto", "txt", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "srt")
|
||||
self.assertEqual(opts["subtitlesformat"], "srt/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertIn("FFmpegSubtitlesConvertor", keys)
|
||||
convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor")
|
||||
self.assertEqual(convertor["format"], "srt")
|
||||
|
||||
def test_get_opts_captions_srt_guarantees_convertor(self):
|
||||
opts = get_opts("captions", "auto", "srt", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "srt/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertIn("FFmpegSubtitlesConvertor", keys)
|
||||
|
||||
def test_get_opts_captions_vtt_guarantees_convertor(self):
|
||||
opts = get_opts("captions", "auto", "vtt", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "vtt/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertIn("FFmpegSubtitlesConvertor", keys)
|
||||
convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor")
|
||||
self.assertEqual(convertor["format"], "vtt")
|
||||
|
||||
def test_get_opts_captions_ttml_has_no_convertor(self):
|
||||
opts = get_opts("captions", "auto", "ttml", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "ttml/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertNotIn("FFmpegSubtitlesConvertor", keys)
|
||||
|
||||
def test_get_opts_merges_existing_postprocessors(self):
|
||||
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
|
||||
@@ -135,5 +160,23 @@ class DlFormatsTests(unittest.TestCase):
|
||||
self.assertEqual(_normalize_subtitle_language(" "), "en")
|
||||
|
||||
|
||||
class MergeYtdlOptionLayersTests(unittest.TestCase):
|
||||
def test_presets_applied_in_order_then_overrides(self):
|
||||
presets_config = {
|
||||
"a": {"x": 1, "y": 1},
|
||||
"b": {"y": 2, "z": 2},
|
||||
}
|
||||
merged = merge_ytdl_option_layers(["a", "b"], {"z": 3, "w": 4}, presets_config)
|
||||
# b overrides a's y; explicit overrides win over presets.
|
||||
self.assertEqual(merged, {"x": 1, "y": 2, "z": 3, "w": 4})
|
||||
|
||||
def test_no_base_options_included(self):
|
||||
# The helper only produces the preset/override layer, never base opts.
|
||||
self.assertEqual(merge_ytdl_option_layers(None, None, {}), {})
|
||||
|
||||
def test_unknown_preset_names_ignored(self):
|
||||
self.assertEqual(merge_ytdl_option_layers(["missing"], {"a": 1}, {}), {"a": 1})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
from ytdl import DownloadInfo, DownloadQueue
|
||||
from ytdl import Download, DownloadInfo, DownloadQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -46,6 +47,37 @@ def test_cancel_add_increments_generation(dq_env):
|
||||
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)
|
||||
@@ -222,6 +254,58 @@ 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_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."""
|
||||
@@ -526,6 +610,9 @@ async def test_add_upcoming_stream_scheduled_without_starting(dq_env):
|
||||
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
|
||||
@@ -784,3 +871,78 @@ def test_download_info_to_public_dict_excludes_server_only_fields():
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -220,42 +220,61 @@ class ParseDownloadOptionsTests(unittest.TestCase):
|
||||
|
||||
def test_clip_url_t_param_strips_query_and_sets_start(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=855s",
|
||||
"url": "https://www.youtube.com/watch?v=1&t=855s",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 855.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_explicit_start_wins_over_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=100",
|
||||
"url": "https://www.youtube.com/watch?v=1&t=100",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "50",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 50.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=999",
|
||||
"url": "https://www.youtube.com/watch?v=1&t=999",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_end": "60",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 0.0)
|
||||
self.assertEqual(parsed["clip_end"], 60.0)
|
||||
|
||||
def test_clip_url_t_param_ignored_on_non_youtube_host(self):
|
||||
# 't' is a generic query param name; only rewrite it on YouTube hosts
|
||||
# so an unrelated site's URL isn't silently mutated with a bogus clip.
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=855s",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1&t=855s")
|
||||
self.assertIsNone(parsed["clip_start"])
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_extract_t_query_youtu_be_short_host(self):
|
||||
cleaned, start = main._extract_t_query_from_url("https://youtu.be/abc123?t=90")
|
||||
self.assertEqual(cleaned, "https://youtu.be/abc123")
|
||||
self.assertEqual(start, 90.0)
|
||||
|
||||
def test_clip_rejects_end_before_start(self):
|
||||
with self.assertRaises(main.web.HTTPBadRequest):
|
||||
main.parse_download_options({
|
||||
@@ -280,5 +299,17 @@ class ParseDownloadOptionsTests(unittest.TestCase):
|
||||
})
|
||||
|
||||
|
||||
class GetCustomDirsTests(unittest.TestCase):
|
||||
def test_works_without_a_running_event_loop(self):
|
||||
# get_custom_dirs() used to time its cache via
|
||||
# asyncio.get_running_loop().time(), which raises RuntimeError outside
|
||||
# a running loop (e.g. when called from a plain executor thread). It
|
||||
# must work from a synchronous context too.
|
||||
result = main.get_custom_dirs()
|
||||
self.assertIn("download_dir", result)
|
||||
self.assertIn("audio_download_dir", result)
|
||||
self.assertIn("", result["download_dir"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shelve
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -29,6 +31,7 @@ sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
||||
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
||||
|
||||
from subscriptions import (
|
||||
SubscriptionInfo,
|
||||
SubscriptionManager,
|
||||
_is_subscriber_only_entry,
|
||||
coerce_optional_bool,
|
||||
@@ -44,6 +47,7 @@ class _Config:
|
||||
self.DOWNLOAD_DIR = state_dir
|
||||
self.TEMP_DIR = state_dir
|
||||
self.YTDL_OPTIONS = {}
|
||||
self.YTDL_OPTIONS_PRESETS = {}
|
||||
|
||||
|
||||
class _Queue:
|
||||
@@ -571,8 +575,16 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
sub_id = result["subscription"]["id"]
|
||||
with self.assertRaises(ValueError):
|
||||
await mgr.update_subscription(sub_id, {"enabled": "maybe"})
|
||||
update_result = await mgr.update_subscription(sub_id, {"enabled": "maybe"})
|
||||
self.assertEqual(update_result["status"], "error")
|
||||
stored = mgr.get(sub_id)
|
||||
self.assertTrue(stored.enabled)
|
||||
|
||||
update_result = await mgr.update_subscription(
|
||||
sub_id, {"check_interval_minutes": "abc"}
|
||||
)
|
||||
self.assertEqual(update_result["status"], "error")
|
||||
self.assertEqual(mgr.get(sub_id).check_interval_minutes, 60)
|
||||
|
||||
async def test_add_subscription_rejects_invalid_title_regex(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -1012,6 +1024,223 @@ class ExtractFlatPlaylistTests(unittest.TestCase):
|
||||
self.assertEqual(info.get("_type"), "playlist")
|
||||
self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"])
|
||||
|
||||
def test_extra_opts_applied_on_top_of_config_options(self):
|
||||
captured: dict = {}
|
||||
|
||||
class _FakeYDL:
|
||||
def __init__(self, params):
|
||||
captured.update(params)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {"_type": "video"}
|
||||
|
||||
cfg = _Config(tempfile.mkdtemp())
|
||||
with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True):
|
||||
extract_flat_playlist(cfg, "https://example.com/v1", 50, extra_opts={"cookiefile": "x"})
|
||||
|
||||
self.assertEqual(captured.get("cookiefile"), "x")
|
||||
|
||||
|
||||
def _make_scan_capturing_fake_ydl(captured_params: list, entries: list[dict]):
|
||||
class _FakeYDL:
|
||||
def __init__(self, params):
|
||||
captured_params.append(params)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {"_type": "channel", "title": "Channel", "entries": entries}
|
||||
|
||||
return _FakeYDL
|
||||
|
||||
|
||||
class SubscriptionScanExtraOptsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_add_subscription_scan_applies_presets_and_overrides(self):
|
||||
captured_params: list = []
|
||||
fake_ydl = _make_scan_capturing_fake_ydl(
|
||||
captured_params,
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
cfg.YTDL_OPTIONS_PRESETS = {"mypreset": {"cookiefile": "preset.txt"}}
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
|
||||
with patch("subscriptions.yt_dlp.YoutubeDL", fake_ydl, create=True):
|
||||
await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=["mypreset"],
|
||||
ytdl_options_overrides={"extra": "override"},
|
||||
)
|
||||
|
||||
self.assertTrue(captured_params)
|
||||
self.assertEqual(captured_params[0].get("cookiefile"), "preset.txt")
|
||||
self.assertEqual(captured_params[0].get("extra"), "override")
|
||||
|
||||
async def test_check_now_scan_applies_stored_subscription_presets(self):
|
||||
entries = [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
cfg.YTDL_OPTIONS_PRESETS = {"mypreset": {"cookiefile": "preset.txt"}}
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
|
||||
add_captured: list = []
|
||||
with patch(
|
||||
"subscriptions.yt_dlp.YoutubeDL",
|
||||
_make_scan_capturing_fake_ydl(add_captured, entries),
|
||||
create=True,
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=["mypreset"],
|
||||
)
|
||||
sub_id = result["subscription"]["id"]
|
||||
|
||||
check_captured: list = []
|
||||
with patch(
|
||||
"subscriptions.yt_dlp.YoutubeDL",
|
||||
_make_scan_capturing_fake_ydl(check_captured, entries),
|
||||
create=True,
|
||||
):
|
||||
await mgr.check_now([sub_id])
|
||||
|
||||
self.assertTrue(check_captured)
|
||||
self.assertEqual(check_captured[0].get("cookiefile"), "preset.txt")
|
||||
|
||||
|
||||
class SubscriptionEventLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_check_now_does_not_block_event_loop(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
return_value=(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
sub_id = result["subscription"]["id"]
|
||||
|
||||
def _slow_extract(config, url, playlistend, **kwargs):
|
||||
time.sleep(0.3)
|
||||
return (
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
)
|
||||
|
||||
with patch("subscriptions.extract_flat_playlist", side_effect=_slow_extract):
|
||||
check_task = asyncio.ensure_future(mgr.check_now([sub_id]))
|
||||
# If check_now() blocked the event loop, this would not complete
|
||||
# until after the slow extraction finishes.
|
||||
await asyncio.wait_for(asyncio.sleep(0.05), timeout=0.2)
|
||||
self.assertFalse(check_task.done())
|
||||
await check_task
|
||||
|
||||
async def test_check_many_isolates_a_crashing_subscription(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
|
||||
good = SubscriptionInfo(id="good", name="Good", url="https://example.com/good")
|
||||
bad = SubscriptionInfo(id="bad", name="Bad", url="https://example.com/bad")
|
||||
other = SubscriptionInfo(id="other", name="Other", url="https://example.com/other")
|
||||
|
||||
checked: list[str] = []
|
||||
|
||||
async def fake_check(sub):
|
||||
if sub.id == "bad":
|
||||
raise RuntimeError("boom")
|
||||
checked.append(sub.id)
|
||||
|
||||
with patch.object(mgr, "_check_one_unlocked", side_effect=fake_check):
|
||||
# The crashing subscription must not prevent the others running.
|
||||
await mgr._check_many([good, bad, other])
|
||||
|
||||
self.assertIn("good", checked)
|
||||
self.assertIn("other", checked)
|
||||
|
||||
async def test_check_many_bounded_concurrency(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
subs = [
|
||||
SubscriptionInfo(id=str(i), name=str(i), url=f"https://example.com/{i}")
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
import subscriptions as subs_mod
|
||||
|
||||
concurrent = 0
|
||||
peak = 0
|
||||
|
||||
async def fake_check(sub):
|
||||
nonlocal concurrent, peak
|
||||
concurrent += 1
|
||||
peak = max(peak, concurrent)
|
||||
await asyncio.sleep(0.02)
|
||||
concurrent -= 1
|
||||
|
||||
with patch.object(mgr, "_check_one_unlocked", side_effect=fake_check):
|
||||
await mgr._check_many(subs)
|
||||
|
||||
# Never exceed the configured bound, but do run more than one at once.
|
||||
self.assertLessEqual(peak, subs_mod._MAX_CONCURRENT_CHECKS)
|
||||
self.assertGreater(peak, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pickle
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
fake_yt_dlp = types.ModuleType("yt_dlp")
|
||||
fake_networking = types.ModuleType("yt_dlp.networking")
|
||||
@@ -37,6 +39,7 @@ sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
||||
sys.modules.setdefault("yt_dlp.utils", fake_utils)
|
||||
|
||||
from ytdl import (
|
||||
Download,
|
||||
DownloadInfo,
|
||||
_compact_persisted_entry,
|
||||
_convert_srt_to_txt_file,
|
||||
@@ -160,6 +163,105 @@ class SanitizeEntryForPickleTests(unittest.TestCase):
|
||||
self.assertEqual(out, {"z": 1, "a": 2})
|
||||
|
||||
|
||||
def _make_test_download() -> Download:
|
||||
info = DownloadInfo(
|
||||
id="id1",
|
||||
title="t",
|
||||
url="http://example.com/v",
|
||||
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="",
|
||||
)
|
||||
return Download("/tmp", "/tmp", "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info)
|
||||
|
||||
|
||||
class ProgressThrottleTests(unittest.TestCase):
|
||||
def test_downloading_ticks_are_throttled(self):
|
||||
dl = _make_test_download()
|
||||
forwarded = []
|
||||
dl.status_queue = types.SimpleNamespace(put=forwarded.append)
|
||||
hook = dl._make_progress_hook()
|
||||
|
||||
with patch("ytdl.time.monotonic", side_effect=[100.0, 100.1, 100.6, 100.7]):
|
||||
hook({"status": "downloading", "downloaded_bytes": 1})
|
||||
hook({"status": "downloading", "downloaded_bytes": 2})
|
||||
hook({"status": "downloading", "downloaded_bytes": 3})
|
||||
hook({"status": "downloading", "downloaded_bytes": 4})
|
||||
|
||||
# Only the 1st and 3rd ticks are >= 0.5s apart from the last forwarded one.
|
||||
self.assertEqual(len(forwarded), 2)
|
||||
|
||||
def test_finished_and_error_statuses_always_forwarded(self):
|
||||
dl = _make_test_download()
|
||||
forwarded = []
|
||||
dl.status_queue = types.SimpleNamespace(put=forwarded.append)
|
||||
hook = dl._make_progress_hook()
|
||||
|
||||
with patch("ytdl.time.monotonic", side_effect=[200.0, 200.1]):
|
||||
hook({"status": "downloading"})
|
||||
hook({"status": "finished"})
|
||||
hook({"status": "downloading"})
|
||||
hook({"status": "error", "msg": "boom"})
|
||||
|
||||
statuses = [item.get("status") for item in forwarded]
|
||||
self.assertIn("finished", statuses)
|
||||
self.assertIn("error", statuses)
|
||||
|
||||
|
||||
class CancelProcessGroupTests(unittest.TestCase):
|
||||
def test_cancel_kills_group_when_child_is_group_leader(self):
|
||||
# Child successfully ran os.setpgrp(): its pgid equals its own pid.
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", return_value=4321) as mock_getpgid, \
|
||||
patch("ytdl.os.killpg") as mock_killpg:
|
||||
dl.cancel()
|
||||
|
||||
mock_getpgid.assert_called_once_with(4321)
|
||||
mock_killpg.assert_called_once_with(4321, signal.SIGKILL)
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
def test_cancel_does_not_killpg_parent_group_kills_child_only(self):
|
||||
# Child has NOT become its own group leader yet (pgid != pid, e.g. it is
|
||||
# still in the server's process group). killpg must NOT be called — that
|
||||
# would SIGKILL the whole server — and we fall back to proc.kill().
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321, kill=MagicMock())
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", return_value=999), \
|
||||
patch("ytdl.os.killpg") as mock_killpg:
|
||||
dl.cancel()
|
||||
|
||||
mock_killpg.assert_not_called()
|
||||
dl.proc.kill.assert_called_once()
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
def test_cancel_falls_back_to_proc_kill_when_getpgid_unavailable(self):
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321, kill=MagicMock())
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", side_effect=OSError("no such process")):
|
||||
dl.cancel()
|
||||
|
||||
dl.proc.kill.assert_called_once()
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
|
||||
class ConvertSrtToTxtTests(unittest.TestCase):
|
||||
def test_basic_conversion(self):
|
||||
srt = """1
|
||||
@@ -180,6 +282,77 @@ Second line
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertIn("Second line", content)
|
||||
|
||||
def test_vtt_input_strips_header_and_metadata(self):
|
||||
# yt-dlp can fall back to VTT even when srt/txt was requested (the
|
||||
# extractor may not offer a native srt track); the converter must not
|
||||
# leak VTT-only header/metadata lines into the plain-text output.
|
||||
vtt = """WEBVTT
|
||||
Kind: captions
|
||||
Language: en
|
||||
|
||||
NOTE
|
||||
This is a note block
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
Hello <b>world</b>
|
||||
|
||||
2
|
||||
00:00:03.000 --> 00:00:04.000
|
||||
Second line
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.vtt"
|
||||
path.write_text(vtt, encoding="utf-8")
|
||||
txt_path = _convert_srt_to_txt_file(str(path))
|
||||
self.assertIsNotNone(txt_path)
|
||||
content = Path(txt_path).read_text(encoding="utf-8")
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertIn("Second line", content)
|
||||
self.assertNotIn("WEBVTT", content)
|
||||
self.assertNotIn("Kind:", content)
|
||||
self.assertNotIn("Language:", content)
|
||||
self.assertNotIn("This is a note block", content)
|
||||
|
||||
def test_vtt_standalone_header_block_is_stripped(self):
|
||||
# Some VTT files put a blank line after WEBVTT, so Kind:/Language: form
|
||||
# their own block. That header block (before the first timed cue) must
|
||||
# still be stripped.
|
||||
vtt = """WEBVTT
|
||||
|
||||
Kind: captions
|
||||
Language: en
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
Hello world
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.vtt"
|
||||
path.write_text(vtt, encoding="utf-8")
|
||||
content = Path(_convert_srt_to_txt_file(str(path))).read_text(encoding="utf-8")
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertNotIn("Kind:", content)
|
||||
self.assertNotIn("Language:", content)
|
||||
|
||||
def test_cue_text_starting_with_metadata_keyword_is_kept(self):
|
||||
# A real caption line beginning with "Kind:"/"Language:" must NOT be
|
||||
# dropped as if it were VTT header metadata.
|
||||
srt = """1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
Kind: regards, everyone
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
Language: they spoke French
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.srt"
|
||||
path.write_text(srt, encoding="utf-8")
|
||||
content = Path(_convert_srt_to_txt_file(str(path))).read_text(encoding="utf-8")
|
||||
self.assertIn("Kind: regards, everyone", content)
|
||||
self.assertIn("Language: they spoke French", content)
|
||||
|
||||
|
||||
class DownloadInfoSetstateTests(unittest.TestCase):
|
||||
def _base_state(self, **kwargs):
|
||||
|
||||
Reference in New Issue
Block a user