From d211f24e00f2cdd74dc4a794845e706219b023c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:27:55 +0000 Subject: [PATCH 1/5] Initial plan From 1f4c4df847cd40066155cffa18c6306a5adc9ecd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:37:35 +0000 Subject: [PATCH 2/5] Implement DEFAULT_DOWNLOAD_FOLDER and CLEAR_COMPLETED_AFTER features (#875, #869) Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- README.md | 2 ++ app/main.py | 2 ++ app/ytdl.py | 14 ++++++++++++++ ui/src/app/app.ts | 4 ++++ 4 files changed, 22 insertions(+) diff --git a/README.md b/README.md index a913a0f..48cc61c 100644 --- a/README.md +++ b/README.md @@ -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`. * __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). +* __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 @@ -43,6 +44,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __AUDIO_DOWNLOAD_DIR__: Path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`. * __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`. * __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`. +* __DEFAULT_DOWNLOAD_FOLDER__: Default subdirectory within __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) to pre-select in the download folder field. Useful when most downloads should go to a specific subfolder. Can be overridden per-download in the UI. Defaults to empty (uses the base download directory). * __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`. * __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`. * __STATE_DIR__: Path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. diff --git a/app/main.py b/app/main.py index 1c4bd66..df8b105 100644 --- a/app/main.py +++ b/app/main.py @@ -60,6 +60,8 @@ class Config: 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', + 'DEFAULT_DOWNLOAD_FOLDER': '', + 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', 'ROBOTS_TXT': '', diff --git a/app/ytdl.py b/app/ytdl.py index bc84b74..f64f669 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -573,6 +573,20 @@ class DownloadQueue: else: self.done.put(download) 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): debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG) diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 15f7ce3..186ed91 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -315,6 +315,10 @@ export class App implements AfterViewInit, OnInit { if (!this.chapterTemplate) { this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER']; } + // Set default download folder from backend config if not already set + if (!this.folder && config['DEFAULT_DOWNLOAD_FOLDER']) { + this.folder = config['DEFAULT_DOWNLOAD_FOLDER']; + } } }); } From 6de4a56f28928802eb625f4bedb0887e6caf76ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 06:37:09 +0000 Subject: [PATCH 3/5] Remove DEFAULT_DOWNLOAD_FOLDER feature Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- README.md | 1 - app/main.py | 1 - ui/src/app/app.ts | 4 ---- 3 files changed, 6 deletions(-) diff --git a/README.md b/README.md index 48cc61c..a5a322d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ Certain values can be set via environment variables, using the `-e` parameter on * __AUDIO_DOWNLOAD_DIR__: Path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`. * __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`. * __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`. -* __DEFAULT_DOWNLOAD_FOLDER__: Default subdirectory within __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) to pre-select in the download folder field. Useful when most downloads should go to a specific subfolder. Can be overridden per-download in the UI. Defaults to empty (uses the base download directory). * __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`. * __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`. * __STATE_DIR__: Path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. diff --git a/app/main.py b/app/main.py index df8b105..54bc952 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,6 @@ class Config: 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', - 'DEFAULT_DOWNLOAD_FOLDER': '', 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 186ed91..15f7ce3 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -315,10 +315,6 @@ export class App implements AfterViewInit, OnInit { if (!this.chapterTemplate) { this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER']; } - // Set default download folder from backend config if not already set - if (!this.folder && config['DEFAULT_DOWNLOAD_FOLDER']) { - this.folder = config['DEFAULT_DOWNLOAD_FOLDER']; - } } }); } From 0d905c0b615adcba56381701f697a38eafc0e06d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:19:05 +0000 Subject: [PATCH 4/5] Fix issues #898, #542, #561, #504 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- README.md | 4 ++-- app/main.py | 31 +++++++++++++++++++++++++++---- app/ytdl.py | 24 ++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a5a322d..77d9ff1 100644 --- a/README.md +++ b/README.md @@ -56,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_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_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_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to empty (uses `OUTPUT_TEMPLATE`). Set to e.g. `%(playlist_title)s/%(title)s.%(ext)s` to group each playlist into its own subdirectory. +* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to empty (uses `OUTPUT_TEMPLATE`). Set to e.g. `%(channel)s/%(title)s.%(ext)s` to group each channel into its own subdirectory. * __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. diff --git a/app/main.py b/app/main.py index 54bc952..d2a1923 100644 --- a/app/main.py +++ b/app/main.py @@ -57,8 +57,8 @@ class Config: 'PUBLIC_HOST_AUDIO_URL': 'audio_download/', 'OUTPUT_TEMPLATE': '%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_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_PLAYLIST': '', + 'OUTPUT_TEMPLATE_CHANNEL': '', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}', @@ -115,6 +115,25 @@ class Config: def _apply_runtime_overrides(self): 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]: try: self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}')) @@ -420,7 +439,7 @@ async def history(request): async def connect(sid, environ): log.info(f"Client connected: {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: await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid) if config.YTDL_OPTIONS_FILE: @@ -448,8 +467,12 @@ def get_custom_dirs(): else: 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('**/')))) + if '' not in dirs: + dirs.insert(0, '') return dirs diff --git a/app/ytdl.py b/app/ytdl.py index f64f669..f73eb36 100644 --- a/app/ytdl.py +++ b/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)) +# 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: """Substitute a single field in an output template, applying any format specifiers to the value.""" pattern = _compile_outtmpl_pattern(field) @@ -631,13 +651,13 @@ class DownloadQueue: output = self.config.OUTPUT_TEMPLATE_PLAYLIST for property, value in entry.items(): 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 len(self.config.OUTPUT_TEMPLATE_CHANNEL): output = self.config.OUTPUT_TEMPLATE_CHANNEL for property, value in entry.items(): 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) playlist_item_limit = getattr(dl, 'playlist_item_limit', 0) if playlist_item_limit > 0: From 2736425e197fffc183374ffebd71988c6f41ec00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:00:29 +0000 Subject: [PATCH 5/5] Revert #504 default change: restore original playlist/channel output templates Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- README.md | 4 ++-- app/main.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77d9ff1..16228af 100644 --- a/README.md +++ b/README.md @@ -56,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_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 empty (uses `OUTPUT_TEMPLATE`). Set to e.g. `%(playlist_title)s/%(title)s.%(ext)s` to group each playlist into its own subdirectory. -* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to empty (uses `OUTPUT_TEMPLATE`). Set to e.g. `%(channel)s/%(title)s.%(ext)s` to group each channel into its own subdirectory. +* __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`. 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. diff --git a/app/main.py b/app/main.py index d2a1923..74ac76a 100644 --- a/app/main.py +++ b/app/main.py @@ -57,8 +57,8 @@ class Config: 'PUBLIC_HOST_AUDIO_URL': 'audio_download/', 'OUTPUT_TEMPLATE': '%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s', - 'OUTPUT_TEMPLATE_PLAYLIST': '', - 'OUTPUT_TEMPLATE_CHANNEL': '', + 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', + 'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}',