feat: add per-download yt-dlp presets and overrides

Agent-Logs-Url: https://github.com/alexta69/metube/sessions/8a3119fc-63d1-4508-a196-8c50ff248812

Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-03 06:16:12 +00:00
committed by GitHub
parent b4d497f53d
commit 565a715037
14 changed files with 427 additions and 1 deletions
+29
View File
@@ -447,6 +447,35 @@
ngbTooltip="How often to poll subscriptions for new videos">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Option Preset</span>
<select class="form-select"
name="ytdlOptionsPreset"
[(ngModel)]="ytdlOptionsPreset"
(change)="ytdlOptionsPresetChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Choose a named yt-dlp option preset configured on the server">
<option value="">Default</option>
@for (preset of ytdlOptionPresetNames; track preset) {
<option [value]="preset">{{ preset }}</option>
}
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom yt-dlp Options</span>
<input type="text"
class="form-control"
placeholder='e.g. {"writesubtitles": true}'
name="ytdlOptionsOverrides"
[(ngModel)]="ytdlOptionsOverrides"
(change)="ytdlOptionsOverridesChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Optional per-download yt-dlp overrides as a JSON object">
</div>
</div>
<div class="col-12">
<div class="row g-2 align-items-center">
<div class="col-auto">
+57
View File
@@ -83,6 +83,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
chapterTemplate: string;
subtitleLanguage: string;
subtitleMode: string;
ytdlOptionsPreset: string;
ytdlOptionsOverrides: string;
ytdlOptionPresetNames: string[] = [];
addInProgress = false;
cancelRequested = false;
subscribeInProgress = false;
@@ -231,6 +234,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
this.ytdlOptionsPreset = this.cookieService.get('metube_ytdl_options_preset') || '';
this.ytdlOptionsOverrides = this.cookieService.get('metube_ytdl_options_overrides') || '';
const allowedDownloadTypes = new Set(this.downloadTypes.map(t => t.id));
const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id));
if (!allowedDownloadTypes.has(this.downloadType)) {
@@ -287,6 +292,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
this.getConfiguration();
this.getYtdlOptionsUpdateTime();
this.getYtdlOptionPresets();
this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme!);
@@ -415,6 +421,39 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
}
getYtdlOptionPresets() {
this.downloads.getPresets().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (data) => {
this.ytdlOptionPresetNames = Array.isArray(data?.presets)
? data.presets.filter((preset): preset is string => typeof preset === 'string')
: [];
if (this.ytdlOptionsPreset && !this.ytdlOptionPresetNames.includes(this.ytdlOptionsPreset)) {
this.ytdlOptionsPreset = '';
this.ytdlOptionsPresetChanged();
}
this.cdr.markForCheck();
},
});
}
private validateYtdlOptionsOverrides(value: string): boolean {
const trimmed = value?.trim() || '';
if (!trimmed) {
return true;
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
alert('Custom yt-dlp options must be a JSON object');
return false;
}
} catch {
alert('Custom yt-dlp options must be valid JSON');
return false;
}
return true;
}
private rebuildCachedSubs() {
this.cachedSubs = Array.from(this.subscriptionsSvc.subscriptions.entries());
const validIds = new Set(this.cachedSubs.map(([id]) => id));
@@ -491,6 +530,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return;
}
this.subscribeInProgress = true;
this.subscriptionsSvc
.subscribe({
@@ -695,6 +737,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.saveSelection(this.downloadType);
}
ytdlOptionsPresetChanged() {
this.cookieService.set('metube_ytdl_options_preset', this.ytdlOptionsPreset, { expires: this.settingsCookieExpiryDays });
}
ytdlOptionsOverridesChanged() {
this.cookieService.set('metube_ytdl_options_overrides', this.ytdlOptionsOverrides, { expires: this.settingsCookieExpiryDays });
}
isVideoType() {
return this.downloadType === 'video';
}
@@ -894,6 +944,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
ytdlOptionsPreset: overrides.ytdlOptionsPreset ?? this.ytdlOptionsPreset,
ytdlOptionsOverrides: overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides,
};
}
@@ -905,6 +957,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return;
}
console.debug('Downloading:', payload);
this.addInProgress = true;
@@ -960,6 +1015,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
chapterTemplate: download.chapter_template,
subtitleLanguage: download.subtitle_language,
subtitleMode: download.subtitle_mode,
ytdlOptionsPreset: download.ytdl_options_preset || '',
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
});
this.downloads.delById('done', [key]).subscribe();
}
+2
View File
@@ -14,6 +14,8 @@ export interface Download {
chapter_template?: string;
subtitle_language?: string;
subtitle_mode?: string;
ytdl_options_preset?: string;
ytdl_options_overrides?: Record<string, unknown>;
status: string;
msg: string;
percent: number;
@@ -39,6 +39,8 @@ function basePayload(): AddDownloadPayload {
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPreset: '',
ytdlOptionsOverrides: '',
};
}
@@ -79,11 +81,22 @@ describe('DownloadsService', () => {
chapter_template: '',
subtitle_language: 'en',
subtitle_mode: 'prefer_manual',
ytdl_options_preset: '',
ytdl_options_overrides: '',
}),
);
req.flush({ status: 'ok' });
});
it('getPresets() fetches configured preset names', () => {
service.getPresets().subscribe((result) => {
expect(result).toEqual({ presets: ['Preset A'] });
});
const req = httpMock.expectOne('presets');
expect(req.request.method).toBe('GET');
req.flush({ presets: ['Preset A'] });
});
it('cancelAdd posts to cancel-add', () => {
service.cancelAdd().subscribe();
const req = httpMock.expectOne('cancel-add');
+10
View File
@@ -20,6 +20,8 @@ export interface AddDownloadPayload {
chapterTemplate: string;
subtitleLanguage: string;
subtitleMode: string;
ytdlOptionsPreset: string;
ytdlOptionsOverrides: string;
}
@Injectable({
providedIn: 'root'
@@ -141,11 +143,19 @@ export class DownloadsService {
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
ytdl_options_preset: payload.ytdlOptionsPreset,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
}).pipe(
catchError(this.handleHTTPError)
);
}
public getPresets() {
return this.http.get<{ presets: string[] }>('presets').pipe(
catchError(() => of({ presets: [] }))
);
}
public startById(ids: string[]) {
return this.http.post('start', {ids: ids});
}
@@ -94,6 +94,8 @@ export class SubscriptionsService {
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
ytdl_options_preset: payload.ytdlOptionsPreset,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
check_interval_minutes: payload.checkIntervalMinutes,
})
.pipe(catchError((err) => this.handleHTTPError(err)));