From a4454ac460ec5fddf2083f20d1b60e2bef602d02 Mon Sep 17 00:00:00 2001 From: tjelite1986 Date: Sun, 16 Aug 2026 12:53:07 +0200 Subject: [PATCH] feat: DEFAULT_FOLDER pre-selects a download folder (closes #875) Most downloads from a given install land in the same custom directory, which today means picking it by hand every time. DEFAULT_FOLDER seeds the folder field once the configuration arrives; the field stays editable, so per-download folders still work, and a folder already typed this session is not overwritten. The value is trimmed of surrounding slashes, and dropped with a warning when CUSTOM_DIRS is off, since the UI hides the field in that mode and the download path check rejects a folder anyway. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + app/main.py | 14 ++++++++++++++ app/tests/test_config.py | 22 ++++++++++++++++++++++ ui/src/app/app.spec.ts | 19 +++++++++++++++++++ ui/src/app/app.ts | 6 ++++++ 5 files changed, 62 insertions(+) diff --git a/README.md b/README.md index e1451b4..50400e0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __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`. * __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 `@`. +* __DEFAULT_FOLDER__: Custom directory to pre-select in the download folder field, relative to __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__), for when most downloads go to the same place. It is only a starting value — the field stays editable, so any other folder can still be picked per download. Requires __CUSTOM_DIRS__; ignored with a warning otherwise. Defaults to empty, i.e. the base download directory. * __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 MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. * __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise. diff --git a/app/main.py b/app/main.py index 12e473b..a94cbd6 100644 --- a/app/main.py +++ b/app/main.py @@ -62,6 +62,7 @@ class Config: 'CUSTOM_DIRS': 'true', 'CREATE_CUSTOM_DIRS': 'true', 'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$', + 'DEFAULT_FOLDER': '', 'DELETE_FILE_ON_TRASHCAN': 'false', 'STATE_DIR': '.', 'URL_PREFIX': '', @@ -127,6 +128,18 @@ class Config: if val and not val.endswith('/'): setattr(self, attr, val + '/') + # DEFAULT_FOLDER only pre-fills the form's folder field, which the UI + # does not even show without CUSTOM_DIRS. Sending one anyway would fail + # every download on the server's own folder check, so drop it and say so + # rather than leaving the user with a form that cannot submit. + self.DEFAULT_FOLDER = self.DEFAULT_FOLDER.strip().strip('/') + if self.DEFAULT_FOLDER and not self.CUSTOM_DIRS: + log.warning( + 'Ignoring DEFAULT_FOLDER "%s" because CUSTOM_DIRS is not enabled', + self.DEFAULT_FOLDER, + ) + self.DEFAULT_FOLDER = '' + # 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()) @@ -187,6 +200,7 @@ class Config: _FRONTEND_KEYS = ( 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', + 'DEFAULT_FOLDER', 'OUTPUT_TEMPLATE_CHAPTER', 'PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL', diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 5fed6fd..a877ef6 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -115,6 +115,28 @@ class ConfigTests(unittest.TestCase): self.assertNotIn("HOST", safe) self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) + def test_default_folder_empty_by_default(self): + with patch.dict(os.environ, _base_env(), clear=False): + c = Config() + self.assertEqual(c.DEFAULT_FOLDER, "") + + def test_default_folder_is_trimmed_and_reaches_the_frontend(self): + with patch.dict(os.environ, _base_env(DEFAULT_FOLDER=" /youtube/ "), clear=False): + c = Config() + self.assertEqual(c.DEFAULT_FOLDER, "youtube") + self.assertEqual(c.frontend_safe()["DEFAULT_FOLDER"], "youtube") + + def test_default_folder_ignored_without_custom_dirs(self): + # The folder field is not shown at all without CUSTOM_DIRS, and sending + # a folder anyway is rejected by the download path check. + with patch.dict( + os.environ, + _base_env(DEFAULT_FOLDER="youtube", CUSTOM_DIRS="false"), + clear=False, + ): + c = Config() + self.assertEqual(c.DEFAULT_FOLDER, "") + def test_allow_ytdl_options_overrides_boolean_loaded(self): with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False): c = Config() diff --git a/ui/src/app/app.spec.ts b/ui/src/app/app.spec.ts index fb529e8..df92e15 100644 --- a/ui/src/app/app.spec.ts +++ b/ui/src/app/app.spec.ts @@ -148,6 +148,25 @@ describe('App', () => { expect(app).toBeTruthy(); }); + it('pre-fills the download folder from DEFAULT_FOLDER', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' }); + + expect(fixture.componentInstance.folder).toBe('youtube'); + }); + + it('does not overwrite a folder the user already typed', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + fixture.componentInstance.folder = 'music'; + + downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' }); + + expect(fixture.componentInstance.folder).toBe('music'); + }); + it('asIsOrder returns a stable comparator value (insertion order preserved)', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index d88cc9d..36124bc 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -434,6 +434,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy { if (!Number.isNaN(playlistItemLimit) && playlistItemLimit > 0) { this.playlistItemLimit = playlistItemLimit; } + // Pre-fill the download folder, unless the user has already typed one + // this session. The server drops DEFAULT_FOLDER when CUSTOM_DIRS is + // off, so there is nothing to guard against here. + if (!this.folder) { + this.folder = String(config['DEFAULT_FOLDER'] ?? ''); + } // Set chapter template from backend config if not already set by cookie if (!this.chapterTemplate) { this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];