Database Storage
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 0bf31bd8..7729ce9e 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3444,6 +3444,7 @@ const WHATS_NEW = {
'2.4.2': [
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.2 dev cycle' },
+ { title: 'Library Disk Usage on Stats Page', desc: 'discord request (samuel [KC]): show how much disk space the library takes. new card on stats → system statistics shows total bytes + per-format breakdown (FLAC vs MP3 vs M4A bars). data comes from `tracks.file_size` populated during deep scan from whatever the media server already returns (plex MediaPart.size, jellyfin MediaSources[].Size, navidrome song.size, soulsync standalone os.path.getsize) — zero filesystem walk overhead. existing libraries see "Run a Deep Scan to populate" until the next deep scan fills in sizes; partial coverage shown as "X tracks measured (+Y pending)". migration is additive (NULL on legacy rows) so upgrading users have nothing to do.', page: 'stats' },
{ title: 'Fix: ReplayGain Wrote Same +52 dB Gain to Every Track', desc: 'noticed every downloaded track came out with `replaygain_track_gain: +52.00 dB` regardless of actual loudness. cause: parser used `re.search` which returned the FIRST `I:` (integrated loudness) reading from ffmpeg\'s ebur128 output. that\'s the per-window measurement at t=0.5s — almost always ~-70 LUFS because tracks start with silence/encoder padding. -18 (RG2 reference) - (-70) = +52 dB on every track. fix: parser now anchors to the `Summary:` block at the end of ffmpeg\'s output and reads the actual integrated loudness from there, not the silent-intro partial. defensive fallback uses the LAST per-window reading if Summary is missing (still better than the first). gains now reflect real per-track loudness.', page: 'downloads' },
{ title: 'Fix: Tracks Showed Completed When File Was Quarantined', desc: 'caught downloading kendrick mr morale: three tracks (rich interlude, savior interlude, savior) showed ✅ completed in the modal but were missing on disk. two layered bugs. (1) the post-process verification wrapper had a fallback that assumed success when no `_final_processed_path` was in context — but integrity-rejected files (which get quarantined instead of moved) leave that path unset, so the wrapper marked them complete. now wrapper explicitly checks `_integrity_failure_msg` and `_race_guard_failed` markers before the assume-success fallback. failed integrity = task marked failed, batch tracker notified with success=false. (2) acoustid skip-logic was too lenient — when fingerprint confidence was very high and either title OR artist matched a bit, it skipped verification with reason "likely same song in different language/script." that fired for english-vs-english by the same artist with the word "interlude" in both — same artist + 0.55 title sim = skip = wrong file accepted. tightened: skip now requires non-ASCII chars present (real language/script case) AND artist match, OR very high title similarity (≥0.80) AND artist match. english-vs-english with very different titles by same artist no longer skipped — verification correctly returns FAIL and the wrong file gets quarantined.', page: 'downloads' },
{ title: 'Stop Navidrome From Splitting Albums Over Inconsistent MBIDs', desc: 'discord report (samuel [KC]): tracks of the same album sometimes carry different MUSICBRAINZ_ALBUMID tags, which causes navidrome to split the album into multiple entries. two-part fix: (1) the MBID Mismatch Detector now does a second scan that groups tracks by db album, finds the consensus (most-common) album mbid, and flags dissenters — fix action rewrites the dissenter\'s tag to match. catches existing inconsistencies in your library. (2) root cause: per-track musicbrainz release lookups went through an in-memory cache that\'s capped at 4096 entries and dies on server restart, so big libraries / restarts could resolve different release ids for tracks of the same album. added a persistent sqlite-backed cache so a release mbid resolved ONCE for an album applies to every future track of that album for the install\'s lifetime. strictly additive: any failure in the persistent layer falls through to the live musicbrainz lookup exactly as before.', page: 'library' },
diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js
index b413f1e7..53540d52 100644
--- a/webui/static/stats-automations.js
+++ b/webui/static/stats-automations.js
@@ -200,6 +200,8 @@ async function loadStatsData() {
// DB storage chart (separate fetch — not part of cached stats)
_loadDbStorageChart();
+ // Library disk usage (separate fetch — populated by deep scan)
+ _loadLibraryDiskUsage();
// Recent plays
_renderRecentPlays(data.recent || []);
@@ -387,6 +389,70 @@ async function _loadDbStorageChart() {
}
}
+async function _loadLibraryDiskUsage() {
+ try {
+ const resp = await fetch('/api/stats/library-disk-usage');
+ const data = await resp.json();
+ if (!data.success) return;
+ _renderLibraryDiskUsage(data);
+ } catch (e) {
+ console.debug('Library disk usage load failed:', e);
+ }
+}
+
+function _formatBytes(n) {
+ if (!n || n <= 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ let v = n;
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+ return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`;
+}
+
+function _renderLibraryDiskUsage(data) {
+ const totalEl = document.getElementById('stats-disk-total-value');
+ const metaEl = document.getElementById('stats-disk-total-meta');
+ const formatsEl = document.getElementById('stats-disk-formats');
+ if (!totalEl || !metaEl || !formatsEl) return;
+
+ if (!data.has_data || !data.total_bytes) {
+ totalEl.textContent = '—';
+ metaEl.textContent = data.tracks_without_size > 0
+ ? `Run a Deep Scan to populate (${data.tracks_without_size.toLocaleString()} tracks pending)`
+ : 'No tracks in library yet';
+ formatsEl.innerHTML = '';
+ return;
+ }
+
+ totalEl.textContent = _formatBytes(data.total_bytes);
+
+ const withSize = data.tracks_with_size || 0;
+ const withoutSize = data.tracks_without_size || 0;
+ const trackBits = `${withSize.toLocaleString()} tracks measured`;
+ const pendingBits = withoutSize > 0
+ ? ` (+${withoutSize.toLocaleString()} pending next Deep Scan)`
+ : '';
+ metaEl.textContent = trackBits + pendingBits;
+
+ // Per-format bars sorted by size descending. Skip if no breakdown.
+ const formats = Object.entries(data.by_format || {}).sort((a, b) => b[1] - a[1]);
+ if (!formats.length) { formatsEl.innerHTML = ''; return; }
+
+ const max = formats[0][1] || 1;
+ formatsEl.innerHTML = formats.map(([ext, bytes]) => {
+ const pct = Math.max(2, Math.round((bytes / max) * 100));
+ return `
+
+ `;
+ }).join('');
+}
+
function _renderDbStorageChart(tables, totalFileSize, method) {
const canvas = document.getElementById('stats-db-storage-chart');
if (!canvas || typeof Chart === 'undefined') return;
diff --git a/webui/static/style.css b/webui/static/style.css
index 9fc3a8cd..25ef3398 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -39449,6 +39449,72 @@ div.artist-hero-badge {
text-align: right;
}
+/* Library Disk Usage */
+.stats-disk-usage-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ margin-top: 8px;
+}
+
+.stats-disk-total-row {
+ display: flex;
+ align-items: baseline;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.stats-disk-total-value {
+ font-size: 28px;
+ font-weight: 700;
+ color: rgb(var(--accent-rgb));
+}
+
+.stats-disk-total-meta {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.stats-disk-formats {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.stats-disk-format-row {
+ display: grid;
+ grid-template-columns: 60px 1fr 80px;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+}
+
+.stats-disk-format-name {
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.stats-disk-format-bar {
+ height: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.stats-disk-format-fill {
+ height: 100%;
+ background: linear-gradient(90deg,
+ rgb(var(--accent-rgb)) 0%,
+ rgba(var(--accent-rgb), 0.6) 100%);
+ border-radius: 4px;
+}
+
+.stats-disk-format-size {
+ text-align: right;
+ color: rgba(255, 255, 255, 0.55);
+ font-variant-numeric: tabular-nums;
+}
+
/* Database Storage Chart */
.stats-db-storage-wrap {
display: flex;