|
1 | 1 | import { prisma } from '../db'; |
2 | 2 | import { sseEmitter } from '../sse/emitter'; |
3 | 3 | 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'; |
5 | 5 | import { join, basename, resolve, extname, sep } from 'path'; |
6 | 6 | import { musicMetadataService } from './music-metadata.service'; |
7 | 7 | import { ytdlpService } from './ytdlp.service'; |
@@ -286,6 +286,108 @@ class LibraryService { |
286 | 286 | }; |
287 | 287 | } |
288 | 288 |
|
| 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 | + |
289 | 391 | async getLibraryUsage(): Promise<{ video: { usedBytes: string; count: number } | null; music: { usedBytes: string; count: number } | null }> { |
290 | 392 | const settings = await this.getSettings(); |
291 | 393 |
|
|
0 commit comments