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 @@
Lade...
+
+
+

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