import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import type Database from 'better-sqlite3'; import { getServerPaths } from './config.js'; import { getDatabase } from './database.js'; import { renderMarkdown } from './markdown.js'; import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js'; import { getShow, movePieceUsage, type MovePieceUsageInput, type PieceUsage, type ShowStatus, type ShowSection } from './shows.js'; type LiveStateRow = { show_id: string; revision: number; current_usage_id: string | null; started_at_utc: string | null; ended_at_utc: string | null; presentation_usage_id: string | null; presentation_revision: number; updated_at_utc: string; }; type OrderedUsageRow = { id: string; section_id: string; section_name: string; section_position: number; position: number; status: PieceUsage['status']; }; export type LiveUsage = PieceUsage & { sectionId: string; sectionName: string; actualDurationSeconds: number; introHtml: string; researchHtml: string; moderationHtml: string; interactionHtml: string; transitionHtml: string; }; export type LiveSection = Omit & { usages: LiveUsage[] }; export type LiveView = { show: { id: string; title: string; status: string; revision: number; totalDurationSeconds: number; }; liveRevision: number; calculatedAtUtc: string; startedAtUtc: string | null; endedAtUtc: string | null; isPaused: boolean; updatedAtUtc: string; presentationUsageId: string | null; presentationRevision: number; sections: LiveSection[]; reserve: LiveUsage[]; current: LiveUsage | null; next: LiveUsage | null; completedCount: number; totalCount: number; sectionTimer: null | { sectionId: string; sectionName: string; plannedDurationSeconds: number; elapsedSeconds: number; runningSinceUtc: string | null; }; totalTimer: { elapsedSeconds: number; runningSinceUtc: string | null; }; remainingEstimate: { seconds: number; activeCountdownSeconds: number; unplannedSectionCount: number; }; }; export type PresentationView = { revision: number; content: null | { mode: PresentationContent['mode']; topics: string[]; title: string; html: string; source: PresentationContent['source']; previewTitle: string; description: string; imageUrl: string; siteName: string; provider: PresentationContent['provider']; providerId: string; embedUrl: string; sources: { label: string; url: string; timecodeSeconds: number | null }[]; images: { id: string; altText: string }[]; }; }; function hashToken(token: string) { return createHash('sha256').update(token).digest('hex'); } function nowIso(now?: Date) { return (now ?? new Date()).toISOString(); } function requireCommandId(commandId: string) { if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND'); } function ensureLiveState(database: Database.Database, showId: string, now: string): LiveStateRow { const show = database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId); if (!show) throw new Error('NOT_FOUND'); database .prepare( `INSERT OR IGNORE INTO show_live_states (show_id, updated_at_utc) VALUES (?, ?)` ) .run(showId, now); return database .prepare('SELECT * FROM show_live_states WHERE show_id = ?') .get(showId) as LiveStateRow; } function orderedUsages(database: Database.Database, showId: string): OrderedUsageRow[] { return database .prepare( `SELECT u.id, s.id AS section_id, s.name AS section_name, s.position AS section_position, u.position, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY s.position, u.position` ) .all(showId) as OrderedUsageRow[]; } function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) { const row = database .prepare( `SELECT s.is_reserve_bank FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE u.id = ? AND s.show_id = ?` ) .get(usageId, showId) as { is_reserve_bank: number } | undefined; if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND'); } function closeActiveRun(database: Database.Database, showId: string, now: string) { database .prepare( `UPDATE live_usage_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL` ) .run(now, showId); } function closeShowTimerRun(database: Database.Database, showId: string, now: string) { database .prepare( `UPDATE live_show_timer_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL` ) .run(now, showId); } function openShowTimerRun(database: Database.Database, showId: string, now: string) { const active = database .prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL') .get(showId); if (!active) { database .prepare( `INSERT INTO live_show_timer_runs (id, show_id, started_at_utc) VALUES (?, ?, ?)` ) .run(randomUUID(), showId, now); } } function showTimerRunning(database: Database.Database, showId: string) { return Boolean( database .prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL') .get(showId) ); } function elapsedRuns(runs: { started_at_utc: string; ended_at_utc: string | null }[], now: string) { return runs.reduce( (total, run) => total + Math.max( 0, Math.floor( (new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) / 1_000 ) ), 0 ); } function closeSectionTimer(database: Database.Database, showId: string, now: string) { const timer = database .prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL') .get(showId) as { id: string } | undefined; if (!timer) return; database .prepare( 'UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id = ? AND ended_at_utc IS NULL' ) .run(now, timer.id); database .prepare('UPDATE live_section_timers SET ended_at_utc = ? WHERE id = ?') .run(now, timer.id); } function resumeSectionTimerRun(database: Database.Database, showId: string, now: string) { const timer = database .prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL') .get(showId) as { id: string } | undefined; if (!timer) return; const running = database .prepare('SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL') .get(timer.id); if (!running) { database .prepare( `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) VALUES (?, ?, ?)` ) .run(randomUUID(), timer.id, now); } } function syncSectionTimer( database: Database.Database, showId: string, usageId: string, now: string ) { const section = database .prepare( `SELECT s.id, s.explicit_duration_seconds FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0` ) .get(usageId, showId) as { id: string; explicit_duration_seconds: number | null } | undefined; if (!section) throw new Error('NOT_FOUND'); const active = database .prepare( 'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL' ) .get(showId) as { id: string; section_id: string } | undefined; if (active?.section_id === section.id) return; if (active) closeSectionTimer(database, showId, now); if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return; const timerId = randomUUID(); database .prepare( `INSERT INTO live_section_timers (id, show_id, section_id, planned_duration_seconds, started_at_utc) VALUES (?, ?, ?, ?, ?)` ) .run(timerId, showId, section.id, section.explicit_duration_seconds, now); database .prepare( `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) VALUES (?, ?, ?)` ) .run(randomUUID(), timerId, now); } function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) { usageBelongsToShow(database, showId, usageId); database .prepare( `UPDATE piece_usages SET status = 'open' WHERE status = 'current' AND id != ? AND id IN ( SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE s.show_id = ? )` ) .run(usageId, showId); database.prepare(`UPDATE piece_usages SET status = 'current' WHERE id = ?`).run(usageId); database .prepare( `INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc) VALUES (?, ?, ?, ?)` ) .run(randomUUID(), showId, usageId, now); database .prepare( 'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?' ) .run(usageId, showId); syncSectionTimer(database, showId, usageId, now); } function executeCommand( showId: string, commandId: string, expectedRevision: number, action: string, operation: (database: Database.Database, state: LiveStateRow, now: string) => void, nowValue?: Date, allowCompleted = false ) { requireCommandId(commandId); const database = getDatabase(); return database.transaction(() => { const duplicate = database .prepare('SELECT result_revision FROM live_commands WHERE show_id = ? AND command_id = ?') .get(showId, commandId) as { result_revision: number } | undefined; if (duplicate) return { revision: duplicate.result_revision, duplicate: true }; const now = nowIso(nowValue); const state = ensureLiveState(database, showId, now); const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as { status: string; }; if (database.prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)) { throw new Error('SHOW_COMPLETED'); } if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED'); if (state.revision !== expectedRevision) throw new Error('CONFLICT'); operation(database, state, now); const updated = database .prepare( `UPDATE show_live_states SET revision = revision + 1, updated_at_utc = ? WHERE show_id = ? AND revision = ?` ) .run(now, showId, expectedRevision); if (updated.changes !== 1) throw new Error('CONFLICT'); const revision = expectedRevision + 1; database .prepare( `INSERT INTO live_commands (show_id, command_id, action, result_revision, created_at_utc) VALUES (?, ?, ?, ?, ?)` ) .run(showId, commandId, action, revision, now); return { revision, duplicate: false }; })(); } function actualDurations(database: Database.Database, showId: string, now: string) { const rows = database .prepare( `SELECT usage_id, started_at_utc, ended_at_utc FROM live_usage_runs WHERE show_id = ?` ) .all(showId) as { usage_id: string; started_at_utc: string; ended_at_utc: string | null }[]; const durations = new Map(); for (const row of rows) { const seconds = Math.max( 0, Math.floor( (new Date(row.ended_at_utc ?? now).getTime() - new Date(row.started_at_utc).getTime()) / 1000 ) ); durations.set(row.usage_id, (durations.get(row.usage_id) ?? 0) + seconds); } return durations; } function liveUsage( usage: PieceUsage, sectionId: string, sectionName: string, actualSeconds: number ) { return { ...usage, sectionId, sectionName, actualDurationSeconds: actualSeconds, introHtml: renderMarkdown(usage.intro), researchHtml: renderMarkdown(usage.research), moderationHtml: renderMarkdown(usage.moderation), interactionHtml: renderMarkdown(usage.interaction), transitionHtml: renderMarkdown(usage.transition) }; } export function getLiveView(showId: string, nowValue?: Date): LiveView { const database = getDatabase(); const now = nowIso(nowValue); const state = ensureLiveState(database, showId, now); const show = getShow(showId); if (!show) throw new Error('NOT_FOUND'); const durations = actualDurations(database, showId, now); const sections = show.sections.map((section) => ({ ...section, usages: section.usages.map((usage) => liveUsage(usage, section.id, section.name, durations.get(usage.id) ?? 0) ) })); const reserve = show.reserve.map((usage) => liveUsage(usage, '', 'Reservebank', durations.get(usage.id) ?? 0) ); const usages = sections.flatMap((section) => section.usages); const current = usages.find((usage) => usage.id === state.current_usage_id) ?? null; const currentIndex = current ? usages.findIndex((usage) => usage.id === current.id) : -1; const next = usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ?? (current ? null : (usages.find((usage) => usage.status === 'open') ?? null)); const timer = database .prepare( `SELECT t.id, t.section_id, s.name AS section_name, t.planned_duration_seconds FROM live_section_timers t JOIN show_sections s ON s.id = t.section_id WHERE t.show_id = ? AND t.ended_at_utc IS NULL` ) .get(showId) as | { id: string; section_id: string; section_name: string; planned_duration_seconds: number } | undefined; let sectionTimer: LiveView['sectionTimer'] = null; if (timer) { const runs = database .prepare( `SELECT started_at_utc, ended_at_utc FROM live_section_timer_runs WHERE timer_id = ? ORDER BY started_at_utc` ) .all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[]; const running = runs.find((run) => run.ended_at_utc === null) ?? null; const elapsedSeconds = elapsedRuns(runs, now); sectionTimer = { sectionId: timer.section_id, sectionName: timer.section_name, plannedDurationSeconds: timer.planned_duration_seconds, elapsedSeconds, runningSinceUtc: running?.started_at_utc ?? null }; } const totalTimerRuns = database .prepare( `SELECT started_at_utc, ended_at_utc FROM live_show_timer_runs WHERE show_id = ? ORDER BY started_at_utc` ) .all(showId) as { started_at_utc: string; ended_at_utc: string | null }[]; const runningTotalTimer = totalTimerRuns.find((run) => run.ended_at_utc === null) ?? null; const totalElapsedSeconds = elapsedRuns(totalTimerRuns, now); const remainingAnchor = current ?? next; let reachedRemaining = !remainingAnchor; let remainingSeconds = 0; let activeCountdownSeconds = 0; let unplannedSectionCount = 0; for (const section of sections) { if (section.id === remainingAnchor?.sectionId) reachedRemaining = true; if (!reachedRemaining) continue; const remainingUsages = section.usages.filter( (usage) => usage.status === 'open' || usage.status === 'current' ); if (remainingUsages.length === 0) continue; if (section.id === current?.sectionId) { if (section.durationSeconds > 0 && sectionTimer?.sectionId === section.id) { activeCountdownSeconds = Math.max(0, section.durationSeconds - sectionTimer.elapsedSeconds); remainingSeconds += activeCountdownSeconds; } else { remainingSeconds += remainingUsages.reduce((total, usage) => { const elapsed = usage.id === current?.id ? usage.actualDurationSeconds : 0; const remainder = Math.max(0, usage.durationSeconds - elapsed); if (usage.id === current?.id) activeCountdownSeconds = remainder; return total + remainder; }, 0); if (section.durationSeconds <= 0) unplannedSectionCount += 1; } } else if (section.durationSeconds > 0) { remainingSeconds += section.durationSeconds; } else { remainingSeconds += remainingUsages.reduce( (total, usage) => total + usage.durationSeconds, 0 ); unplannedSectionCount += 1; } } return { show: { id: show.id, title: show.title, status: show.status, revision: show.revision, totalDurationSeconds: show.totalDurationSeconds }, liveRevision: state.revision, calculatedAtUtc: now, startedAtUtc: state.started_at_utc, endedAtUtc: state.ended_at_utc, isPaused: Boolean(state.started_at_utc && !state.ended_at_utc && !runningTotalTimer), updatedAtUtc: state.updated_at_utc, presentationUsageId: state.presentation_usage_id, presentationRevision: state.presentation_revision, sections, reserve, current, next, completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped') .length, totalCount: usages.length, sectionTimer, totalTimer: { elapsedSeconds: totalElapsedSeconds, runningSinceUtc: runningTotalTimer?.started_at_utc ?? null }, remainingEstimate: { seconds: remainingSeconds, activeCountdownSeconds, unplannedSectionCount } }; } export function startOrResumeLive( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'start', (database, state, now) => { database .prepare( `UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ? AND status != 'completed'` ) .run(now, showId); database .prepare( `UPDATE show_live_states SET started_at_utc = COALESCE(started_at_utc, ?), ended_at_utc = NULL WHERE show_id = ?` ) .run(now, showId); openShowTimerRun(database, showId, now); const activeRun = database .prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL') .get(showId); if (state.current_usage_id && activeRun) { resumeSectionTimerRun(database, showId, now); return; } const current = state.current_usage_id ? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id) : undefined; const target = current ?? orderedUsages(database, showId).find((usage) => usage.status === 'open'); if (target) activateUsage(database, showId, target.id, now); }, nowValue ); } export function pauseLive( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'pause', (database, state, now) => { if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE'); if (!showTimerRunning(database, showId)) throw new Error('ALREADY_PAUSED'); closeShowTimerRun(database, showId, now); closeActiveRun(database, showId, now); database .prepare( `UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id IN ( SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL ) AND ended_at_utc IS NULL` ) .run(now, showId); }, nowValue ); } export function resumeLive( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'resume', (database, state, now) => { if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE'); if (showTimerRunning(database, showId)) throw new Error('ALREADY_RUNNING'); openShowTimerRun(database, showId, now); if (state.current_usage_id) { database .prepare( `INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc) VALUES (?, ?, ?, ?)` ) .run(randomUUID(), showId, state.current_usage_id, now); } resumeSectionTimerRun(database, showId, now); }, nowValue ); } export function resetSectionTimer( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'reset-section-timer', (database, _state, now) => { const timer = database .prepare( `SELECT section_id, planned_duration_seconds FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL` ) .get(showId) as { section_id: string; planned_duration_seconds: number } | undefined; if (!timer) throw new Error('NO_SECTION_TIMER'); const running = showTimerRunning(database, showId); closeSectionTimer(database, showId, now); const timerId = randomUUID(); database .prepare( `INSERT INTO live_section_timers (id, show_id, section_id, planned_duration_seconds, started_at_utc) VALUES (?, ?, ?, ?, ?)` ) .run(timerId, showId, timer.section_id, timer.planned_duration_seconds, now); if (running) { database .prepare( `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) VALUES (?, ?, ?)` ) .run(randomUUID(), timerId, now); } }, nowValue ); } export function resetTotalTimer( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'reset-total-timer', (database, state, now) => { if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE'); const running = showTimerRunning(database, showId); database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId); if (running) openShowTimerRun(database, showId, now); }, nowValue ); } export function setShowStatus( showId: string, commandId: string, expectedRevision: number, expectedShowRevision: number, status: Extract, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, `status-${status}`, (database, state, now) => { if (state.started_at_utc) throw new Error('ALREADY_STARTED'); const result = database .prepare( 'UPDATE shows SET status = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?' ) .run(status, now, showId, expectedShowRevision); if (result.changes !== 1) throw new Error('CONFLICT'); }, nowValue ); } export function endLive( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'end', (database, state, now) => { if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE'); closeShowTimerRun(database, showId, now); closeActiveRun(database, showId, now); database .prepare( `UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id IN ( SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL ) AND ended_at_utc IS NULL` ) .run(now, showId); database .prepare(`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`) .run(now, showId); database .prepare(`UPDATE shows SET revision = revision + 1, updated_at_utc = ? WHERE id = ?`) .run(now, showId); }, nowValue ); } export function resumeEndedLive( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'resume-ended', (database, state, now) => { if (!state.ended_at_utc) throw new Error('NOT_ENDED'); database .prepare( `UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?` ) .run(now, showId); database .prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?') .run(showId); openShowTimerRun(database, showId, now); if (state.current_usage_id) { database .prepare( `INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc) VALUES (?, ?, ?, ?)` ) .run(randomUUID(), showId, state.current_usage_id, now); resumeSectionTimerRun(database, showId, now); } }, nowValue, true ); } export function startNewLiveRun( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'start-new-run', (database, state, now) => { if (!state.ended_at_utc) throw new Error('NOT_ENDED'); database.prepare('DELETE FROM live_usage_runs WHERE show_id = ?').run(showId); database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId); database.prepare('DELETE FROM live_section_timers WHERE show_id = ?').run(showId); database .prepare( `UPDATE piece_usages SET status = 'open' WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)` ) .run(showId); database .prepare( `UPDATE show_live_states SET current_usage_id = NULL, started_at_utc = ?, ended_at_utc = NULL WHERE show_id = ?` ) .run(now, showId); database .prepare( `UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?` ) .run(now, showId); openShowTimerRun(database, showId, now); const target = orderedUsages(database, showId).find((usage) => usage.status === 'open'); if (target) activateUsage(database, showId, target.id, now); }, nowValue, true ); } function advanceCurrent( showId: string, commandId: string, expectedRevision: number, status: 'done' | 'skipped', nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, status, (database, state, now) => { if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED'); if (!state.current_usage_id) throw new Error('NO_CURRENT'); const ordered = orderedUsages(database, showId); const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id); if (currentIndex < 0) throw new Error('NOT_FOUND'); closeActiveRun(database, showId, now); database .prepare('UPDATE piece_usages SET status = ? WHERE id = ?') .run(status, state.current_usage_id); const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open'); if (next) activateUsage(database, showId, next.id, now); else { closeSectionTimer(database, showId, now); database .prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?') .run(showId); } }, nowValue ); } export function completeCurrent( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return advanceCurrent(showId, commandId, expectedRevision, 'done', nowValue); } export function skipCurrent( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return advanceCurrent(showId, commandId, expectedRevision, 'skipped', nowValue); } export function goBack( showId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'back', (database, state, now) => { if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED'); const ordered = orderedUsages(database, showId); const currentIndex = state.current_usage_id ? ordered.findIndex((usage) => usage.id === state.current_usage_id) : ordered.length; const previous = ordered .slice(0, Math.max(0, currentIndex)) .reverse() .find((usage) => usage.status === 'done' || usage.status === 'skipped'); if (!previous) throw new Error('NO_PREVIOUS'); if (state.current_usage_id) { closeActiveRun(database, showId, now); database .prepare(`UPDATE piece_usages SET status = 'open' WHERE id = ?`) .run(state.current_usage_id); } activateUsage(database, showId, previous.id, now); }, nowValue ); } export function selectUsage( showId: string, usageId: string, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, 'select', (database, state, now) => { if (!state.started_at_utc) throw new Error('NOT_STARTED'); if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED'); usageBelongsToShow(database, showId, usageId); if (state.current_usage_id === usageId) return; if (state.current_usage_id) { closeActiveRun(database, showId, now); database .prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?") .run(state.current_usage_id); } activateUsage(database, showId, usageId, now); }, nowValue ); } export function presentUsage( showId: string, usageId: string | null, commandId: string, expectedRevision: number, nowValue?: Date ) { return executeCommand( showId, commandId, expectedRevision, usageId ? 'present' : 'clear-presentation', (database, _state, now) => { if (usageId) usageBelongsToShow(database, showId, usageId); database .prepare( `UPDATE show_live_states SET presentation_usage_id = ?, presentation_revision = presentation_revision + 1, updated_at_utc = ? WHERE show_id = ?` ) .run(usageId, now, showId); }, nowValue ); } export function moveOpenUsage( showId: string, usageId: string, expectedShowRevision: number, input: MovePieceUsageInput ) { const database = getDatabase(); const usage = database .prepare( `SELECT u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE u.id = ? AND s.show_id = ?` ) .get(usageId, showId) as { status: PieceUsage['status'] } | undefined; if (!usage) throw new Error('NOT_FOUND'); if (usage.status !== 'open') throw new Error('LIVE_ORDER_LOCKED'); if (input.sectionId) { const target = database .prepare( `SELECT u.id, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE s.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY u.position` ) .all(input.sectionId, showId) as { id: string; status: PieceUsage['status'] }[]; if (!target.length) { const section = database .prepare('SELECT 1 FROM show_sections WHERE id = ? AND show_id = ? AND is_reserve_bank = 0') .get(input.sectionId, showId); if (!section) throw new Error('NOT_FOUND'); } const withoutMoved = target.filter((item) => item.id !== usageId); const firstOpenPosition = withoutMoved.findIndex((item) => item.status === 'open'); const minimum = firstOpenPosition < 0 ? withoutMoved.length : firstOpenPosition; if (input.position < minimum) throw new Error('LIVE_ORDER_LOCKED'); } return movePieceUsage(showId, usageId, expectedShowRevision, input); } export function issuePresentationAccess(showId: string) { const database = getDatabase(); const now = nowIso(); ensureLiveState(database, showId, now); return database.transaction(() => { let channel = database .prepare("SELECT * FROM presentation_channels WHERE id = 'default'") .get() as { access_key: string; show_id: string | null } | undefined; if (!channel) { const accessKey = randomBytes(32).toString('base64url'); clearPresentationState(database, showId); database .prepare( `INSERT INTO presentation_channels (id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc) VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)` ) .run(accessKey, hashToken(accessKey), showId, now, now); channel = { access_key: accessKey, show_id: showId }; } else if (channel.show_id !== showId) { clearPresentationState(database, showId); database .prepare( `UPDATE presentation_channels SET show_id = ?, revision = revision + 1, updated_at_utc = ? WHERE id = 'default'` ) .run(showId, now); } return channel.access_key; })(); } function clearPresentationState(database: Database.Database, showId: string) { database .prepare( `UPDATE show_live_states SET presentation_usage_id = NULL, presentation_revision = presentation_revision + 1, updated_at_utc = ? WHERE show_id = ? AND presentation_usage_id IS NOT NULL` ) .run(nowIso(), showId); } export function clearPresentationChannel() { const now = nowIso(); getDatabase().transaction(() => { const database = getDatabase(); const channel = database .prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'") .get() as { show_id: string | null } | undefined; if (!channel) throw new Error('NOT_FOUND'); if (channel.show_id) clearPresentationState(database, channel.show_id); database .prepare( `UPDATE presentation_channels SET show_id = NULL, revision = revision + 1, updated_at_utc = ? WHERE id = 'default'` ) .run(now); })(); } export function rotatePresentationChannelKey(showId: string) { issuePresentationAccess(showId); const accessKey = randomBytes(32).toString('base64url'); const now = nowIso(); getDatabase() .prepare( `UPDATE presentation_channels SET access_key = ?, key_hash = ?, revision = revision + 1, updated_at_utc = ? WHERE id = 'default'` ) .run(accessKey, hashToken(accessKey), now); return accessKey; } export function presentationChannelAccess(channelId: string, accessKey: string) { if (!channelId || !accessKey || accessKey.length > 200) return false; return Boolean( getDatabase() .prepare('SELECT 1 FROM presentation_channels WHERE id = ? AND key_hash = ?') .get(channelId, hashToken(accessKey)) ); } function presentationShowId(database: Database.Database, token: string) { if (!token || token.length > 200) return null; const channel = database .prepare('SELECT show_id FROM presentation_channels WHERE key_hash = ?') .get(hashToken(token)) as { show_id: string | null } | undefined; if (channel) return channel.show_id; const row = database .prepare( `SELECT show_id FROM presentation_access_tokens WHERE token_hash = ? AND revoked_at_utc IS NULL` ) .get(hashToken(token)) as { show_id: string } | undefined; return row?.show_id ?? null; } export function getPresentationChannelView(channelId: string): PresentationView | null { const channel = getDatabase() .prepare('SELECT show_id, revision FROM presentation_channels WHERE id = ?') .get(channelId) as { show_id: string | null; revision: number } | undefined; if (!channel) return null; if (!channel.show_id) return { revision: channel.revision * 1_000_000_000, content: null }; const view = getPresentationViewForShow(channel.show_id); return { ...view, revision: channel.revision * 1_000_000_000 + view.revision }; } export function getPresentationView(token: string): PresentationView | null { const database = getDatabase(); const showId = presentationShowId(database, token); if (!showId) return null; return getPresentationViewForShow(showId); } function getPresentationViewForShow(showId: string): PresentationView { const database = getDatabase(); const state = ensureLiveState(database, showId, nowIso()); if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; const usage = database .prepare( `SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, u.presentation_provider_snapshot, u.presentation_provider_id_snapshot FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0` ) .get(state.presentation_usage_id, showId) as | { title_snapshot: string; output_title_snapshot: string; presentation_snapshot: string; presentation_mode_snapshot: PresentationContent['mode']; presentation_source_label_snapshot: string; presentation_source_url_snapshot: string; presentation_source_timecode_snapshot: number | null; presentation_title_meta_snapshot: string; presentation_description_snapshot: string; presentation_image_url_snapshot: string; presentation_site_name_snapshot: string; presentation_provider_snapshot: PresentationContent['provider']; presentation_provider_id_snapshot: string; } | undefined; if (!usage) return { revision: state.presentation_revision, content: null }; return { revision: state.presentation_revision, content: { mode: usage.presentation_mode_snapshot, topics: ( database .prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') .all(state.presentation_usage_id) as { name: string }[] ).map((topic) => topic.name), title: usage.output_title_snapshot || usage.title_snapshot, html: renderMarkdown(usage.presentation_snapshot), source: usage.presentation_source_url_snapshot ? { label: usage.presentation_source_label_snapshot, url: usage.presentation_source_url_snapshot, timecodeSeconds: usage.presentation_source_timecode_snapshot } : null, previewTitle: usage.presentation_title_meta_snapshot, description: usage.presentation_description_snapshot, imageUrl: usage.presentation_image_url_snapshot, siteName: usage.presentation_site_name_snapshot, provider: usage.presentation_provider_snapshot, providerId: usage.presentation_provider_id_snapshot, embedUrl: buildEmbedUrl( usage.presentation_provider_snapshot, usage.presentation_provider_id_snapshot, usage.presentation_source_timecode_snapshot ), sources: [], images: ( database .prepare( `SELECT id, alt_text FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1` ) .all(state.presentation_usage_id) as { id: string; alt_text: string }[] ).map((media) => ({ id: media.id, altText: media.alt_text })) } }; } export async function getPresentationPreviewImage(token: string) { const view = getPresentationView(token); if (!view?.content?.imageUrl || view.content.mode === 'text') return null; try { return await fetchPresentationImage(view.content.imageUrl); } catch { return null; } } export async function getPresentationMedia( token: string, snapshotId: string ): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> { const database = getDatabase(); const showId = presentationShowId(database, token); if (!showId || !snapshotId) return null; const media = database .prepare( `SELECT pm.storage_name, pm.mime_type, pm.byte_size, ums.alt_text FROM show_live_states state JOIN usage_media_snapshots ums ON ums.usage_id = state.presentation_usage_id JOIN piece_usages u ON u.id = ums.usage_id JOIN show_sections s ON s.id = u.show_section_id JOIN piece_media pm ON pm.id = ums.original_media_id WHERE state.show_id = ? AND s.show_id = state.show_id AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1` ) .get(showId, snapshotId) as { storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined; if (!media) return null; try { return { data: await readFile(path.join(getServerPaths().uploads, media.storage_name)), mimeType: media.mime_type, byteSize: media.byte_size, altText: media.alt_text }; } catch { return null; } }