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
+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))