diff --git a/README.md b/README.md index f8fdf48..6d05c57 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 20c399f..5718ad9 100644 --- a/app/main.py +++ b/app/main.py @@ -264,11 +264,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) + ); + } }