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:
parent
2e859c88f3
commit
fdb096fa96
@ -480,6 +480,18 @@
|
||||
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
|
||||
</select>
|
||||
</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">
|
||||
<label id="performanceModeLabel">Performance-Profil</label>
|
||||
<select id="performanceMode">
|
||||
@ -505,6 +517,10 @@
|
||||
<input type="checkbox" id="autoResumeQueueToggle">
|
||||
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
|
||||
</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 class="form-group">
|
||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||
|
||||
74
src/main.ts
74
src/main.ts
@ -205,6 +205,8 @@ interface Config {
|
||||
parallel_downloads: number;
|
||||
auto_resume_queue_on_startup: boolean;
|
||||
downloaded_vod_ids: string[];
|
||||
streamlink_quality: string;
|
||||
notify_on_each_completion: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeMetrics {
|
||||
@ -318,9 +320,32 @@ const defaultConfig: Config = {
|
||||
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
|
||||
parallel_downloads: 1,
|
||||
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 {
|
||||
const value = (template || '').trim();
|
||||
return value || fallback;
|
||||
@ -364,7 +389,9 @@ function normalizeConfigTemplates(input: Config): Config {
|
||||
persist_queue_on_restart: input.persist_queue_on_restart !== false,
|
||||
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
const streamlinkCmd = getStreamlinkCommand();
|
||||
const args = [...streamlinkCmd.prefixArgs, url, 'best', '-o', filename, '--force'];
|
||||
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force'];
|
||||
let lastErrorLine = '';
|
||||
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
||||
let lastStreamlinkPercent = 0;
|
||||
@ -3311,6 +3338,40 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
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) {
|
||||
// Record the VOD ID so the renderer can mark this VOD as
|
||||
// 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, [
|
||||
...streamlinkCmd.prefixArgs,
|
||||
`https://clips.twitch.tv/${clipId}`,
|
||||
'best',
|
||||
getStreamlinkStreamArg(),
|
||||
'-o', filename,
|
||||
'--force'
|
||||
], { windowsHide: true });
|
||||
@ -4298,6 +4359,11 @@ ipcMain.handle('open-debug-log-file', (): boolean => {
|
||||
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('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
|
||||
|
||||
@ -87,6 +87,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
openFile: (path: string) => ipcRenderer.invoke('open-file', path),
|
||||
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
|
||||
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
|
||||
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
||||
|
||||
// Video Cutter
|
||||
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
|
||||
3
src/renderer-globals.d.ts
vendored
3
src/renderer-globals.d.ts
vendored
@ -18,6 +18,8 @@ interface AppConfig {
|
||||
parallel_downloads?: number;
|
||||
auto_resume_queue_on_startup?: boolean;
|
||||
downloaded_vod_ids?: string[];
|
||||
streamlink_quality?: string;
|
||||
notify_on_each_completion?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@ -204,6 +206,7 @@ interface ApiBridge {
|
||||
openFile(path: string): Promise<boolean>;
|
||||
showInFolder(path: string): Promise<boolean>;
|
||||
openDebugLogFile(): Promise<boolean>;
|
||||
checkFolderWritable(path: string): Promise<boolean>;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
|
||||
@ -69,6 +69,14 @@ const UI_TEXT_DE = {
|
||||
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
||||
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.',
|
||||
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',
|
||||
streamerListFilterPlaceholder: 'Filtern...',
|
||||
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
|
||||
|
||||
@ -69,6 +69,14 @@ const UI_TEXT_EN = {
|
||||
persistQueueLabel: 'Keep queue between app restarts',
|
||||
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.',
|
||||
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',
|
||||
streamerListFilterPlaceholder: 'Filter...',
|
||||
streamerBulkRemoveTitle: 'Remove all (or filtered)',
|
||||
|
||||
@ -387,6 +387,8 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
|
||||
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').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
|
||||
};
|
||||
}
|
||||
@ -430,6 +432,8 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
effective.prevent_duplicate_downloads !== false,
|
||||
effective.persist_queue_on_restart !== false,
|
||||
effective.auto_resume_queue_on_startup === true,
|
||||
effective.notify_on_each_completion === true,
|
||||
effective.streamlink_quality ?? 'best',
|
||||
effective.metadata_cache_minutes ?? 10,
|
||||
effective.filename_template_vod ?? '{title}.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>('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>('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>('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';
|
||||
@ -559,7 +565,9 @@ function initSettingsAutoSave(): void {
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
'persistQueueToggle',
|
||||
'autoResumeQueueToggle'
|
||||
'autoResumeQueueToggle',
|
||||
'notifyEachCompletionToggle',
|
||||
'streamlinkQuality'
|
||||
] as const;
|
||||
|
||||
const debouncedSaveIds = [
|
||||
@ -643,6 +651,18 @@ async function selectFolder(): Promise<void> {
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = 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 {
|
||||
|
||||
@ -117,6 +117,15 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
|
||||
setTitle('autoResumeQueueLabel', 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);
|
||||
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user