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.
This commit is contained in:
Elad Levi 2025-08-02 13:01:32 +03:00
parent ee83bb8165
commit 2141db22a8
5 changed files with 129 additions and 5 deletions

View file

@ -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 wont 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`.

View file

@ -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:

View file

@ -207,6 +207,34 @@
</div>
</div>
<!-- Download Configuration -->
<div class="row mt-3" *ngIf="isAdvancedOpen">
<div class="col">
<h6>Download Configuration</h6>
<div class="row">
<div class="col-md-6">
<label for="downloadMode" class="form-label">Download Mode</label>
<select id="downloadMode" class="form-select" [(ngModel)]="downloadMode" (change)="onDownloadModeChange()">
<option *ngFor="let mode of availableDownloadModes" [value]="mode.id">{{mode.name}}</option>
</select>
<div class="form-text">
<small>Sequential: Downloads one at a time<br>
Limited: Downloads up to the specified number concurrently<br>
Unlimited: No limit on concurrent downloads</small>
</div>
</div>
<div class="col-md-6" *ngIf="downloadMode === 'limited'">
<label for="maxConcurrent" class="form-label">Max Concurrent Downloads</label>
<input type="number" id="maxConcurrent" class="form-control" min="1" max="20"
[(ngModel)]="maxConcurrentDownloads" (change)="onMaxConcurrentChange()">
<div class="form-text">
<small>Maximum number of simultaneous downloads (1-20)</small>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Actions -->
<div class="row">
<div class="col-12">

View file

@ -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();
}
}

View file

@ -157,5 +157,12 @@ export class DownloadsService {
return Array.from(this.queue.values()).map(download => download.url);
}
public updateDownloadConfig(downloadMode: string, maxConcurrentDownloads: number): Observable<Status> {
return this.http.post<Status>('update_download_config', {
download_mode: downloadMode,
max_concurrent_downloads: maxConcurrentDownloads
}).pipe(
catchError(this.handleHTTPError)
);
}
}