feat: per-streamer storage stats panel — see what eats the disk

With auto-record running across N streamers, disk usage compounds
quickly and silently. New Settings -> Storage card walks the
download folder once per Refresh click and shows per-streamer
totals so the user can decide which folders to thin out.

Server:
- new computeStorageStats() — readdirSync the download_path top
  level, classify each subfolder as a known streamer (matches
  config.streamers case-insensitive), the special "Clips" bucket,
  or extra (unknown user-created folder, surfaced separately so
  it does not get conflated with archive bytes). Recursive
  walkFolderForStats counts files + total bytes + live-only bytes
  (subfolder named "live" — populated by the live-recording
  feature) + chat bytes (anything matching .chat.json or
  .chat.jsonl). Skips per-entry on permission errors so a single
  blocked folder can not abort the whole scan.
- Sort order: largest first, both for streamers and extras.
- IPC get-storage-stats returns the structured result.

Renderer:
- Settings card with a Refresh button + summary line ("X files,
  Y bytes, free disk Z") + two tables (known-streamers, then
  extras) with columns for file count, total bytes, live bytes,
  chat bytes, and a per-row Open button that drops the user
  straight into Explorer at that folder.
- Tables built via createElement (no innerHTML) so a streamer
  named with HTML special chars cannot escape the cell.
- DE + EN labels for everything; column headers and the Open
  button locale-switch on the fly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 20:54:19 +02:00
parent 97d8cc10ef
commit b7c7b9eb7c
8 changed files with 303 additions and 0 deletions

View File

@ -596,6 +596,16 @@
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div>
<div class="settings-card">
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
<h3 id="storageCardTitle" style="margin:0;">Storage</h3>
<button class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
</div>
<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>
</div>
<div class="settings-card">
<h3 id="discordCardTitle">Discord-Webhook</h3>
<p id="discordCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>

View File

@ -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<string>(
((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);

View File

@ -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<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),

View File

@ -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<AppConfig>;
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
@ -218,6 +237,7 @@ interface ApiBridge {
showInFolder(path: string): Promise<boolean>;
openDebugLogFile(): Promise<boolean>;
checkFolderWritable(path: string): Promise<boolean>;
getStorageStats(): Promise<StorageStatsResult>;
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 }>;

View File

@ -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',

View File

@ -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',

View File

@ -265,6 +265,125 @@ async function runPreflight(autoFix = false): Promise<void> {
}
}
async function refreshStorageStats(): Promise<void> {
const summary = byId('storageSummary');
const list = byId('storageList');
const btn = byId<HTMLButtonElement>('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<string | HTMLElement> = [
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<void> {
const result = await window.api.exportConfig();
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;

View File

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