diff --git a/README.md b/README.md index e588b5e..bfa709a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/main.py b/app/main.py index 284198d..4ffe2cb 100644 --- a/app/main.py +++ b/app/main.py @@ -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)) diff --git a/app/subscriptions.py b/app/subscriptions.py index 901ff59..94a1d3b 100644 --- a/app/subscriptions.py +++ b/app/subscriptions.py @@ -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", diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 4aa18e8..5af4e25 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -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) diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 0461ba1..558f998 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -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() diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index dfa21c7..5397c15 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -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 diff --git a/app/tests/test_main_helpers.py b/app/tests/test_main_helpers.py index 4258b79..e6cfcfd 100644 --- a/app/tests/test_main_helpers.py +++ b/app/tests/test_main_helpers.py @@ -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() diff --git a/app/ytdl.py b/app/ytdl.py index 889c0fb..61d514e 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -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, ) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 303bdd1..73d4f77 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -447,6 +447,35 @@ ngbTooltip="How often to poll subscriptions for new videos"> +