Release 0.5.8

This commit is contained in:
hnzio committed 2026-10-23 07:47:27 +02:00
1 parent 22ae3a68d6
commit 2851c7d936
33 files changed
+1263 -272

No files matched your search

+134 -56
View File
@@ -1,6 +1,5 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -414,6 +413,34 @@ export function goBack(
);
}
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');
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,
@@ -480,25 +507,90 @@ export function issuePresentationAccess(showId: string) {
const database = getDatabase();
const now = nowIso();
ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url');
database.transaction(() => {
database
.prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
)
.run(now, showId);
database
.prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, hashToken(token), 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;
})();
return token;
}
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
@@ -508,15 +600,30 @@ function presentationShowId(database: Database.Database, token: string) {
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.presentation_snapshot, u.original_piece_id,
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id,
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,
@@ -528,6 +635,7 @@ export function getPresentationView(token: string): PresentationView | null {
.get(state.presentation_usage_id, showId) as
| {
title_snapshot: string;
output_title_snapshot: string;
presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode'];
@@ -543,33 +651,11 @@ export function getPresentationView(token: string): PresentationView | null {
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
title: usage.title_snapshot,
title: usage.output_title_snapshot || usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot
? {
@@ -589,8 +675,8 @@ export function getPresentationView(token: string): PresentationView | null {
usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot
),
sources,
images
sources: [],
images: []
}
};
}
@@ -605,19 +691,11 @@ export async function getPresentationPreviewImage(token: string) {
}
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const allowed = database
.prepare(
`SELECT 1 FROM show_live_states l
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
export async function getPresentationMedia(
token: string,
mediaId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
void token;
void mediaId;
return null;
}