Skip to content

Commit d28e269

Browse files
committed
Re-add global total cache limit with dual cache bars for admins
The per-user-quota change repurposed Settings.cacheQuotaBytes as the default per-user limit, dropping the global total cap. Non-PVC installs need to bound total cache manually. Adds Settings.totalCacheQuotaBytes (null = auto: disk capacity − 5 GB), enforced by enforceTotalCacheQuota (oldest-first eviction across all users) after each completion. Health API now reports the requester's own cache plus a gated global total; HealthPanel shows two bars (Total Cache + Your Cache). Admins set the limit in Settings → Storage. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent a51f1fc commit d28e269

8 files changed

Lines changed: 205 additions & 8 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Global total cache cap across all users (null = auto: disk capacity − 5 GB).
2+
ALTER TABLE "settings" ADD COLUMN "totalCacheQuotaBytes" BIGINT;

prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,8 @@ model Settings {
360360
// Library & cache
361361
libraryPath String?
362362
musicLibraryPath String?
363-
cacheQuotaBytes BigInt @default(10737418240) // 10 GB
363+
cacheQuotaBytes BigInt @default(10737418240) // 10 GB — default per-user cache limit
364+
totalCacheQuotaBytes BigInt? // global total cache cap across all users; null = auto (disk capacity − 5 GB)
364365
365366
// Jellyfin integration
366367
jellyfinUrl String?

src/lib/components/ui/HealthPanel.svelte

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,29 @@
145145

146146
<div class="stat-card wide">
147147
<div class="card-title"><i class="bi bi-hdd"></i> Storage</div>
148+
{#if data.storage.totalCache}
149+
<div class="progress-section">
150+
<div class="progress-header">
151+
<span class="stat-label">Total Cache</span>
152+
<span class="stat-detail">
153+
{#if data.storage.totalCache.quotaBytes}
154+
{formatBytes(data.storage.totalCache.usedBytes)} / {formatBytes(data.storage.totalCache.quotaBytes)}
155+
{:else}
156+
{formatBytes(data.storage.totalCache.usedBytes)} &middot; No limit
157+
{/if}
158+
</span>
159+
</div>
160+
<div class="health-progress">
161+
<div
162+
class="health-progress-bar"
163+
style="width: {data.storage.totalCache.percentage}%; background: {progressColor(data.storage.totalCache.percentage)};"
164+
></div>
165+
</div>
166+
</div>
167+
{/if}
148168
<div class="progress-section">
149169
<div class="progress-header">
150-
<span class="stat-label">{isAdmin ? 'Cache' : 'Your Cache'}</span>
170+
<span class="stat-label">Your Cache</span>
151171
<span class="stat-detail">{formatBytes(data.storage.cache.usedBytes)} / {formatBytes(data.storage.cache.quotaBytes)}</span>
152172
</div>
153173
<div class="health-progress">

src/lib/server/services/download.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,10 +748,13 @@ class DownloadService {
748748
notificationService.notifyComplete(download.title || download.url).catch(() => {});
749749

750750
// Enforce cache quota asynchronously — per-user so each user is evicted
751-
// against their own limit.
751+
// against their own limit, then globally against the total cache cap.
752752
libraryService.enforceCacheQuota(download.userId ?? undefined).catch((error) => {
753753
console.error('[DownloadService] Cache quota enforcement failed:', error);
754754
});
755+
libraryService.enforceTotalCacheQuota().catch((error) => {
756+
console.error('[DownloadService] Total cache quota enforcement failed:', error);
757+
});
755758
}
756759

757760
/**

src/lib/server/services/library.service.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { prisma } from '../db';
22
import { sseEmitter } from '../sse/emitter';
33
import { DownloadStatus } from '@prisma/client';
4-
import { copyFile, unlink, mkdir, access, writeFile } from 'fs/promises';
4+
import { copyFile, unlink, mkdir, access, writeFile, statfs } from 'fs/promises';
55
import { join, basename, resolve, extname, sep } from 'path';
66
import { musicMetadataService } from './music-metadata.service';
77
import { ytdlpService } from './ytdlp.service';
@@ -286,6 +286,108 @@ class LibraryService {
286286
};
287287
}
288288

289+
/** Total capacity of the disk backing the cache/download path, or null if unreadable. */
290+
private async getDiskTotalBytes(downloadPath: string): Promise<bigint | null> {
291+
try {
292+
const stats = await statfs(downloadPath);
293+
return BigInt(stats.bsize) * BigInt(stats.blocks);
294+
} catch {
295+
return null;
296+
}
297+
}
298+
299+
/**
300+
* Effective global cache cap. An explicit Settings.totalCacheQuotaBytes wins;
301+
* otherwise default to (disk capacity − 5 GB) so non-PVC installs leave headroom.
302+
* Returns null when no explicit cap is set and the disk can't be read — callers
303+
* treat null as "no global enforcement" (fail-safe, never mass-evict on bad data).
304+
*/
305+
async getEffectiveTotalCacheQuota(
306+
settings?: { totalCacheQuotaBytes: bigint | null; downloadPath: string | null }
307+
): Promise<bigint | null> {
308+
const s = settings ?? (await this.getSettings());
309+
if (s.totalCacheQuotaBytes != null) return s.totalCacheQuotaBytes;
310+
311+
const FIVE_GIB = BigInt(5_368_709_120);
312+
const diskTotal = await this.getDiskTotalBytes(s.downloadPath || '/downloads');
313+
if (diskTotal == null) return null;
314+
const cap = diskTotal - FIVE_GIB;
315+
return cap > BigInt(0) ? cap : BigInt(0);
316+
}
317+
318+
/** Global cache usage across all users vs the effective global cap. */
319+
async getTotalCacheUsage(): Promise<{ usedBytes: string; quotaBytes: string | null; percentage: number }> {
320+
const quotaBytes = await this.getEffectiveTotalCacheQuota();
321+
322+
const result = await prisma.download.aggregate({
323+
where: { storagePool: 'cache', status: DownloadStatus.COMPLETED },
324+
_sum: { filesize: true },
325+
});
326+
const usedBytes = result._sum.filesize ?? BigInt(0);
327+
328+
const percentage = quotaBytes && quotaBytes > BigInt(0)
329+
? Math.min(Number((usedBytes * BigInt(10000)) / quotaBytes) / 100, 100)
330+
: 0;
331+
332+
return {
333+
usedBytes: usedBytes.toString(),
334+
quotaBytes: quotaBytes == null ? null : quotaBytes.toString(),
335+
percentage,
336+
};
337+
}
338+
339+
/**
340+
* Enforce the global total cache cap by evicting the oldest completed cache
341+
* downloads across ALL users until total usage is back under the cap. No-op when
342+
* there's no effective cap. Mirrors the per-user eviction in enforceCacheQuota.
343+
*/
344+
async enforceTotalCacheQuota(): Promise<void> {
345+
const quotaBytes = await this.getEffectiveTotalCacheQuota();
346+
if (quotaBytes == null) return;
347+
348+
const result = await prisma.download.aggregate({
349+
where: { storagePool: 'cache', status: DownloadStatus.COMPLETED },
350+
_sum: { filesize: true },
351+
});
352+
353+
const usedBytes = result._sum.filesize ?? BigInt(0);
354+
if (usedBytes <= quotaBytes) return;
355+
356+
const candidates = await prisma.download.findMany({
357+
where: { storagePool: 'cache', status: DownloadStatus.COMPLETED },
358+
orderBy: { completedAt: 'asc' },
359+
select: { id: true, filesize: true, filepath: true, userId: true },
360+
});
361+
362+
let currentUsage = usedBytes;
363+
for (const candidate of candidates) {
364+
if (currentUsage <= quotaBytes) break;
365+
366+
if (candidate.filepath) {
367+
try {
368+
await unlink(candidate.filepath);
369+
} catch {
370+
continue;
371+
}
372+
}
373+
374+
const videoId = await this.getVideoIdForDownload(candidate.id);
375+
if (videoId) {
376+
await prisma.archive.deleteMany({ where: { videoId } });
377+
}
378+
379+
await prisma.download.delete({ where: { id: candidate.id } });
380+
381+
if (candidate.userId) {
382+
sseEmitter.broadcastToUser('download:deleted', { id: candidate.id, reason: 'cache_quota' }, candidate.userId);
383+
} else {
384+
sseEmitter.broadcast('download:deleted', { id: candidate.id, reason: 'cache_quota' });
385+
}
386+
387+
currentUsage -= candidate.filesize ?? BigInt(0);
388+
}
389+
}
390+
289391
async getLibraryUsage(): Promise<{ video: { usedBytes: string; count: number } | null; music: { usedBytes: string; count: number } | null }> {
290392
const settings = await this.getSettings();
291393

src/routes/api/health/+server.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const GET = apiRoute('/api/health', 'GET', {
2121
connection: { type: 'object', properties: { sseClients: { type: 'integer' } } },
2222
downloads: { type: 'object', properties: { active: { type: 'integer' }, queued: { type: 'integer' }, completed: { type: 'integer' }, failed: { type: 'integer' } } },
2323
queue: { type: 'object', properties: { metadata: { type: 'integer' }, downloads: { type: 'integer' }, active: { type: 'integer' }, maxConcurrent: { type: 'integer' } } },
24-
storage: { type: 'object', properties: { cache: { type: 'object' }, library: { type: 'object' }, disk: { type: 'object', nullable: true } } },
24+
storage: { type: 'object', properties: { cache: { type: 'object' }, totalCache: { type: 'object', nullable: true }, library: { type: 'object' }, disk: { type: 'object', nullable: true } } },
2525
system: { type: 'object', properties: { ytdlpVersion: { type: 'string', nullable: true }, uptimeMs: { type: 'integer' } } },
2626
subscriptions: { type: 'object', properties: { total: { type: 'integer' }, active: { type: 'integer' } } },
2727
monitors: { type: 'object', properties: { total: { type: 'integer' }, enabled: { type: 'integer' }, live: { type: 'integer' } } },
@@ -58,6 +58,7 @@ export const GET = apiRoute('/api/health', 'GET', {
5858

5959
const [
6060
cacheUsage,
61+
totalCacheUsage,
6162
libraryUsage,
6263
downloadCounts,
6364
subscriptionTotal,
@@ -66,7 +67,10 @@ export const GET = apiRoute('/api/health', 'GET', {
6667
monitorEnabled,
6768
monitorLive,
6869
] = await Promise.all([
69-
libraryService.getCacheUsage(isAdmin ? undefined : userId),
70+
// Always the requester's OWN cache usage vs their per-user limit.
71+
libraryService.getCacheUsage(userId),
72+
// Global total cache usage — gated like other totals (admins always).
73+
showTotals ? libraryService.getTotalCacheUsage() : Promise.resolve(null),
7074
showTotals ? libraryService.getLibraryUsage() : Promise.resolve(null),
7175
prisma.download.groupBy({ by: ['status'], _count: true }),
7276
prisma.subscription.count(),
@@ -122,6 +126,7 @@ export const GET = apiRoute('/api/health', 'GET', {
122126
},
123127
storage: {
124128
cache: cacheUsage,
129+
totalCache: totalCacheUsage,
125130
library: libraryUsage,
126131
disk,
127132
},

src/routes/api/settings/+server.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const ALLOWED_SETTINGS_FIELDS = new Set([
2121
'libraryPath',
2222
'musicLibraryPath',
2323
'cacheQuotaBytes',
24+
'totalCacheQuotaBytes',
2425
'jellyfinUrl',
2526
'jellyfinApiKey',
2627
'jellyfinExternalUrl',
@@ -134,6 +135,7 @@ export const GET = apiRoute('/api/settings', 'GET', {
134135
ldapBindPassword: settings.ldapBindPassword ? '***SET***' : null,
135136
appriseUrl: settings.appriseUrl ? '***SET***' : null,
136137
cacheQuotaBytes: settings.cacheQuotaBytes.toString(),
138+
totalCacheQuotaBytes: settings.totalCacheQuotaBytes?.toString() ?? null,
137139
oidcConfigured: isOidcConfigured(),
138140
oidcDisplayName: isOidcConfigured() ? getOidcDisplayName() : null,
139141
canUsePasswordOnly,
@@ -160,7 +162,8 @@ export const PATCH = apiRoute('/api/settings', 'PATCH', {
160162
authMode: { type: 'string', description: 'Authentication mode', enum: ['password', 'oidc', 'both'] },
161163
libraryPath: { type: 'string', description: 'Library directory path' },
162164
musicLibraryPath: { type: 'string', description: 'Music library path' },
163-
cacheQuotaBytes: { type: 'string', description: 'Cache quota in bytes' },
165+
cacheQuotaBytes: { type: 'string', description: 'Default per-user cache quota in bytes' },
166+
totalCacheQuotaBytes: { type: 'string', description: 'Global total cache cap in bytes; empty/null = auto (disk − 5 GB)', nullable: true },
164167
jellyfinUrl: { type: 'string', description: 'Jellyfin server URL' },
165168
jellyfinApiKey: { type: 'string', description: 'Jellyfin API key' },
166169
jellyfinExternalUrl: { type: 'string', description: 'Jellyfin external URL' },
@@ -296,6 +299,33 @@ export const PATCH = apiRoute('/api/settings', 'PATCH', {
296299
updates.cacheQuotaBytes = val;
297300
}
298301

302+
if (updates.totalCacheQuotaBytes !== undefined) {
303+
// Empty string or null clears the override → auto (disk − 5 GB).
304+
if (updates.totalCacheQuotaBytes === null || updates.totalCacheQuotaBytes === '') {
305+
updates.totalCacheQuotaBytes = null;
306+
} else {
307+
const val = BigInt(updates.totalCacheQuotaBytes);
308+
if (val < BigInt(0)) {
309+
throw error(400, 'Total cache quota must be positive');
310+
}
311+
312+
const currentSettings = await prisma.settings.findUnique({ where: { id: 'singleton' } });
313+
const downloadPath = updates.downloadPath || currentSettings?.downloadPath || '/downloads';
314+
try {
315+
const stats = await statfs(downloadPath);
316+
const totalBytes = BigInt(stats.bsize) * BigInt(stats.blocks);
317+
if (val > totalBytes) {
318+
const totalGB = Number(totalBytes) / (1024 * 1024 * 1024);
319+
throw error(400, `Total cache quota exceeds total disk space (${totalGB.toFixed(1)} GB)`);
320+
}
321+
} catch (e: any) {
322+
if (e.status) throw e;
323+
}
324+
325+
updates.totalCacheQuotaBytes = val;
326+
}
327+
}
328+
299329
if (updates.maxConcurrentDownloads !== undefined) {
300330
const val = Number(updates.maxConcurrentDownloads);
301331
if (!Number.isInteger(val) || val < 1 || val > 20) {
@@ -377,6 +407,7 @@ export const PATCH = apiRoute('/api/settings', 'PATCH', {
377407
return json({
378408
...settings,
379409
cacheQuotaBytes: settings.cacheQuotaBytes.toString(),
410+
totalCacheQuotaBytes: settings.totalCacheQuotaBytes?.toString() ?? null,
380411
});
381412
} catch (e: any) {
382413
console.error('Failed to update settings:', e);

src/routes/settings/+page.svelte

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,12 +373,16 @@
373373
await loadUsers();
374374
}
375375
376-
const SAVEABLE_FIELDS = ['maxConcurrentDownloads', 'downloadPath', 'ytdlpPath', 'autoUpdateYtdlp', 'updateCheckInterval', 'enableArchive', 'archivePath', 'authMode', 'libraryPath', 'musicLibraryPath', 'cacheQuotaBytes', 'jellyfinUrl', 'jellyfinApiKey', 'maxDurationSeconds', 'jellyfinExternalUrl', 'plexUrl', 'plexToken', 'cleanupEnabled', 'cleanupUserIds', 'cleanupIntervalSeconds', 'cleanupProfileTypes', 'cleanupGraceHours', 'autoDeleteWatchedDays', 'appriseUrl', 'notifyOnComplete', 'notifyOnFail', 'backupEnabled', 'backupCron', 'backupPath', 'ldapEnabled', 'ldapUrl', 'ldapBindDn', 'ldapBindPassword', 'ldapSearchBase', 'ldapSearchFilter', 'rateLimit', 'sleepInterval', 'proxyAuthEnabled', 'proxyAuthHeader', 'versionCheckEnabled', 'rydEnabled', 'libraryAccessMode', 'statsVisibleToNonAdmins', 'showTotalSizeToNonAdmins'];
376+
const SAVEABLE_FIELDS = ['maxConcurrentDownloads', 'downloadPath', 'ytdlpPath', 'autoUpdateYtdlp', 'updateCheckInterval', 'enableArchive', 'archivePath', 'authMode', 'libraryPath', 'musicLibraryPath', 'cacheQuotaBytes', 'totalCacheQuotaBytes', 'jellyfinUrl', 'jellyfinApiKey', 'maxDurationSeconds', 'jellyfinExternalUrl', 'plexUrl', 'plexToken', 'cleanupEnabled', 'cleanupUserIds', 'cleanupIntervalSeconds', 'cleanupProfileTypes', 'cleanupGraceHours', 'autoDeleteWatchedDays', 'appriseUrl', 'notifyOnComplete', 'notifyOnFail', 'backupEnabled', 'backupCron', 'backupPath', 'ldapEnabled', 'ldapUrl', 'ldapBindDn', 'ldapBindPassword', 'ldapSearchBase', 'ldapSearchFilter', 'rateLimit', 'sleepInterval', 'proxyAuthEnabled', 'proxyAuthHeader', 'versionCheckEnabled', 'rydEnabled', 'libraryAccessMode', 'statsVisibleToNonAdmins', 'showTotalSizeToNonAdmins'];
377377
378378
let diskInfo = $state<{ totalBytes: string; availableBytes: string } | null>(null);
379379
let diskTotalGB = $derived(diskInfo ? Number(BigInt(diskInfo.totalBytes)) / (1024 * 1024 * 1024) : null);
380380
let cacheQuotaGB = $derived(settings ? Math.floor(Number(BigInt(settings.cacheQuotaBytes || '10737418240')) / (1024 * 1024 * 1024)) : 10);
381381
let cacheQuotaExceedsDisk = $derived(diskTotalGB !== null && cacheQuotaGB > diskTotalGB);
382+
// Global total cache cap: blank input = auto (disk − 5 GB).
383+
let totalCacheGB = $derived(settings && settings.totalCacheQuotaBytes ? Math.floor(Number(BigInt(settings.totalCacheQuotaBytes)) / (1024 * 1024 * 1024)) : '');
384+
let autoTotalCacheGB = $derived(diskTotalGB !== null ? Math.max(0, Math.floor(diskTotalGB - 5)) : null);
385+
let totalCacheExceedsDisk = $derived(diskTotalGB !== null && totalCacheGB !== '' && Number(totalCacheGB) > diskTotalGB);
382386
let libraryEnabled = $derived(settings ? !!settings.libraryPath : false);
383387
let jellyfinEnabled = $derived(settings ? !!(settings.jellyfinUrl || settings.jellyfinApiKey) : false);
384388
let plexEnabled = $derived(settings ? !!(settings.plexUrl || settings.plexToken) : false);
@@ -401,6 +405,12 @@
401405
}
402406
}
403407
408+
function updateTotalCacheQuota(raw: string) {
409+
if (!settings) return;
410+
const trimmed = raw.trim();
411+
settings.totalCacheQuotaBytes = trimmed === '' ? null : String(Math.round(parseFloat(trimmed) * 1024 * 1024 * 1024));
412+
}
413+
404414
function toggleLibrary(enabled: boolean) {
405415
if (!settings) return;
406416
if (enabled) {
@@ -1095,6 +1105,29 @@
10951105
</div>
10961106
</div>
10971107

1108+
<div class="form-row">
1109+
<div class="form-group">
1110+
<label for="totalCacheQuota">Total Cache Limit (GB)</label>
1111+
<input
1112+
type="number"
1113+
id="totalCacheQuota"
1114+
value={totalCacheGB}
1115+
oninput={(e) => updateTotalCacheQuota(e.currentTarget.value)}
1116+
placeholder={autoTotalCacheGB !== null ? `Auto (${autoTotalCacheGB})` : 'Auto'}
1117+
min="1"
1118+
max={diskTotalGB ? Math.floor(diskTotalGB) : undefined}
1119+
step="1"
1120+
/>
1121+
{#if totalCacheExceedsDisk && diskTotalGB}
1122+
<p class="help-text error-text">Exceeds total disk space ({diskTotalGB.toFixed(1)} GB)</p>
1123+
{:else}
1124+
<p class="help-text">
1125+
Global cap across all users. Blank = auto{autoTotalCacheGB !== null ? ` (disk − 5 GB ≈ ${autoTotalCacheGB} GB)` : ' (disk − 5 GB)'}. Oldest cached items across all users are auto-removed when exceeded. Use this when not bounded by a PVC.
1126+
</p>
1127+
{/if}
1128+
</div>
1129+
</div>
1130+
10981131
<div class="form-group">
10991132
<label class="toggle-label">
11001133
<input

0 commit comments

Comments
 (0)