Files
api-HydrusNetwork/src/libraryCache.ts

164 lines
5.4 KiB
TypeScript
Raw Normal View History

2026-03-26 03:26:37 -07:00
import type { Track } from './types'
type CacheServerDescriptor = {
id: string
host: string
port?: string | number
ssl?: boolean
}
type PersistedTrack = Omit<Track, 'id'>
type LibraryCacheRecord = {
cacheKey: string
updatedAt: number
tracks: PersistedTrack[]
searchCache: Record<string, number[]>
}
export type LibraryCacheSnapshot = {
tracks: PersistedTrack[]
searchCache: Record<string, number[]>
}
2026-03-26 13:10:00 -07:00
export type LibraryCacheStats = {
snapshotCount: number
staleSnapshotCount: number
totalBytes: number
activeBytes: number
trackCount: number
searchEntryCount: number
updatedAt: number | null
}
2026-03-26 03:26:37 -07:00
const DATABASE_NAME = 'api-mediaplayer-library-cache'
const DATABASE_VERSION = 1
const STORE_NAME = 'snapshots'
const MAX_TRACKS = 5000
const MAX_SEARCHES = 250
2026-03-26 13:10:00 -07:00
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
2026-03-26 03:26:37 -07:00
export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) {
return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|')
}
function openLibraryCacheDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.onupgradeneeded = () => {
const database = request.result
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'cacheKey' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open library cache database'))
})
}
function withStore<T>(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
openLibraryCacheDatabase()
.then((database) => {
const transaction = database.transaction(STORE_NAME, mode)
const store = transaction.objectStore(STORE_NAME)
const request = handler(store)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
transaction.oncomplete = () => database.close()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
.catch(reject)
})
}
2026-03-26 13:10:00 -07:00
function estimateRecordBytes(record: LibraryCacheRecord) {
const payload = JSON.stringify(record)
if (!textEncoder) return payload.length
return textEncoder.encode(payload).length
}
async function listLibraryCacheRecords(): Promise<LibraryCacheRecord[]> {
if (typeof indexedDB === 'undefined') return []
const records = await withStore<LibraryCacheRecord[]>('readonly', (store) => store.getAll())
return Array.isArray(records) ? records : []
}
2026-03-26 03:26:37 -07:00
export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSnapshot | null> {
if (!cacheKey || typeof indexedDB === 'undefined') return null
const record = await withStore<LibraryCacheRecord | undefined>('readonly', (store) => store.get(cacheKey))
if (!record) return null
return {
tracks: Array.isArray(record.tracks) ? record.tracks : [],
searchCache: record.searchCache && typeof record.searchCache === 'object' ? record.searchCache : {},
}
}
2026-03-26 13:10:00 -07:00
export async function pruneLibraryCache(activeCacheKey: string) {
if (!activeCacheKey || typeof indexedDB === 'undefined') return 0
const records = await listLibraryCacheRecords()
const staleKeys = records
.map((record) => record.cacheKey)
.filter((cacheKey) => cacheKey !== activeCacheKey)
if (staleKeys.length === 0) return 0
await Promise.all(staleKeys.map((cacheKey) => withStore('readwrite', (store) => store.delete(cacheKey))))
return staleKeys.length
}
export async function getLibraryCacheStats(activeCacheKey: string): Promise<LibraryCacheStats> {
if (!activeCacheKey || typeof indexedDB === 'undefined') {
return {
snapshotCount: 0,
staleSnapshotCount: 0,
totalBytes: 0,
activeBytes: 0,
trackCount: 0,
searchEntryCount: 0,
updatedAt: null,
}
}
const records = await listLibraryCacheRecords()
const activeRecord = records.find((record) => record.cacheKey === activeCacheKey)
return {
snapshotCount: records.length,
staleSnapshotCount: records.filter((record) => record.cacheKey !== activeCacheKey).length,
totalBytes: records.reduce((sum, record) => sum + estimateRecordBytes(record), 0),
activeBytes: activeRecord ? estimateRecordBytes(activeRecord) : 0,
trackCount: activeRecord?.tracks.length ?? 0,
searchEntryCount: activeRecord ? Object.keys(activeRecord.searchCache || {}).length : 0,
updatedAt: activeRecord?.updatedAt ?? null,
}
}
2026-03-26 03:26:37 -07:00
export async function saveLibraryCache(cacheKey: string, tracks: Track[], searchCache: Record<string, number[]>) {
if (!cacheKey || typeof indexedDB === 'undefined') return
const trimmedTracks = tracks
.filter((track) => track.serverId && track.fileId != null && track.url)
.slice(-MAX_TRACKS)
.map(({ id: _id, ...track }) => track)
const trimmedSearchCache = Object.fromEntries(Object.entries(searchCache).slice(-MAX_SEARCHES))
const record: LibraryCacheRecord = {
cacheKey,
updatedAt: Date.now(),
tracks: trimmedTracks,
searchCache: trimmedSearchCache,
}
await withStore('readwrite', (store) => store.put(record))
2026-03-26 13:10:00 -07:00
await pruneLibraryCache(cacheKey)
2026-03-26 03:26:37 -07:00
}