Files
sendewerk/src/lib/server/live.ts
T
2026-10-25 09:45:31 +01:00

1239 lines
38 KiB
TypeScript

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<ShowSection, 'usages'> & { 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<string, number>();
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<ShowStatus, 'draft' | 'ready'>,
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;
}
}