Files
sendewerk/src/lib/server/shows.ts
T
2026-10-22 07:56:25 +02:00

1180 lines
38 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
export type ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type ShowListItem = {
id: string;
title: string;
startsAtUtc: string | null;
status: ShowStatus;
revision: number;
totalDurationSeconds: number;
sectionCount: number;
};
export type ShowTemplateSection = {
id: string;
name: string;
categoryId: string | null;
position: number;
explicitDurationSeconds: number | null;
};
export type ShowTemplate = {
id: string;
name: string;
description: string;
sections: ShowTemplateSection[];
};
export type PieceUsage = {
id: string;
originalPieceId: string | null;
position: number;
status: UsageStatus;
durationSeconds: number;
title: string;
intro: string;
research: string;
moderation: string;
transition: string;
presentation: string;
presentationMode: PresentationMode;
presentationSourceLabel: string;
presentationSourceUrl: string;
presentationSourceTimecodeSeconds: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProviderId: string;
ownNotes: string;
};
export type SentPieceUsage = {
usageId: string;
showId: string;
showTitle: string;
usedAtUtc: string;
};
export type PieceUsageContext = {
sentHistory: SentPieceUsage[];
isPlannedInActiveShow: boolean;
isPlannedInCurrentShow: boolean;
};
export type ShowSection = {
id: string;
name: string;
categoryId: string | null;
category: string | null;
categoryColor: string | null;
position: number;
explicitDurationSeconds: number | null;
durationSeconds: number;
usages: PieceUsage[];
};
export type Show = {
id: string;
title: string;
startsAtUtc: string | null;
twitchTitle: string;
announcementText: string;
status: ShowStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
sections: ShowSection[];
reserve: PieceUsage[];
totalDurationSeconds: number;
};
export type CreateShowInput = {
title: string | null;
startsAtUtc: string | null;
templateId: string | null;
};
export type ShowMetadataInput = {
title: string;
startsAtUtc: string | null;
twitchTitle: string;
announcementText: string;
status: EditableShowStatus;
};
export type ShowSectionInput = {
name: string;
categoryId: string | null;
explicitDurationSeconds: number | null;
position?: number;
};
export type PieceUsageInput = {
pieceId: string;
sectionId: string | null;
position?: number;
};
export type PieceUsageUpdate = {
title: string;
intro: string;
research: string;
moderation: string;
transition: string;
presentation: string;
presentationMode: PresentationMode;
presentationSourceLabel: string;
presentationSourceUrl: string;
presentationSourceTimecodeSeconds: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
ownNotes: string;
durationSeconds: number;
};
export type MovePieceUsageInput = {
sectionId: string | null;
position: number;
};
export type MutationResult = { revision: number; updatedAtUtc: string };
export type CreatedMutationResult = MutationResult & { id: string };
type ShowRow = {
id: string;
title: string;
starts_at_utc: string | null;
twitch_title: string;
announcement_text: string;
status: ShowStatus;
revision: number;
created_at_utc: string;
updated_at_utc: string;
};
type SectionRow = {
id: string;
name: string;
category_id: string | null;
category: string | null;
category_color: string | null;
position: number;
explicit_duration_seconds: number | null;
is_reserve_bank: number;
};
type UsageRow = {
id: string;
show_section_id: string;
original_piece_id: string | null;
position: number;
status: UsageStatus;
duration_seconds: number;
title_snapshot: string;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode;
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: string;
presentation_provider_id_snapshot: string;
own_notes: string;
};
function cleanText(value: unknown, maximum: number, field: string, required = false): string {
const text = String(value ?? '').trim();
if (text.length > maximum || (required && !text)) {
throw new Error(`INVALID_${field.toUpperCase()}`);
}
return text;
}
function validDuration(value: number | null): number | null {
if (value === null) return null;
if (!Number.isInteger(value) || value < 0 || value > 86_400) {
throw new Error('INVALID_DURATION');
}
return value;
}
function validPosition(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum);
}
function validTimestamp(value: string | null): string | null {
if (value === null) return null;
if (!value.endsWith('Z')) throw new Error('INVALID_TIMESTAMP');
const date = new Date(value);
if (Number.isNaN(date.valueOf())) throw new Error('INVALID_TIMESTAMP');
return date.toISOString();
}
function validPresentation(
mode: PresentationMode,
sourceLabel: string,
sourceUrl: string,
timecodeSeconds: number | null,
title: string,
description: string,
imageUrl: string,
siteName: string
) {
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) {
if (!value) continue;
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
} catch {
throw new Error('INVALID_PRESENTATION');
}
}
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if (
timecodeSeconds !== null &&
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
) {
throw new Error('INVALID_PRESENTATION');
}
const detected = detectVideoProvider(cleanUrl);
return {
mode,
sourceLabel: cleanText(sourceLabel, 200, 'presentation_source'),
sourceUrl: cleanUrl,
timecodeSeconds,
title: cleanText(title, 300, 'presentation_title'),
description: cleanText(description, 1_000, 'presentation_description'),
imageUrl: cleanImageUrl,
siteName: cleanText(siteName, 200, 'presentation_site'),
provider: detected?.provider ?? '',
providerId: detected?.id ?? ''
};
}
function requireRevision(expectedRevision: number): void {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
}
function requireCategory(database: Database.Database, categoryId: string | null): void {
if (!categoryId) return;
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(categoryId)) {
throw new Error('INVALID_CATEGORY');
}
}
function bumpShow(
database: Database.Database,
showId: string,
expectedRevision: number
): MutationResult {
requireRevision(expectedRevision);
const updatedAtUtc = new Date().toISOString();
const result = database
.prepare(
`UPDATE shows SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'completed'`
)
.run(updatedAtUtc, showId, expectedRevision);
if (result.changes !== 1) {
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as
{ status: ShowStatus } | undefined;
if (!show) throw new Error('NOT_FOUND');
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
throw new Error('CONFLICT');
}
return { revision: expectedRevision + 1, updatedAtUtc };
}
function reserveSectionId(database: Database.Database, showId: string): string {
const row = database
.prepare('SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1')
.get(showId) as { id: string } | undefined;
if (!row) throw new Error('INVALID_RESERVE_BANK');
return row.id;
}
function sectionBelongsToShow(
database: Database.Database,
showId: string,
sectionId: string,
allowReserve = false
): void {
const row = database
.prepare('SELECT is_reserve_bank FROM show_sections WHERE id = ? AND show_id = ?')
.get(sectionId, showId) as { is_reserve_bank: number } | undefined;
if (!row || (!allowReserve && row.is_reserve_bank === 1)) throw new Error('NOT_FOUND');
}
function usageSection(
database: Database.Database,
showId: string,
usageId: string
): { sectionId: string } {
const row = database
.prepare(
`SELECT u.show_section_id AS sectionId 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 { sectionId: string } | undefined;
if (!row) throw new Error('NOT_FOUND');
return row;
}
function normalizePositions(
database: Database.Database,
table: 'show_sections' | 'piece_usages',
ids: string[]
): void {
if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', ');
const maximum = database
.prepare(
`SELECT COALESCE(max(position), -1) AS maximum FROM ${table} WHERE id IN (${placeholders})`
)
.get(...ids) as { maximum: number };
const offset = maximum.maximum + ids.length + 1;
database
.prepare(`UPDATE ${table} SET position = position + ? WHERE id IN (${placeholders})`)
.run(offset, ...ids);
const update = database.prepare(`UPDATE ${table} SET position = ? WHERE id = ?`);
ids.forEach((id, position) => update.run(position, id));
}
function usageFromRow(row: UsageRow): PieceUsage {
return {
id: row.id,
originalPieceId: row.original_piece_id,
position: row.position,
status: row.status,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
intro: row.intro_snapshot,
research: row.research_snapshot,
moderation: row.moderation_snapshot,
transition: row.transition_snapshot,
presentation: row.presentation_snapshot,
presentationMode: row.presentation_mode_snapshot,
presentationSourceLabel: row.presentation_source_label_snapshot,
presentationSourceUrl: row.presentation_source_url_snapshot,
presentationSourceTimecodeSeconds: row.presentation_source_timecode_snapshot,
presentationTitle: row.presentation_title_meta_snapshot,
presentationDescription: row.presentation_description_snapshot,
presentationImageUrl: row.presentation_image_url_snapshot,
presentationSiteName: row.presentation_site_name_snapshot,
presentationProvider: row.presentation_provider_snapshot,
presentationProviderId: row.presentation_provider_id_snapshot,
ownNotes: row.own_notes
};
}
export function listShows(): ShowListItem[] {
return (
getDatabase()
.prepare(
`SELECT sh.id, sh.title, sh.starts_at_utc, sh.status, sh.revision,
count(DISTINCT CASE WHEN s.is_reserve_bank = 0 THEN s.id END) AS section_count,
COALESCE(sum(CASE WHEN s.is_reserve_bank = 0 THEN
COALESCE(s.explicit_duration_seconds,
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
ELSE 0 END), 0) AS total_duration_seconds
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
GROUP BY sh.id
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
)
.all() as {
id: string;
title: string;
starts_at_utc: string | null;
status: ShowStatus;
revision: number;
section_count: number;
total_duration_seconds: number;
}[]
).map((row) => ({
id: row.id,
title: row.title,
startsAtUtc: row.starts_at_utc,
status: row.status,
revision: row.revision,
totalDurationSeconds: row.total_duration_seconds,
sectionCount: row.section_count
}));
}
export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase();
const templates = database
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
.all() as Omit<ShowTemplate, 'sections'>[];
const sectionStatement = database.prepare(
`SELECT id, name, category_id, position, explicit_duration_seconds
FROM show_template_sections WHERE template_id = ? ORDER BY position`
);
return templates.map((template) => ({
...template,
sections: (
sectionStatement.all(template.id) as {
id: string;
name: string;
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
}[]
).map((section) => ({
id: section.id,
name: section.name,
categoryId: section.category_id,
position: section.position,
explicitDurationSeconds: section.explicit_duration_seconds
}))
}));
}
export function getShow(id: string): Show | null {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc FROM shows WHERE id = ?`
)
.get(id) as ShowRow | undefined;
if (!show) return null;
const sectionRows = database
.prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.position, s.explicit_duration_seconds, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
.all(id) as SectionRow[];
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_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, u.own_notes
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? ORDER BY s.position, u.position`
)
.all(id) as UsageRow[];
const usagesBySection = new Map<string, PieceUsage[]>();
for (const row of usageRows) {
const usages = usagesBySection.get(row.show_section_id) ?? [];
usages.push(usageFromRow(row));
usagesBySection.set(row.show_section_id, usages);
}
const sections = sectionRows
.filter((row) => row.is_reserve_bank === 0)
.map((row) => {
const usages = usagesBySection.get(row.id) ?? [];
return {
id: row.id,
name: row.name,
categoryId: row.category_id,
category: row.category,
categoryColor: row.category_color,
position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds,
durationSeconds:
row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
const reserveRow = sectionRows.find((row) => row.is_reserve_bank === 1);
return {
id: show.id,
title: show.title,
startsAtUtc: show.starts_at_utc,
twitchTitle: show.twitch_title,
announcementText: show.announcement_text,
status: show.status,
revision: show.revision,
createdAtUtc: show.created_at_utc,
updatedAtUtc: show.updated_at_utc,
sections,
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
};
}
export function getPieceUsageContexts(
pieceIds: string[],
currentShowId?: string
): Record<string, PieceUsageContext> {
const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))];
const contexts = Object.fromEntries(
uniquePieceIds.map((pieceId) => [
pieceId,
{
sentHistory: [],
isPlannedInActiveShow: false,
isPlannedInCurrentShow: false
} satisfies PieceUsageContext
])
) as Record<string, PieceUsageContext>;
if (!uniquePieceIds.length) return contexts;
const database = getDatabase();
const history = database
.prepare(
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id,
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE u.original_piece_id IS NOT NULL AND u.status = 'done'
AND sh.status = 'completed' AND s.is_reserve_bank = 0
ORDER BY used_at_utc DESC, u.id DESC`
)
.all() as {
usage_id: string;
original_piece_id: string;
show_id: string;
show_title: string;
used_at_utc: string;
}[];
for (const usage of history) {
contexts[usage.original_piece_id]?.sentHistory.push({
usageId: usage.usage_id,
showId: usage.show_id,
showTitle: usage.show_title,
usedAtUtc: usage.used_at_utc
});
}
const plannedInActiveShows = database
.prepare(
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status != 'completed' AND u.original_piece_id IS NOT NULL`
)
.all() as { original_piece_id: string }[];
for (const usage of plannedInActiveShows) {
const context = contexts[usage.original_piece_id];
if (context) context.isPlannedInActiveShow = true;
}
if (currentShowId) {
const planned = database
.prepare(
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND u.original_piece_id IS NOT NULL`
)
.all(currentShowId) as { original_piece_id: string }[];
for (const usage of planned) {
const context = contexts[usage.original_piece_id];
if (context) context.isPlannedInCurrentShow = true;
}
}
return contexts;
}
export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase();
const id = randomUUID();
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString();
const create = database.transaction(() => {
if (
input.templateId &&
!database.prepare('SELECT 1 FROM show_templates WHERE id = ?').get(input.templateId)
) {
throw new Error('INVALID_TEMPLATE');
}
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
status, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
)
.run(id, title, startsAtUtc, now, now);
const templateSections = input.templateId
? (database
.prepare(
`SELECT name, category_id, position, explicit_duration_seconds
FROM show_template_sections WHERE template_id = ? ORDER BY position`
)
.all(input.templateId) as {
name: string;
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
}[])
: [];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)`
);
for (const section of templateSections) {
insertSection.run(
randomUUID(),
id,
section.name,
section.category_id,
section.position,
section.explicit_duration_seconds,
0
);
}
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
});
create();
return { id, revision: 1, updatedAtUtc: now };
}
export function saveShowMetadata(
showId: string,
expectedRevision: number,
input: ShowMetadataInput
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const twitchTitle = cleanText(input.twitchTitle, 200, 'twitch_title');
const announcementText = cleanText(input.announcementText, 20_000, 'announcement');
if (!['draft', 'ready', 'live'].includes(input.status)) throw new Error('INVALID_STATUS');
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
database
.prepare(
`UPDATE shows SET title = ?, starts_at_utc = ?, twitch_title = ?,
announcement_text = ?, status = ? WHERE id = ?`
)
.run(title, startsAtUtc, twitchTitle, announcementText, input.status, showId);
return result;
})();
}
export function addShowSection(
showId: string,
expectedRevision: number,
input: ShowSectionInput
): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const visibleIds = (
database
.prepare(
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
)
.all(showId) as { id: string }[]
).map((row) => row.id);
const position = validPosition(input.position ?? visibleIds.length, visibleIds.length);
const id = randomUUID();
const allIds = (
database
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
.all(showId) as {
id: string;
}[]
).map((row) => row.id);
const temporaryPosition = allIds.length;
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)`
)
.run(id, showId, name, input.categoryId, temporaryPosition, duration);
visibleIds.splice(position, 0, id);
const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
return { ...result, id };
})();
}
export function updateShowSection(
showId: string,
sectionId: string,
expectedRevision: number,
input: Omit<ShowSectionInput, 'position'>
): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
database
.prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?
WHERE id = ?`
)
.run(name, input.categoryId, duration, sectionId);
return result;
})();
}
export function removeShowSection(
showId: string,
sectionId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
const ids = (
database
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
.all(showId) as {
id: string;
}[]
).map((row) => row.id);
normalizePositions(database, 'show_sections', ids);
return result;
})();
}
export function moveShowSection(
showId: string,
sectionId: string,
targetPosition: number,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
const ids = (
database
.prepare(
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
)
.all(showId) as { id: string }[]
).map((row) => row.id);
const current = ids.indexOf(sectionId);
ids.splice(current, 1);
ids.splice(validPosition(targetPosition, ids.length), 0, sectionId);
ids.push(reserveSectionId(database, showId));
normalizePositions(database, 'show_sections', ids);
return result;
})();
}
export function addPieceUsage(
showId: string,
expectedRevision: number,
input: PieceUsageInput
): CreatedMutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const sectionId = input.sectionId ?? reserveSectionId(database, showId);
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?`
)
.get(input.pieceId) as
| {
title: string;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_label: string;
presentation_source_url: string;
presentation_source_timecode: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
status: string;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(sectionId) as { id: string }[]
).map((row) => row.id);
const position = validPosition(input.position ?? ids.length, ids.length);
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
sectionId,
input.pieceId,
ids.length,
piece.duration_seconds,
piece.title,
piece.intro,
piece.research_text,
piece.moderation_notes,
piece.transition_text,
piece.presentation_text,
piece.presentation_mode,
piece.presentation_source_label,
piece.presentation_source_url,
piece.presentation_source_timecode,
piece.presentation_title,
piece.presentation_description,
piece.presentation_image_url,
piece.presentation_site_name,
piece.presentation_provider,
piece.presentation_provider_id,
input.sectionId === null ? 1 : 0
);
ids.splice(position, 0, id);
normalizePositions(database, 'piece_usages', ids);
return { ...result, id };
})();
}
export function duplicatePieceUsage(
showId: string,
usageId: string,
expectedRevision: number
): CreatedMutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const sourceSection = usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?`
)
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
is_reserve: number;
};
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(sourceSection.sectionId) as { id: string }[]
).map((row) => row.id);
const sourcePosition = ids.indexOf(usageId);
if (sourcePosition < 0) throw new Error('NOT_FOUND');
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
sourceSection.sectionId,
source.original_piece_id,
ids.length,
source.duration_seconds,
source.title_snapshot,
source.intro_snapshot,
source.research_snapshot,
source.moderation_snapshot,
source.transition_snapshot,
source.presentation_snapshot,
source.presentation_mode_snapshot,
source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot,
source.presentation_title_meta_snapshot,
source.presentation_description_snapshot,
source.presentation_image_url_snapshot,
source.presentation_site_name_snapshot,
source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot,
source.own_notes,
source.is_reserve
);
ids.splice(sourcePosition + 1, 0, id);
normalizePositions(database, 'piece_usages', ids);
return { ...result, id };
})();
}
export function refreshPieceUsageFromOriginal(
showId: string,
usageId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position
WHERE u.id = ?`
)
.get(usageId) as
| {
title: string;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_label: string;
presentation_source_url: string;
presentation_source_timecode: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
}
| undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?`
)
.run(
source.title,
source.duration_seconds,
source.intro,
source.research_text,
source.moderation_notes,
source.transition_text,
source.presentation_text,
source.presentation_mode,
source.presentation_source_label,
source.presentation_source_url,
source.presentation_source_timecode,
source.presentation_title,
source.presentation_description,
source.presentation_image_url,
source.presentation_site_name,
source.presentation_provider,
source.presentation_provider_id,
usageId
);
return result;
})();
}
export function updatePieceUsage(
showId: string,
usageId: string,
expectedRevision: number,
input: PieceUsageUpdate
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const duration = validDuration(input.durationSeconds)!;
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
moderation: cleanText(input.moderation, 50_000, 'moderation'),
transition: cleanText(input.transition, 50_000, 'transition'),
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
};
const presentation = validPresentation(
input.presentationMode,
input.presentationSourceLabel,
input.presentationSourceUrl,
input.presentationSourceTimecodeSeconds,
input.presentationTitle,
input.presentationDescription,
input.presentationImageUrl,
input.presentationSiteName
);
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?`
)
.run(
title,
values.intro,
values.research,
values.moderation,
values.transition,
values.presentation,
presentation.mode,
presentation.sourceLabel,
presentation.sourceUrl,
presentation.timecodeSeconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName,
presentation.provider,
presentation.providerId,
values.ownNotes,
duration,
usageId
);
return result;
})();
}
export function removePieceUsage(
showId: string,
usageId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(source.sectionId) as { id: string }[]
).map((row) => row.id);
normalizePositions(database, 'piece_usages', ids);
return result;
})();
}
export function movePieceUsage(
showId: string,
usageId: string,
expectedRevision: number,
input: MovePieceUsageInput
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
const targetSectionId = input.sectionId ?? reserveSectionId(database, showId);
sectionBelongsToShow(database, showId, targetSectionId, input.sectionId === null);
const sourceIds = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(source.sectionId) as { id: string }[]
).map((row) => row.id);
if (source.sectionId === targetSectionId) {
sourceIds.splice(sourceIds.indexOf(usageId), 1);
sourceIds.splice(validPosition(input.position, sourceIds.length), 0, usageId);
normalizePositions(database, 'piece_usages', sourceIds);
} else {
const targetIds = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(targetSectionId) as { id: string }[]
).map((row) => row.id);
const remainingSourceIds = sourceIds.filter((id) => id !== usageId);
normalizePositions(database, 'piece_usages', sourceIds);
normalizePositions(database, 'piece_usages', targetIds);
const temporaryPosition = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS position FROM piece_usages WHERE show_section_id = ?'
)
.get(targetSectionId) as { position: number }
).position;
database
.prepare(
`UPDATE piece_usages SET show_section_id = ?, position = ?, is_reserve = ? WHERE id = ?`
)
.run(targetSectionId, temporaryPosition, input.sectionId === null ? 1 : 0, usageId);
targetIds.splice(validPosition(input.position, targetIds.length), 0, usageId);
normalizePositions(database, 'piece_usages', remainingSourceIds);
normalizePositions(database, 'piece_usages', targetIds);
}
return result;
})();
}