change option presets to be multi-select

This commit is contained in:
Alex Shnitman
2026-04-04 10:25:46 +03:00
parent d41bdf61e2
commit dd0f98d12f
15 changed files with 234 additions and 74 deletions
+24 -9
View File
@@ -253,6 +253,23 @@ def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict:
return value
def _parse_ytdl_options_presets(post: dict) -> list[str]:
"""Normalize preset names from add/subscribe body; supports list or legacy singular string."""
raw = post.get('ytdl_options_presets')
if raw is None:
raw = post.get('ytdl_options_preset')
if raw is None:
return []
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
if isinstance(raw, str):
s = raw.strip()
return [s] if s else []
raise web.HTTPBadRequest(
reason='ytdl_options_presets must be a JSON array of strings (or legacy ytdl_options_preset string)',
)
def _migrate_legacy_request(post: dict) -> dict:
"""
BACKWARD COMPATIBILITY: Translate old API request schema into the new one.
@@ -441,7 +458,6 @@ def parse_download_options(post: dict) -> dict:
chapter_template = post.get('chapter_template')
subtitle_language = post.get('subtitle_language')
subtitle_mode = post.get('subtitle_mode')
ytdl_options_preset = post.get('ytdl_options_preset')
ytdl_options_overrides = post.get('ytdl_options_overrides')
if custom_name_prefix is None:
@@ -460,15 +476,13 @@ def parse_download_options(post: dict) -> dict:
subtitle_language = 'en'
if subtitle_mode is None:
subtitle_mode = 'prefer_manual'
if ytdl_options_preset is None:
ytdl_options_preset = ''
download_type = str(download_type).strip().lower()
codec = str(codec or 'auto').strip().lower()
format = str(format or '').strip().lower()
quality = str(quality).strip().lower()
subtitle_language = str(subtitle_language).strip()
subtitle_mode = str(subtitle_mode).strip()
ytdl_options_preset = str(ytdl_options_preset).strip()
ytdl_options_presets = _parse_ytdl_options_presets(post)
ytdl_options_overrides = _parse_ytdl_options_overrides(
ytdl_options_overrides,
enabled=config.ALLOW_YTDL_OPTIONS_OVERRIDES,
@@ -480,8 +494,9 @@ def parse_download_options(post: dict) -> dict:
raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters')
if subtitle_mode not in VALID_SUBTITLE_MODES:
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}')
if ytdl_options_preset and ytdl_options_preset not in config.YTDL_OPTIONS_PRESETS:
raise web.HTTPBadRequest(reason='ytdl_options_preset must match a configured preset')
for preset_name in ytdl_options_presets:
if preset_name not in config.YTDL_OPTIONS_PRESETS:
raise web.HTTPBadRequest(reason='ytdl_options_presets must only contain configured preset names')
if download_type not in VALID_DOWNLOAD_TYPES:
raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}')
@@ -534,7 +549,7 @@ def parse_download_options(post: dict) -> dict:
'chapter_template': chapter_template,
'subtitle_language': subtitle_language,
'subtitle_mode': subtitle_mode,
'ytdl_options_preset': ytdl_options_preset,
'ytdl_options_presets': ytdl_options_presets,
'ytdl_options_overrides': ytdl_options_overrides,
}
@@ -570,7 +585,7 @@ async def add(request):
o['chapter_template'],
o['subtitle_language'],
o['subtitle_mode'],
o['ytdl_options_preset'],
o['ytdl_options_presets'],
o['ytdl_options_overrides'],
)
return web.Response(text=serializer.encode(status))
@@ -621,7 +636,7 @@ async def subscribe(request):
chapter_template=o['chapter_template'],
subtitle_language=o['subtitle_language'],
subtitle_mode=o['subtitle_mode'],
ytdl_options_preset=o['ytdl_options_preset'],
ytdl_options_presets=o['ytdl_options_presets'],
ytdl_options_overrides=o['ytdl_options_overrides'],
)
return web.Response(text=serializer.encode(result))
+29 -9
View File
@@ -145,7 +145,7 @@ class SubscriptionInfo:
chapter_template: str = ""
subtitle_language: str = "en"
subtitle_mode: str = "prefer_manual"
ytdl_options_preset: str = ""
ytdl_options_presets: list[str] = field(default_factory=list)
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
last_checked: Optional[float] = None
seen_ids: list[str] = field(default_factory=list)
@@ -192,7 +192,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
"chapter_template": sub.chapter_template,
"subtitle_language": sub.subtitle_language,
"subtitle_mode": sub.subtitle_mode,
"ytdl_options_preset": sub.ytdl_options_preset,
"ytdl_options_presets": list(sub.ytdl_options_presets),
"ytdl_options_overrides": sub.ytdl_options_overrides,
"last_checked": sub.last_checked,
"seen_ids": list(sub.seen_ids),
@@ -200,13 +200,32 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
}
def _normalize_subscription_record(rec: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy ytdl_options_preset (str) to ytdl_options_presets (list)."""
out = dict(rec)
if "ytdl_options_presets" not in out:
old = out.pop("ytdl_options_preset", None)
if old is None:
out["ytdl_options_presets"] = []
elif isinstance(old, list):
out["ytdl_options_presets"] = [str(x).strip() for x in old if str(x).strip()]
elif isinstance(old, str):
out["ytdl_options_presets"] = [old.strip()] if old.strip() else []
else:
out["ytdl_options_presets"] = []
else:
out.pop("ytdl_options_preset", None)
return out
def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]:
field_names = {f.name for f in fields(SubscriptionInfo)}
if isinstance(record, SubscriptionInfo):
return record
if isinstance(record, dict):
try:
return SubscriptionInfo(**{k: v for k, v in record.items() if k in field_names})
normalized = _normalize_subscription_record(dict(record))
return SubscriptionInfo(**{k: v for k, v in normalized.items() if k in field_names})
except TypeError:
return None
return None
@@ -315,11 +334,12 @@ class SubscriptionManager:
chapter_template: str,
subtitle_language: str,
subtitle_mode: str,
ytdl_options_preset: str = "",
ytdl_options_presets: Optional[list[str]] = None,
ytdl_options_overrides: Optional[dict[str, Any]] = None,
) -> tuple[list[str], list[str]]:
queued_ids: list[str] = []
queue_errors: list[str] = []
presets = list(ytdl_options_presets or [])
for ent in entries:
eid = _entry_id(ent)
vurl = _entry_video_url(ent)
@@ -342,7 +362,7 @@ class SubscriptionManager:
chapter_template or None,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
presets,
ytdl_options_overrides,
)
if isinstance(result, dict) and result.get("status") == "error":
@@ -411,7 +431,7 @@ class SubscriptionManager:
chapter_template: str,
subtitle_language: str,
subtitle_mode: str,
ytdl_options_preset: str = "",
ytdl_options_presets: Optional[list[str]] = None,
ytdl_options_overrides: Optional[dict[str, Any]] = None,
) -> dict:
url = self._normalize_url(url)
@@ -470,7 +490,7 @@ class SubscriptionManager:
chapter_template=chapter_template or "",
subtitle_language=subtitle_language,
subtitle_mode=subtitle_mode,
ytdl_options_preset=ytdl_options_preset,
ytdl_options_presets=list(ytdl_options_presets or []),
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
last_checked=time.time(),
seen_ids=list(dict.fromkeys(all_ids)),
@@ -620,7 +640,7 @@ class SubscriptionManager:
dl_chapter = cur.chapter_template
dl_sublang = cur.subtitle_language
dl_submode = cur.subtitle_mode
dl_ytdl_preset = cur.ytdl_options_preset
dl_ytdl_presets = list(cur.ytdl_options_presets)
dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
new_entries: list[dict] = []
@@ -646,7 +666,7 @@ class SubscriptionManager:
chapter_template=dl_chapter or "",
subtitle_language=dl_sublang,
subtitle_mode=dl_submode,
ytdl_options_preset=dl_ytdl_preset,
ytdl_options_presets=dl_ytdl_presets,
ytdl_options_overrides=dl_ytdl_overrides,
)
log.info(
+17 -4
View File
@@ -37,7 +37,7 @@ def _valid_video_add_body(**kwargs):
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "",
"ytdl_options_presets": [],
"ytdl_options_overrides": "",
}
base.update(kwargs)
@@ -67,7 +67,7 @@ async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True)
req = _json_request(
_valid_video_add_body(
ytdl_options_preset="Preset A",
ytdl_options_presets=["Preset A"],
ytdl_options_overrides='{"writesubtitles": true}',
)
)
@@ -75,10 +75,23 @@ async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[13] == "Preset A"
assert call.args[13] == ["Preset A"]
assert call.args[14] == {"writesubtitles": True}
@pytest.mark.asyncio
async def test_add_legacy_string_preset_normalized(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Legacy": {}})
body = _valid_video_add_body()
del body["ytdl_options_presets"]
body["ytdl_options_preset"] = "Legacy"
req = _json_request(body)
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call.args[13] == ["Legacy"]
@pytest.mark.asyncio
async def test_add_missing_url_returns_400(mock_dqueue):
req = _json_request({"download_type": "video", "quality": "best", "format": "any"})
@@ -171,7 +184,7 @@ async def test_add_allows_any_ytdl_options_override_key_when_enabled(mock_dqueue
@pytest.mark.asyncio
async def test_add_unknown_ytdl_preset(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing"))
req = _json_request(_valid_video_add_body(ytdl_options_presets=["Missing"]))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
+7 -3
View File
@@ -182,7 +182,10 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
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"}}
dq_env.YTDL_OPTIONS_PRESETS = {
"Preset A": {"writesubtitles": True, "proxy": "http://preset-a"},
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
}
def fake_extract(self, url):
return {
@@ -205,13 +208,14 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
"",
0,
auto_start=False,
ytdl_options_preset="Preset A",
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 True
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
+53 -2
View File
@@ -140,9 +140,44 @@ class ParseDownloadOptionsTests(unittest.TestCase):
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = previous_allow
self.assertEqual(parsed["ytdl_options_preset"], "With subtitles")
self.assertEqual(parsed["ytdl_options_presets"], ["With subtitles"])
self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True})
def test_accepts_multiple_presets_in_order(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {
"A": {"writesubtitles": True},
"B": {"writesubtitles": False},
}
try:
parsed = main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_presets": ["A", "B"],
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
self.assertEqual(parsed["ytdl_options_presets"], ["A", "B"])
def test_legacy_singular_preset_string_normalized_to_list(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {"Solo": {}}
try:
parsed = main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "Solo",
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
self.assertEqual(parsed["ytdl_options_presets"], ["Solo"])
def test_rejects_unknown_preset(self):
with self.assertRaises(main.web.HTTPBadRequest):
main.parse_download_options({
@@ -151,9 +186,25 @@ class ParseDownloadOptionsTests(unittest.TestCase):
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "Missing preset",
"ytdl_options_presets": ["Missing preset"],
})
def test_rejects_unknown_preset_in_list(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {"Known": {}}
try:
with self.assertRaises(main.web.HTTPBadRequest):
main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_presets": ["Known", "Nope"],
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
if __name__ == "__main__":
unittest.main()
+25 -16
View File
@@ -188,7 +188,7 @@ class DownloadInfo:
chapter_template,
subtitle_language="en",
subtitle_mode="prefer_manual",
ytdl_options_preset="",
ytdl_options_presets=None,
ytdl_options_overrides=None,
):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
@@ -212,7 +212,7 @@ class DownloadInfo:
self.chapter_template = chapter_template
self.subtitle_language = subtitle_language
self.subtitle_mode = subtitle_mode
self.ytdl_options_preset = ytdl_options_preset
self.ytdl_options_presets = list(ytdl_options_presets or [])
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
self.subtitle_files = []
@@ -266,8 +266,14 @@ class DownloadInfo:
self.subtitle_language = "en"
if not hasattr(self, "subtitle_mode"):
self.subtitle_mode = "prefer_manual"
if not hasattr(self, "ytdl_options_preset"):
self.ytdl_options_preset = ""
legacy_preset = self.__dict__.pop("ytdl_options_preset", None)
if "ytdl_options_presets" not in self.__dict__:
if isinstance(legacy_preset, str) and legacy_preset.strip():
self.ytdl_options_presets = [legacy_preset.strip()]
elif isinstance(legacy_preset, list):
self.ytdl_options_presets = [str(x).strip() for x in legacy_preset if str(x).strip()]
else:
self.ytdl_options_presets = []
if not hasattr(self, "ytdl_options_overrides"):
self.ytdl_options_overrides = {}
if not hasattr(self, "entry"):
@@ -293,7 +299,7 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"chapter_template",
"subtitle_language",
"subtitle_mode",
"ytdl_options_preset",
"ytdl_options_presets",
"ytdl_options_overrides",
"status",
"timestamp",
@@ -838,8 +844,7 @@ class DownloadQueue:
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
output = _resolve_outtmpl_fields(output, sanitized, ('channel',))
ytdl_options = dict(self.config.YTDL_OPTIONS)
preset_name = getattr(dl, 'ytdl_options_preset', '')
if preset_name:
for preset_name in getattr(dl, 'ytdl_options_presets', None) or []:
ytdl_options.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
ytdl_options.update(getattr(dl, 'ytdl_options_overrides', {}) or {})
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
@@ -869,7 +874,7 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_presets,
ytdl_options_overrides,
already,
_add_gen=None,
@@ -903,7 +908,7 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_presets,
ytdl_options_overrides,
already,
_add_gen,
@@ -952,7 +957,7 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_presets,
ytdl_options_overrides,
already,
_add_gen,
@@ -985,7 +990,7 @@ class DownloadQueue:
chapter_template=chapter_template,
subtitle_language=subtitle_language,
subtitle_mode=subtitle_mode,
ytdl_options_preset=ytdl_options_preset,
ytdl_options_presets=ytdl_options_presets,
ytdl_options_overrides=ytdl_options_overrides,
)
await self.__add_download(dl, auto_start)
@@ -1007,15 +1012,17 @@ class DownloadQueue:
chapter_template=None,
subtitle_language="en",
subtitle_mode="prefer_manual",
ytdl_options_preset="",
ytdl_options_presets=None,
ytdl_options_overrides=None,
already=None,
_add_gen=None,
):
if ytdl_options_presets is None:
ytdl_options_presets = []
log.info(
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} '
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_preset=}'
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=}'
)
if already is None:
_add_gen = self._add_generation
@@ -1044,7 +1051,7 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_presets,
ytdl_options_overrides,
already,
_add_gen,
@@ -1065,9 +1072,11 @@ class DownloadQueue:
chapter_template=None,
subtitle_language="en",
subtitle_mode="prefer_manual",
ytdl_options_preset="",
ytdl_options_presets=None,
ytdl_options_overrides=None,
):
if ytdl_options_presets is None:
ytdl_options_presets = []
normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry
already = set()
return await self.__add_entry(
@@ -1084,7 +1093,7 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_presets,
ytdl_options_overrides,
already,
None,