embedded meetus
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
* GET /api/original?path=<rel> stream the original file (native viewer / packaging)
|
||||
*/
|
||||
import type { Plugin } from 'vite'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
@@ -80,7 +80,7 @@ export function doocusApi(opts: Options): Plugin {
|
||||
server.middlewares.use('/api', (req, res, next) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
handle(res, parts, url.searchParams, req.headers.range).catch((err) => {
|
||||
handle(req, res, parts, url.searchParams).catch((err) => {
|
||||
server.config.logger.error(`[doocus-api] ${String(err)}`)
|
||||
sendJson(res, 500, { error: String(err) })
|
||||
}).then((handled) => { if (!handled) next() })
|
||||
@@ -88,7 +88,8 @@ export function doocusApi(opts: Options): Plugin {
|
||||
},
|
||||
}
|
||||
|
||||
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise<boolean> {
|
||||
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[], q: URLSearchParams): Promise<boolean> {
|
||||
const range = req.headers.range
|
||||
if (parts[0] === 'tree') {
|
||||
const index = readIndex()
|
||||
if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true }
|
||||
@@ -117,6 +118,14 @@ export function doocusApi(opts: Options): Plugin {
|
||||
return true
|
||||
}
|
||||
|
||||
// meetus-shaped run API (id = meeting rel path), so the composed meetus
|
||||
// review store works unchanged when embedded in doocus.
|
||||
if (parts[0] === 'runs' && parts.length >= 2) {
|
||||
const id = decodeURIComponent(parts[1])
|
||||
if (parts.length === 2 && req.method === 'GET') { await getRunShaped(res, id); return true }
|
||||
if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') { await saveRunReview(req, res, id); return true }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -125,10 +134,16 @@ export function doocusApi(opts: Options): Plugin {
|
||||
return { dir: path.join(outputDir, rel + '.meetus'), stem: path.parse(rel).name }
|
||||
}
|
||||
|
||||
async function getMeeting(res: ServerResponse, rel: string): Promise<void> {
|
||||
interface MeetingData {
|
||||
segments: Array<{ start: number; end: number; text: string; speaker: string | null }>
|
||||
frames: Array<{ file: string; url: string; time: number | null; size: number }>
|
||||
enhancedAvailable: boolean
|
||||
}
|
||||
|
||||
/** Read a meeting's .meetus output into the shared review shape. */
|
||||
async function meetingData(rel: string): Promise<MeetingData> {
|
||||
const { dir, stem } = meetusDirFor(rel)
|
||||
// Whisper segments (may be null-flagged in manifest but present on disk).
|
||||
const segments: Array<{ start: number; end: number; text: string; speaker: string | null }> = []
|
||||
const segments: MeetingData['segments'] = []
|
||||
const whisper = path.join(dir, `${stem}.json`)
|
||||
if (fs.existsSync(whisper)) {
|
||||
try {
|
||||
@@ -159,28 +174,66 @@ export function doocusApi(opts: Options): Plugin {
|
||||
} catch { /* none */ }
|
||||
}
|
||||
|
||||
const frames: Array<{ file: string; url: string; time: number | null }> = []
|
||||
const frames: MeetingData['frames'] = []
|
||||
try {
|
||||
for (const file of (await fsp.readdir(path.join(dir, 'frames'))).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) {
|
||||
const framesDir = path.join(dir, 'frames')
|
||||
for (const file of (await fsp.readdir(framesDir)).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) {
|
||||
const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i)
|
||||
let size = 0
|
||||
try { size = (await fsp.stat(path.join(framesDir, file))).size } catch { /* */ }
|
||||
frames.push({
|
||||
file,
|
||||
url: `/api/meeting/frame?path=${encodeURIComponent(rel)}&file=${encodeURIComponent(file)}`,
|
||||
time: fny ? Number(fny[1]) : times.get(file) ?? null,
|
||||
size,
|
||||
})
|
||||
}
|
||||
} catch { /* no frames */ }
|
||||
|
||||
return { segments, frames, enhancedAvailable }
|
||||
}
|
||||
|
||||
async function getMeeting(res: ServerResponse, rel: string): Promise<void> {
|
||||
const { dir } = meetusDirFor(rel)
|
||||
const data = await meetingData(rel)
|
||||
sendJson(res, 200, {
|
||||
path: rel,
|
||||
hasOutput: fs.existsSync(dir),
|
||||
segments,
|
||||
frames,
|
||||
enhancedAvailable,
|
||||
...data,
|
||||
videoUrl: `/api/original?path=${encodeURIComponent(rel)}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** meetus store's /api/runs/:id shape (id = meeting rel path). */
|
||||
async function getRunShaped(res: ServerResponse, id: string): Promise<void> {
|
||||
const { dir } = meetusDirFor(id)
|
||||
const data = await meetingData(id)
|
||||
let review: unknown = null
|
||||
try { review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8')) } catch { /* none */ }
|
||||
sendJson(res, 200, {
|
||||
id,
|
||||
manifest: { video: { name: path.basename(id) } },
|
||||
segments: data.segments,
|
||||
frames: data.frames,
|
||||
enhancedAvailable: data.enhancedAvailable,
|
||||
hasVideo: true,
|
||||
videoUrl: `/api/original?path=${encodeURIComponent(id)}`,
|
||||
review,
|
||||
})
|
||||
}
|
||||
|
||||
/** Persist the review sidecar next to the meeting's .meetus output (atomic). */
|
||||
async function saveRunReview(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
||||
const { dir } = meetusDirFor(id)
|
||||
if (!fs.existsSync(dir)) { sendJson(res, 404, { error: 'no meetus output for this meeting' }); return }
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse((await readBody(req)) || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }); return }
|
||||
const tmp = path.join(dir, `.review.${process.pid}.tmp`)
|
||||
await fsp.writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8')
|
||||
await fsp.rename(tmp, path.join(dir, 'review.json'))
|
||||
sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
function serveMeetingFrame(res: ServerResponse, rel: string, file: string): void {
|
||||
const { dir } = meetusDirFor(rel)
|
||||
const safe = path.basename(file)
|
||||
@@ -262,3 +315,12 @@ function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
||||
function isFile(p: string): boolean {
|
||||
try { return fs.statSync(p).isFile() } catch { return false }
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = ''
|
||||
req.on('data', (c) => { data += c; if (data.length > 5_000_000) reject(new Error('body too large')) })
|
||||
req.on('end', () => resolve(data))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user