import { randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; import { createPiece } from './content.js'; 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; 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; outputTitle: 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; sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[]; tools: { id: string; originalToolId: string | null; name: string; startUrl: string; workUrl: string; note: string; }[]; topics: UsageTopic[]; media: PieceUsageMedia[]; presentationMediaId: string | null; }; export type UsageTopic = { name: string; iconName: string }; export type PieceUsageMedia = { id: string; originalMediaId: string; altText: string; position: number; isPresentation: boolean; }; 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; outputTitle: 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; tools?: { id: string; workUrl: string; note: string }[]; presentationMediaId?: string | null; }; 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; output_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 requireUsagesRemovable( database: Database.Database, showId: string, usageIds: string[] ): void { if (!usageIds.length) return; const placeholders = usageIds.map(() => '?').join(', '); const state = database .prepare( `SELECT current_usage_id, presentation_usage_id FROM show_live_states WHERE show_id = ?` ) .get(showId) as { current_usage_id: string | null; presentation_usage_id: string | null } | undefined; if (state?.current_usage_id && usageIds.includes(state.current_usage_id)) { throw new Error('USAGE_IS_CURRENT'); } if (state?.presentation_usage_id && usageIds.includes(state.presentation_usage_id)) { throw new Error('USAGE_IS_PRESENTED'); } if ( database .prepare(`SELECT 1 FROM live_usage_runs WHERE usage_id IN (${placeholders}) LIMIT 1`) .get(...usageIds) ) { throw new Error('USAGE_HAS_LIVE_RUNS'); } if ( database .prepare( `SELECT 1 FROM piece_usages WHERE id IN (${placeholders}) AND status != 'open' LIMIT 1` ) .get(...usageIds) ) { throw new Error('USAGE_NOT_OPEN'); } } 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(database: Database.Database, 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, outputTitle: row.output_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, sources: ( database .prepare( `SELECT id, label, url, timecode_seconds FROM usage_source_snapshots WHERE usage_id = ? ORDER BY position` ) .all(row.id) as { id: string; label: string; url: string; timecode_seconds: number | null; }[] ).map((source) => ({ id: source.id, label: source.label, url: source.url, timecodeSeconds: source.timecode_seconds })), tools: ( database .prepare( `SELECT id, original_tool_id, name, start_url, work_url, note FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position` ) .all(row.id) as { id: string; original_tool_id: string | null; name: string; start_url: string; work_url: string; note: string; }[] ).map((tool) => ({ id: tool.id, originalToolId: tool.original_tool_id, name: tool.name, startUrl: tool.start_url, workUrl: tool.work_url, note: tool.note })), topics: ( database .prepare( 'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position' ) .all(row.id) as { name: string; icon_name: string }[] ).map((topic) => ({ name: topic.name, iconName: topic.icon_name })), media: ( database .prepare( `SELECT id, original_media_id, alt_text, position, is_presentation FROM usage_media_snapshots WHERE usage_id = ? ORDER BY position` ) .all(row.id) as { id: string; original_media_id: string; alt_text: string; position: number; is_presentation: number; }[] ).map((media) => ({ id: media.id, originalMediaId: media.original_media_id, altText: media.alt_text, position: media.position, isPresentation: media.is_presentation === 1 })), presentationMediaId: ( database .prepare( 'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1' ) .get(row.id) as { id: string } | undefined )?.id ?? null }; } function snapshotPieceRelations(database: Database.Database, usageId: string, pieceId: string) { database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId); database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId); database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId); database.prepare('DELETE FROM usage_media_snapshots WHERE usage_id = ?').run(usageId); const sources = database .prepare( `SELECT label, url, timecode_seconds, position FROM piece_sources WHERE piece_id = ? ORDER BY position` ) .all(pieceId) as { label: string; url: string; timecode_seconds: number | null; position: number; }[]; const addSource = database.prepare( `INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position) VALUES (?, ?, ?, ?, ?, ?)` ); for (const source of sources) { addSource.run( randomUUID(), usageId, source.label, source.url, source.timecode_seconds, source.position ); } const tools = database .prepare( `SELECT pt.tool_id, t.name, t.start_url, pt.work_url, pt.note, pt.position FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id WHERE pt.piece_id = ? ORDER BY pt.position` ) .all(pieceId) as { tool_id: string; name: string; start_url: string; work_url: string; note: string; position: number; }[]; const addTool = database.prepare( `INSERT INTO usage_tool_snapshots (id, usage_id, original_tool_id, name, start_url, work_url, note, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); for (const tool of tools) { addTool.run( randomUUID(), usageId, tool.tool_id, tool.name, tool.start_url, tool.work_url, tool.note, tool.position ); } const topics = database .prepare( `SELECT t.name, t.icon_name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id WHERE pt.piece_id = ? ORDER BY t.name, t.id` ) .all(pieceId) as { name: string; icon_name: string }[]; const addTopic = database.prepare( 'INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position) VALUES (?, ?, ?, ?)' ); topics.forEach((topic, position) => addTopic.run(usageId, topic.name, topic.icon_name, position)); const media = database .prepare( `SELECT pm.id, pm.alt_text, pm.position, CASE WHEN ppm.media_id IS NULL THEN 0 ELSE 1 END AS is_presentation FROM piece_media pm LEFT JOIN piece_presentation_media ppm ON ppm.piece_id = pm.piece_id AND ppm.media_id = pm.id WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position` ) .all(pieceId) as { id: string; alt_text: string; position: number; is_presentation: number; }[]; const addMedia = database.prepare( `INSERT INTO usage_media_snapshots (id, usage_id, original_media_id, alt_text, position, is_presentation) VALUES (?, ?, ?, ?, ?, ?)` ); for (const item of media) { addMedia.run( randomUUID(), usageId, item.id, item.alt_text, item.position, item.is_presentation ); } } function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) { const sources = database .prepare( `SELECT label, url, timecode_seconds, position FROM usage_source_snapshots WHERE usage_id = ? ORDER BY position` ) .all(sourceUsageId) as { label: string; url: string; timecode_seconds: number | null; position: number; }[]; const addSource = database.prepare( `INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position) VALUES (?, ?, ?, ?, ?, ?)` ); for (const source of sources) addSource.run( randomUUID(), usageId, source.label, source.url, source.timecode_seconds, source.position ); const tools = database .prepare( `SELECT original_tool_id, name, start_url, work_url, note, position FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position` ) .all(sourceUsageId) as { original_tool_id: string | null; name: string; start_url: string; work_url: string; note: string; position: number; }[]; const addTool = database.prepare( `INSERT INTO usage_tool_snapshots (id, usage_id, original_tool_id, name, start_url, work_url, note, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); for (const tool of tools) addTool.run( randomUUID(), usageId, tool.original_tool_id, tool.name, tool.start_url, tool.work_url, tool.note, tool.position ); database .prepare( `INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position) SELECT ?, name, icon_name, position FROM usage_topic_snapshots WHERE usage_id = ?` ) .run(usageId, sourceUsageId); database .prepare( `INSERT INTO usage_media_snapshots (id, usage_id, original_media_id, alt_text, position, is_presentation) SELECT lower(hex(randomblob(16))), ?, original_media_id, alt_text, position, is_presentation FROM usage_media_snapshots WHERE usage_id = ?` ) .run(usageId, sourceUsageId); } 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[]; 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.output_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(); for (const row of usageRows) { const usages = usagesBySection.get(row.show_section_id) ?? []; usages.push(usageFromRow(database, 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 { const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))]; const contexts = Object.fromEntries( uniquePieceIds.map((pieceId) => [ pieceId, { sentHistory: [], isPlannedInActiveShow: false, isPlannedInCurrentShow: false } satisfies PieceUsageContext ]) ) as Record; 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 ): 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); const usageIds = ( database.prepare('SELECT id FROM piece_usages WHERE show_section_id = ?').all(sectionId) as { id: string; }[] ).map((usage) => usage.id); requireUsagesRemovable(database, showId, usageIds); 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.output_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; output_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 ( !database .prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL') .get(input.pieceId) ) { 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, output_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.output_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 ); snapshotPieceRelations(database, id, input.pieceId); ids.splice(position, 0, id); normalizePositions(database, 'piece_usages', ids); return { ...result, id }; })(); } export function createAndAddPieceUsage( showId: string, expectedRevision: number, commandId: string, input: Omit & { title: string; templateId: string } ) { if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND'); const title = cleanText(input.title, 200, 'title', true); const database = getDatabase(); return database.transaction(() => { const duplicate = database .prepare( `SELECT piece_id, usage_id, result_revision FROM planning_commands WHERE show_id = ? AND command_id = ?` ) .get(showId, commandId) as { piece_id: string; usage_id: string; result_revision: number } | undefined; if (duplicate) { return { pieceId: duplicate.piece_id, usageId: duplicate.usage_id, revision: duplicate.result_revision, duplicate: true }; } const pieceId = createPiece(input.templateId || 'blank'); database .prepare( `UPDATE pieces SET title = ?, status = 'ready', revision = revision + 1, updated_at_utc = ? WHERE id = ?` ) .run(title, new Date().toISOString(), pieceId); const usage = addPieceUsage(showId, expectedRevision, { pieceId, sectionId: input.sectionId, position: input.position }); database .prepare( `INSERT INTO planning_commands (show_id, command_id, piece_id, usage_id, result_revision, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)` ) .run(showId, commandId, pieceId, usage.id, usage.revision, new Date().toISOString()); return { pieceId, usageId: usage.id, revision: usage.revision, duplicate: false }; })(); } 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, output_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 & { 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, output_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.output_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 ); copyUsageRelations(database, usageId, id); 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.output_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 = ? AND p.deleted_at_utc IS NULL` ) .get(usageId) as | { title: string; output_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 = ?, output_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.output_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 ); snapshotPieceRelations( database, usageId, ( database .prepare('SELECT original_piece_id FROM piece_usages WHERE id = ?') .get(usageId) as { original_piece_id: string } ).original_piece_id ); return result; })(); } export function updatePieceUsage( showId: string, usageId: string, expectedRevision: number, input: PieceUsageUpdate ): MutationResult { const title = cleanText(input.title, 200, 'title', true); const outputTitle = cleanText(input.outputTitle, 300, 'output_title'); 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 = ?, output_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, outputTitle, 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 ); if (input.tools) { const existing = database .prepare('SELECT id FROM usage_tool_snapshots WHERE usage_id = ?') .all(usageId) as { id: string }[]; const existingIds = new Set(existing.map((tool) => tool.id)); if ( input.tools.length !== existing.length || input.tools.some((tool) => !existingIds.has(tool.id)) ) { throw new Error('INVALID_TOOLS'); } const updateTool = database.prepare( 'UPDATE usage_tool_snapshots SET work_url = ?, note = ? WHERE id = ? AND usage_id = ?' ); for (const tool of input.tools) { const workUrl = cleanText(tool.workUrl, 2_000, 'tools'); if (workUrl) { try { const url = new URL(workUrl); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { throw new Error(); } } catch { throw new Error('INVALID_TOOLS'); } } updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId); } } if (input.presentationMediaId !== undefined) { if ( input.presentationMediaId !== null && !database .prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?') .get(input.presentationMediaId, usageId) ) { throw new Error('INVALID_PRESENTATION_MEDIA'); } database .prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?') .run(usageId); if (input.presentationMediaId !== null) { database .prepare( 'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?' ) .run(input.presentationMediaId, 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); requireUsagesRemovable(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; })(); }