diff --git a/src/index.html b/src/index.html index 8b6eaae..5b6eeea 100644 --- a/src/index.html +++ b/src/index.html @@ -604,6 +604,39 @@

Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.

+ +
+

Auto-Cleanup

+

Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.

+ +
+ + + +
+
+ + +
+
diff --git a/src/main.ts b/src/main.ts index ab9c86d..d8f90b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -221,6 +221,10 @@ interface Config { discord_notify_live_start: boolean; discord_notify_live_end: boolean; discord_notify_vod_complete: boolean; + auto_cleanup_enabled: boolean; + auto_cleanup_days: number; + auto_cleanup_target: 'live_only' | 'all'; + auto_cleanup_action: 'delete' | 'archive'; } interface RuntimeMetrics { @@ -345,7 +349,11 @@ const defaultConfig: Config = { discord_webhook_url: '', discord_notify_live_start: false, discord_notify_live_end: false, - discord_notify_vod_complete: false + discord_notify_vod_complete: false, + auto_cleanup_enabled: false, + auto_cleanup_days: 30, + auto_cleanup_target: 'live_only', + auto_cleanup_action: 'archive' }; const AUTO_RECORD_POLL_MIN_SECONDS = 30; @@ -451,7 +459,15 @@ function normalizeConfigTemplates(input: Config): Config { discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '', discord_notify_live_start: input.discord_notify_live_start === true, discord_notify_live_end: input.discord_notify_live_end === true, - discord_notify_vod_complete: input.discord_notify_vod_complete === true + discord_notify_vod_complete: input.discord_notify_vod_complete === true, + auto_cleanup_enabled: input.auto_cleanup_enabled === true, + auto_cleanup_days: (() => { + const n = Number(input.auto_cleanup_days); + if (!Number.isFinite(n) || n < 1) return 30; + return Math.min(3650, Math.floor(n)); + })(), + auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only', + auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive' }; } @@ -3090,6 +3106,233 @@ function chatReplayPathFor(vodFilePath: string): string { return `${base}.chat.json`; } +// ========================================== +// AUTO-CLEANUP +// ========================================== +// Targets old recording artifacts (.mp4/.ts/.mkv plus their sibling +// .chat.json/.chat.jsonl) older than auto_cleanup_days. Two scopes — +// live_only (only files inside a streamer/live/ subfolder, set-and- +// forget for auto-record users) or all (everything under the streamer +// folders). Two actions — delete or archive (move to a parallel +// archived/{streamer}/{YYYY-MM}/ tree). Archive is the safer default. +// Sibling chat files travel with the video so we don't end up with +// an orphan transcript. +interface CleanupCandidate { + videoPath: string; + sidecarPaths: string[]; + streamer: string; + bytes: number; + ageDays: number; +} +interface CleanupReport { + enabled: boolean; + dryRun: boolean; + cutoffDays: number; + target: 'live_only' | 'all'; + action: 'delete' | 'archive'; + scannedAt: string; + candidates: number; + processed: number; + failed: number; + bytesFreed: number; + failures: Array<{ path: string; error: string }>; +} + +const VIDEO_FILE_REGEX = /\.(mp4|ts|mkv|mov|avi)$/i; + +function findCleanupCandidates(cutoffDays: number, target: 'live_only' | 'all'): CleanupCandidate[] { + const out: CleanupCandidate[] = []; + const root = config.download_path; + if (!root || !fs.existsSync(root)) return out; + const cutoffMs = Date.now() - cutoffDays * 24 * 60 * 60 * 1000; + const knownStreamers = new Set(((config.streamers as string[]) || []).map((s) => s.toLowerCase())); + + let topEntries: fs.Dirent[]; + try { + topEntries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return out; + } + + const visit = (dir: string, streamer: string, mustBeUnderLive: boolean): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Never walk back into the archived/ tree we own. + if (entry.name === 'archived') continue; + const enteringLive = entry.name === 'live'; + visit(full, streamer, mustBeUnderLive && !enteringLive); + continue; + } + if (!entry.isFile()) continue; + if (!VIDEO_FILE_REGEX.test(entry.name)) continue; + if (mustBeUnderLive) continue; // live_only mode + we're not under live/ + + let stat: fs.Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (stat.mtimeMs > cutoffMs) continue; + + // Find sibling chat files (same basename, .chat.json / .chat.jsonl) + const ext = path.extname(full); + const base = ext ? full.slice(0, -ext.length) : full; + const sidecars: string[] = []; + for (const sidecarExt of ['.chat.json', '.chat.jsonl']) { + const candidate = base + sidecarExt; + if (fs.existsSync(candidate)) sidecars.push(candidate); + } + + out.push({ + videoPath: full, + sidecarPaths: sidecars, + streamer, + bytes: stat.size, + ageDays: Math.floor((Date.now() - stat.mtimeMs) / (24 * 60 * 60 * 1000)) + }); + } + }; + + for (const top of topEntries) { + if (!top.isDirectory()) continue; + if (top.name === 'archived') continue; // never recurse into the archive tree + const lowered = top.name.toLowerCase(); + const isKnown = knownStreamers.has(lowered) || top.name === 'Clips'; + if (!isKnown) continue; + const folderPath = path.join(root, top.name); + // For live_only mode, we descend with mustBeUnderLive=true; the + // visit() call flips it to false the moment we enter a "live" + // subfolder. For "all" mode, mustBeUnderLive is false from the + // top so every video matches. + visit(folderPath, top.name, target === 'live_only'); + } + + return out; +} + +function archivePathForCleanup(streamer: string, originalPath: string, mtimeMs: number): string { + const root = config.download_path; + const date = new Date(mtimeMs); + const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; + const dir = path.join(root, 'archived', streamer, monthKey); + fs.mkdirSync(dir, { recursive: true }); + return ensureUniqueFilename(path.join(dir, path.basename(originalPath)), null); +} + +function runStorageCleanup(opts: { dryRun: boolean }): CleanupReport { + const report: CleanupReport = { + enabled: config.auto_cleanup_enabled === true, + dryRun: opts.dryRun, + cutoffDays: Number(config.auto_cleanup_days) || 30, + target: config.auto_cleanup_target === 'all' ? 'all' : 'live_only', + action: config.auto_cleanup_action === 'delete' ? 'delete' : 'archive', + scannedAt: new Date().toISOString(), + candidates: 0, + processed: 0, + failed: 0, + bytesFreed: 0, + failures: [] + }; + + const candidates = findCleanupCandidates(report.cutoffDays, report.target); + report.candidates = candidates.length; + if (opts.dryRun) { + for (const c of candidates) { + report.bytesFreed += c.bytes; + for (const sc of c.sidecarPaths) { + try { report.bytesFreed += fs.statSync(sc).size; } catch { /* ignore */ } + } + } + appendDebugLog('storage-cleanup-dry-run', { candidates: report.candidates, bytes: report.bytesFreed }); + return report; + } + + for (const c of candidates) { + const allPaths = [c.videoPath, ...c.sidecarPaths]; + try { + if (report.action === 'delete') { + for (const p of allPaths) { + let bytes = 0; + try { bytes = fs.statSync(p).size; } catch { /* ignore */ } + fs.unlinkSync(p); + report.bytesFreed += bytes; + } + } else { + // Archive: keep the same basename, group by streamer + month. + const stat = fs.statSync(c.videoPath); + const archived = archivePathForCleanup(c.streamer, c.videoPath, stat.mtimeMs); + fs.renameSync(c.videoPath, archived); + report.bytesFreed += stat.size; + // Move sidecars to the same archive folder. + const archDir = path.dirname(archived); + for (const sc of c.sidecarPaths) { + try { + const dest = ensureUniqueFilename(path.join(archDir, path.basename(sc)), null); + fs.renameSync(sc, dest); + } catch (err) { + report.failures.push({ path: sc, error: String(err) }); + } + } + } + report.processed += 1; + } catch (err) { + report.failed += 1; + report.failures.push({ path: c.videoPath, error: String(err) }); + } + } + + appendDebugLog('storage-cleanup-run', { + candidates: report.candidates, + processed: report.processed, + failed: report.failed, + bytes: report.bytesFreed, + action: report.action, + target: report.target + }); + return report; +} + +let autoCleanupTimer: NodeJS.Timeout | null = null; +let lastAutoCleanupAt = 0; + +function stopAutoCleanupTimer(): void { + if (autoCleanupTimer) { + clearInterval(autoCleanupTimer); + autoCleanupTimer = null; + } +} + +function restartAutoCleanupTimer(): void { + stopAutoCleanupTimer(); + if (!config.auto_cleanup_enabled) return; + // Run every 6 hours while the app is running. Skip the first cycle if + // the previous run was less than 6h ago to avoid hammering on every + // settings save. + const SIX_HOURS_MS = 6 * 60 * 60 * 1000; + autoCleanupTimer = setInterval(() => { + if (Date.now() - lastAutoCleanupAt < SIX_HOURS_MS) return; + lastAutoCleanupAt = Date.now(); + try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } + }, SIX_HOURS_MS); + autoCleanupTimer.unref?.(); + + // First run is delayed 60s so it doesn't compete with startup IO. + setTimeout(() => { + if (!config.auto_cleanup_enabled) return; + if (Date.now() - lastAutoCleanupAt < 60 * 1000) return; + lastAutoCleanupAt = Date.now(); + try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } + }, 60 * 1000); +} + // ========================================== // STORAGE STATS // ========================================== @@ -4753,6 +4996,10 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => { restartAutoRecordPoller(); } + // Restart cleanup timer when the toggle flips; harmless to call when + // unchanged because restartAutoCleanupTimer just resets the interval. + restartAutoCleanupTimer(); + return config; }); @@ -5264,6 +5511,10 @@ ipcMain.handle('get-storage-stats', (): StorageStatsResult => { return computeStorageStats(); }); +ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): CleanupReport => { + return runStorageCleanup({ dryRun: options?.dryRun === true }); +}); + ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => { if (typeof folderPath !== 'string' || !folderPath) return false; return isDownloadPathWritable(folderPath); @@ -5456,6 +5707,7 @@ app.whenReady().then(() => { startMetadataCacheCleanup(); startDebugLogFlushTimer(); restartAutoRecordPoller(); + restartAutoCleanupTimer(); createWindow(); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); @@ -5483,6 +5735,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void { cleanupMetadataCaches('shutdown'); stopAutoUpdatePolling(); stopAutoRecordPoller(); + stopAutoCleanupTimer(); // Kill all active children: queue downloads, standalone clip downloads, // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to diff --git a/src/preload.ts b/src/preload.ts index b0e4a87..30fcfbc 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -90,6 +90,7 @@ contextBridge.exposeInMainWorld('api', { openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'), checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), + runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), // 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 3e65486..ef49aaa 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -29,6 +29,10 @@ interface AppConfig { discord_notify_live_start?: boolean; discord_notify_live_end?: boolean; discord_notify_vod_complete?: boolean; + auto_cleanup_enabled?: boolean; + auto_cleanup_days?: number; + auto_cleanup_target?: 'live_only' | 'all'; + auto_cleanup_action?: 'delete' | 'archive'; [key: string]: unknown; } @@ -197,6 +201,19 @@ interface StreamerStorageEntry { chatBytes: number; folderPath: string; } +interface CleanupReport { + enabled: boolean; + dryRun: boolean; + cutoffDays: number; + target: 'live_only' | 'all'; + action: 'delete' | 'archive'; + scannedAt: string; + candidates: number; + processed: number; + failed: number; + bytesFreed: number; + failures: Array<{ path: string; error: string }>; +} interface StorageStatsResult { downloadPath: string; rootExists: boolean; @@ -238,6 +255,7 @@ interface ApiBridge { openDebugLogFile(): Promise; checkFolderWritable(path: string): Promise; getStorageStats(): Promise; + runStorageCleanup(options?: { dryRun?: boolean }): 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 827a404..3ac62b9 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -67,6 +67,22 @@ const UI_TEXT_DE = { storageColumnChat: 'Chat', storageOpen: 'Oeffnen', storageOtherFolders: 'Andere Ordner im Download-Pfad', + cleanupTitle: 'Auto-Cleanup', + cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.', + cleanupEnabledLabel: 'Auto-Cleanup aktivieren', + cleanupDaysLabel: 'Tage-Schwelle', + cleanupTargetLabel: 'Bereich', + cleanupTargetLive: 'Nur Live-Aufnahmen', + cleanupTargetAll: 'Alle Aufnahmen', + cleanupActionLabel: 'Aktion', + cleanupActionArchive: 'In Archiv verschieben', + cleanupActionDelete: 'Loeschen', + cleanupDryRun: 'Vorschau', + cleanupRunNow: 'Jetzt ausfuehren', + cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.', + cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}', + cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.', + cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.', discordCardTitle: 'Discord-Webhook', discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.', discordWebhookUrlLabel: 'Webhook-URL', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 61bbb6e..084ca63 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -67,6 +67,22 @@ const UI_TEXT_EN = { storageColumnChat: 'Chat', storageOpen: 'Open', storageOtherFolders: 'Other folders in download path', + cleanupTitle: 'Auto-cleanup', + cleanupIntro: 'Move recordings older than N days to an archive folder, or delete them outright. Sibling chat files (.chat.json/.chat.jsonl) travel with the video.', + cleanupEnabledLabel: 'Enable auto-cleanup', + cleanupDaysLabel: 'Age threshold (days)', + cleanupTargetLabel: 'Scope', + cleanupTargetLive: 'Live recordings only', + cleanupTargetAll: 'All recordings', + cleanupActionLabel: 'Action', + cleanupActionArchive: 'Move to archive folder', + cleanupActionDelete: 'Delete', + cleanupDryRun: 'Preview', + cleanupRunNow: 'Run now', + cleanupReportPreview: 'Would touch {count} files (~{size}). No files have been moved or deleted.', + cleanupReportDone: 'Processed {count} files, freed ~{size}.{failed}', + cleanupReportFailedSuffix: ' {failed} failed.', + cleanupReportEmpty: 'No recordings older than {days} days found.', discordCardTitle: 'Discord webhook', discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.', discordWebhookUrlLabel: 'Webhook URL', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index edf3347..6f6ba43 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -265,6 +265,49 @@ async function runPreflight(autoFix = false): Promise { } } +async function runCleanupDryRun(): Promise { + await runCleanupOnce(true); +} + +async function runCleanupNow(): Promise { + await runCleanupOnce(false); +} + +async function runCleanupOnce(dryRun: boolean): Promise { + const reportEl = byId('cleanupReport'); + const dryBtn = byId('btnCleanupDryRun'); + const runBtn = byId('btnCleanupRunNow'); + dryBtn.disabled = true; + runBtn.disabled = true; + reportEl.textContent = UI_TEXT.static.storageScanning; + + try { + const report = await window.api.runStorageCleanup({ dryRun }); + if (report.candidates === 0) { + reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays)); + } else if (dryRun) { + reportEl.textContent = UI_TEXT.static.cleanupReportPreview + .replace('{count}', String(report.candidates)) + .replace('{size}', formatBytesForMetrics(report.bytesFreed)); + } else { + const failedSuffix = report.failed > 0 + ? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed)) + : ''; + reportEl.textContent = UI_TEXT.static.cleanupReportDone + .replace('{count}', String(report.processed)) + .replace('{size}', formatBytesForMetrics(report.bytesFreed)) + .replace('{failed}', failedSuffix); + // Refresh the storage list since files moved/disappeared. + void refreshStorageStats(); + } + } catch (e) { + reportEl.textContent = String(e); + } finally { + dryBtn.disabled = false; + runBtn.disabled = false; + } +} + async function refreshStorageStats(): Promise { const summary = byId('storageSummary'); const list = byId('storageList'); @@ -514,6 +557,10 @@ function collectDownloadSettingsPayload(): Partial { discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, discord_notify_vod_complete: byId('discordNotifyVodCompleteToggle').checked, + auto_cleanup_enabled: byId('autoCleanupEnabledToggle').checked, + auto_cleanup_days: parseInt(byId('autoCleanupDays').value, 10) || 30, + auto_cleanup_target: byId('autoCleanupTarget').value === 'all' ? 'all' : 'live_only', + auto_cleanup_action: byId('autoCleanupAction').value === 'delete' ? 'delete' : 'archive', streamlink_quality: byId('streamlinkQuality').value, metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 }; @@ -566,6 +613,10 @@ function getSettingsFingerprint(payload: Partial): string { effective.discord_notify_live_start === true, effective.discord_notify_live_end === true, effective.discord_notify_vod_complete === true, + effective.auto_cleanup_enabled === true, + effective.auto_cleanup_days ?? 30, + effective.auto_cleanup_target ?? 'live_only', + effective.auto_cleanup_action ?? 'archive', effective.streamlink_quality ?? 'best', effective.metadata_cache_minutes ?? 10, effective.filename_template_vod ?? '{title}.mp4', @@ -593,6 +644,10 @@ function syncSettingsFormFromConfig(): void { byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; byId('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true; + byId('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true; + byId('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30); + byId('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only'; + byId('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive'; 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'; @@ -713,6 +768,9 @@ function initSettingsAutoSave(): void { 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', + 'autoCleanupEnabledToggle', + 'autoCleanupTarget', + 'autoCleanupAction', 'streamlinkQuality' ] as const; @@ -722,7 +780,8 @@ function initSettingsAutoSave(): void { 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate', - 'discordWebhookUrl' + 'discordWebhookUrl', + 'autoCleanupDays' ] as const; const credentialIds = [ diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 2a0f6ed..6a173d5 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -176,6 +176,18 @@ function applyLanguageToStaticUI(): void { setText('storageCardTitle', UI_TEXT.static.storageCardTitle); setText('storageCardIntro', UI_TEXT.static.storageCardIntro); setText('btnRefreshStorage', UI_TEXT.static.storageRefresh); + setText('cleanupTitle', UI_TEXT.static.cleanupTitle); + setText('cleanupIntro', UI_TEXT.static.cleanupIntro); + setText('autoCleanupEnabledLabel', UI_TEXT.static.cleanupEnabledLabel); + setText('autoCleanupDaysLabel', UI_TEXT.static.cleanupDaysLabel); + setText('autoCleanupTargetLabel', UI_TEXT.static.cleanupTargetLabel); + setText('autoCleanupTargetLive', UI_TEXT.static.cleanupTargetLive); + setText('autoCleanupTargetAll', UI_TEXT.static.cleanupTargetAll); + setText('autoCleanupActionLabel', UI_TEXT.static.cleanupActionLabel); + setText('autoCleanupActionArchive', UI_TEXT.static.cleanupActionArchive); + setText('autoCleanupActionDelete', UI_TEXT.static.cleanupActionDelete); + setText('btnCleanupDryRun', UI_TEXT.static.cleanupDryRun); + setText('btnCleanupRunNow', UI_TEXT.static.cleanupRunNow); setText('discordCardTitle', UI_TEXT.static.discordCardTitle); setText('discordCardIntro', UI_TEXT.static.discordCardIntro); setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);