fix yt-dlp options overrides (closes #958)

This commit is contained in:
Alex Shnitman
2026-04-18 08:46:29 +03:00
parent ab42325db5
commit e9f979b349
3 changed files with 123 additions and 14 deletions
+4
View File
@@ -104,6 +104,8 @@ MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves
When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options. When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options.
In JSON presets and overrides, setting an option to **`null`** clears that option for that download (for example, `"download_archive": null` overrides a global archive path so the archive is not used). This follows yt-dlps usual meaning of `None` for that option.
### Option format ### Option format
yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON. yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
@@ -204,6 +206,8 @@ When a download starts, the final set of yt-dlp options is built in this order:
2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys). 2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys).
3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys). 3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys).
MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written. **Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written.
### Configuration cookbooks ### Configuration cookbooks
+93 -4
View File
@@ -56,7 +56,7 @@ def test_get_returns_tuple_of_lists(dq_env):
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env): async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid1", "id": "vid1",
@@ -86,7 +86,7 @@ async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
async def test_cancel_removes_from_pending(dq_env): async def test_cancel_removes_from_pending(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid1", "id": "vid1",
@@ -118,7 +118,7 @@ async def test_cancel_removes_from_pending(dq_env):
async def test_start_pending_moves_to_queue(dq_env): async def test_start_pending_moves_to_queue(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid1", "id": "vid1",
@@ -187,7 +187,7 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
"Preset B": {"writesubtitles": False, "ratelimit": 1000}, "Preset B": {"writesubtitles": False, "ratelimit": 1000},
} }
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid2", "id": "vid2",
@@ -219,3 +219,92 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
assert queued.ytdl_opts["ratelimit"] == 1000 assert queued.ytdl_opts["ratelimit"] == 1000
assert queued.ytdl_opts["proxy"] == "http://override" assert queued.ytdl_opts["proxy"] == "http://override"
assert queued.ytdl_opts["embed_thumbnail"] is True assert queued.ytdl_opts["embed_thumbnail"] is True
@pytest.mark.asyncio
async def test_extract_info_preset_null_download_archive_overrides_global(dq_env):
"""Preset download_archive:null must apply during extract_info (global archive otherwise wins first)."""
dq_env.YTDL_OPTIONS = {"download_archive": "/tmp/archive.txt"}
dq_env.YTDL_OPTIONS_PRESETS = {"NoArchive": {"download_archive": None}}
captured_params: list = []
class FakeYoutubeDL:
def __init__(self, params=None):
captured_params.append(params)
def extract_info(self, url, download=False):
return {
"_type": "video",
"id": "vid-archive",
"title": "Archive Test",
"url": url,
"webpage_url": url,
}
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
result = await dq.add(
"https://example.com/archive-test",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_presets=["NoArchive"],
)
assert result["status"] == "ok"
assert len(captured_params) == 1
extract_params = captured_params[0]
assert extract_params.get("download_archive") is None
assert extract_params["extract_flat"] is True
assert extract_params["noplaylist"] is True
@pytest.mark.asyncio
async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
"""MeTube's flat-extract settings must not be overridden by presets."""
dq_env.YTDL_OPTIONS = {}
dq_env.YTDL_OPTIONS_PRESETS = {
"TryOverride": {"extract_flat": False, "noplaylist": False},
}
captured_params: list = []
class FakeYoutubeDL:
def __init__(self, params=None):
captured_params.append(params)
def extract_info(self, url, download=False):
return {
"_type": "video",
"id": "vid-flat",
"title": "Flat Test",
"url": url,
"webpage_url": url,
}
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
result = await dq.add(
"https://example.com/flat-test",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_presets=["TryOverride"],
)
assert result["status"] == "ok"
assert captured_params[0]["extract_flat"] is True
assert captured_params[0]["noplaylist"] is True
+26 -10
View File
@@ -9,6 +9,7 @@ from collections import OrderedDict
import time import time
import asyncio import asyncio
import multiprocessing import multiprocessing
from functools import partial
import logging import logging
import re import re
import types import types
@@ -796,9 +797,19 @@ class DownloadQueue:
log.debug(f'Auto-clearing completed download: {url}') log.debug(f'Auto-clearing completed download: {url}')
await self.clear([url]) await self.clear([url])
def __extract_info(self, url): def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
"""Merge global options, presets (in order), and per-download overrides."""
opts = dict(self.config.YTDL_OPTIONS)
for preset_name in ytdl_options_presets or []:
opts.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
opts.update(ytdl_options_overrides or {})
return opts
def __extract_info(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG) debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
return yt_dlp.YoutubeDL(params={ user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides)
params = {
**user_opts,
'quiet': not debug_logging, 'quiet': not debug_logging,
'verbose': debug_logging, 'verbose': debug_logging,
'no_color': True, 'no_color': True,
@@ -806,9 +817,11 @@ class DownloadQueue:
'ignore_no_formats_error': True, 'ignore_no_formats_error': True,
'noplaylist': True, 'noplaylist': True,
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR}, 'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
**self.config.YTDL_OPTIONS, }
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}), imp = user_opts.get('impersonate')
}).extract_info(url, download=False) if imp is not None:
params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp)
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
def __calc_download_path(self, download_type, folder): def __calc_download_path(self, download_type, folder):
base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
@@ -844,10 +857,10 @@ class DownloadQueue:
output = self.config.OUTPUT_TEMPLATE_CHANNEL output = self.config.OUTPUT_TEMPLATE_CHANNEL
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 = self._build_ytdl_options(
for preset_name in getattr(dl, 'ytdl_options_presets', None) or []: getattr(dl, 'ytdl_options_presets', None),
ytdl_options.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {})) getattr(dl, 'ytdl_options_overrides', {}) or {},
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')
@@ -1037,7 +1050,10 @@ class DownloadQueue:
else: else:
already.add(url) already.add(url)
try: try:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) entry = await asyncio.get_running_loop().run_in_executor(
None,
partial(self.__extract_info, url, ytdl_options_presets, ytdl_options_overrides),
)
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(exc)} return {'status': 'error', 'msg': str(exc)}
return await self.__add_entry( return await self.__add_entry(