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>
|
<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>
|
||||||
|
|||||||
74
src/main.ts
74
src/main.ts
@ -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());
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
3
src/renderer-globals.d.ts
vendored
3
src/renderer-globals.d.ts
vendored
@ -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 }>;
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user