mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
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:
committed by
GitHub
parent
b4d497f53d
commit
565a715037
@@ -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.
|
* __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__: 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_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
|
### 🌐 Web Server & URLs
|
||||||
|
|
||||||
|
|||||||
+107
@@ -57,6 +57,8 @@ class Config:
|
|||||||
'CLEAR_COMPLETED_AFTER': '0',
|
'CLEAR_COMPLETED_AFTER': '0',
|
||||||
'YTDL_OPTIONS': '{}',
|
'YTDL_OPTIONS': '{}',
|
||||||
'YTDL_OPTIONS_FILE': '',
|
'YTDL_OPTIONS_FILE': '',
|
||||||
|
'YTDL_OPTIONS_PRESETS': '{}',
|
||||||
|
'YTDL_OPTIONS_PRESETS_FILE': '',
|
||||||
'ROBOTS_TXT': '',
|
'ROBOTS_TXT': '',
|
||||||
'HOST': '0.0.0.0',
|
'HOST': '0.0.0.0',
|
||||||
'PORT': '8081',
|
'PORT': '8081',
|
||||||
@@ -91,12 +93,17 @@ class Config:
|
|||||||
# Convert relative addresses to absolute addresses to prevent the failure of file address comparison
|
# 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('.'):
|
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
||||||
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
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 = {}
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
success,_ = self.load_ytdl_options()
|
success,_ = self.load_ytdl_options()
|
||||||
if not success:
|
if not success:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
success,_ = self.load_ytdl_option_presets()
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def set_runtime_override(self, key, value):
|
def set_runtime_override(self, key, value):
|
||||||
self._runtime_overrides[key] = value
|
self._runtime_overrides[key] = value
|
||||||
@@ -160,6 +167,37 @@ class Config:
|
|||||||
self._apply_runtime_overrides()
|
self._apply_runtime_overrides()
|
||||||
return (True, '')
|
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()
|
config = Config()
|
||||||
# Align root logger level with Config (keeps a single source of truth).
|
# 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
|
# 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_VIDEO_FORMATS = {'any', 'mp4', 'ios'}
|
||||||
VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'}
|
VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'}
|
||||||
VALID_THUMBNAIL_FORMATS = {'jpg'}
|
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:
|
def _migrate_legacy_request(post: dict) -> dict:
|
||||||
@@ -384,6 +469,8 @@ def parse_download_options(post: dict) -> dict:
|
|||||||
chapter_template = post.get('chapter_template')
|
chapter_template = post.get('chapter_template')
|
||||||
subtitle_language = post.get('subtitle_language')
|
subtitle_language = post.get('subtitle_language')
|
||||||
subtitle_mode = post.get('subtitle_mode')
|
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:
|
if custom_name_prefix is None:
|
||||||
custom_name_prefix = ''
|
custom_name_prefix = ''
|
||||||
@@ -401,12 +488,16 @@ def parse_download_options(post: dict) -> dict:
|
|||||||
subtitle_language = 'en'
|
subtitle_language = 'en'
|
||||||
if subtitle_mode is None:
|
if subtitle_mode is None:
|
||||||
subtitle_mode = 'prefer_manual'
|
subtitle_mode = 'prefer_manual'
|
||||||
|
if ytdl_options_preset is None:
|
||||||
|
ytdl_options_preset = ''
|
||||||
download_type = str(download_type).strip().lower()
|
download_type = str(download_type).strip().lower()
|
||||||
codec = str(codec or 'auto').strip().lower()
|
codec = str(codec or 'auto').strip().lower()
|
||||||
format = str(format or '').strip().lower()
|
format = str(format or '').strip().lower()
|
||||||
quality = str(quality).strip().lower()
|
quality = str(quality).strip().lower()
|
||||||
subtitle_language = str(subtitle_language).strip()
|
subtitle_language = str(subtitle_language).strip()
|
||||||
subtitle_mode = str(subtitle_mode).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('\\')):
|
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')
|
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')
|
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:
|
if subtitle_mode not in VALID_SUBTITLE_MODES:
|
||||||
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(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:
|
if download_type not in VALID_DOWNLOAD_TYPES:
|
||||||
raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(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,
|
'chapter_template': chapter_template,
|
||||||
'subtitle_language': subtitle_language,
|
'subtitle_language': subtitle_language,
|
||||||
'subtitle_mode': subtitle_mode,
|
'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['chapter_template'],
|
||||||
o['subtitle_language'],
|
o['subtitle_language'],
|
||||||
o['subtitle_mode'],
|
o['subtitle_mode'],
|
||||||
|
o['ytdl_options_preset'],
|
||||||
|
o['ytdl_options_overrides'],
|
||||||
)
|
)
|
||||||
return web.Response(text=serializer.encode(status))
|
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')
|
@routes.post(config.URL_PREFIX + 'cancel-add')
|
||||||
async def cancel_add(request):
|
async def cancel_add(request):
|
||||||
dqueue.cancel_add()
|
dqueue.cancel_add()
|
||||||
@@ -541,6 +646,8 @@ async def subscribe(request):
|
|||||||
chapter_template=o['chapter_template'],
|
chapter_template=o['chapter_template'],
|
||||||
subtitle_language=o['subtitle_language'],
|
subtitle_language=o['subtitle_language'],
|
||||||
subtitle_mode=o['subtitle_mode'],
|
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))
|
return web.Response(text=serializer.encode(result))
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ class SubscriptionInfo:
|
|||||||
chapter_template: str = ""
|
chapter_template: str = ""
|
||||||
subtitle_language: str = "en"
|
subtitle_language: str = "en"
|
||||||
subtitle_mode: str = "prefer_manual"
|
subtitle_mode: str = "prefer_manual"
|
||||||
|
ytdl_options_preset: str = ""
|
||||||
|
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
|
||||||
last_checked: Optional[float] = None
|
last_checked: Optional[float] = None
|
||||||
seen_ids: list[str] = field(default_factory=list)
|
seen_ids: list[str] = field(default_factory=list)
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
@@ -190,6 +192,8 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
|||||||
"chapter_template": sub.chapter_template,
|
"chapter_template": sub.chapter_template,
|
||||||
"subtitle_language": sub.subtitle_language,
|
"subtitle_language": sub.subtitle_language,
|
||||||
"subtitle_mode": sub.subtitle_mode,
|
"subtitle_mode": sub.subtitle_mode,
|
||||||
|
"ytdl_options_preset": sub.ytdl_options_preset,
|
||||||
|
"ytdl_options_overrides": sub.ytdl_options_overrides,
|
||||||
"last_checked": sub.last_checked,
|
"last_checked": sub.last_checked,
|
||||||
"seen_ids": list(sub.seen_ids),
|
"seen_ids": list(sub.seen_ids),
|
||||||
"error": sub.error,
|
"error": sub.error,
|
||||||
@@ -311,6 +315,8 @@ class SubscriptionManager:
|
|||||||
chapter_template: str,
|
chapter_template: str,
|
||||||
subtitle_language: str,
|
subtitle_language: str,
|
||||||
subtitle_mode: str,
|
subtitle_mode: str,
|
||||||
|
ytdl_options_preset: str = "",
|
||||||
|
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||||
) -> tuple[list[str], list[str]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
queued_ids: list[str] = []
|
queued_ids: list[str] = []
|
||||||
queue_errors: list[str] = []
|
queue_errors: list[str] = []
|
||||||
@@ -336,6 +342,8 @@ class SubscriptionManager:
|
|||||||
chapter_template or None,
|
chapter_template or None,
|
||||||
subtitle_language,
|
subtitle_language,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
|
ytdl_options_preset,
|
||||||
|
ytdl_options_overrides,
|
||||||
)
|
)
|
||||||
if isinstance(result, dict) and result.get("status") == "error":
|
if isinstance(result, dict) and result.get("status") == "error":
|
||||||
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
|
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
|
||||||
@@ -403,6 +411,8 @@ class SubscriptionManager:
|
|||||||
chapter_template: str,
|
chapter_template: str,
|
||||||
subtitle_language: str,
|
subtitle_language: str,
|
||||||
subtitle_mode: str,
|
subtitle_mode: str,
|
||||||
|
ytdl_options_preset: str = "",
|
||||||
|
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = self._normalize_url(url)
|
url = self._normalize_url(url)
|
||||||
if not url:
|
if not url:
|
||||||
@@ -460,6 +470,8 @@ class SubscriptionManager:
|
|||||||
chapter_template=chapter_template or "",
|
chapter_template=chapter_template or "",
|
||||||
subtitle_language=subtitle_language,
|
subtitle_language=subtitle_language,
|
||||||
subtitle_mode=subtitle_mode,
|
subtitle_mode=subtitle_mode,
|
||||||
|
ytdl_options_preset=ytdl_options_preset,
|
||||||
|
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
|
||||||
last_checked=time.time(),
|
last_checked=time.time(),
|
||||||
seen_ids=list(dict.fromkeys(all_ids)),
|
seen_ids=list(dict.fromkeys(all_ids)),
|
||||||
error=None,
|
error=None,
|
||||||
@@ -608,6 +620,8 @@ class SubscriptionManager:
|
|||||||
dl_chapter = cur.chapter_template
|
dl_chapter = cur.chapter_template
|
||||||
dl_sublang = cur.subtitle_language
|
dl_sublang = cur.subtitle_language
|
||||||
dl_submode = cur.subtitle_mode
|
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_entries: list[dict] = []
|
||||||
new_ids: list[str] = []
|
new_ids: list[str] = []
|
||||||
@@ -632,6 +646,8 @@ class SubscriptionManager:
|
|||||||
chapter_template=dl_chapter or "",
|
chapter_template=dl_chapter or "",
|
||||||
subtitle_language=dl_sublang,
|
subtitle_language=dl_sublang,
|
||||||
subtitle_mode=dl_submode,
|
subtitle_mode=dl_submode,
|
||||||
|
ytdl_options_preset=dl_ytdl_preset,
|
||||||
|
ytdl_options_overrides=dl_ytdl_overrides,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"Subscription check finished for %s: %d new, %d queued, %d failed",
|
"Subscription check finished for %s: %d new, %d queued, %d failed",
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ def _valid_video_add_body(**kwargs):
|
|||||||
"codec": "auto",
|
"codec": "auto",
|
||||||
"format": "any",
|
"format": "any",
|
||||||
"quality": "best",
|
"quality": "best",
|
||||||
|
"ytdl_options_preset": "",
|
||||||
|
"ytdl_options_overrides": "",
|
||||||
}
|
}
|
||||||
base.update(kwargs)
|
base.update(kwargs)
|
||||||
return base
|
return base
|
||||||
@@ -59,6 +61,23 @@ async def test_add_ok(mock_dqueue):
|
|||||||
mock_dqueue.add.assert_awaited_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_missing_url_returns_400(mock_dqueue):
|
async def test_add_missing_url_returns_400(mock_dqueue):
|
||||||
req = _json_request({"download_type": "video", "quality": "best", "format": "any"})
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_delete_missing_ids(mock_dqueue):
|
async def test_delete_missing_ids(mock_dqueue):
|
||||||
req = _json_request({"where": "queue"})
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cookie_status(mock_dqueue):
|
async def test_cookie_status(mock_dqueue):
|
||||||
req = MagicMock(spec=web.Request)
|
req = MagicMock(spec=web.Request)
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ class ConfigTests(unittest.TestCase):
|
|||||||
c = Config()
|
c = Config()
|
||||||
self.assertEqual(c.YTDL_OPTIONS["quiet"], True)
|
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):
|
def test_invalid_ytdl_options_exits(self):
|
||||||
with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False):
|
with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
@@ -73,6 +83,21 @@ class ConfigTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def dq_env():
|
|||||||
cfg.TEMP_DIR = dl
|
cfg.TEMP_DIR = dl
|
||||||
cfg.MAX_CONCURRENT_DOWNLOADS = "3"
|
cfg.MAX_CONCURRENT_DOWNLOADS = "3"
|
||||||
cfg.YTDL_OPTIONS = {}
|
cfg.YTDL_OPTIONS = {}
|
||||||
|
cfg.YTDL_OPTIONS_PRESETS = {}
|
||||||
cfg.CUSTOM_DIRS = True
|
cfg.CUSTOM_DIRS = True
|
||||||
cfg.CREATE_CUSTOM_DIRS = True
|
cfg.CREATE_CUSTOM_DIRS = True
|
||||||
cfg.CLEAR_COMPLETED_AFTER = "0"
|
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 result["status"] == "ok"
|
||||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
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
|
||||||
|
|||||||
@@ -101,5 +101,49 @@ class FrontendSafeTests(unittest.TestCase):
|
|||||||
self.assertNotIn("DOWNLOAD_DIR", safe)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+31
-1
@@ -188,6 +188,8 @@ class DownloadInfo:
|
|||||||
chapter_template,
|
chapter_template,
|
||||||
subtitle_language="en",
|
subtitle_language="en",
|
||||||
subtitle_mode="prefer_manual",
|
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.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}'
|
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.chapter_template = chapter_template
|
||||||
self.subtitle_language = subtitle_language
|
self.subtitle_language = subtitle_language
|
||||||
self.subtitle_mode = subtitle_mode
|
self.subtitle_mode = subtitle_mode
|
||||||
|
self.ytdl_options_preset = ytdl_options_preset
|
||||||
|
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
||||||
self.subtitle_files = []
|
self.subtitle_files = []
|
||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
@@ -262,6 +266,10 @@ class DownloadInfo:
|
|||||||
self.subtitle_language = "en"
|
self.subtitle_language = "en"
|
||||||
if not hasattr(self, "subtitle_mode"):
|
if not hasattr(self, "subtitle_mode"):
|
||||||
self.subtitle_mode = "prefer_manual"
|
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"):
|
if not hasattr(self, "entry"):
|
||||||
self.entry = None
|
self.entry = None
|
||||||
if not hasattr(self, "subtitle_files"):
|
if not hasattr(self, "subtitle_files"):
|
||||||
@@ -285,6 +293,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
|||||||
"chapter_template",
|
"chapter_template",
|
||||||
"subtitle_language",
|
"subtitle_language",
|
||||||
"subtitle_mode",
|
"subtitle_mode",
|
||||||
|
"ytdl_options_preset",
|
||||||
|
"ytdl_options_overrides",
|
||||||
"status",
|
"status",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"error",
|
"error",
|
||||||
@@ -828,6 +838,10 @@ class DownloadQueue:
|
|||||||
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
|
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
|
||||||
output = _resolve_outtmpl_fields(output, sanitized, ('channel',))
|
output = _resolve_outtmpl_fields(output, sanitized, ('channel',))
|
||||||
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
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)
|
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
||||||
if playlist_item_limit > 0:
|
if playlist_item_limit > 0:
|
||||||
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
@@ -855,6 +869,8 @@ class DownloadQueue:
|
|||||||
chapter_template,
|
chapter_template,
|
||||||
subtitle_language,
|
subtitle_language,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
|
ytdl_options_preset,
|
||||||
|
ytdl_options_overrides,
|
||||||
already,
|
already,
|
||||||
_add_gen=None,
|
_add_gen=None,
|
||||||
):
|
):
|
||||||
@@ -887,6 +903,8 @@ class DownloadQueue:
|
|||||||
chapter_template,
|
chapter_template,
|
||||||
subtitle_language,
|
subtitle_language,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
|
ytdl_options_preset,
|
||||||
|
ytdl_options_overrides,
|
||||||
already,
|
already,
|
||||||
_add_gen,
|
_add_gen,
|
||||||
)
|
)
|
||||||
@@ -934,6 +952,8 @@ class DownloadQueue:
|
|||||||
chapter_template,
|
chapter_template,
|
||||||
subtitle_language,
|
subtitle_language,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
|
ytdl_options_preset,
|
||||||
|
ytdl_options_overrides,
|
||||||
already,
|
already,
|
||||||
_add_gen,
|
_add_gen,
|
||||||
)
|
)
|
||||||
@@ -965,6 +985,8 @@ class DownloadQueue:
|
|||||||
chapter_template=chapter_template,
|
chapter_template=chapter_template,
|
||||||
subtitle_language=subtitle_language,
|
subtitle_language=subtitle_language,
|
||||||
subtitle_mode=subtitle_mode,
|
subtitle_mode=subtitle_mode,
|
||||||
|
ytdl_options_preset=ytdl_options_preset,
|
||||||
|
ytdl_options_overrides=ytdl_options_overrides,
|
||||||
)
|
)
|
||||||
await self.__add_download(dl, auto_start)
|
await self.__add_download(dl, auto_start)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
@@ -985,13 +1007,15 @@ class DownloadQueue:
|
|||||||
chapter_template=None,
|
chapter_template=None,
|
||||||
subtitle_language="en",
|
subtitle_language="en",
|
||||||
subtitle_mode="prefer_manual",
|
subtitle_mode="prefer_manual",
|
||||||
|
ytdl_options_preset="",
|
||||||
|
ytdl_options_overrides=None,
|
||||||
already=None,
|
already=None,
|
||||||
_add_gen=None,
|
_add_gen=None,
|
||||||
):
|
):
|
||||||
log.info(
|
log.info(
|
||||||
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} '
|
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'{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:
|
if already is None:
|
||||||
_add_gen = self._add_generation
|
_add_gen = self._add_generation
|
||||||
@@ -1020,6 +1044,8 @@ class DownloadQueue:
|
|||||||
chapter_template,
|
chapter_template,
|
||||||
subtitle_language,
|
subtitle_language,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
|
ytdl_options_preset,
|
||||||
|
ytdl_options_overrides,
|
||||||
already,
|
already,
|
||||||
_add_gen,
|
_add_gen,
|
||||||
)
|
)
|
||||||
@@ -1039,6 +1065,8 @@ class DownloadQueue:
|
|||||||
chapter_template=None,
|
chapter_template=None,
|
||||||
subtitle_language="en",
|
subtitle_language="en",
|
||||||
subtitle_mode="prefer_manual",
|
subtitle_mode="prefer_manual",
|
||||||
|
ytdl_options_preset="",
|
||||||
|
ytdl_options_overrides=None,
|
||||||
):
|
):
|
||||||
normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry
|
normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry
|
||||||
already = set()
|
already = set()
|
||||||
@@ -1056,6 +1084,8 @@ class DownloadQueue:
|
|||||||
chapter_template,
|
chapter_template,
|
||||||
subtitle_language,
|
subtitle_language,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
|
ytdl_options_preset,
|
||||||
|
ytdl_options_overrides,
|
||||||
already,
|
already,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -447,6 +447,35 @@
|
|||||||
ngbTooltip="How often to poll subscriptions for new videos">
|
ngbTooltip="How often to poll subscriptions for new videos">
|
||||||
</div>
|
</div>
|
||||||
</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="col-12">
|
||||||
<div class="row g-2 align-items-center">
|
<div class="row g-2 align-items-center">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
chapterTemplate: string;
|
chapterTemplate: string;
|
||||||
subtitleLanguage: string;
|
subtitleLanguage: string;
|
||||||
subtitleMode: string;
|
subtitleMode: string;
|
||||||
|
ytdlOptionsPreset: string;
|
||||||
|
ytdlOptionsOverrides: string;
|
||||||
|
ytdlOptionPresetNames: string[] = [];
|
||||||
addInProgress = false;
|
addInProgress = false;
|
||||||
cancelRequested = false;
|
cancelRequested = false;
|
||||||
subscribeInProgress = false;
|
subscribeInProgress = false;
|
||||||
@@ -231,6 +234,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||||
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
|
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
|
||||||
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
|
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 allowedDownloadTypes = new Set(this.downloadTypes.map(t => t.id));
|
||||||
const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id));
|
const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id));
|
||||||
if (!allowedDownloadTypes.has(this.downloadType)) {
|
if (!allowedDownloadTypes.has(this.downloadType)) {
|
||||||
@@ -287,6 +292,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
this.getConfiguration();
|
this.getConfiguration();
|
||||||
this.getYtdlOptionsUpdateTime();
|
this.getYtdlOptionsUpdateTime();
|
||||||
|
this.getYtdlOptionPresets();
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
this.customDirs$ = this.getMatchingCustomDir();
|
||||||
this.setTheme(this.activeTheme!);
|
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() {
|
private rebuildCachedSubs() {
|
||||||
this.cachedSubs = Array.from(this.subscriptionsSvc.subscriptions.entries());
|
this.cachedSubs = Array.from(this.subscriptionsSvc.subscriptions.entries());
|
||||||
const validIds = new Set(this.cachedSubs.map(([id]) => id));
|
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)');
|
alert('Chapter template must include %(section_number)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.subscribeInProgress = true;
|
this.subscribeInProgress = true;
|
||||||
this.subscriptionsSvc
|
this.subscriptionsSvc
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -695,6 +737,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.saveSelection(this.downloadType);
|
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() {
|
isVideoType() {
|
||||||
return this.downloadType === 'video';
|
return this.downloadType === 'video';
|
||||||
}
|
}
|
||||||
@@ -894,6 +944,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
|
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
|
||||||
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
|
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
|
||||||
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
|
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)');
|
alert('Chapter template must include %(section_number)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('Downloading:', payload);
|
console.debug('Downloading:', payload);
|
||||||
this.addInProgress = true;
|
this.addInProgress = true;
|
||||||
@@ -960,6 +1015,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
chapterTemplate: download.chapter_template,
|
chapterTemplate: download.chapter_template,
|
||||||
subtitleLanguage: download.subtitle_language,
|
subtitleLanguage: download.subtitle_language,
|
||||||
subtitleMode: download.subtitle_mode,
|
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();
|
this.downloads.delById('done', [key]).subscribe();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface Download {
|
|||||||
chapter_template?: string;
|
chapter_template?: string;
|
||||||
subtitle_language?: string;
|
subtitle_language?: string;
|
||||||
subtitle_mode?: string;
|
subtitle_mode?: string;
|
||||||
|
ytdl_options_preset?: string;
|
||||||
|
ytdl_options_overrides?: Record<string, unknown>;
|
||||||
status: string;
|
status: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ function basePayload(): AddDownloadPayload {
|
|||||||
chapterTemplate: '',
|
chapterTemplate: '',
|
||||||
subtitleLanguage: 'en',
|
subtitleLanguage: 'en',
|
||||||
subtitleMode: 'prefer_manual',
|
subtitleMode: 'prefer_manual',
|
||||||
|
ytdlOptionsPreset: '',
|
||||||
|
ytdlOptionsOverrides: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +81,22 @@ describe('DownloadsService', () => {
|
|||||||
chapter_template: '',
|
chapter_template: '',
|
||||||
subtitle_language: 'en',
|
subtitle_language: 'en',
|
||||||
subtitle_mode: 'prefer_manual',
|
subtitle_mode: 'prefer_manual',
|
||||||
|
ytdl_options_preset: '',
|
||||||
|
ytdl_options_overrides: '',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
req.flush({ status: 'ok' });
|
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', () => {
|
it('cancelAdd posts to cancel-add', () => {
|
||||||
service.cancelAdd().subscribe();
|
service.cancelAdd().subscribe();
|
||||||
const req = httpMock.expectOne('cancel-add');
|
const req = httpMock.expectOne('cancel-add');
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface AddDownloadPayload {
|
|||||||
chapterTemplate: string;
|
chapterTemplate: string;
|
||||||
subtitleLanguage: string;
|
subtitleLanguage: string;
|
||||||
subtitleMode: string;
|
subtitleMode: string;
|
||||||
|
ytdlOptionsPreset: string;
|
||||||
|
ytdlOptionsOverrides: string;
|
||||||
}
|
}
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -141,11 +143,19 @@ export class DownloadsService {
|
|||||||
chapter_template: payload.chapterTemplate,
|
chapter_template: payload.chapterTemplate,
|
||||||
subtitle_language: payload.subtitleLanguage,
|
subtitle_language: payload.subtitleLanguage,
|
||||||
subtitle_mode: payload.subtitleMode,
|
subtitle_mode: payload.subtitleMode,
|
||||||
|
ytdl_options_preset: payload.ytdlOptionsPreset,
|
||||||
|
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPresets() {
|
||||||
|
return this.http.get<{ presets: string[] }>('presets').pipe(
|
||||||
|
catchError(() => of({ presets: [] }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public startById(ids: string[]) {
|
public startById(ids: string[]) {
|
||||||
return this.http.post('start', {ids: ids});
|
return this.http.post('start', {ids: ids});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export class SubscriptionsService {
|
|||||||
chapter_template: payload.chapterTemplate,
|
chapter_template: payload.chapterTemplate,
|
||||||
subtitle_language: payload.subtitleLanguage,
|
subtitle_language: payload.subtitleLanguage,
|
||||||
subtitle_mode: payload.subtitleMode,
|
subtitle_mode: payload.subtitleMode,
|
||||||
|
ytdl_options_preset: payload.ytdlOptionsPreset,
|
||||||
|
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||||
check_interval_minutes: payload.checkIntervalMinutes,
|
check_interval_minutes: payload.checkIntervalMinutes,
|
||||||
})
|
})
|
||||||
.pipe(catchError((err) => this.handleHTTPError(err)));
|
.pipe(catchError((err) => this.handleHTTPError(err)));
|
||||||
|
|||||||
Reference in New Issue
Block a user