feat: add per-download yt-dlp presets and overrides

Agent-Logs-Url: https://github.com/alexta69/metube/sessions/8a3119fc-63d1-4508-a196-8c50ff248812

Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-03 06:16:12 +00:00
committed by GitHub
parent b4d497f53d
commit 565a715037
14 changed files with 427 additions and 1 deletions
+2
View File
@@ -68,6 +68,8 @@ 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_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`.
### 🌐 Web Server & URLs
+107
View File
@@ -57,6 +57,8 @@ class Config:
'CLEAR_COMPLETED_AFTER': '0',
'YTDL_OPTIONS': '{}',
'YTDL_OPTIONS_FILE': '',
'YTDL_OPTIONS_PRESETS': '{}',
'YTDL_OPTIONS_PRESETS_FILE': '',
'ROBOTS_TXT': '',
'HOST': '0.0.0.0',
'PORT': '8081',
@@ -91,12 +93,17 @@ class Config:
# Convert relative addresses to absolute addresses to prevent the failure of file address comparison
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
if self.YTDL_OPTIONS_PRESETS_FILE and self.YTDL_OPTIONS_PRESETS_FILE.startswith('.'):
self.YTDL_OPTIONS_PRESETS_FILE = str(Path(self.YTDL_OPTIONS_PRESETS_FILE).resolve())
self._runtime_overrides = {}
success,_ = self.load_ytdl_options()
if not success:
sys.exit(1)
success,_ = self.load_ytdl_option_presets()
if not success:
sys.exit(1)
def set_runtime_override(self, key, value):
self._runtime_overrides[key] = value
@@ -160,6 +167,37 @@ class Config:
self._apply_runtime_overrides()
return (True, '')
def load_ytdl_option_presets(self) -> tuple[bool, str]:
try:
self.YTDL_OPTIONS_PRESETS = json.loads(os.environ.get('YTDL_OPTIONS_PRESETS', '{}'))
assert isinstance(self.YTDL_OPTIONS_PRESETS, dict)
assert all(isinstance(name, str) and isinstance(options, dict) for name, options in self.YTDL_OPTIONS_PRESETS.items())
except (json.decoder.JSONDecodeError, AssertionError):
msg = 'Environment variable YTDL_OPTIONS_PRESETS is invalid'
log.error(msg)
return (False, msg)
if not self.YTDL_OPTIONS_PRESETS_FILE:
return (True, '')
log.info(f'Loading yt-dlp option presets from "{self.YTDL_OPTIONS_PRESETS_FILE}"')
if not os.path.exists(self.YTDL_OPTIONS_PRESETS_FILE):
msg = f'File "{self.YTDL_OPTIONS_PRESETS_FILE}" not found'
log.error(msg)
return (False, msg)
try:
with open(self.YTDL_OPTIONS_PRESETS_FILE) as json_data:
opts = json.load(json_data)
assert isinstance(opts, dict)
assert all(isinstance(name, str) and isinstance(options, dict) for name, options in opts.items())
except (json.decoder.JSONDecodeError, AssertionError):
msg = 'YTDL_OPTIONS_PRESETS_FILE contents is invalid'
log.error(msg)
return (False, msg)
self.YTDL_OPTIONS_PRESETS.update(opts)
return (True, '')
config = Config()
# Align root logger level with Config (keeps a single source of truth).
# This re-applies the log level after Config loads, in case LOGLEVEL was
@@ -194,6 +232,53 @@ VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'}
VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'}
VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'}
VALID_THUMBNAIL_FORMATS = {'jpg'}
BLOCKED_YTDL_OVERRIDE_KEYS = frozenset({
'exec',
'exec_before_dl',
'exec_cmd',
'external_downloader',
'external_downloader_args',
'format',
'ignore_no_formats_error',
'no_color',
'outtmpl',
'paths',
'postprocessor_hooks',
'progress_hooks',
'quiet',
'socket_timeout',
'verbose',
})
def _iter_nested_keys(value):
if isinstance(value, dict):
for key, nested in value.items():
yield str(key)
yield from _iter_nested_keys(nested)
elif isinstance(value, list):
for item in value:
yield from _iter_nested_keys(item)
def _parse_ytdl_options_overrides(value) -> dict:
if value is None or value == '':
return {}
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as exc:
raise web.HTTPBadRequest(reason='ytdl_options_overrides must be valid JSON') from exc
if not isinstance(value, dict):
raise web.HTTPBadRequest(reason='ytdl_options_overrides must be a JSON object')
blocked_keys = sorted({key for key in _iter_nested_keys(value) if key in BLOCKED_YTDL_OVERRIDE_KEYS})
if blocked_keys:
raise web.HTTPBadRequest(reason=f'ytdl_options_overrides contains disallowed keys: {blocked_keys}')
return value
def _migrate_legacy_request(post: dict) -> dict:
@@ -384,6 +469,8 @@ 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:
custom_name_prefix = ''
@@ -401,12 +488,16 @@ 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_overrides = _parse_ytdl_options_overrides(ytdl_options_overrides)
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')):
raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
@@ -414,6 +505,8 @@ 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')
if download_type not in VALID_DOWNLOAD_TYPES:
raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}')
@@ -466,6 +559,8 @@ 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_overrides': ytdl_options_overrides,
}
@@ -500,9 +595,19 @@ async def add(request):
o['chapter_template'],
o['subtitle_language'],
o['subtitle_mode'],
o['ytdl_options_preset'],
o['ytdl_options_overrides'],
)
return web.Response(text=serializer.encode(status))
@routes.get(config.URL_PREFIX + 'presets')
async def presets(request):
return web.Response(
text=serializer.encode({'presets': sorted(config.YTDL_OPTIONS_PRESETS.keys())}),
content_type='application/json',
)
@routes.post(config.URL_PREFIX + 'cancel-add')
async def cancel_add(request):
dqueue.cancel_add()
@@ -541,6 +646,8 @@ 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_overrides=o['ytdl_options_overrides'],
)
return web.Response(text=serializer.encode(result))
+16
View File
@@ -145,6 +145,8 @@ class SubscriptionInfo:
chapter_template: str = ""
subtitle_language: str = "en"
subtitle_mode: str = "prefer_manual"
ytdl_options_preset: str = ""
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
last_checked: Optional[float] = None
seen_ids: list[str] = field(default_factory=list)
error: Optional[str] = None
@@ -190,6 +192,8 @@ 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_overrides": sub.ytdl_options_overrides,
"last_checked": sub.last_checked,
"seen_ids": list(sub.seen_ids),
"error": sub.error,
@@ -311,6 +315,8 @@ class SubscriptionManager:
chapter_template: str,
subtitle_language: str,
subtitle_mode: str,
ytdl_options_preset: str = "",
ytdl_options_overrides: Optional[dict[str, Any]] = None,
) -> tuple[list[str], list[str]]:
queued_ids: list[str] = []
queue_errors: list[str] = []
@@ -336,6 +342,8 @@ class SubscriptionManager:
chapter_template or None,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
)
if isinstance(result, dict) and result.get("status") == "error":
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
@@ -403,6 +411,8 @@ class SubscriptionManager:
chapter_template: str,
subtitle_language: str,
subtitle_mode: str,
ytdl_options_preset: str = "",
ytdl_options_overrides: Optional[dict[str, Any]] = None,
) -> dict:
url = self._normalize_url(url)
if not url:
@@ -460,6 +470,8 @@ class SubscriptionManager:
chapter_template=chapter_template or "",
subtitle_language=subtitle_language,
subtitle_mode=subtitle_mode,
ytdl_options_preset=ytdl_options_preset,
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
last_checked=time.time(),
seen_ids=list(dict.fromkeys(all_ids)),
error=None,
@@ -608,6 +620,8 @@ 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_overrides = dict(cur.ytdl_options_overrides)
new_entries: list[dict] = []
new_ids: list[str] = []
@@ -632,6 +646,8 @@ class SubscriptionManager:
chapter_template=dl_chapter or "",
subtitle_language=dl_sublang,
subtitle_mode=dl_submode,
ytdl_options_preset=dl_ytdl_preset,
ytdl_options_overrides=dl_ytdl_overrides,
)
log.info(
"Subscription check finished for %s: %d new, %d queued, %d failed",
+49
View File
@@ -37,6 +37,8 @@ def _valid_video_add_body(**kwargs):
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "",
"ytdl_options_overrides": "",
}
base.update(kwargs)
return base
@@ -59,6 +61,23 @@ async def test_add_ok(mock_dqueue):
mock_dqueue.add.assert_awaited_once()
@pytest.mark.asyncio
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
req = _json_request(
_valid_video_add_body(
ytdl_options_preset="Preset A",
ytdl_options_overrides='{"writesubtitles": true}',
)
)
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[13] == "Preset A"
assert call.args[14] == {"writesubtitles": True}
@pytest.mark.asyncio
async def test_add_missing_url_returns_400(mock_dqueue):
req = _json_request({"download_type": "video", "quality": "best", "format": "any"})
@@ -124,6 +143,27 @@ async def test_add_invalid_json_body(mock_dqueue):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_ytdl_options_override_json(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_overrides="{bad json}"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_blocked_ytdl_options_override_key(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}'))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_unknown_ytdl_preset(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_delete_missing_ids(mock_dqueue):
req = _json_request({"where": "queue"})
@@ -168,6 +208,15 @@ async def test_version_json(mock_dqueue):
assert "yt-dlp" in body and "version" in body
@pytest.mark.asyncio
async def test_presets_endpoint_returns_names(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset B": {}, "Preset A": {}})
req = MagicMock(spec=web.Request)
resp = await main.presets(req)
assert resp.status == 200
assert json.loads(resp.text) == {"presets": ["Preset A", "Preset B"]}
@pytest.mark.asyncio
async def test_cookie_status(mock_dqueue):
req = MagicMock(spec=web.Request)
+25
View File
@@ -33,6 +33,16 @@ class ConfigTests(unittest.TestCase):
c = Config()
self.assertEqual(c.YTDL_OPTIONS["quiet"], True)
def test_ytdl_option_presets_json_loaded(self):
presets = {"Audio extras": {"embed_thumbnail": True}}
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)),
clear=False,
):
c = Config()
self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True)
def test_invalid_ytdl_options_exits(self):
with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False):
with self.assertRaises(SystemExit):
@@ -73,6 +83,21 @@ class ConfigTests(unittest.TestCase):
finally:
os.unlink(path)
def test_ytdl_option_presets_file_merges(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump({"With subtitles": {"writesubtitles": True}}, f)
path = f.name
try:
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path),
clear=False,
):
c = Config()
self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS)
finally:
os.unlink(path)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -25,6 +25,7 @@ def dq_env():
cfg.TEMP_DIR = dl
cfg.MAX_CONCURRENT_DOWNLOADS = "3"
cfg.YTDL_OPTIONS = {}
cfg.YTDL_OPTIONS_PRESETS = {}
cfg.CUSTOM_DIRS = True
cfg.CREATE_CUSTOM_DIRS = True
cfg.CLEAR_COMPLETED_AFTER = "0"
@@ -175,3 +176,42 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
assert result["status"] == "ok"
assert dq.pending.exists("https://example.com/watch?v=1")
@pytest.mark.asyncio
async def test_add_merges_global_preset_and_override_options(dq_env):
notifier = AsyncMock()
dq_env.YTDL_OPTIONS = {"writesubtitles": False, "cookiefile": "/tmp/global.txt"}
dq_env.YTDL_OPTIONS_PRESETS = {"Preset A": {"writesubtitles": True, "proxy": "http://preset"}}
def fake_extract(self, url):
return {
"_type": "video",
"id": "vid2",
"title": "Preset Video",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
result = await dq.add(
"https://example.com/preset",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_preset="Preset A",
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["proxy"] == "http://override"
assert queued.ytdl_opts["embed_thumbnail"] is True
+44
View File
@@ -101,5 +101,49 @@ class FrontendSafeTests(unittest.TestCase):
self.assertNotIn("DOWNLOAD_DIR", safe)
class ParseYtdlOverridesTests(unittest.TestCase):
def test_empty_override_string_returns_empty_dict(self):
self.assertEqual(main._parse_ytdl_options_overrides(""), {})
def test_rejects_non_object_json(self):
with self.assertRaises(main.web.HTTPBadRequest):
main._parse_ytdl_options_overrides('["bad"]')
def test_rejects_blocked_keys(self):
with self.assertRaises(main.web.HTTPBadRequest):
main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}')
class ParseDownloadOptionsTests(unittest.TestCase):
def test_accepts_known_preset_and_overrides(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}}
try:
parsed = main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "With subtitles",
"ytdl_options_overrides": '{"writesubtitles": true}',
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
self.assertEqual(parsed["ytdl_options_preset"], "With subtitles")
self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True})
def test_rejects_unknown_preset(self):
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_preset": "Missing preset",
})
if __name__ == "__main__":
unittest.main()
+31 -1
View File
@@ -188,6 +188,8 @@ class DownloadInfo:
chapter_template,
subtitle_language="en",
subtitle_mode="prefer_manual",
ytdl_options_preset="",
ytdl_options_overrides=None,
):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
@@ -210,6 +212,8 @@ 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_overrides = dict(ytdl_options_overrides or {})
self.subtitle_files = []
def __setstate__(self, state):
@@ -262,6 +266,10 @@ 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 = ""
if not hasattr(self, "ytdl_options_overrides"):
self.ytdl_options_overrides = {}
if not hasattr(self, "entry"):
self.entry = None
if not hasattr(self, "subtitle_files"):
@@ -285,6 +293,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"chapter_template",
"subtitle_language",
"subtitle_mode",
"ytdl_options_preset",
"ytdl_options_overrides",
"status",
"timestamp",
"error",
@@ -828,6 +838,10 @@ 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:
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)
if playlist_item_limit > 0:
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
@@ -855,6 +869,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen=None,
):
@@ -887,6 +903,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen,
)
@@ -934,6 +952,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen,
)
@@ -965,6 +985,8 @@ class DownloadQueue:
chapter_template=chapter_template,
subtitle_language=subtitle_language,
subtitle_mode=subtitle_mode,
ytdl_options_preset=ytdl_options_preset,
ytdl_options_overrides=ytdl_options_overrides,
)
await self.__add_download(dl, auto_start)
return {'status': 'ok'}
@@ -985,13 +1007,15 @@ class DownloadQueue:
chapter_template=None,
subtitle_language="en",
subtitle_mode="prefer_manual",
ytdl_options_preset="",
ytdl_options_overrides=None,
already=None,
_add_gen=None,
):
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=}'
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_preset=}'
)
if already is None:
_add_gen = self._add_generation
@@ -1020,6 +1044,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen,
)
@@ -1039,6 +1065,8 @@ class DownloadQueue:
chapter_template=None,
subtitle_language="en",
subtitle_mode="prefer_manual",
ytdl_options_preset="",
ytdl_options_overrides=None,
):
normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry
already = set()
@@ -1056,6 +1084,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
None,
)
+29
View File
@@ -447,6 +447,35 @@
ngbTooltip="How often to poll subscriptions for new videos">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Option Preset</span>
<select class="form-select"
name="ytdlOptionsPreset"
[(ngModel)]="ytdlOptionsPreset"
(change)="ytdlOptionsPresetChanged()"
[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>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom yt-dlp Options</span>
<input type="text"
class="form-control"
placeholder='e.g. {"writesubtitles": true}'
name="ytdlOptionsOverrides"
[(ngModel)]="ytdlOptionsOverrides"
(change)="ytdlOptionsOverridesChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Optional per-download yt-dlp overrides as a JSON object">
</div>
</div>
<div class="col-12">
<div class="row g-2 align-items-center">
<div class="col-auto">
+57
View File
@@ -83,6 +83,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
chapterTemplate: string;
subtitleLanguage: string;
subtitleMode: string;
ytdlOptionsPreset: string;
ytdlOptionsOverrides: string;
ytdlOptionPresetNames: string[] = [];
addInProgress = false;
cancelRequested = false;
subscribeInProgress = false;
@@ -231,6 +234,8 @@ 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.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));
if (!allowedDownloadTypes.has(this.downloadType)) {
@@ -287,6 +292,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
this.getConfiguration();
this.getYtdlOptionsUpdateTime();
this.getYtdlOptionPresets();
this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme!);
@@ -415,6 +421,39 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
}
getYtdlOptionPresets() {
this.downloads.getPresets().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (data) => {
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();
}
this.cdr.markForCheck();
},
});
}
private validateYtdlOptionsOverrides(value: string): boolean {
const trimmed = value?.trim() || '';
if (!trimmed) {
return true;
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
alert('Custom yt-dlp options must be a JSON object');
return false;
}
} catch {
alert('Custom yt-dlp options must be valid JSON');
return false;
}
return true;
}
private rebuildCachedSubs() {
this.cachedSubs = Array.from(this.subscriptionsSvc.subscriptions.entries());
const validIds = new Set(this.cachedSubs.map(([id]) => id));
@@ -491,6 +530,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return;
}
this.subscribeInProgress = true;
this.subscriptionsSvc
.subscribe({
@@ -695,6 +737,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.saveSelection(this.downloadType);
}
ytdlOptionsPresetChanged() {
this.cookieService.set('metube_ytdl_options_preset', this.ytdlOptionsPreset, { expires: this.settingsCookieExpiryDays });
}
ytdlOptionsOverridesChanged() {
this.cookieService.set('metube_ytdl_options_overrides', this.ytdlOptionsOverrides, { expires: this.settingsCookieExpiryDays });
}
isVideoType() {
return this.downloadType === 'video';
}
@@ -894,6 +944,8 @@ 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,
ytdlOptionsOverrides: overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides,
};
}
@@ -905,6 +957,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return;
}
console.debug('Downloading:', payload);
this.addInProgress = true;
@@ -960,6 +1015,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
chapterTemplate: download.chapter_template,
subtitleLanguage: download.subtitle_language,
subtitleMode: download.subtitle_mode,
ytdlOptionsPreset: download.ytdl_options_preset || '',
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
});
this.downloads.delById('done', [key]).subscribe();
}
+2
View File
@@ -14,6 +14,8 @@ export interface Download {
chapter_template?: string;
subtitle_language?: string;
subtitle_mode?: string;
ytdl_options_preset?: string;
ytdl_options_overrides?: Record<string, unknown>;
status: string;
msg: string;
percent: number;
@@ -39,6 +39,8 @@ function basePayload(): AddDownloadPayload {
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPreset: '',
ytdlOptionsOverrides: '',
};
}
@@ -79,11 +81,22 @@ describe('DownloadsService', () => {
chapter_template: '',
subtitle_language: 'en',
subtitle_mode: 'prefer_manual',
ytdl_options_preset: '',
ytdl_options_overrides: '',
}),
);
req.flush({ status: 'ok' });
});
it('getPresets() fetches configured preset names', () => {
service.getPresets().subscribe((result) => {
expect(result).toEqual({ presets: ['Preset A'] });
});
const req = httpMock.expectOne('presets');
expect(req.request.method).toBe('GET');
req.flush({ presets: ['Preset A'] });
});
it('cancelAdd posts to cancel-add', () => {
service.cancelAdd().subscribe();
const req = httpMock.expectOne('cancel-add');
+10
View File
@@ -20,6 +20,8 @@ export interface AddDownloadPayload {
chapterTemplate: string;
subtitleLanguage: string;
subtitleMode: string;
ytdlOptionsPreset: string;
ytdlOptionsOverrides: string;
}
@Injectable({
providedIn: 'root'
@@ -141,11 +143,19 @@ export class DownloadsService {
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
ytdl_options_preset: payload.ytdlOptionsPreset,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
}).pipe(
catchError(this.handleHTTPError)
);
}
public getPresets() {
return this.http.get<{ presets: string[] }>('presets').pipe(
catchError(() => of({ presets: [] }))
);
}
public startById(ids: string[]) {
return this.http.post('start', {ids: ids});
}
@@ -94,6 +94,8 @@ export class SubscriptionsService {
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
ytdl_options_preset: payload.ytdlOptionsPreset,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
check_interval_minutes: payload.checkIntervalMinutes,
})
.pipe(catchError((err) => this.handleHTTPError(err)));