From 2141db22a87ba163b9538a0af4056429a675eceb Mon Sep 17 00:00:00 2001 From: Elad Levi <99.elad.levi@gmail.com> Date: Sat, 2 Aug 2025 13:01:32 +0300 Subject: [PATCH] Add UI and backend support for download configuration Introduces UI controls to set download mode and max concurrent downloads, with backend API to update these settings dynamically. The configuration is now synchronized between frontend and backend, allowing users to adjust download scheduling directly from the UI. --- README.md | 6 ++-- app/main.py | 54 ++++++++++++++++++++++++++++++++- ui/src/app/app.component.html | 28 +++++++++++++++++ ui/src/app/app.component.ts | 37 ++++++++++++++++++++++ ui/src/app/downloads.service.ts | 9 +++++- 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 53291af..b3a25f6 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,12 @@ Certain values can be set via environment variables, using the `-e` parameter on * __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#L220). 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. * __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container -* __DOWNLOAD_MODE__ :This flag controls how downloads are scheduled and executed. Options are `sequential`, `concurrent`, and `limited`. Defaults to `limited`: - * `sequential`: Downloads are processed one at a time. A new download won’t start until the previous one has finished. This mode is useful for conserving system resources or ensuring downloads occur in a strict order. +* __DOWNLOAD_MODE__ :This flag controls how downloads are scheduled and executed. Options are `sequential`, `concurrent`, and `limited`. Defaults to `limited`. Can also be configured in the UI: + * `sequential`: Downloads are processed one at a time. A new download won't start until the previous one has finished. This mode is useful for conserving system resources or ensuring downloads occur in a strict order. * `concurrent`: Downloads are started immediately as they are added, with no built-in limit on how many run simultaneously. This mode may overwhelm your system if too many downloads start at once. * `limited`: Downloads are started concurrently but are capped by a concurrency limit. In this mode, a semaphore is used so that at most a fixed number of downloads run at any given time. * **MAX\_CONCURRENT\_DOWNLOADS** This flag is used only when **DOWNLOAD\_MODE** is set to **limited**. - It specifies the 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`. + It specifies the 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`. Can also be configured in the UI. * __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` or `NONE`. Defaults to `INFO`. * __ENABLE_ACCESSLOG__: whether to enable access log. Defaults to `false`. diff --git a/app/main.py b/app/main.py index 3ba5ae4..f132fb9 100644 --- a/app/main.py +++ b/app/main.py @@ -261,11 +261,63 @@ async def history(request): log.info("Sending download history") return web.Response(text=serializer.encode(history)) +@routes.post(config.URL_PREFIX + 'update_download_config') +async def update_download_config(request): + post = await request.json() + download_mode = post.get('download_mode') + max_concurrent = post.get('max_concurrent_downloads') + + if download_mode not in ['sequential', 'concurrent', 'limited']: + raise web.HTTPBadRequest(text='Invalid download_mode') + + if max_concurrent is not None: + try: + max_concurrent = int(max_concurrent) + if max_concurrent < 1: + raise ValueError() + except ValueError: + raise web.HTTPBadRequest(text='max_concurrent_downloads must be a positive integer') + + # Update config + config.DOWNLOAD_MODE = download_mode + if max_concurrent is not None: + config.MAX_CONCURRENT_DOWNLOADS = max_concurrent + + # Update download queue configuration + if download_mode == 'sequential': + if not hasattr(dqueue, 'seq_lock'): + dqueue.seq_lock = asyncio.Lock() + dqueue.semaphore = None + elif download_mode == 'limited': + dqueue.semaphore = asyncio.Semaphore(int(config.MAX_CONCURRENT_DOWNLOADS)) + if hasattr(dqueue, 'seq_lock'): + delattr(dqueue, 'seq_lock') + else: # concurrent + dqueue.semaphore = None + if hasattr(dqueue, 'seq_lock'): + delattr(dqueue, 'seq_lock') + + # Notify all clients of the configuration change + await sio.emit('configuration', serializer.encode(config)) + + return web.Response(text=serializer.encode({'status': 'ok'})) + @sio.event 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) + # Include download settings in configuration + config_dict = { + 'CUSTOM_DIRS': config.CUSTOM_DIRS, + 'CREATE_CUSTOM_DIRS': config.CREATE_CUSTOM_DIRS, + 'DEFAULT_OPTION_PLAYLIST_STRICT_MODE': config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE, + 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT': config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT, + 'PUBLIC_HOST_URL': config.PUBLIC_HOST_URL, + 'PUBLIC_HOST_AUDIO_URL': config.PUBLIC_HOST_AUDIO_URL, + 'DOWNLOAD_MODE': config.DOWNLOAD_MODE, + 'MAX_CONCURRENT_DOWNLOADS': config.MAX_CONCURRENT_DOWNLOADS + } + await sio.emit('configuration', serializer.encode(config_dict), to=sid) if config.CUSTOM_DIRS: await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid) if config.YTDL_OPTIONS_FILE: diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index a536154..cda355a 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -207,6 +207,34 @@ + +
+
+
Download Configuration
+
+
+ + +
+ Sequential: Downloads one at a time
+ Limited: Downloads up to the specified number concurrently
+ Unlimited: No limit on concurrent downloads
+
+
+
+ + +
+ Maximum number of simultaneous downloads (1-20) +
+
+
+
+
+
diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 1f9b144..0fdfa2d 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -44,6 +44,15 @@ export class AppComponent implements AfterViewInit { metubeVersion: string | null = null; isAdvancedOpen = false; + // Download configuration + downloadMode: string = 'limited'; + maxConcurrentDownloads: number = 3; + availableDownloadModes = [ + { id: 'sequential', name: 'Sequential (one at a time)' }, + { id: 'limited', name: 'Limited concurrent' }, + { id: 'concurrent', name: 'Unlimited concurrent' } + ]; + // Download metrics activeDownloads = 0; queuedDownloads = 0; @@ -196,6 +205,14 @@ export class AppComponent implements AfterViewInit { if (playlistItemLimit !== '0') { this.playlistItemLimit = playlistItemLimit; } + + // Update download configuration + if (config['DOWNLOAD_MODE']) { + this.downloadMode = config['DOWNLOAD_MODE']; + } + if (config['MAX_CONCURRENT_DOWNLOADS']) { + this.maxConcurrentDownloads = config['MAX_CONCURRENT_DOWNLOADS']; + } } }); } @@ -516,4 +533,24 @@ export class AppComponent implements AfterViewInit { this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0); } + + updateDownloadConfig() { + this.downloads.updateDownloadConfig(this.downloadMode, this.maxConcurrentDownloads) + .subscribe((status: Status) => { + if (status.status === 'error') { + alert(`Error updating download configuration: ${status.msg}`); + } + }); + } + + onDownloadModeChange() { + this.updateDownloadConfig(); + } + + onMaxConcurrentChange() { + if (this.maxConcurrentDownloads < 1) { + this.maxConcurrentDownloads = 1; + } + this.updateDownloadConfig(); + } } diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cf63a5e..760e2e5 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -157,5 +157,12 @@ export class DownloadsService { return Array.from(this.queue.values()).map(download => download.url); } - + public updateDownloadConfig(downloadMode: string, maxConcurrentDownloads: number): Observable { + return this.http.post('update_download_config', { + download_mode: downloadMode, + max_concurrent_downloads: maxConcurrentDownloads + }).pipe( + catchError(this.handleHTTPError) + ); + } }