diff --git a/src/index.html b/src/index.html index 1a46749..2f2cac6 100644 --- a/src/index.html +++ b/src/index.html @@ -480,6 +480,18 @@ +
+ + +
Queue beim Start automatisch fortsetzen +
diff --git a/src/main.ts b/src/main.ts index f1efe26..e883004 100644 --- a/src/main.ts +++ b/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 { 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 { 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()); diff --git a/src/preload.ts b/src/preload.ts index 7446c92..0700350 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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 => ipcRenderer.invoke('get-video-info', filePath), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 5124f42..23e1bd3 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -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; showInFolder(path: string): Promise; openDebugLogFile(): Promise; + checkFolderWritable(path: string): Promise; getVideoInfo(filePath: string): Promise; extractFrame(filePath: string, timeSeconds: number): Promise; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 504c346..42beee3 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -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)', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index ddae00c..71444e2 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -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)', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 286da45..a188ebc 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -387,6 +387,8 @@ function collectDownloadSettingsPayload(): Partial { prevent_duplicate_downloads: byId('duplicatePreventionToggle').checked, persist_queue_on_restart: byId('persistQueueToggle').checked, auto_resume_queue_on_startup: byId('autoResumeQueueToggle').checked, + notify_on_each_completion: byId('notifyEachCompletionToggle').checked, + streamlink_quality: byId('streamlinkQuality').value, metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 }; } @@ -430,6 +432,8 @@ function getSettingsFingerprint(payload: Partial): 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('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false; byId('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true; + byId('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true; + byId('streamlinkQuality').value = (config.streamlink_quality as string) || 'best'; byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; byId('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 { byId('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 { diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index f65d90d..a11e989 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -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);