feat: auto-cleanup — archive or delete old recordings, keep disk under control

Closes the Storage-Management loop. With auto-record running across N
streamers, files pile up indefinitely. Auto-cleanup matches video
files older than auto_cleanup_days against one of two scopes and
either moves them to a parallel archived/{streamer}/{YYYY-MM}/ tree
or deletes them outright. Sidecar .chat.json/.chat.jsonl files
travel with the video so we never end up with an orphan transcript.

Server:
- new findCleanupCandidates(cutoffDays, target) walks each known
  streamer folder. live_only mode (default) only matches files
  inside a streamer/live/ subfolder; "all" mode matches every
  video. Files matched by mtime against the cutoff. Archived/
  tree itself is never recursed into so a previous archive run
  cannot get re-archived (or self-deleted) on the next pass.
- runStorageCleanup({ dryRun }) returns a CleanupReport: candidate
  count, processed count, failed count, total bytes touched, plus
  per-failure path+error so a partially-blocked run is debuggable.
  Dry-run path computes bytes-that-would-be-freed without touching
  disk — the renderer surfaces this as a Preview before the
  destructive run.
- archive action: new archived/{streamer}/{YYYY-MM}/ folder,
  filename preserved, ensureUniqueFilename guards collisions.
  delete action: fs.unlinkSync the video and every sidecar.
- Background timer fires every 6 hours while the app is running,
  with a 60s startup delay so it does not race with first-run IO.
  Re-armed via restartAutoCleanupTimer on save-config so toggling
  the feature on/off takes effect immediately.

Renderer:
- Storage settings card extended with the Auto-Cleanup section:
  enable toggle, days threshold, scope (live_only/all), action
  (archive/delete), Preview + Run-now buttons. Preview is
  destructive-action insurance — user can see "would touch N
  files" before pressing Run.
- After a destructive run, the panel auto-refreshes the storage
  stats list so the freed bytes are reflected immediately.
- DE + EN locale strings for every label, button, and report
  message; locale switch live-updates everything.

Settings autosave: enable/days/target/action all included in the
fingerprint so each change persists. autoCleanupDays goes through
the debounced text-input path; the rest are immediate-save
toggles/selects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 21:34:18 +02:00
parent f7cf1b8cd9
commit 8634834d16
8 changed files with 411 additions and 3 deletions

View File

@ -604,6 +604,39 @@
<p id="storageCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p> <p id="storageCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
<div id="storageSummary" style="color: var(--text-secondary); font-size:12px; margin-bottom:8px;"></div> <div id="storageSummary" style="color: var(--text-secondary); font-size:12px; margin-bottom:8px;"></div>
<div id="storageList"></div> <div id="storageList"></div>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:16px 0;">
<h4 id="cleanupTitle" style="margin:0 0 8px 0; font-size:14px;">Auto-Cleanup</h4>
<p id="cleanupIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label style="display:flex; align-items:center; gap:8px; margin-bottom: 8px;">
<input type="checkbox" id="autoCleanupEnabledToggle">
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
</label>
<div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;">
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:120px;">
<span id="autoCleanupDaysLabel" style="font-size:12px; color:var(--text-secondary);">Tage-Schwelle</span>
<input type="number" id="autoCleanupDays" min="1" max="3650" value="30">
</label>
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:160px;">
<span id="autoCleanupTargetLabel" style="font-size:12px; color:var(--text-secondary);">Bereich</span>
<select id="autoCleanupTarget">
<option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option>
<option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:160px;">
<span id="autoCleanupActionLabel" style="font-size:12px; color:var(--text-secondary);">Aktion</span>
<select id="autoCleanupAction">
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
<option value="delete" id="autoCleanupActionDelete">Loeschen</option>
</select>
</label>
</div>
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
<button class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
</div>
<div id="cleanupReport" style="color: var(--text-secondary); font-size:12px;"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">

View File

@ -221,6 +221,10 @@ interface Config {
discord_notify_live_start: boolean; discord_notify_live_start: boolean;
discord_notify_live_end: boolean; discord_notify_live_end: boolean;
discord_notify_vod_complete: 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 { interface RuntimeMetrics {
@ -345,7 +349,11 @@ const defaultConfig: Config = {
discord_webhook_url: '', discord_webhook_url: '',
discord_notify_live_start: false, discord_notify_live_start: false,
discord_notify_live_end: 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; 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_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_start: input.discord_notify_live_start === true,
discord_notify_live_end: input.discord_notify_live_end === 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`; 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<string>(((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 // STORAGE STATS
// ========================================== // ==========================================
@ -4753,6 +4996,10 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
restartAutoRecordPoller(); restartAutoRecordPoller();
} }
// Restart cleanup timer when the toggle flips; harmless to call when
// unchanged because restartAutoCleanupTimer just resets the interval.
restartAutoCleanupTimer();
return config; return config;
}); });
@ -5264,6 +5511,10 @@ ipcMain.handle('get-storage-stats', (): StorageStatsResult => {
return computeStorageStats(); 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 => { ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false; if (typeof folderPath !== 'string' || !folderPath) return false;
return isDownloadPathWritable(folderPath); return isDownloadPathWritable(folderPath);
@ -5456,6 +5707,7 @@ app.whenReady().then(() => {
startMetadataCacheCleanup(); startMetadataCacheCleanup();
startDebugLogFlushTimer(); startDebugLogFlushTimer();
restartAutoRecordPoller(); restartAutoRecordPoller();
restartAutoCleanupTimer();
createWindow(); createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@ -5483,6 +5735,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
cleanupMetadataCaches('shutdown'); cleanupMetadataCaches('shutdown');
stopAutoUpdatePolling(); stopAutoUpdatePolling();
stopAutoRecordPoller(); stopAutoRecordPoller();
stopAutoCleanupTimer();
// Kill all active children: queue downloads, standalone clip downloads, // Kill all active children: queue downloads, standalone clip downloads,
// and any in-flight cutter/merger/splitter ffmpeg. before-quit used to // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to

View File

@ -90,6 +90,7 @@ contextBridge.exposeInMainWorld('api', {
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'), openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path), checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
// 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

@ -29,6 +29,10 @@ interface AppConfig {
discord_notify_live_start?: boolean; discord_notify_live_start?: boolean;
discord_notify_live_end?: boolean; discord_notify_live_end?: boolean;
discord_notify_vod_complete?: 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; [key: string]: unknown;
} }
@ -197,6 +201,19 @@ interface StreamerStorageEntry {
chatBytes: number; chatBytes: number;
folderPath: string; 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 { interface StorageStatsResult {
downloadPath: string; downloadPath: string;
rootExists: boolean; rootExists: boolean;
@ -238,6 +255,7 @@ interface ApiBridge {
openDebugLogFile(): Promise<boolean>; openDebugLogFile(): Promise<boolean>;
checkFolderWritable(path: string): Promise<boolean>; checkFolderWritable(path: string): Promise<boolean>;
getStorageStats(): Promise<StorageStatsResult>; getStorageStats(): Promise<StorageStatsResult>;
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
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

@ -67,6 +67,22 @@ const UI_TEXT_DE = {
storageColumnChat: 'Chat', storageColumnChat: 'Chat',
storageOpen: 'Oeffnen', storageOpen: 'Oeffnen',
storageOtherFolders: 'Andere Ordner im Download-Pfad', 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', discordCardTitle: 'Discord-Webhook',
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.', discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
discordWebhookUrlLabel: 'Webhook-URL', discordWebhookUrlLabel: 'Webhook-URL',

View File

@ -67,6 +67,22 @@ const UI_TEXT_EN = {
storageColumnChat: 'Chat', storageColumnChat: 'Chat',
storageOpen: 'Open', storageOpen: 'Open',
storageOtherFolders: 'Other folders in download path', 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', discordCardTitle: 'Discord webhook',
discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.', discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.',
discordWebhookUrlLabel: 'Webhook URL', discordWebhookUrlLabel: 'Webhook URL',

View File

@ -265,6 +265,49 @@ async function runPreflight(autoFix = false): Promise<void> {
} }
} }
async function runCleanupDryRun(): Promise<void> {
await runCleanupOnce(true);
}
async function runCleanupNow(): Promise<void> {
await runCleanupOnce(false);
}
async function runCleanupOnce(dryRun: boolean): Promise<void> {
const reportEl = byId('cleanupReport');
const dryBtn = byId<HTMLButtonElement>('btnCleanupDryRun');
const runBtn = byId<HTMLButtonElement>('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<void> { async function refreshStorageStats(): Promise<void> {
const summary = byId('storageSummary'); const summary = byId('storageSummary');
const list = byId('storageList'); const list = byId('storageList');
@ -514,6 +557,10 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked, discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked, discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked, discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value, 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
}; };
@ -566,6 +613,10 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.discord_notify_live_start === true, effective.discord_notify_live_start === true,
effective.discord_notify_live_end === true, effective.discord_notify_live_end === true,
effective.discord_notify_vod_complete === 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.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',
@ -593,6 +644,10 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true; byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
byId<HTMLSelectElement>('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive';
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best'; 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';
@ -713,6 +768,9 @@ function initSettingsAutoSave(): void {
'discordNotifyLiveStartToggle', 'discordNotifyLiveStartToggle',
'discordNotifyLiveEndToggle', 'discordNotifyLiveEndToggle',
'discordNotifyVodCompleteToggle', 'discordNotifyVodCompleteToggle',
'autoCleanupEnabledToggle',
'autoCleanupTarget',
'autoCleanupAction',
'streamlinkQuality' 'streamlinkQuality'
] as const; ] as const;
@ -722,7 +780,8 @@ function initSettingsAutoSave(): void {
'vodFilenameTemplate', 'vodFilenameTemplate',
'partsFilenameTemplate', 'partsFilenameTemplate',
'defaultClipFilenameTemplate', 'defaultClipFilenameTemplate',
'discordWebhookUrl' 'discordWebhookUrl',
'autoCleanupDays'
] as const; ] as const;
const credentialIds = [ const credentialIds = [

View File

@ -176,6 +176,18 @@ function applyLanguageToStaticUI(): void {
setText('storageCardTitle', UI_TEXT.static.storageCardTitle); setText('storageCardTitle', UI_TEXT.static.storageCardTitle);
setText('storageCardIntro', UI_TEXT.static.storageCardIntro); setText('storageCardIntro', UI_TEXT.static.storageCardIntro);
setText('btnRefreshStorage', UI_TEXT.static.storageRefresh); 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('discordCardTitle', UI_TEXT.static.discordCardTitle);
setText('discordCardIntro', UI_TEXT.static.discordCardIntro); setText('discordCardIntro', UI_TEXT.static.discordCardIntro);
setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel); setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);