mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
Compare commits
1 Commits
2026.04.03
...
2026.04.04
| Author | SHA1 | Date | |
|---|---|---|---|
| dd0f98d12f |
Vendored
+2
@@ -14,6 +14,7 @@
|
||||
"DOWNLOAD_DIR": "${env:USERPROFILE}/Downloads",
|
||||
"STATE_DIR": "${env:TEMP}",
|
||||
"ALLOW_YTDL_OPTIONS_OVERRIDES": "true",
|
||||
"YTDL_OPTIONS_PRESETS": "{\"sponsorblock\": {\"postprocessors\": [{\"key\": \"SponsorBlock\", \"categories\": [\"sponsor\", \"selfpromo\", \"interaction\"]}, {\"key\": \"ModifyChapters\", \"remove_sponsor_segments\": [\"sponsor\", \"selfpromo\", \"interaction\"]}]}, \"embed-subs\": {\"writesubtitles\": true, \"writeautomaticsub\": true, \"subtitleslangs\": [\"en\", \"de\"], \"postprocessors\": [{\"key\": \"FFmpegEmbedSubtitle\"}]}, \"limit-rate\": {\"ratelimit\": 5000000}}",
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
@@ -21,6 +22,7 @@
|
||||
"DOWNLOAD_DIR": "${env:HOME}/Downloads",
|
||||
"STATE_DIR": "${env:TMPDIR}",
|
||||
"ALLOW_YTDL_OPTIONS_OVERRIDES": "true",
|
||||
"YTDL_OPTIONS_PRESETS": "{\"sponsorblock\": {\"postprocessors\": [{\"key\": \"SponsorBlock\", \"categories\": [\"sponsor\", \"selfpromo\", \"interaction\"]}, {\"key\": \"ModifyChapters\", \"remove_sponsor_segments\": [\"sponsor\", \"selfpromo\", \"interaction\"]}]}, \"embed-subs\": {\"writesubtitles\": true, \"writeautomaticsub\": true, \"subtitleslangs\": [\"en\", \"de\"], \"postprocessors\": [{\"key\": \"FFmpegEmbedSubtitle\"}]}, \"limit-rate\": {\"ratelimit\": 5000000}}",
|
||||
}
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
|
||||
@@ -68,7 +68,27 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead.
|
||||
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`.
|
||||
* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected.
|
||||
* __YTDL_OPTIONS_PRESETS__: A JSON object mapping preset names to yt-dlp option objects. These preset names are exposed in the web UI's Advanced Options panel so users can pick per-download overrides without changing the global `YTDL_OPTIONS`.
|
||||
* __YTDL_OPTIONS_PRESETS__: Lets you define **named presets**—each preset is a bundle of extra download settings. In the web UI (Advanced Options), you can pick **one or more** presets for a download instead of changing the server-wide defaults. If several presets are selected, they are applied **in order**; if two presets touch the same setting, the **later** one wins. If you turn on the optional “custom yt-dlp options” field, those choices override presets. Define each preset the same way as global `YTDL_OPTIONS` (see above). Example:
|
||||
```json
|
||||
{
|
||||
"sponsorblock": {
|
||||
"postprocessors": [
|
||||
{ "key": "SponsorBlock", "categories": ["sponsor", "selfpromo", "interaction"] },
|
||||
{ "key": "ModifyChapters", "remove_sponsor_segments": ["sponsor", "selfpromo", "interaction"] }
|
||||
]
|
||||
},
|
||||
"embed-subs": {
|
||||
"writesubtitles": true,
|
||||
"writeautomaticsub": true,
|
||||
"subtitleslangs": ["en", "de"],
|
||||
"postprocessors": [{ "key": "FFmpegEmbedSubtitle" }]
|
||||
},
|
||||
"limit-rate": {
|
||||
"ratelimit": 5000000
|
||||
}
|
||||
}
|
||||
```
|
||||
With this file, the UI would offer **sponsorblock** (remove sponsor/self-promo/interaction segments), **embed-subs** (add English and German subtitles into the video file), and **limit-rate** (slow downloads to about 5 MB/s)—things the normal quality/format controls do not cover.
|
||||
* __YTDL_OPTIONS_PRESETS_FILE__: A path to a JSON file containing `YTDL_OPTIONS_PRESETS`. If both are specified, values from `YTDL_OPTIONS_PRESETS_FILE` are merged into `YTDL_OPTIONS_PRESETS`.
|
||||
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show the web UI field for manual per-download `ytdl_options_overrides`. Defaults to `false`. Enabling this allows arbitrary yt-dlp API options to be supplied by UI users, which may enable arbitrary command execution inside the container depending on the options used. Enable only if you understand and accept that risk.
|
||||
|
||||
|
||||
+24
-9
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+11
-11
@@ -482,18 +482,18 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Option Preset</span>
|
||||
<select class="form-select"
|
||||
name="ytdlOptionsPreset"
|
||||
[(ngModel)]="ytdlOptionsPreset"
|
||||
(change)="ytdlOptionsPresetChanged()"
|
||||
<span class="input-group-text">Option Presets</span>
|
||||
<ng-select
|
||||
class="flex-grow-1"
|
||||
name="ytdlOptionsPresets"
|
||||
[items]="ytdlOptionPresetNames"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
placeholder="Default"
|
||||
[(ngModel)]="ytdlOptionsPresets"
|
||||
(ngModelChange)="ytdlOptionsPresetsChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Choose a named yt-dlp option preset configured on the server">
|
||||
<option value="">Default</option>
|
||||
@for (preset of ytdlOptionPresetNames; track preset) {
|
||||
<option [value]="preset">{{ preset }}</option>
|
||||
}
|
||||
</select>
|
||||
ngbTooltip="Choose one or more yt-dlp option presets configured on the server (applied in order)" />
|
||||
</div>
|
||||
</div>
|
||||
@if (allowYtdlOptionsOverrides()) {
|
||||
|
||||
@@ -140,10 +140,10 @@ describe('App', () => {
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).toBeNull();
|
||||
|
||||
const presetWrapper = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.col-12');
|
||||
const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12');
|
||||
expect(presetWrapper?.classList.contains('col-md-6')).toBe(false);
|
||||
|
||||
const presetRow = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.row');
|
||||
const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row');
|
||||
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -157,10 +157,10 @@ describe('App', () => {
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
|
||||
|
||||
const presetWrapper = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.col-12');
|
||||
const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12');
|
||||
expect(presetWrapper?.classList.contains('col-md-6')).toBe(true);
|
||||
|
||||
const presetRow = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.row');
|
||||
const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row');
|
||||
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
|
||||
expect(presetRow?.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
+35
-9
@@ -83,7 +83,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
chapterTemplate: string;
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
ytdlOptionsPreset: string;
|
||||
ytdlOptionsPresets: string[] = [];
|
||||
ytdlOptionsOverrides: string;
|
||||
ytdlOptionPresetNames: string[] = [];
|
||||
addInProgress = false;
|
||||
@@ -234,7 +234,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
|
||||
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
|
||||
this.ytdlOptionsPreset = this.cookieService.get('metube_ytdl_options_preset') || '';
|
||||
this.ytdlOptionsPresets = this.loadYtdlOptionsPresetsFromCookie();
|
||||
this.ytdlOptionsOverrides = this.cookieService.get('metube_ytdl_options_overrides') || '';
|
||||
const allowedDownloadTypes = new Set(this.downloadTypes.map(t => t.id));
|
||||
const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id));
|
||||
@@ -431,15 +431,35 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.ytdlOptionPresetNames = Array.isArray(data?.presets)
|
||||
? data.presets.filter((preset): preset is string => typeof preset === 'string')
|
||||
: [];
|
||||
if (this.ytdlOptionsPreset && !this.ytdlOptionPresetNames.includes(this.ytdlOptionsPreset)) {
|
||||
this.ytdlOptionsPreset = '';
|
||||
this.ytdlOptionsPresetChanged();
|
||||
if (this.ytdlOptionsPresets?.length) {
|
||||
const valid = new Set(this.ytdlOptionPresetNames);
|
||||
const filtered = this.ytdlOptionsPresets.filter((p) => valid.has(p));
|
||||
if (filtered.length !== this.ytdlOptionsPresets.length) {
|
||||
this.ytdlOptionsPresets = filtered;
|
||||
this.ytdlOptionsPresetsChanged();
|
||||
}
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadYtdlOptionsPresetsFromCookie(): string[] {
|
||||
const jsonCookie = this.cookieService.get('metube_ytdl_options_presets');
|
||||
if (jsonCookie) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCookie) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy cookie
|
||||
}
|
||||
}
|
||||
const legacy = this.cookieService.get('metube_ytdl_options_preset')?.trim();
|
||||
return legacy ? [legacy] : [];
|
||||
}
|
||||
|
||||
private validateYtdlOptionsOverrides(value: string): boolean {
|
||||
if (!this.allowYtdlOptionsOverrides()) {
|
||||
return true;
|
||||
@@ -744,8 +764,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.saveSelection(this.downloadType);
|
||||
}
|
||||
|
||||
ytdlOptionsPresetChanged() {
|
||||
this.cookieService.set('metube_ytdl_options_preset', this.ytdlOptionsPreset, { expires: this.settingsCookieExpiryDays });
|
||||
ytdlOptionsPresetsChanged() {
|
||||
this.cookieService.set(
|
||||
'metube_ytdl_options_presets',
|
||||
JSON.stringify(this.ytdlOptionsPresets ?? []),
|
||||
{ expires: this.settingsCookieExpiryDays },
|
||||
);
|
||||
}
|
||||
|
||||
ytdlOptionsOverridesChanged() {
|
||||
@@ -952,7 +976,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
|
||||
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
|
||||
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
|
||||
ytdlOptionsPreset: overrides.ytdlOptionsPreset ?? this.ytdlOptionsPreset,
|
||||
ytdlOptionsPresets: overrides.ytdlOptionsPresets ?? [...this.ytdlOptionsPresets],
|
||||
ytdlOptionsOverrides: allowYtdlOptionsOverrides
|
||||
? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides)
|
||||
: '',
|
||||
@@ -1025,7 +1049,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
chapterTemplate: download.chapter_template,
|
||||
subtitleLanguage: download.subtitle_language,
|
||||
subtitleMode: download.subtitle_mode,
|
||||
ytdlOptionsPreset: download.ytdl_options_preset || '',
|
||||
ytdlOptionsPresets: download.ytdl_options_presets?.length
|
||||
? [...download.ytdl_options_presets]
|
||||
: [],
|
||||
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
|
||||
});
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface Download {
|
||||
chapter_template?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
ytdl_options_preset?: string;
|
||||
ytdl_options_presets?: string[];
|
||||
ytdl_options_overrides?: Record<string, unknown>;
|
||||
status: string;
|
||||
msg: string;
|
||||
|
||||
@@ -39,7 +39,7 @@ function basePayload(): AddDownloadPayload {
|
||||
chapterTemplate: '',
|
||||
subtitleLanguage: 'en',
|
||||
subtitleMode: 'prefer_manual',
|
||||
ytdlOptionsPreset: '',
|
||||
ytdlOptionsPresets: [],
|
||||
ytdlOptionsOverrides: '',
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,7 @@ describe('DownloadsService', () => {
|
||||
chapter_template: '',
|
||||
subtitle_language: 'en',
|
||||
subtitle_mode: 'prefer_manual',
|
||||
ytdl_options_preset: '',
|
||||
ytdl_options_presets: [],
|
||||
ytdl_options_overrides: '',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface AddDownloadPayload {
|
||||
chapterTemplate: string;
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
ytdlOptionsPreset: string;
|
||||
ytdlOptionsPresets: string[];
|
||||
ytdlOptionsOverrides: string;
|
||||
}
|
||||
@Injectable({
|
||||
@@ -143,7 +143,7 @@ export class DownloadsService {
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
ytdl_options_preset: payload.ytdlOptionsPreset,
|
||||
ytdl_options_presets: payload.ytdlOptionsPresets,
|
||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||
}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
|
||||
@@ -94,7 +94,7 @@ export class SubscriptionsService {
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
ytdl_options_preset: payload.ytdlOptionsPreset,
|
||||
ytdl_options_presets: payload.ytdlOptionsPresets,
|
||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||
check_interval_minutes: payload.checkIntervalMinutes,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user