feat: streamlink quality preference + per-item notifications + path validation

Three Phase-11 wins.

1. Streamlink stream quality is now configurable. config.streamlink_quality
   defaults to "best" (preserves prior behaviour) but can be set to source,
   1080p60, 720p60, 720p, 480p, or audio_only via a new dropdown in
   Settings -> Download. The chosen quality is passed as STREAMS to
   streamlink with ",best" appended as a fallback so an old VOD lacking
   the chosen rendition still completes. Used by both the queue
   downloadVODPart and the standalone download-clip IPC. The whitelist is
   enforced via normalizeStreamlinkQuality so an arbitrary string in the
   config file falls back to "best".

2. Per-item completion notifications. Default off because long queues
   would spam the OS notifications panel. When enabled (Settings ->
   Queue zwischen App-Starts checkbox area), every successful download
   pops a "{title}" notification whose click brings the window forward
   AND opens shell.showItemInFolder on the produced file (or the
   download folder if the file is gone). The end-of-queue summary
   notification still fires regardless.

3. Download-path writability check on selectFolder. The renderer now
   asks the new check-folder-writable IPC after the user picks a
   folder; if isDownloadPathWritable returns false, a warning toast
   surfaces immediately instead of the next download failing with a
   cryptic "datei zu klein" / "ENOENT" error. Save proceeds anyway —
   the user might be picking a USB-stick path that is offline at the
   moment.

Plus DE + EN locale strings for every label/option/hint, all wired
through applyLanguageToStaticUI for live language switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 20:00:36 +02:00
parent 2e859c88f3
commit fdb096fa96
8 changed files with 136 additions and 5 deletions

View File

@ -480,6 +480,18 @@
<option value="2" id="parallelDownloads2">2 (Parallel)</option> <option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select> </select>
</div> </div>
<div class="form-group">
<label id="streamlinkQualityLabel">Stream-Qualitaet</label>
<select id="streamlinkQuality">
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
<option value="720p60" id="streamlinkQuality720p60">720p60</option>
<option value="720p" id="streamlinkQuality720p">720p</option>
<option value="480p" id="streamlinkQuality480p">480p</option>
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label id="performanceModeLabel">Performance-Profil</label> <label id="performanceModeLabel">Performance-Profil</label>
<select id="performanceMode"> <select id="performanceMode">
@ -505,6 +517,10 @@
<input type="checkbox" id="autoResumeQueueToggle"> <input type="checkbox" id="autoResumeQueueToggle">
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span> <span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
</label> </label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="notifyEachCompletionToggle">
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label> <label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>

View File

@ -205,6 +205,8 @@ interface Config {
parallel_downloads: number; parallel_downloads: number;
auto_resume_queue_on_startup: boolean; auto_resume_queue_on_startup: boolean;
downloaded_vod_ids: string[]; downloaded_vod_ids: string[];
streamlink_quality: string;
notify_on_each_completion: boolean;
} }
interface RuntimeMetrics { interface RuntimeMetrics {
@ -318,9 +320,32 @@ const defaultConfig: Config = {
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES, metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
parallel_downloads: 1, parallel_downloads: 1,
auto_resume_queue_on_startup: false, auto_resume_queue_on_startup: false,
downloaded_vod_ids: [] downloaded_vod_ids: [],
streamlink_quality: 'best',
notify_on_each_completion: false
}; };
// Whitelist of streamlink stream specifiers we surface in Settings. The
// user's choice is passed to streamlink with "best" appended as a fallback
// (streamlink supports comma-separated stream lists, picks the first match)
// so a missing quality on the source stream still produces a download.
const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
function normalizeStreamlinkQuality(value: unknown): string {
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
return value;
}
return 'best';
}
function getStreamlinkStreamArg(): string {
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
if (choice === 'best') return 'best';
// Fall back to "best" if the chosen rendition isn't offered (e.g. an
// older stream archived before that resolution existed).
return `${choice},best`;
}
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string { function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
const value = (template || '').trim(); const value = (template || '').trim();
return value || fallback; return value || fallback;
@ -364,7 +389,9 @@ function normalizeConfigTemplates(input: Config): Config {
persist_queue_on_restart: input.persist_queue_on_restart !== false, persist_queue_on_restart: input.persist_queue_on_restart !== false,
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes), metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true, auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
downloaded_vod_ids: trimmedIds downloaded_vod_ids: trimmedIds,
streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality),
notify_on_each_completion: input.notify_on_each_completion === true
}; };
} }
@ -2576,7 +2603,7 @@ function downloadVODPart(
): Promise<DownloadResult> { ): Promise<DownloadResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const streamlinkCmd = getStreamlinkCommand(); const streamlinkCmd = getStreamlinkCommand();
const args = [...streamlinkCmd.prefixArgs, url, 'best', '-o', filename, '--force']; const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force'];
let lastErrorLine = ''; let lastErrorLine = '';
const expectedDurationSeconds = parseClockDurationSeconds(endTime); const expectedDurationSeconds = parseClockDurationSeconds(endTime);
let lastStreamlinkPercent = 0; let lastStreamlinkPercent = 0;
@ -3311,6 +3338,40 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
item.outputFiles = [...finalResult.outputFiles]; item.outputFiles = [...finalResult.outputFiles];
} }
// Per-VOD completion notification (separate from the queue-end
// notification fired at the end of processQueue). Off by default
// because users with long queues would get spammed.
if (finalResult.success && config.notify_on_each_completion) {
try {
if (Notification.isSupported()) {
const itemNotification = new Notification({
title: 'Twitch VOD Manager',
body: `${item.title || item.url}`
});
const firstFile = item.outputFiles?.[0];
itemNotification.on('click', () => {
try {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// Click on a per-item notification opens the
// file directly when we know it; falls back to
// the download folder otherwise.
if (firstFile && fs.existsSync(firstFile)) {
shell.showItemInFolder(firstFile);
} else if (config.download_path && fs.existsSync(config.download_path)) {
void shell.openPath(config.download_path);
}
} catch (e) {
appendDebugLog('per-item-notification-click-failed', String(e));
}
});
itemNotification.show();
}
} catch { /* notifications optional */ }
}
if (finalResult.success) { if (finalResult.success) {
// Record the VOD ID so the renderer can mark this VOD as // Record the VOD ID so the renderer can mark this VOD as
// already-downloaded the next time the user browses the // already-downloaded the next time the user browses the
@ -4232,7 +4293,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
const proc = spawn(streamlinkCmd.command, [ const proc = spawn(streamlinkCmd.command, [
...streamlinkCmd.prefixArgs, ...streamlinkCmd.prefixArgs,
`https://clips.twitch.tv/${clipId}`, `https://clips.twitch.tv/${clipId}`,
'best', getStreamlinkStreamArg(),
'-o', filename, '-o', filename,
'--force' '--force'
], { windowsHide: true }); ], { windowsHide: true });
@ -4298,6 +4359,11 @@ ipcMain.handle('open-debug-log-file', (): boolean => {
return true; return true;
}); });
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false;
return isDownloadPathWritable(folderPath);
});
ipcMain.handle('is-downloading', () => isDownloading); ipcMain.handle('is-downloading', () => isDownloading);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());

View File

@ -87,6 +87,7 @@ contextBridge.exposeInMainWorld('api', {
openFile: (path: string) => ipcRenderer.invoke('open-file', path), openFile: (path: string) => ipcRenderer.invoke('open-file', path),
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path), showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'), openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
// Video Cutter // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),

View File

@ -18,6 +18,8 @@ interface AppConfig {
parallel_downloads?: number; parallel_downloads?: number;
auto_resume_queue_on_startup?: boolean; auto_resume_queue_on_startup?: boolean;
downloaded_vod_ids?: string[]; downloaded_vod_ids?: string[];
streamlink_quality?: string;
notify_on_each_completion?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }
@ -204,6 +206,7 @@ interface ApiBridge {
openFile(path: string): Promise<boolean>; openFile(path: string): Promise<boolean>;
showInFolder(path: string): Promise<boolean>; showInFolder(path: string): Promise<boolean>;
openDebugLogFile(): Promise<boolean>; openDebugLogFile(): Promise<boolean>;
checkFolderWritable(path: string): Promise<boolean>;
getVideoInfo(filePath: string): Promise<VideoInfo | null>; getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;

View File

@ -69,6 +69,14 @@ const UI_TEXT_DE = {
persistQueueLabel: 'Queue zwischen App-Starts speichern', persistQueueLabel: 'Queue zwischen App-Starts speichern',
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen', autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.', autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
streamlinkQualityLabel: 'Stream-Qualitaet',
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
streamlinkQualityBest: 'Best (Standard)',
streamlinkQualitySource: 'Source (Original)',
streamlinkQualityAudio: 'Nur Audio',
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
streamerSectionTitle: 'Streamer', streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...', streamerListFilterPlaceholder: 'Filtern...',
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)', streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',

View File

@ -69,6 +69,14 @@ const UI_TEXT_EN = {
persistQueueLabel: 'Keep queue between app restarts', persistQueueLabel: 'Keep queue between app restarts',
autoResumeQueueLabel: 'Auto-resume the queue on startup', autoResumeQueueLabel: 'Auto-resume the queue on startup',
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.', autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
notifyEachCompletionLabel: 'Notify on every completed download',
notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.',
streamlinkQualityLabel: 'Stream quality',
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
streamlinkQualityBest: 'Best (default)',
streamlinkQualitySource: 'Source (original)',
streamlinkQualityAudio: 'Audio only',
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
streamerSectionTitle: 'Streamer', streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filter...', streamerListFilterPlaceholder: 'Filter...',
streamerBulkRemoveTitle: 'Remove all (or filtered)', streamerBulkRemoveTitle: 'Remove all (or filtered)',

View File

@ -387,6 +387,8 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked, prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked, persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked, auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10 metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
}; };
} }
@ -430,6 +432,8 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.prevent_duplicate_downloads !== false, effective.prevent_duplicate_downloads !== false,
effective.persist_queue_on_restart !== false, effective.persist_queue_on_restart !== false,
effective.auto_resume_queue_on_startup === true, effective.auto_resume_queue_on_startup === true,
effective.notify_on_each_completion === true,
effective.streamlink_quality ?? 'best',
effective.metadata_cache_minutes ?? 10, effective.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_vod ?? '{title}.mp4',
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
@ -448,6 +452,8 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false; byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true; byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
@ -559,7 +565,9 @@ function initSettingsAutoSave(): void {
'smartSchedulerToggle', 'smartSchedulerToggle',
'duplicatePreventionToggle', 'duplicatePreventionToggle',
'persistQueueToggle', 'persistQueueToggle',
'autoResumeQueueToggle' 'autoResumeQueueToggle',
'notifyEachCompletionToggle',
'streamlinkQuality'
] as const; ] as const;
const debouncedSaveIds = [ const debouncedSaveIds = [
@ -643,6 +651,18 @@ async function selectFolder(): Promise<void> {
byId<HTMLInputElement>('downloadPath').value = folder; byId<HTMLInputElement>('downloadPath').value = folder;
config = await window.api.saveConfig({ download_path: folder }); config = await window.api.saveConfig({ download_path: folder });
// Warn-only validation — the user explicitly chose this folder, so don't
// refuse to save (they might be picking a path on a USB stick that's
// currently disconnected). Just surface the writability problem early
// instead of letting the next download fail with a cryptic error.
try {
const writable = await window.api.checkFolderWritable(folder);
if (!writable) {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
}
} catch { /* ignore — preflight will catch it later */ }
} }
function openFolder(): void { function openFolder(): void {

View File

@ -117,6 +117,15 @@ function applyLanguageToStaticUI(): void {
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel); setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint); setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint); setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel);
setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint);
setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint);
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle); setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder); setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle); setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);