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);