diff --git a/README.md b/README.md index 3a7cee2..a5710eb 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 9ba4546..9b4a3a5 100644 --- a/ui/src/app/app.spec.ts +++ b/ui/src/app/app.spec.ts @@ -149,6 +149,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 c486085..2fb458f 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -437,6 +437,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'];