mirror of
https://github.com/alexta69/metube.git
synced 2026-03-18 22:43:51 +00:00
Merge pull request #922 from alexta69/copilot/scan-open-issues-for-fixes
Fix #898, #542, #561, #504: config leak, custom-dirs fallback, NTFS path sanitization
This commit is contained in:
@@ -36,6 +36,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
||||||
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
||||||
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
||||||
|
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
|
||||||
|
|
||||||
### 📁 Storage & Directories
|
### 📁 Storage & Directories
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
|
|
||||||
* __OUTPUT_TEMPLATE__: The template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
* __OUTPUT_TEMPLATE__: The template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
||||||
* __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
|
* __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
|
||||||
* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used.
|
* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)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`. When empty, then `OUTPUT_TEMPLATE` is used.
|
* __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.
|
||||||
|
|
||||||
|
|||||||
28
app/main.py
28
app/main.py
@@ -60,6 +60,7 @@ class Config:
|
|||||||
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
||||||
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
||||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
||||||
|
'CLEAR_COMPLETED_AFTER': '0',
|
||||||
'YTDL_OPTIONS': '{}',
|
'YTDL_OPTIONS': '{}',
|
||||||
'YTDL_OPTIONS_FILE': '',
|
'YTDL_OPTIONS_FILE': '',
|
||||||
'ROBOTS_TXT': '',
|
'ROBOTS_TXT': '',
|
||||||
@@ -114,6 +115,25 @@ class Config:
|
|||||||
def _apply_runtime_overrides(self):
|
def _apply_runtime_overrides(self):
|
||||||
self.YTDL_OPTIONS.update(self._runtime_overrides)
|
self.YTDL_OPTIONS.update(self._runtime_overrides)
|
||||||
|
|
||||||
|
# Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS,
|
||||||
|
# paths, TLS config, etc.) are intentionally excluded.
|
||||||
|
_FRONTEND_KEYS = (
|
||||||
|
'CUSTOM_DIRS',
|
||||||
|
'CREATE_CUSTOM_DIRS',
|
||||||
|
'OUTPUT_TEMPLATE_CHAPTER',
|
||||||
|
'PUBLIC_HOST_URL',
|
||||||
|
'PUBLIC_HOST_AUDIO_URL',
|
||||||
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||||
|
)
|
||||||
|
|
||||||
|
def frontend_safe(self) -> dict:
|
||||||
|
"""Return only the config keys that are safe to expose to browser clients.
|
||||||
|
|
||||||
|
Sensitive or server-only keys (YTDL_OPTIONS, file-system paths, TLS
|
||||||
|
settings, etc.) are intentionally excluded.
|
||||||
|
"""
|
||||||
|
return {k: getattr(self, k) for k in self._FRONTEND_KEYS}
|
||||||
|
|
||||||
def load_ytdl_options(self) -> tuple[bool, str]:
|
def load_ytdl_options(self) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
||||||
@@ -419,7 +439,7 @@ async def history(request):
|
|||||||
async def connect(sid, environ):
|
async def connect(sid, environ):
|
||||||
log.info(f"Client connected: {sid}")
|
log.info(f"Client connected: {sid}")
|
||||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||||
await sio.emit('configuration', serializer.encode(config), to=sid)
|
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||||
if config.CUSTOM_DIRS:
|
if config.CUSTOM_DIRS:
|
||||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||||
if config.YTDL_OPTIONS_FILE:
|
if config.YTDL_OPTIONS_FILE:
|
||||||
@@ -447,8 +467,12 @@ def get_custom_dirs():
|
|||||||
else:
|
else:
|
||||||
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
||||||
|
|
||||||
# Recursively lists all subdirectories of DOWNLOAD_DIR
|
# Recursively lists all subdirectories of DOWNLOAD_DIR.
|
||||||
|
# Always include '' (the base directory itself) even when the
|
||||||
|
# directory is empty or does not yet exist.
|
||||||
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
||||||
|
if '' not in dirs:
|
||||||
|
dirs.insert(0, '')
|
||||||
|
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|||||||
38
app/ytdl.py
38
app/ytdl.py
@@ -29,6 +29,26 @@ def _compile_outtmpl_pattern(field: str) -> re.Pattern:
|
|||||||
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
||||||
|
|
||||||
|
|
||||||
|
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||||
|
# sanitised when substituting playlist/channel titles into output templates so
|
||||||
|
# that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts.
|
||||||
|
_WINDOWS_INVALID_PATH_CHARS = re.compile(r'[\\:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_path_component(value: Any) -> Any:
|
||||||
|
"""Replace characters that are invalid in Windows path components with '_'.
|
||||||
|
|
||||||
|
Non-string values (int, float, None, …) are passed through unchanged so
|
||||||
|
that ``_outtmpl_substitute_field`` can still coerce them with format specs
|
||||||
|
(e.g. ``%(playlist_index)02d``). Only string values are sanitised because
|
||||||
|
Windows-invalid characters are only a concern for human-readable strings
|
||||||
|
(titles, channel names, etc.) that may end up as directory names.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
|
||||||
|
|
||||||
|
|
||||||
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
||||||
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
||||||
pattern = _compile_outtmpl_pattern(field)
|
pattern = _compile_outtmpl_pattern(field)
|
||||||
@@ -573,6 +593,20 @@ class DownloadQueue:
|
|||||||
else:
|
else:
|
||||||
self.done.put(download)
|
self.done.put(download)
|
||||||
asyncio.create_task(self.notifier.completed(download.info))
|
asyncio.create_task(self.notifier.completed(download.info))
|
||||||
|
try:
|
||||||
|
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||||
|
except ValueError:
|
||||||
|
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
|
||||||
|
clear_after = 0
|
||||||
|
if clear_after > 0:
|
||||||
|
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
||||||
|
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
||||||
|
|
||||||
|
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
if self.done.exists(url):
|
||||||
|
log.debug(f'Auto-clearing completed download: {url}')
|
||||||
|
await self.clear([url])
|
||||||
|
|
||||||
def __extract_info(self, url):
|
def __extract_info(self, url):
|
||||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||||
@@ -617,13 +651,13 @@ class DownloadQueue:
|
|||||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = _outtmpl_substitute_field(output, property, value)
|
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||||
if entry is not None and entry.get('channel_index') is not None:
|
if entry is not None and entry.get('channel_index') is not None:
|
||||||
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
||||||
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("channel"):
|
if property.startswith("channel"):
|
||||||
output = _outtmpl_substitute_field(output, property, value)
|
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||||
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
||||||
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user