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:
parent
97d8cc10ef
commit
b7c7b9eb7c
@ -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>
|
||||
|
||||
124
src/main.ts
124
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<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);
|
||||
|
||||
@ -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),
|
||||
|
||||
20
src/renderer-globals.d.ts
vendored
20
src/renderer-globals.d.ts
vendored
@ -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 }>;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user