diff --git a/src/index.html b/src/index.html
index 8606646..8b6eaae 100644
--- a/src/index.html
+++ b/src/index.html
@@ -596,6 +596,16 @@
+
+
Storage
+
+
+
Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.
+
+
+
+
Discord-Webhook
Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.
diff --git a/src/main.ts b/src/main.ts
index 2d42c5e..ab9c86d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3090,6 +3090,126 @@ function chatReplayPathFor(vodFilePath: string): string {
return `${base}.chat.json`;
}
+// ==========================================
+// STORAGE STATS
+// ==========================================
+// Walks the download folder once on demand and reports per-streamer disk
+// usage so the user can see which streamers are eating their archive
+// budget. Only enumerates direct subfolders that match a known streamer
+// name (from config.streamers) plus a special "Clips" bucket. Refusing
+// to recurse the entire filesystem means a user with a huge unrelated
+// download_path doesn't pay for it here.
+interface StreamerStorageEntry {
+ name: string;
+ fileCount: number;
+ totalBytes: number;
+ liveBytes: number;
+ chatBytes: number;
+ folderPath: string;
+}
+interface StorageStatsResult {
+ downloadPath: string;
+ rootExists: boolean;
+ freeBytes: number | null;
+ totalFiles: number;
+ totalBytes: number;
+ streamers: StreamerStorageEntry[];
+ extras: StreamerStorageEntry[];
+ scannedAt: string;
+}
+
+function walkFolderForStats(folderPath: string): { files: number; bytes: number; liveBytes: number; chatBytes: number } {
+ const result = { files: 0, bytes: 0, liveBytes: 0, chatBytes: 0 };
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(folderPath, { withFileTypes: true });
+ } catch {
+ return result;
+ }
+ for (const entry of entries) {
+ const full = path.join(folderPath, entry.name);
+ try {
+ if (entry.isDirectory()) {
+ const sub = walkFolderForStats(full);
+ result.files += sub.files;
+ result.bytes += sub.bytes;
+ if (entry.name === 'live') {
+ result.liveBytes += sub.bytes;
+ }
+ } else if (entry.isFile()) {
+ const st = fs.statSync(full);
+ result.files += 1;
+ result.bytes += st.size;
+ if (/\.chat\.json(l)?$/i.test(entry.name)) {
+ result.chatBytes += st.size;
+ }
+ }
+ } catch {
+ // Symlink / permissions blip — skip the entry, continue.
+ }
+ }
+ return result;
+}
+
+function computeStorageStats(): StorageStatsResult {
+ const root = config.download_path;
+ const result: StorageStatsResult = {
+ downloadPath: root,
+ rootExists: false,
+ freeBytes: null,
+ totalFiles: 0,
+ totalBytes: 0,
+ streamers: [],
+ extras: [],
+ scannedAt: new Date().toISOString()
+ };
+
+ if (!root || !fs.existsSync(root)) return result;
+ result.rootExists = true;
+ result.freeBytes = getFreeDiskBytes(root);
+
+ 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 result;
+ }
+
+ for (const entry of topEntries) {
+ if (!entry.isDirectory()) continue;
+ const full = path.join(root, entry.name);
+ const safeName = entry.name.replace(/[^a-zA-Z0-9_-]/g, '');
+ const isKnownStreamer = knownStreamers.has(safeName.toLowerCase());
+ // Treat Clips/ + anything that matches known streamers as a tracked
+ // bucket; everything else (random user folders) lives in `extras`.
+ const sub = walkFolderForStats(full);
+ const stats: StreamerStorageEntry = {
+ name: entry.name,
+ fileCount: sub.files,
+ totalBytes: sub.bytes,
+ liveBytes: sub.liveBytes,
+ chatBytes: sub.chatBytes,
+ folderPath: full
+ };
+ if (isKnownStreamer || entry.name === 'Clips') {
+ result.streamers.push(stats);
+ } else {
+ result.extras.push(stats);
+ }
+ result.totalFiles += sub.files;
+ result.totalBytes += sub.bytes;
+ }
+
+ // Largest first — that's what the user wants to see.
+ result.streamers.sort((a, b) => b.totalBytes - a.totalBytes);
+ result.extras.sort((a, b) => b.totalBytes - a.totalBytes);
+ return result;
+}
+
// ==========================================
// DISCORD WEBHOOK NOTIFICATIONS
// ==========================================
@@ -5140,6 +5260,10 @@ ipcMain.handle('open-debug-log-file', (): boolean => {
return true;
});
+ipcMain.handle('get-storage-stats', (): StorageStatsResult => {
+ return computeStorageStats();
+});
+
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false;
return isDownloadPathWritable(folderPath);
diff --git a/src/preload.ts b/src/preload.ts
index 96a8f0f..b0e4a87 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -89,6 +89,7 @@ contextBridge.exposeInMainWorld('api', {
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
+ getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
// 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 42b7793..3e65486 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -189,6 +189,25 @@ interface PreflightResult {
timestamp: string;
}
+interface StreamerStorageEntry {
+ name: string;
+ fileCount: number;
+ totalBytes: number;
+ liveBytes: number;
+ chatBytes: number;
+ folderPath: string;
+}
+interface StorageStatsResult {
+ downloadPath: string;
+ rootExists: boolean;
+ freeBytes: number | null;
+ totalFiles: number;
+ totalBytes: number;
+ streamers: StreamerStorageEntry[];
+ extras: StreamerStorageEntry[];
+ scannedAt: string;
+}
+
interface ApiBridge {
getConfig(): Promise;
saveConfig(config: Partial): Promise;
@@ -218,6 +237,7 @@ interface ApiBridge {
showInFolder(path: string): Promise;
openDebugLogFile(): Promise;
checkFolderWritable(path: string): Promise;
+ getStorageStats(): 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 f049f3c..827a404 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -54,6 +54,19 @@ const UI_TEXT_DE = {
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Log-Datei oeffnen',
+ storageCardTitle: 'Speicher',
+ storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.',
+ storageRefresh: 'Aktualisieren',
+ storageEmpty: 'Download-Ordner ist leer oder nicht lesbar.',
+ storageScanning: 'Scanne...',
+ storageSummary: 'Gesamt: {files} Dateien, {size} — Freier Speicher: {free}',
+ storageColumnFolder: 'Ordner',
+ storageColumnFiles: 'Dateien',
+ storageColumnTotal: 'Gesamt',
+ storageColumnLive: 'Live',
+ storageColumnChat: 'Chat',
+ storageOpen: 'Oeffnen',
+ storageOtherFolders: 'Andere Ordner im Download-Pfad',
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 8026b93..61bbb6e 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -54,6 +54,19 @@ const UI_TEXT_EN = {
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Open log file',
+ storageCardTitle: 'Storage',
+ storageCardIntro: 'Per-streamer disk usage in the current download folder. Live recordings are surfaced separately.',
+ storageRefresh: 'Refresh',
+ storageEmpty: 'Download folder is empty or unreadable.',
+ storageScanning: 'Scanning...',
+ storageSummary: 'Total: {files} files, {size} — Free disk: {free}',
+ storageColumnFolder: 'Folder',
+ storageColumnFiles: 'Files',
+ storageColumnTotal: 'Total',
+ storageColumnLive: 'Live',
+ storageColumnChat: 'Chat',
+ storageOpen: 'Open',
+ storageOtherFolders: 'Other folders in download path',
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 908b92b..edf3347 100644
--- a/src/renderer-settings.ts
+++ b/src/renderer-settings.ts
@@ -265,6 +265,125 @@ async function runPreflight(autoFix = false): Promise {
}
}
+async function refreshStorageStats(): Promise {
+ const summary = byId('storageSummary');
+ const list = byId('storageList');
+ const btn = byId('btnRefreshStorage');
+ const old = btn.textContent || '';
+ btn.disabled = true;
+ btn.textContent = UI_TEXT.static.storageScanning;
+ summary.textContent = UI_TEXT.static.storageScanning;
+ list.replaceChildren();
+
+ try {
+ const stats = await window.api.getStorageStats();
+ renderStorageStats(stats);
+ } catch {
+ summary.textContent = UI_TEXT.static.storageEmpty;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = old || UI_TEXT.static.storageRefresh;
+ }
+}
+
+function renderStorageStats(stats: StorageStatsResult): void {
+ const summary = byId('storageSummary');
+ const list = byId('storageList');
+
+ if (!stats.rootExists) {
+ summary.textContent = UI_TEXT.static.storageEmpty;
+ list.replaceChildren();
+ return;
+ }
+
+ summary.textContent = UI_TEXT.static.storageSummary
+ .replace('{files}', String(stats.totalFiles))
+ .replace('{size}', formatBytesForMetrics(stats.totalBytes))
+ .replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-');
+
+ list.replaceChildren();
+ if (stats.streamers.length === 0 && stats.extras.length === 0) return;
+
+ const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => {
+ const table = document.createElement('table');
+ table.style.width = '100%';
+ table.style.borderCollapse = 'collapse';
+ table.style.fontSize = '12px';
+
+ const thead = document.createElement('thead');
+ const headRow = document.createElement('tr');
+ const headers = [
+ UI_TEXT.static.storageColumnFolder,
+ UI_TEXT.static.storageColumnFiles,
+ UI_TEXT.static.storageColumnTotal,
+ UI_TEXT.static.storageColumnLive,
+ UI_TEXT.static.storageColumnChat,
+ ''
+ ];
+ for (const h of headers) {
+ const th = document.createElement('th');
+ th.textContent = h;
+ th.style.textAlign = 'left';
+ th.style.padding = '4px 8px';
+ th.style.color = 'var(--text-secondary)';
+ th.style.borderBottom = '1px solid var(--border-soft)';
+ th.style.fontWeight = '500';
+ headRow.appendChild(th);
+ }
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ const tbody = document.createElement('tbody');
+ for (const row of rows) {
+ const tr = document.createElement('tr');
+ const cells: Array = [
+ row.name,
+ String(row.fileCount),
+ formatBytesForMetrics(row.totalBytes),
+ row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-',
+ row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-'
+ ];
+ for (const c of cells) {
+ const td = document.createElement('td');
+ if (typeof c === 'string') td.textContent = c;
+ else td.appendChild(c);
+ td.style.padding = '4px 8px';
+ td.style.borderBottom = '1px solid var(--border-soft)';
+ tr.appendChild(td);
+ }
+ const openCell = document.createElement('td');
+ openCell.style.padding = '4px 8px';
+ openCell.style.borderBottom = '1px solid var(--border-soft)';
+ const openBtn = document.createElement('button');
+ openBtn.textContent = UI_TEXT.static.storageOpen;
+ openBtn.className = 'btn-secondary';
+ openBtn.style.fontSize = '11px';
+ openBtn.style.padding = '2px 8px';
+ openBtn.addEventListener('click', () => {
+ void window.api.openFolder(row.folderPath);
+ });
+ openCell.appendChild(openBtn);
+ tr.appendChild(openCell);
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ return table;
+ };
+
+ if (stats.streamers.length > 0) {
+ list.appendChild(buildTable(stats.streamers));
+ }
+ if (stats.extras.length > 0) {
+ const heading = document.createElement('div');
+ heading.textContent = UI_TEXT.static.storageOtherFolders;
+ heading.style.color = 'var(--text-secondary)';
+ heading.style.fontSize = '12px';
+ heading.style.margin = '12px 0 4px';
+ list.appendChild(heading);
+ list.appendChild(buildTable(stats.extras));
+ }
+}
+
async function exportConfigToFile(): Promise {
const result = await window.api.exportConfig();
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index 463599d..2a0f6ed 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -173,6 +173,9 @@ function applyLanguageToStaticUI(): void {
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
+ setText('storageCardTitle', UI_TEXT.static.storageCardTitle);
+ setText('storageCardIntro', UI_TEXT.static.storageCardIntro);
+ setText('btnRefreshStorage', UI_TEXT.static.storageRefresh);
setText('discordCardTitle', UI_TEXT.static.discordCardTitle);
setText('discordCardIntro', UI_TEXT.static.discordCardIntro);
setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);