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:
parent
f7cf1b8cd9
commit
8634834d16
@ -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>
|
||||
<div id="storageSummary" style="color: var(--text-secondary); font-size:12px; margin-bottom:8px;"></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 class="settings-card">
|
||||
|
||||
257
src/main.ts
257
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<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
|
||||
// ==========================================
|
||||
@ -4753,6 +4996,10 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
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
|
||||
|
||||
@ -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<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
|
||||
18
src/renderer-globals.d.ts
vendored
18
src/renderer-globals.d.ts
vendored
@ -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<boolean>;
|
||||
checkFolderWritable(path: string): Promise<boolean>;
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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> {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
@ -514,6 +557,10 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').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,
|
||||
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_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<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>('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<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||
byId<HTMLInputElement>('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 = [
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user