Files
aigen/server/utils/storage.ts
T

194 lines
6.1 KiB
TypeScript

import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { statfs } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { libraryRoot, ownerRoot } from '~/server/utils/library'
const execFileAsync = promisify(execFile)
const OVERLAY_TYPES = new Set(['overlay', 'aufs', 'fuse-overlayfs', 'overlayfs'])
export interface StorageBreakdown {
clips: number
stills: number
tmp: number
other: number
}
export interface VolumeStats {
ok: boolean
totalBytes: number | null
usedBytes: number | null
freeBytes: number | null
overlay: boolean
}
export interface LibraryStorage {
libraryBytes: number
breakdown: StorageBreakdown
volume: VolumeStats
counted: string
}
function emptyBreakdown(): StorageBreakdown {
return { clips: 0, stills: 0, tmp: 0, other: 0 }
}
function bucketFor(prefix: string): keyof StorageBreakdown {
if (prefix === 'files') return 'clips'
if (prefix === 'stills') return 'stills'
if (prefix === 'tmp') return 'tmp'
return 'other'
}
function walkDir(dir: string, breakdown: StorageBreakdown, prefix: string) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
walkDir(full, breakdown, prefix || entry.name)
continue
}
if (!entry.isFile()) continue
let size = 0
try {
size = statSync(full).size
} catch {
continue
}
breakdown[bucketFor(prefix)] += size
}
}
function ownerLibraryBytes(owner: string) {
const breakdown = emptyBreakdown()
const root = ownerRoot(owner)
if (existsSync(root)) walkDir(root, breakdown, '')
return {
libraryBytes: breakdown.clips + breakdown.stills + breakdown.tmp + breakdown.other,
breakdown
}
}
function decodeMountField(value: string) {
return value.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
}
function mountFor(target: string): { mountPoint: string; fstype: string; source: string } | null {
if (!existsSync('/proc/self/mountinfo')) return null
let text = ''
try {
text = readFileSync('/proc/self/mountinfo', 'utf8')
} catch {
return null
}
const normalized = target.replace(/\\/g, '/').replace(/\/$/, '') || '/'
let best: { mountPoint: string; fstype: string; source: string } | null = null
for (const line of text.split('\n')) {
if (!line.trim()) continue
const parts = line.split(' - ')
if (parts.length < 2) continue
const left = parts[0].split(' ')
const right = parts[1].split(' ')
const mountPoint = decodeMountField(left[4] || '')
const fstype = right[0] || ''
const source = decodeMountField(right[1] || '')
if (!mountPoint) continue
const prefix = mountPoint === '/' ? '/' : `${mountPoint.replace(/\/$/, '')}/`
const hit = normalized === mountPoint || normalized.startsWith(prefix) || `${normalized}/` === prefix
if (!hit) continue
if (!best || mountPoint.length > best.mountPoint.length) {
best = { mountPoint, fstype, source }
}
}
return best
}
function isOverlayFs(fstype: string, source: string) {
const type = (fstype || '').toLowerCase()
const src = (source || '').toLowerCase()
return OVERLAY_TYPES.has(type) || src === 'overlay'
}
async function volumeFromStatfs(path: string): Promise<Omit<VolumeStats, 'overlay'> | null> {
try {
const stats = await statfs(path)
const bsize = Number(stats.bsize)
const total = Number(stats.blocks) * bsize
const free = Number(stats.bavail) * bsize
if (!Number.isFinite(total) || total <= 0) return null
return {
ok: true,
totalBytes: total,
usedBytes: Math.max(0, total - (Number.isFinite(free) ? free : 0)),
freeBytes: Number.isFinite(free) ? Math.max(0, free) : null
}
} catch {
return null
}
}
async function volumeFromDf(path: string): Promise<Omit<VolumeStats, 'overlay'> | null> {
try {
const { stdout } = await execFileAsync('df', ['-kP', path], { timeout: 4000, windowsHide: true })
const lines = String(stdout || '').trim().split(/\r?\n/).filter(Boolean)
const row = lines[lines.length - 1]
if (!row || lines.length < 2) return null
const cols = row.trim().split(/\s+/)
if (cols.length < 6) return null
const totalK = Number(cols[cols.length - 5])
const usedK = Number(cols[cols.length - 4])
const availK = Number(cols[cols.length - 3])
if (!Number.isFinite(totalK) || totalK <= 0) return null
const total = totalK * 1024
const free = Number.isFinite(availK) ? availK * 1024 : null
const used = Number.isFinite(usedK) ? usedK * 1024 : (free == null ? null : Math.max(0, total - free))
return { ok: true, totalBytes: total, usedBytes: used, freeBytes: free }
} catch {
return null
}
}
function volumeProbePath() {
const root = libraryRoot()
const candidates = [root, dirname(resolve(root))]
for (const candidate of candidates) {
if (candidate && existsSync(candidate)) {
try {
return realpathSync(candidate)
} catch {
return resolve(candidate)
}
}
}
return resolve(root)
}
async function volumeStats(): Promise<VolumeStats> {
const path = volumeProbePath()
const mount = mountFor(path.replace(/\\/g, '/'))
const overlay = mount ? isOverlayFs(mount.fstype, mount.source) : false
const fromStatfs = await volumeFromStatfs(path)
if (fromStatfs) return { ...fromStatfs, overlay }
const fromDf = await volumeFromDf(path)
if (fromDf) return { ...fromDf, overlay }
return { ok: false, totalBytes: null, usedBytes: null, freeBytes: null, overlay }
}
export async function libraryStorage(owner: string): Promise<LibraryStorage> {
const { libraryBytes, breakdown } = ownerLibraryBytes(owner)
return {
libraryBytes,
breakdown,
volume: await volumeStats(),
counted: "This account's clips, stills, thumbs, catalogs, queues, and temp files. Shared pending JSON and other accounts are not included."
}
}