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
|
|
|
}
|