Use the parent clip last frame and new-segment join for extensions, not the stitched file's first frame. Show this account's library storage in Settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user