fix: replace IndexedDB with Origin Private File System (OPFS)
OPFS (navigator.storage.getDirectory) provides direct filesystem
access scoped to the app's origin. Much more reliable than IndexedDB
for large blob storage — no transaction blocking, no connection issues.
Layout: recordings/{id}.frames (packed binary) + index.json (metadata)
Each recording stored as a single file write then close.
This commit is contained in:
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MJPEG Stream Viewer</title>
|
||||
<script type="module" crossorigin src="/assets/index-B6qcBY8r.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-2XEhnc0l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BTgh_FTp.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* IndexedDB wrapper — singleton connection, never closed during app lifetime.
|
||||
* Storage layer for MJPEG recordings.
|
||||
*
|
||||
* All frames packed into a single Blob per recording to avoid
|
||||
* multi-blob transaction issues.
|
||||
* Uses Origin Private File System (OPFS) for reliable large-blob storage,
|
||||
* with metadata stored alongside as a JSON index.
|
||||
*
|
||||
* Binary format inside `data`:
|
||||
* Directory layout under OPFS root:
|
||||
* recordings/
|
||||
* index.json — array of RecordingMeta
|
||||
* {id}.frames — packed binary blob (all frames)
|
||||
*
|
||||
* Binary format inside `{id}.frames`:
|
||||
* [4B BE uint32 frameCount]
|
||||
* Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes]
|
||||
*/
|
||||
@@ -14,62 +19,52 @@ export interface Frame {
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
export interface RecordingMeta {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string // ISO
|
||||
duration: number
|
||||
frameCount: number
|
||||
dataSize: number
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
duration: number
|
||||
frameCount: number
|
||||
/** Blob reference — lazy-loaded from OPFS */
|
||||
data: Blob
|
||||
}
|
||||
|
||||
const DB_NAME = 'mjpeg-viewer'
|
||||
const DB_VERSION = 2
|
||||
const STORE_NAME = 'recordings'
|
||||
const DIR = 'recordings'
|
||||
const INDEX_FILE = 'index.json'
|
||||
|
||||
// ---- Singleton connection ----
|
||||
let db: IDBDatabase | null = null
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
// ---- OPFS helpers ----
|
||||
|
||||
function getDB(): Promise<IDBDatabase> {
|
||||
if (db) return Promise.resolve(db)
|
||||
if (dbPromise) return dbPromise
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const d = request.result
|
||||
if (d.objectStoreNames.contains(STORE_NAME)) {
|
||||
d.deleteObjectStore(STORE_NAME)
|
||||
}
|
||||
d.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
async function getDir(): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
return root.getDirectoryHandle(DIR, { create: true })
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result
|
||||
// If connection is lost (browser closes it), reset
|
||||
db.onclose = () => {
|
||||
db = null
|
||||
dbPromise = null
|
||||
async function readIndex(dir: FileSystemDirectoryHandle): Promise<RecordingMeta[]> {
|
||||
try {
|
||||
const fh = await dir.getFileHandle(INDEX_FILE)
|
||||
const file = await fh.getFile()
|
||||
const text = await file.text()
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
// index doesn't exist yet
|
||||
return []
|
||||
}
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
dbPromise = null
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onblocked = () => {
|
||||
// A previous connection is still open — this shouldn't happen
|
||||
// with singleton pattern, but if it does, reject to trigger retry
|
||||
dbPromise = null
|
||||
reject(new Error('Database blocked by another connection'))
|
||||
}
|
||||
})
|
||||
|
||||
return dbPromise
|
||||
async function writeIndex(dir: FileSystemDirectoryHandle, index: RecordingMeta[]): Promise<void> {
|
||||
const fh = await dir.getFileHandle(INDEX_FILE, { create: true })
|
||||
const writable = await fh.createWritable()
|
||||
await writable.write(JSON.stringify(index))
|
||||
await writable.close()
|
||||
}
|
||||
|
||||
// ---- Binary pack / unpack ----
|
||||
@@ -114,45 +109,63 @@ export async function unpackFrames(data: Blob): Promise<Frame[]> {
|
||||
// ---- CRUD ----
|
||||
|
||||
export async function saveRecording(recording: Recording): Promise<void> {
|
||||
const d = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = d.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
store.put({
|
||||
...recording,
|
||||
const dir = await getDir()
|
||||
|
||||
// Write frame data blob
|
||||
const dataFh = await dir.getFileHandle(`${recording.id}.frames`, { create: true })
|
||||
const writable = await dataFh.createWritable()
|
||||
await writable.write(recording.data)
|
||||
await writable.close()
|
||||
|
||||
// Update index
|
||||
const index = await readIndex(dir)
|
||||
index.unshift({
|
||||
id: recording.id,
|
||||
name: recording.name,
|
||||
createdAt: recording.createdAt.toISOString(),
|
||||
duration: recording.duration,
|
||||
frameCount: recording.frameCount,
|
||||
dataSize: recording.data.size,
|
||||
})
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
tx.onabort = () => reject(new Error('Transaction aborted'))
|
||||
})
|
||||
await writeIndex(dir, index)
|
||||
}
|
||||
|
||||
export async function getAllRecordings(): Promise<Recording[]> {
|
||||
const d = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = d.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.getAll()
|
||||
request.onsuccess = () => {
|
||||
const results = (request.result || []).map((r: any) => ({
|
||||
...r,
|
||||
createdAt: new Date(r.createdAt),
|
||||
}))
|
||||
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
resolve(results)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
const dir = await getDir()
|
||||
const index = await readIndex(dir)
|
||||
|
||||
// Load data blob for each recording
|
||||
const results: Recording[] = []
|
||||
for (const meta of index) {
|
||||
try {
|
||||
const fh = await dir.getFileHandle(`${meta.id}.frames`)
|
||||
const file = await fh.getFile()
|
||||
results.push({
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
createdAt: new Date(meta.createdAt),
|
||||
duration: meta.duration,
|
||||
frameCount: meta.frameCount,
|
||||
data: file,
|
||||
})
|
||||
} catch {
|
||||
// Orphaned index entry — skip
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function deleteRecording(id: string): Promise<void> {
|
||||
const d = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = d.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
store.delete(id)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
const dir = await getDir()
|
||||
|
||||
// Delete frame data
|
||||
try {
|
||||
await dir.removeEntry(`${id}.frames`)
|
||||
} catch {
|
||||
// file already gone
|
||||
}
|
||||
|
||||
// Update index
|
||||
const index = await readIndex(dir)
|
||||
await writeIndex(dir, index.filter((m) => m.id !== id))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user