diff --git a/README.md b/README.md index 4c9e3bf..562ce28 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,48 @@ display and thumbnail routes stream private S3 objects through the API with a one-year immutable cache policy. Original downloads use private, no-store caching. +## Playlists + +Playlist and entry metadata is authoritative in SQLite. The API surface is: + +- `GET /api/playlists` +- `POST /api/playlists` +- `GET /api/playlists/:playlistId` +- `PATCH /api/playlists/:playlistId` +- `DELETE /api/playlists/:playlistId` +- `POST /api/playlists/:playlistId/entries` +- `PATCH /api/playlists/:playlistId/entries/:entryId` +- `DELETE /api/playlists/:playlistId/entries/:entryId` +- `PUT /api/playlists/:playlistId/entries/order` +- `POST /api/playlists/:playlistId/activate` + +Entry positions are zero-based and contiguous. Reordering requires the complete +ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a +playlist, but the same asset can be used independently by any number of other +playlists. Only ready, unexpired assets can be newly added. + +Entry duration overrides may be null (inherit the global setting) or 1,000 to +3,600,000 milliseconds. Presentation overrides are nullable; darkening is an +inherit/off/on boolean and blur always comes from the global setting. Schedule +instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric +UTC offset, and intervals are evaluated as `[start, end)`. + +Playlist detail responses include effective duration and presentation, +server-evaluated eligibility, immutable display/thumbnail URLs, and the +settings revision and evaluation time used. Playlist revisions increment once +per semantic content change. Exact no-ops and reads do not increment them. + +Activation is explicit. A request is held in the existing pending-activation +state while a playable normal slide is mid-duration; otherwise the target +playlist is prepared immediately at its first currently playable entry. The +output-off flag remains only a visual gate, and static hold is preserved while +the hidden normal playlist changes. Phase 3A does not include the advancing +playback timer that will consume pending activation at a slide boundary. + +Socket.IO publishes `playlists:changed` as a refetch invalidation after +committed playlist changes and after settings or referenced-asset edits that +affect resolved playlist data. Socket.IO is not a mutation API. + ## Storage and health The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`, diff --git a/server/src/app.js b/server/src/app.js index a40e5c4..08fce48 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -6,24 +6,55 @@ const { const { createAssetRouter } = require("./assets/routes"); const { createMediaRouter } = require("./assets/media-routes"); const { createAssetService } = require("./assets/service"); +const { + createPlaylistRouter, + createPlaylistService, +} = require("./playlists"); const { createSettingsRouter } = require("./settings/routes"); const { createSettingsService } = require("./settings/service"); +function safeNotifier(notifier, logger) { + const warn = (error) => { + try { + logger?.warn?.("Playlist invalidation notification failed", error); + } catch { + // A best-effort notification or warning must not undo a database commit. + } + }; + return (event) => { + try { + const result = notifier(event); + result?.catch?.(warn); + } catch (error) { + warn(error); + } + }; +} + function createApp({ config, database, imageProcessor, lifecycle, + logger = console, migrations, + now = Date.now, + onPlaylistsChanged = () => {}, storage, }) { const app = express(); + const notifyPlaylistsChanged = safeNotifier(onPlaylistsChanged, logger); const settingsService = createSettingsService(database); const assetService = createAssetService({ database, imageProcessor, storage, }); + const playlistService = createPlaylistService({ + database, + now, + onChanged: notifyPlaylistsChanged, + }); app.disable("x-powered-by"); app.set("trust proxy", config.trustProxy); @@ -51,11 +82,21 @@ function createApp({ "/api/status", createDetailedHealthRouter(detailedHealthRouterOptions), ); - app.use("/api/settings", createSettingsRouter(settingsService)); + app.use( + "/api/settings", + createSettingsRouter(settingsService, { + onPlaylistsChanged: notifyPlaylistsChanged, + }), + ); app.use( "/api/assets", - createAssetRouter({ assetService, processingConfig: config.processing }), + createAssetRouter({ + assetService, + onPlaylistsChanged: notifyPlaylistsChanged, + processingConfig: config.processing, + }), ); + app.use("/api/playlists", createPlaylistRouter(playlistService)); app.use("/media", createMediaRouter(assetService)); app.use((request, response) => { diff --git a/server/src/assets/routes.js b/server/src/assets/routes.js index 17712ec..e55ac8b 100644 --- a/server/src/assets/routes.js +++ b/server/src/assets/routes.js @@ -6,7 +6,11 @@ const { } = require("./multipart"); const { sendOriginal } = require("./streaming"); -function createAssetRouter({ assetService, processingConfig }) { +function createAssetRouter({ + assetService, + onPlaylistsChanged = () => {}, + processingConfig, +}) { const router = express.Router(); const receiveUpload = createMultipartUpload(processingConfig); @@ -80,7 +84,23 @@ function createAssetRouter({ assetService, processingConfig }) { router.patch("/:assetId", (request, response, next) => { try { - response.json(assetService.update(request.params.assetId, request.body)); + const previous = assetService.get(request.params.assetId); + const playlistIds = [ + ...new Set( + assetService + .references(request.params.assetId) + .playlists.map((reference) => reference.playlistId), + ), + ]; + const updated = assetService.update(request.params.assetId, request.body); + if (updated.updatedAt !== previous.updatedAt && playlistIds.length > 0) { + onPlaylistsChanged({ + reason: "asset_updated", + playlistIds, + assetId: updated.id, + }); + } + response.json(updated); } catch (error) { next(error); } diff --git a/server/src/lifecycle/eligibility.js b/server/src/lifecycle/eligibility.js new file mode 100644 index 0000000..0e36b8a --- /dev/null +++ b/server/src/lifecycle/eligibility.js @@ -0,0 +1,106 @@ +const ELIGIBILITY_REASONS = Object.freeze({ + DISABLED: "disabled", + SCHEDULED_FUTURE: "scheduled_future", + SCHEDULED_ENDED: "scheduled_ended", + ASSET_NOT_READY: "asset_not_ready", + ASSET_EXPIRED: "asset_expired", +}); + +function read(object, snakeCaseName, camelCaseName) { + if (object && Object.hasOwn(object, snakeCaseName)) { + return object[snakeCaseName]; + } + return object?.[camelCaseName]; +} + +function validateNow(nowMs) { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError("nowMs must be a non-negative, safe epoch-millisecond integer"); + } +} + +function evaluateEntryEligibility(entry, asset, nowMs) { + validateNow(nowMs); + + const enabled = read(entry, "enabled", "enabled"); + if (enabled !== true && enabled !== 1) { + return { playable: false, reason: ELIGIBILITY_REASONS.DISABLED }; + } + + const scheduleStartAt = read(entry, "schedule_start_at", "scheduleStartAt"); + if (scheduleStartAt != null && nowMs < scheduleStartAt) { + return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE }; + } + + const scheduleEndAt = read(entry, "schedule_end_at", "scheduleEndAt"); + if (scheduleEndAt != null && nowMs >= scheduleEndAt) { + return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED }; + } + + if (read(asset, "status", "status") !== "ready") { + return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY }; + } + + const expirationAt = read(asset, "expiration_at", "expirationAt"); + if (expirationAt != null && nowMs >= expirationAt) { + return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED }; + } + + return { playable: true, reason: null }; +} + +function resolveEntrySettings(entry, appSettings) { + const durationOverrideMs = read( + entry, + "duration_override_ms", + "durationOverrideMs", + ); + const backgroundModeOverride = read( + entry, + "background_mode_override", + "backgroundModeOverride", + ); + const backgroundColorOverride = read( + entry, + "background_color_override", + "backgroundColorOverride", + ); + const backgroundDarkenedOverride = read( + entry, + "background_darkened_override", + "backgroundDarkenedOverride", + ); + + return { + durationMs: + durationOverrideMs ?? + read(appSettings, "default_duration_ms", "defaultDurationMs"), + presentation: { + backgroundMode: + backgroundModeOverride ?? + read(appSettings, "default_background_mode", "defaultBackgroundMode"), + backgroundColor: + backgroundColorOverride ?? + read( + appSettings, + "default_solid_background_color", + "defaultSolidBackgroundColor", + ), + backgroundDarkened: Boolean( + backgroundDarkenedOverride ?? + read( + appSettings, + "default_background_darkened", + "defaultBackgroundDarkened", + ), + ), + blurPx: read(appSettings, "default_blur_px", "defaultBlurPx"), + }, + }; +} + +module.exports = { + ELIGIBILITY_REASONS, + evaluateEntryEligibility, + resolveEntrySettings, +}; diff --git a/server/src/lifecycle/index.js b/server/src/lifecycle/index.js new file mode 100644 index 0000000..abaa053 --- /dev/null +++ b/server/src/lifecycle/index.js @@ -0,0 +1 @@ +module.exports = require("./eligibility"); diff --git a/server/src/playlists/errors.js b/server/src/playlists/errors.js new file mode 100644 index 0000000..06ba6c1 --- /dev/null +++ b/server/src/playlists/errors.js @@ -0,0 +1,31 @@ +class PlaylistError extends Error { + constructor(code, message, { statusCode = 400, details, cause } = {}) { + super(message, cause ? { cause } : undefined); + this.name = "PlaylistError"; + this.code = code; + this.statusCode = statusCode; + if (details !== undefined) { + this.details = details; + } + } +} + +function playlistNotFound(playlistId) { + return new PlaylistError("PLAYLIST_NOT_FOUND", "Playlist not found", { + statusCode: 404, + details: { playlistId }, + }); +} + +function playlistEntryNotFound(playlistId, entryId) { + return new PlaylistError("PLAYLIST_ENTRY_NOT_FOUND", "Playlist entry not found", { + statusCode: 404, + details: { playlistId, entryId }, + }); +} + +module.exports = { + PlaylistError, + playlistEntryNotFound, + playlistNotFound, +}; diff --git a/server/src/playlists/index.js b/server/src/playlists/index.js new file mode 100644 index 0000000..83d0bcc --- /dev/null +++ b/server/src/playlists/index.js @@ -0,0 +1,7 @@ +const { createPlaylistRouter } = require("./routes"); +const { createPlaylistService } = require("./service"); + +module.exports = { + createPlaylistRouter, + createPlaylistService, +}; diff --git a/server/src/playlists/routes.js b/server/src/playlists/routes.js new file mode 100644 index 0000000..94477bd --- /dev/null +++ b/server/src/playlists/routes.js @@ -0,0 +1,136 @@ +const express = require("express"); +const { PlaylistError } = require("./errors"); + +function orderedEntryIds(body) { + if (body === null || typeof body !== "object" || Array.isArray(body)) { + throw new PlaylistError( + "INVALID_PLAYLIST_ORDER", + "Playlist order must be an object containing entryIds", + ); + } + const fields = Object.keys(body); + if (fields.length !== 1 || fields[0] !== "entryIds") { + throw new PlaylistError( + "INVALID_PLAYLIST_ORDER", + "Playlist order must contain only entryIds", + { + details: { + fields, + requiredFields: ["entryIds"], + }, + }, + ); + } + return body.entryIds; +} + +function createPlaylistRouter(playlistService) { + const router = express.Router(); + + router.get("/", (request, response, next) => { + try { + response.json({ playlists: playlistService.list() }); + } catch (error) { + next(error); + } + }); + + router.post("/", (request, response, next) => { + try { + response.status(201).json(playlistService.create(request.body)); + } catch (error) { + next(error); + } + }); + + router.get("/:playlistId", (request, response, next) => { + try { + response.json(playlistService.get(request.params.playlistId)); + } catch (error) { + next(error); + } + }); + + router.patch("/:playlistId", (request, response, next) => { + try { + response.json( + playlistService.update(request.params.playlistId, request.body), + ); + } catch (error) { + next(error); + } + }); + + router.delete("/:playlistId", (request, response, next) => { + try { + response.json(playlistService.remove(request.params.playlistId)); + } catch (error) { + next(error); + } + }); + + router.post("/:playlistId/entries", (request, response, next) => { + try { + response + .status(201) + .json(playlistService.addEntry(request.params.playlistId, request.body)); + } catch (error) { + next(error); + } + }); + + router.patch("/:playlistId/entries/:entryId", (request, response, next) => { + try { + response.json( + playlistService.updateEntry( + request.params.playlistId, + request.params.entryId, + request.body, + ), + ); + } catch (error) { + next(error); + } + }); + + router.delete("/:playlistId/entries/:entryId", (request, response, next) => { + try { + response.json( + playlistService.removeEntry( + request.params.playlistId, + request.params.entryId, + ), + ); + } catch (error) { + next(error); + } + }); + + router.put("/:playlistId/entries/order", (request, response, next) => { + try { + response.json( + playlistService.reorderEntries( + request.params.playlistId, + orderedEntryIds(request.body), + ), + ); + } catch (error) { + next(error); + } + }); + + router.post("/:playlistId/activate", (request, response, next) => { + try { + response.json(playlistService.activate(request.params.playlistId)); + } catch (error) { + next(error); + } + }); + + return router; +} + +module.exports = { + createPlaylistRouter, + orderedEntryIds, +}; diff --git a/server/src/playlists/service.js b/server/src/playlists/service.js new file mode 100644 index 0000000..215cf20 --- /dev/null +++ b/server/src/playlists/service.js @@ -0,0 +1,1055 @@ +const crypto = require("node:crypto"); +const { + evaluateEntryEligibility, + resolveEntrySettings, +} = require("../lifecycle"); +const { + PlaylistError, + playlistEntryNotFound, + playlistNotFound, +} = require("./errors"); + +const MIN_DURATION_MS = 1_000; +const MAX_DURATION_MS = 3_600_000; +const MAX_PLAYLIST_NAME_LENGTH = 200; +const BACKGROUND_MODES = new Set(["blurred", "solid", "black"]); +const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; +const ENTRY_PATCH_FIELDS = new Set([ + "enabled", + "durationOverrideMs", + "backgroundModeOverride", + "backgroundColorOverride", + "backgroundDarkenedOverride", + "scheduleStartAt", + "scheduleEndAt", +]); + +function normalizeObject(input, code, message) { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + throw new PlaylistError(code, message); + } + return input; +} + +function normalizePlaylistName(value) { + if (typeof value !== "string") { + throw new PlaylistError( + "INVALID_PLAYLIST_NAME", + "Playlist name must be a string", + ); + } + const normalized = value + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) { + throw new PlaylistError( + "INVALID_PLAYLIST_NAME", + "Playlist name cannot be empty", + ); + } + if ([...normalized].length > MAX_PLAYLIST_NAME_LENGTH) { + throw new PlaylistError( + "INVALID_PLAYLIST_NAME", + `Playlist name cannot exceed ${MAX_PLAYLIST_NAME_LENGTH} characters`, + { details: { maxLength: MAX_PLAYLIST_NAME_LENGTH } }, + ); + } + return normalized; +} + +function normalizeBoolean(value, field) { + if (typeof value !== "boolean") { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + `${field} must be a boolean`, + { details: { field } }, + ); + } + return value ? 1 : 0; +} + +function normalizeNullableDuration(value) { + if (value === null) return null; + if ( + !Number.isSafeInteger(value) || + value < MIN_DURATION_MS || + value > MAX_DURATION_MS + ) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + `durationOverrideMs must be null or an integer from ${MIN_DURATION_MS} through ${MAX_DURATION_MS}`, + { + details: { + field: "durationOverrideMs", + min: MIN_DURATION_MS, + max: MAX_DURATION_MS, + }, + }, + ); + } + return value; +} + +function normalizeNullableBackgroundMode(value) { + if (value === null) return null; + if (typeof value !== "string" || !BACKGROUND_MODES.has(value)) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + "backgroundModeOverride must be null, blurred, solid, or black", + { details: { field: "backgroundModeOverride" } }, + ); + } + return value; +} + +function normalizeNullableColor(value) { + if (value === null) return null; + if (typeof value !== "string" || !HEX_COLOR_PATTERN.test(value)) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + "backgroundColorOverride must be null or a six-digit hex color", + { details: { field: "backgroundColorOverride" } }, + ); + } + return value.toUpperCase(); +} + +function normalizeNullableDarkening(value) { + if (value === null) return null; + return normalizeBoolean(value, "backgroundDarkenedOverride"); +} + +function normalizeNullableInstant(value, field) { + if (value === null) return null; + if (Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === "string" && value.trim()) { + const instant = value.trim(); + if (!/([zZ]|[+-]\d{2}:\d{2})$/.test(instant)) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + `${field} ISO dates must include Z or a numeric UTC offset`, + { details: { field } }, + ); + } + const timestamp = Date.parse(instant); + if (Number.isSafeInteger(timestamp) && timestamp >= 0) return timestamp; + } + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + `${field} must be null, an epoch-millisecond integer, or an ISO date`, + { details: { field } }, + ); +} + +function validateSchedule(startAt, endAt) { + if (startAt !== null && endAt !== null && startAt >= endAt) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY_SCHEDULE", + "scheduleStartAt must be earlier than scheduleEndAt", + { details: { scheduleStartAt: startAt, scheduleEndAt: endAt } }, + ); + } +} + +function mapPlaylist(row) { + return { + id: row.id, + name: row.name, + revision: row.revision, + entryCount: Number(row.entry_count || 0), + isActive: Boolean(row.is_active), + isPendingActivation: Boolean(row.is_pending), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapPlaybackState(row) { + return { + outputId: row.output_id, + mode: row.mode, + activePlaylistId: row.active_playlist_id, + pendingPlaylistId: row.pending_playlist_id, + pendingPlaylistRequestedAt: row.pending_playlist_requested_at, + currentEntryId: row.current_entry_id, + rotationPaused: Boolean(row.rotation_paused), + pausedRemainingMs: row.paused_remaining_ms, + staticAssetId: row.static_asset_id, + slideStartedAt: row.slide_started_at, + nextSlideAt: row.next_slide_at, + revision: row.revision, + updatedAt: row.updated_at, + }; +} + +function mediaUrl(assetId, kind, sha256) { + return sha256 + ? `/media/assets/${assetId}/${kind}/${sha256}.webp` + : null; +} + +function mapResolvedEntry(row, settings, nowMs) { + const eligibility = evaluateEntryEligibility(row, row, nowMs); + const resolved = resolveEntrySettings(row, settings); + return { + id: row.entry_id, + playlistId: row.playlist_id, + assetId: row.asset_id, + enabled: Boolean(row.enabled), + position: row.position, + durationOverrideMs: row.duration_override_ms, + effectiveDurationMs: resolved.durationMs, + backgroundModeOverride: row.background_mode_override, + backgroundColorOverride: row.background_color_override, + backgroundDarkenedOverride: + row.background_darkened_override === null + ? null + : Boolean(row.background_darkened_override), + effectivePresentation: resolved.presentation, + scheduleStartAt: row.schedule_start_at, + scheduleEndAt: row.schedule_end_at, + playable: eligibility.playable, + notPlayableReason: eligibility.reason, + eligibility, + asset: { + id: row.asset_id, + displayName: row.asset_display_name, + originalFilename: row.asset_original_filename, + status: row.status, + expirationAt: row.expiration_at, + display: { + width: row.display_width, + height: row.display_height, + url: mediaUrl(row.asset_id, "display", row.display_sha256), + }, + thumbnail: { + width: row.thumbnail_width, + height: row.thumbnail_height, + url: mediaUrl(row.asset_id, "thumbnail", row.thumbnail_sha256), + }, + }, + createdAt: row.entry_created_at, + updatedAt: row.entry_updated_at, + }; +} + +function createPlaylistService({ + database, + now = Date.now, + createId = crypto.randomUUID, + onChanged = () => {}, +}) { + const playlistSelect = ` + SELECT p.*, + (SELECT COUNT(*) FROM playlist_entries pe WHERE pe.playlist_id = p.id) + AS entry_count, + EXISTS ( + SELECT 1 FROM playback_state ps WHERE ps.active_playlist_id = p.id + ) AS is_active, + EXISTS ( + SELECT 1 FROM playback_state ps WHERE ps.pending_playlist_id = p.id + ) AS is_pending + FROM playlists p + `; + const listPlaylists = database.prepare( + `${playlistSelect} ORDER BY p.name COLLATE NOCASE, p.id`, + ); + const findPlaylist = database.prepare(`${playlistSelect} WHERE p.id = ?`); + const findPlaylistByName = database.prepare( + `${playlistSelect} WHERE p.name = ? COLLATE NOCASE`, + ); + const insertPlaylist = database.prepare(` + INSERT INTO playlists (id, name, revision, created_at, updated_at) + VALUES (?, ?, 1, ?, ?) + `); + const renamePlaylist = database.prepare(` + UPDATE playlists + SET name = ?, revision = revision + 1, updated_at = ? + WHERE id = ? + `); + const deletePlaylist = database.prepare("DELETE FROM playlists WHERE id = ?"); + const bumpPlaylistRevision = database.prepare(` + UPDATE playlists + SET revision = revision + 1, updated_at = ? + WHERE id = ? + `); + const readSettings = database.prepare("SELECT * FROM app_settings WHERE id = 1"); + const readPlayback = database.prepare( + "SELECT * FROM playback_state WHERE output_id = 'global'", + ); + const resolvedEntries = database.prepare(` + SELECT + pe.id AS entry_id, + pe.playlist_id, + pe.asset_id, + pe.enabled, + pe.duration_override_ms, + pe.background_mode_override, + pe.background_color_override, + pe.background_darkened_override, + pe.schedule_start_at, + pe.schedule_end_at, + pe.position, + pe.created_at AS entry_created_at, + pe.updated_at AS entry_updated_at, + a.display_name AS asset_display_name, + a.original_filename AS asset_original_filename, + a.status, + a.expiration_at, + a.display_sha256, + a.display_width, + a.display_height, + a.thumbnail_sha256, + a.thumbnail_width, + a.thumbnail_height + FROM playlist_entries pe + JOIN assets a ON a.id = pe.asset_id + WHERE pe.playlist_id = ? + ORDER BY pe.position, pe.id + `); + const findEntry = database.prepare(` + SELECT pe.* + FROM playlist_entries pe + WHERE pe.playlist_id = ? AND pe.id = ? + `); + const findEntryByAsset = database.prepare(` + SELECT pe.* + FROM playlist_entries pe + WHERE pe.playlist_id = ? AND pe.asset_id = ? + `); + const findEntryOwner = database.prepare( + "SELECT playlist_id FROM playlist_entries WHERE id = ?", + ); + const listEntryIds = database.prepare(` + SELECT id, position + FROM playlist_entries + WHERE playlist_id = ? + ORDER BY position, id + `); + const findAsset = database.prepare("SELECT * FROM assets WHERE id = ?"); + const nextPosition = database.prepare(` + SELECT COALESCE(MAX(position), -1) + 1 AS position + FROM playlist_entries + WHERE playlist_id = ? + `); + const insertEntry = database.prepare(` + INSERT INTO playlist_entries ( + id, playlist_id, asset_id, enabled, duration_override_ms, + background_mode_override, background_color_override, + background_darkened_override, schedule_start_at, schedule_end_at, + position, created_at, updated_at + ) VALUES ( + @id, @playlistId, @assetId, @enabled, @durationOverrideMs, + @backgroundModeOverride, @backgroundColorOverride, + @backgroundDarkenedOverride, @scheduleStartAt, @scheduleEndAt, + @position, @createdAt, @updatedAt + ) + `); + const updateEntry = database.prepare(` + UPDATE playlist_entries + SET enabled = @enabled, + duration_override_ms = @durationOverrideMs, + background_mode_override = @backgroundModeOverride, + background_color_override = @backgroundColorOverride, + background_darkened_override = @backgroundDarkenedOverride, + schedule_start_at = @scheduleStartAt, + schedule_end_at = @scheduleEndAt, + updated_at = @updatedAt + WHERE playlist_id = @playlistId AND id = @entryId + `); + const deleteEntry = database.prepare( + "DELETE FROM playlist_entries WHERE playlist_id = ? AND id = ?", + ); + const moveEntriesOutOfRange = database.prepare(` + UPDATE playlist_entries + SET position = position + ? + WHERE playlist_id = ? + `); + const setEntryPosition = database.prepare(` + UPDATE playlist_entries + SET position = ?, updated_at = ? + WHERE playlist_id = ? AND id = ? + `); + const clearDeletedCurrentEntry = database.prepare(` + UPDATE playback_state + SET current_entry_id = NULL, + slide_started_at = NULL, + next_slide_at = NULL, + paused_remaining_ms = NULL, + revision = revision + 1, + updated_at = MAX(updated_at + 1, ?) + WHERE current_entry_id = ? + `); + const setPendingActivation = database.prepare(` + UPDATE playback_state + SET pending_playlist_id = ?, + pending_playlist_requested_at = ?, + revision = revision + 1, + updated_at = ? + WHERE output_id = 'global' + `); + const clearPendingActivation = database.prepare(` + UPDATE playback_state + SET pending_playlist_id = NULL, + pending_playlist_requested_at = NULL, + revision = revision + 1, + updated_at = ? + WHERE output_id = 'global' + `); + const activateImmediately = database.prepare(` + UPDATE playback_state + SET active_playlist_id = @playlistId, + pending_playlist_id = NULL, + pending_playlist_requested_at = NULL, + current_entry_id = @currentEntryId, + paused_remaining_ms = @pausedRemainingMs, + slide_started_at = @slideStartedAt, + next_slide_at = @nextSlideAt, + revision = revision + 1, + updated_at = @updatedAt + WHERE output_id = 'global' + `); + const findCurrentEntry = database.prepare(` + SELECT pe.*, a.status, a.expiration_at + FROM playlist_entries pe + JOIN assets a ON a.id = pe.asset_id + WHERE pe.id = ? AND pe.playlist_id = ? + `); + + function getPlaylistRow(playlistId) { + const row = findPlaylist.get(playlistId); + if (!row) throw playlistNotFound(playlistId); + return row; + } + + function getEntryRow(playlistId, entryId) { + getPlaylistRow(playlistId); + const row = findEntry.get(playlistId, entryId); + if (!row) throw playlistEntryNotFound(playlistId, entryId); + return row; + } + + function nextTimestamp(row) { + return Math.max(now(), row.updated_at + 1); + } + + function notify(change) { + try { + onChanged(change); + } catch { + // The database commit is authoritative; invalidation must not undo it. + } + } + + function duplicateName(name, row = findPlaylistByName.get(name)) { + return new PlaylistError( + "PLAYLIST_NAME_CONFLICT", + "Another playlist already uses that name", + { + statusCode: 409, + details: row ? { playlist: mapPlaylist(row) } : { name }, + }, + ); + } + + function validateCreate(input) { + normalizeObject(input, "INVALID_PLAYLIST", "Playlist must be an object"); + const unknown = Object.keys(input).filter((key) => key !== "name"); + if (unknown.length > 0) { + throw new PlaylistError( + "INVALID_PLAYLIST", + "Playlist contains unsupported fields", + { details: { fields: unknown } }, + ); + } + if (!Object.hasOwn(input, "name")) { + throw new PlaylistError("INVALID_PLAYLIST", "Playlist name is required", { + details: { field: "name" }, + }); + } + return normalizePlaylistName(input.name); + } + + function validateEntryFields(input, { creating = false, current = null } = {}) { + normalizeObject( + input, + "INVALID_PLAYLIST_ENTRY", + "Playlist entry must be an object", + ); + const allowed = creating + ? new Set(["assetId", ...ENTRY_PATCH_FIELDS]) + : ENTRY_PATCH_FIELDS; + const unknown = Object.keys(input).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + "Playlist entry contains unsupported fields", + { details: { fields: unknown } }, + ); + } + if (!creating && Object.keys(input).length === 0) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + "At least one playlist entry field is required", + ); + } + if (creating && (typeof input.assetId !== "string" || !input.assetId.trim())) { + throw new PlaylistError( + "INVALID_PLAYLIST_ENTRY", + "assetId is required", + { details: { field: "assetId" } }, + ); + } + + const value = (field, fallback) => + Object.hasOwn(input, field) ? input[field] : fallback; + const normalized = { + assetId: creating ? input.assetId.trim() : current.asset_id, + enabled: Object.hasOwn(input, "enabled") + ? normalizeBoolean(input.enabled, "enabled") + : (current?.enabled ?? 1), + durationOverrideMs: Object.hasOwn(input, "durationOverrideMs") + ? normalizeNullableDuration(input.durationOverrideMs) + : (current?.duration_override_ms ?? null), + backgroundModeOverride: Object.hasOwn(input, "backgroundModeOverride") + ? normalizeNullableBackgroundMode(input.backgroundModeOverride) + : (current?.background_mode_override ?? null), + backgroundColorOverride: Object.hasOwn(input, "backgroundColorOverride") + ? normalizeNullableColor(input.backgroundColorOverride) + : (current?.background_color_override ?? null), + backgroundDarkenedOverride: Object.hasOwn( + input, + "backgroundDarkenedOverride", + ) + ? normalizeNullableDarkening(input.backgroundDarkenedOverride) + : (current?.background_darkened_override ?? null), + scheduleStartAt: normalizeNullableInstant( + value("scheduleStartAt", current?.schedule_start_at ?? null), + "scheduleStartAt", + ), + scheduleEndAt: normalizeNullableInstant( + value("scheduleEndAt", current?.schedule_end_at ?? null), + "scheduleEndAt", + ), + }; + validateSchedule(normalized.scheduleStartAt, normalized.scheduleEndAt); + return normalized; + } + + function entryValuesEqual(current, next) { + return ( + current.enabled === next.enabled && + current.duration_override_ms === next.durationOverrideMs && + current.background_mode_override === next.backgroundModeOverride && + current.background_color_override === next.backgroundColorOverride && + current.background_darkened_override === next.backgroundDarkenedOverride && + current.schedule_start_at === next.scheduleStartAt && + current.schedule_end_at === next.scheduleEndAt + ); + } + + function resolvedDetail(playlistId, nowMs = now()) { + const playlist = mapPlaylist(getPlaylistRow(playlistId)); + const settings = readSettings.get(); + if (!settings) throw new Error("The app_settings singleton row is missing"); + return { + ...playlist, + evaluatedAt: nowMs, + settingsRevision: settings.revision, + entries: resolvedEntries + .all(playlistId) + .map((row) => mapResolvedEntry(row, settings, nowMs)), + }; + } + + const createTransaction = database.transaction((input) => { + const name = validateCreate(input); + const existing = findPlaylistByName.get(name); + if (existing) throw duplicateName(name, existing); + const timestamp = now(); + const id = createId(); + try { + insertPlaylist.run(id, name, timestamp, timestamp); + } catch (error) { + const conflict = findPlaylistByName.get(name); + if (conflict) throw duplicateName(name, conflict); + throw error; + } + return mapPlaylist(getPlaylistRow(id)); + }); + + function create(input) { + const playlist = createTransaction(input); + notify({ reason: "created", playlistId: playlist.id, revision: playlist.revision }); + return playlist; + } + + function list() { + return listPlaylists.all().map(mapPlaylist); + } + + function get(playlistId) { + return resolvedDetail(playlistId); + } + + const updateTransaction = database.transaction((playlistId, input) => { + normalizeObject(input, "INVALID_PLAYLIST", "Playlist update must be an object"); + const unknown = Object.keys(input).filter((key) => key !== "name"); + if (unknown.length > 0) { + throw new PlaylistError( + "INVALID_PLAYLIST", + "Playlist update contains unsupported fields", + { details: { fields: unknown } }, + ); + } + if (!Object.hasOwn(input, "name") || Object.keys(input).length !== 1) { + throw new PlaylistError( + "INVALID_PLAYLIST", + "Playlist update requires a name", + { details: { field: "name" } }, + ); + } + const current = getPlaylistRow(playlistId); + const name = normalizePlaylistName(input.name); + if (current.name === name) { + return { changed: false, playlist: mapPlaylist(current) }; + } + const conflict = findPlaylistByName.get(name); + if (conflict && conflict.id !== playlistId) throw duplicateName(name, conflict); + try { + renamePlaylist.run(name, nextTimestamp(current), playlistId); + } catch (error) { + const raced = findPlaylistByName.get(name); + if (raced && raced.id !== playlistId) throw duplicateName(name, raced); + throw error; + } + return { changed: true, playlist: mapPlaylist(getPlaylistRow(playlistId)) }; + }); + + function update(playlistId, input) { + const result = updateTransaction(playlistId, input); + if (result.changed) { + notify({ + reason: "renamed", + playlistId, + revision: result.playlist.revision, + }); + } + return result.playlist; + } + + const removeTransaction = database.transaction((playlistId) => { + const playlist = getPlaylistRow(playlistId); + const state = readPlayback.get(); + if (!state) throw new Error("The global playback_state row is missing"); + if (state.active_playlist_id === playlistId) { + throw new PlaylistError( + "PLAYLIST_ACTIVE", + "The active playlist cannot be deleted", + { statusCode: 409, details: { playlistId } }, + ); + } + if (state.pending_playlist_id === playlistId) { + throw new PlaylistError( + "PLAYLIST_PENDING_ACTIVATION", + "A playlist pending activation cannot be deleted", + { statusCode: 409, details: { playlistId } }, + ); + } + deletePlaylist.run(playlistId); + return playlist; + }); + + function remove(playlistId) { + const playlist = removeTransaction(playlistId); + notify({ reason: "deleted", playlistId, revision: playlist.revision }); + return { id: playlistId, deleted: true }; + } + + const addEntryTransaction = database.transaction((playlistId, input) => { + const playlist = getPlaylistRow(playlistId); + const fields = validateEntryFields(input, { creating: true }); + const operationNow = now(); + const asset = findAsset.get(fields.assetId); + if (!asset) { + throw new PlaylistError("ASSET_NOT_FOUND", "Library asset not found", { + statusCode: 404, + details: { assetId: fields.assetId }, + }); + } + if (asset.status !== "ready") { + throw new PlaylistError( + "ASSET_NOT_READY", + "Only ready library assets can be added to playlists", + { + statusCode: 409, + details: { assetId: asset.id, status: asset.status }, + }, + ); + } + if (asset.expiration_at !== null && asset.expiration_at <= operationNow) { + throw new PlaylistError( + "ASSET_EXPIRED", + "Expired library assets cannot be added to playlists", + { + statusCode: 409, + details: { assetId: asset.id, expirationAt: asset.expiration_at }, + }, + ); + } + const existing = findEntryByAsset.get(playlistId, fields.assetId); + if (existing) { + throw new PlaylistError( + "PLAYLIST_ENTRY_EXISTS", + "This asset already appears in the playlist", + { + statusCode: 409, + details: { playlistId, assetId: fields.assetId, entryId: existing.id }, + }, + ); + } + const timestamp = Math.max(operationNow, playlist.updated_at + 1); + const id = createId(); + try { + insertEntry.run({ + id, + playlistId, + ...fields, + position: nextPosition.get(playlistId).position, + createdAt: timestamp, + updatedAt: timestamp, + }); + } catch (error) { + const raced = findEntryByAsset.get(playlistId, fields.assetId); + if (raced) { + throw new PlaylistError( + "PLAYLIST_ENTRY_EXISTS", + "This asset already appears in the playlist", + { + statusCode: 409, + details: { playlistId, assetId: fields.assetId, entryId: raced.id }, + }, + ); + } + throw error; + } + bumpPlaylistRevision.run(timestamp, playlistId); + return { + playlist: getPlaylistRow(playlistId), + entryId: id, + detail: resolvedDetail(playlistId, now()), + }; + }); + + function addEntry(playlistId, input) { + const result = addEntryTransaction(playlistId, input); + notify({ + reason: "entry_added", + playlistId, + revision: result.playlist.revision, + entryId: result.entryId, + }); + return result.detail.entries.find((entry) => entry.id === result.entryId); + } + + const patchEntryTransaction = database.transaction((playlistId, entryId, input) => { + const playlist = getPlaylistRow(playlistId); + const current = getEntryRow(playlistId, entryId); + const fields = validateEntryFields(input, { current }); + if (entryValuesEqual(current, fields)) { + return { + changed: false, + playlist, + detail: resolvedDetail(playlistId, now()), + }; + } + const timestamp = nextTimestamp(playlist); + updateEntry.run({ + playlistId, + entryId, + ...fields, + updatedAt: timestamp, + }); + bumpPlaylistRevision.run(timestamp, playlistId); + return { + changed: true, + playlist: getPlaylistRow(playlistId), + detail: resolvedDetail(playlistId, now()), + }; + }); + + function updateEntryById(playlistId, entryId, input) { + const result = patchEntryTransaction(playlistId, entryId, input); + if (result.changed) { + notify({ + reason: "entry_updated", + playlistId, + revision: result.playlist.revision, + entryId, + }); + } + return result.detail.entries.find((entry) => entry.id === entryId); + } + + const removeEntryTransaction = database.transaction((playlistId, entryId) => { + const playlist = getPlaylistRow(playlistId); + getEntryRow(playlistId, entryId); + const timestamp = nextTimestamp(playlist); + const playbackChanged = clearDeletedCurrentEntry.run(timestamp, entryId).changes > 0; + const result = deleteEntry.run(playlistId, entryId); + if (result.changes !== 1) throw playlistEntryNotFound(playlistId, entryId); + const remaining = listEntryIds.all(playlistId); + if (remaining.some((entry, index) => entry.position !== index)) { + moveEntriesOutOfRange.run(remaining.length + 1, playlistId); + remaining.forEach((entry, position) => { + setEntryPosition.run(position, timestamp, playlistId, entry.id); + }); + } + bumpPlaylistRevision.run(timestamp, playlistId); + return { playlist: getPlaylistRow(playlistId), playbackChanged }; + }); + + function removeEntryById(playlistId, entryId) { + const result = removeEntryTransaction(playlistId, entryId); + const playback = result.playbackChanged ? readPlayback.get() : null; + notify({ + reason: "entry_deleted", + playlistId, + revision: result.playlist.revision, + entryId, + ...(playback ? { playbackRevision: playback.revision } : {}), + }); + return { id: entryId, deleted: true }; + } + + function validateOrder(playlistId, orderedEntryIds) { + if (!Array.isArray(orderedEntryIds)) { + throw new PlaylistError( + "INVALID_PLAYLIST_ORDER", + "Playlist order must be a complete array of entry IDs", + ); + } + if (orderedEntryIds.some((id) => typeof id !== "string" || !id)) { + throw new PlaylistError( + "INVALID_PLAYLIST_ORDER", + "Every ordered entry ID must be a non-empty string", + ); + } + const duplicates = orderedEntryIds.filter( + (id, index) => orderedEntryIds.indexOf(id) !== index, + ); + if (duplicates.length > 0) { + throw new PlaylistError( + "PLAYLIST_ORDER_DUPLICATE_IDS", + "Playlist order contains duplicate entry IDs", + { details: { entryIds: [...new Set(duplicates)] } }, + ); + } + + const current = listEntryIds.all(playlistId); + const currentIds = new Set(current.map((entry) => entry.id)); + const suppliedIds = new Set(orderedEntryIds); + const extra = orderedEntryIds.filter((id) => !currentIds.has(id)); + if (extra.length > 0) { + const foreign = extra.filter((id) => { + const owner = findEntryOwner.get(id); + return owner && owner.playlist_id !== playlistId; + }); + if (foreign.length > 0) { + throw new PlaylistError( + "PLAYLIST_ORDER_FOREIGN_ENTRY", + "Playlist order contains an entry from another playlist", + { details: { entryIds: foreign } }, + ); + } + throw new PlaylistError( + "PLAYLIST_ORDER_UNKNOWN_ENTRY", + "Playlist order contains an unknown entry ID", + { details: { entryIds: extra } }, + ); + } + const missing = current + .map((entry) => entry.id) + .filter((id) => !suppliedIds.has(id)); + if (missing.length > 0) { + throw new PlaylistError( + "PLAYLIST_ORDER_INCOMPLETE", + "Playlist order is missing current entries", + { details: { entryIds: missing } }, + ); + } + return current; + } + + const reorderTransaction = database.transaction((playlistId, orderedEntryIds) => { + const playlist = getPlaylistRow(playlistId); + const current = validateOrder(playlistId, orderedEntryIds); + if (current.every((entry, index) => entry.id === orderedEntryIds[index])) { + return { changed: false, playlist, detail: resolvedDetail(playlistId, now()) }; + } + const timestamp = nextTimestamp(playlist); + const offset = Math.max(current.length, 1); + moveEntriesOutOfRange.run(offset, playlistId); + orderedEntryIds.forEach((entryId, position) => { + setEntryPosition.run(position, timestamp, playlistId, entryId); + }); + bumpPlaylistRevision.run(timestamp, playlistId); + return { + changed: true, + playlist: getPlaylistRow(playlistId), + detail: resolvedDetail(playlistId, now()), + }; + }); + + function reorderEntries(playlistId, orderedEntryIds) { + const result = reorderTransaction(playlistId, orderedEntryIds); + if (result.changed) { + notify({ reason: "entries_reordered", playlistId, revision: result.playlist.revision }); + } + return result.detail; + } + + function isCurrentNormalSlideMidDuration(state, nowMs) { + if (state.static_asset_id !== null || state.current_entry_id === null) return false; + if (state.active_playlist_id === null) return false; + const current = findCurrentEntry.get( + state.current_entry_id, + state.active_playlist_id, + ); + if (!current || !evaluateEntryEligibility(current, current, nowMs).playable) { + return false; + } + if (state.rotation_paused) { + return ( + Number.isSafeInteger(state.paused_remaining_ms) && + state.paused_remaining_ms > 0 + ); + } + return Number.isSafeInteger(state.next_slide_at) && state.next_slide_at > nowMs; + } + + function prepareImmediateTarget(playlistId, state, nowMs) { + const settings = readSettings.get(); + if (!settings) throw new Error("The app_settings singleton row is missing"); + const target = resolvedEntries + .all(playlistId) + .find((row) => evaluateEntryEligibility(row, row, nowMs).playable); + if (!target) { + return { + currentEntryId: null, + pausedRemainingMs: null, + slideStartedAt: null, + nextSlideAt: null, + }; + } + + const durationMs = resolveEntrySettings(target, settings).durationMs; + if (state.static_asset_id !== null || state.rotation_paused) { + return { + currentEntryId: target.entry_id, + pausedRemainingMs: state.rotation_paused ? durationMs : null, + slideStartedAt: null, + nextSlideAt: null, + }; + } + return { + currentEntryId: target.entry_id, + pausedRemainingMs: null, + slideStartedAt: nowMs, + nextSlideAt: nowMs + durationMs, + }; + } + + const activateTransaction = database.transaction((playlistId) => { + const playlist = getPlaylistRow(playlistId); + const state = readPlayback.get(); + if (!state) throw new Error("The global playback_state row is missing"); + const requestedAt = now(); + + if (state.active_playlist_id === playlistId) { + if (state.pending_playlist_id === null) { + return { changed: false, activation: "already_active", playlist, state }; + } + const timestamp = Math.max(requestedAt, state.updated_at + 1); + clearPendingActivation.run(timestamp); + return { + changed: true, + activation: "already_active", + playlist, + state: readPlayback.get(), + }; + } + if ( + state.pending_playlist_id === playlistId && + isCurrentNormalSlideMidDuration(state, requestedAt) + ) { + return { changed: false, activation: "pending", playlist, state }; + } + + const timestamp = Math.max(requestedAt, state.updated_at + 1); + if (isCurrentNormalSlideMidDuration(state, requestedAt)) { + setPendingActivation.run(playlistId, requestedAt, timestamp); + return { + changed: true, + activation: "pending", + playlist, + state: readPlayback.get(), + }; + } + + activateImmediately.run({ + playlistId, + ...prepareImmediateTarget(playlistId, state, requestedAt), + updatedAt: timestamp, + }); + return { + changed: true, + activation: "active", + playlist, + state: readPlayback.get(), + }; + }); + + function activate(playlistId) { + const result = activateTransaction(playlistId); + const playback = mapPlaybackState(result.state); + if (result.changed) { + notify({ + reason: + result.activation === "pending" + ? "activation_requested" + : "activated", + playlistId, + revision: result.playlist.revision, + playbackRevision: playback.revision, + }); + } + return { + activation: result.activation, + playlistId, + playback, + }; + } + + return { + activate, + addEntry, + create, + get, + list, + remove, + removeEntry: removeEntryById, + reorderEntries, + update, + updateEntry: updateEntryById, + }; +} + +module.exports = { + MAX_DURATION_MS, + MIN_DURATION_MS, + createPlaylistService, + mapPlaybackState, + normalizePlaylistName, +}; diff --git a/server/src/runtime.js b/server/src/runtime.js index 37b78cd..a843844 100644 --- a/server/src/runtime.js +++ b/server/src/runtime.js @@ -53,13 +53,18 @@ function createRuntime({ throw error; } let app; + let io = null; try { app = createApp({ config, database, imageProcessor, lifecycle, + logger, migrations, + onPlaylistsChanged(event) { + io?.emit("playlists:changed", event); + }, storage, }); } catch (error) { @@ -68,7 +73,7 @@ function createRuntime({ throw error; } const httpServer = http.createServer(app); - const io = new SocketServer(httpServer, { + io = new SocketServer(httpServer, { serveClient: false, }); diff --git a/server/src/settings/routes.js b/server/src/settings/routes.js index 3c101d8..bc437a2 100644 --- a/server/src/settings/routes.js +++ b/server/src/settings/routes.js @@ -1,6 +1,9 @@ const express = require("express"); -function createSettingsRouter(settingsService) { +function createSettingsRouter( + settingsService, + { onPlaylistsChanged = () => {} } = {}, +) { const router = express.Router(); router.get("/", (request, response) => { @@ -9,7 +12,16 @@ function createSettingsRouter(settingsService) { router.patch("/", (request, response, next) => { try { - response.json(settingsService.update(request.body)); + const previous = settingsService.get(); + const updated = settingsService.update(request.body); + if (updated.revision !== previous.revision) { + onPlaylistsChanged({ + reason: "settings_updated", + playlistIds: null, + settingsRevision: updated.revision, + }); + } + response.json(updated); } catch (error) { next(error); } diff --git a/server/test/eligibility.test.js b/server/test/eligibility.test.js new file mode 100644 index 0000000..9891c15 --- /dev/null +++ b/server/test/eligibility.test.js @@ -0,0 +1,218 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + ELIGIBILITY_REASONS, + evaluateEntryEligibility, + resolveEntrySettings, +} = require("../src/lifecycle"); + +const NOW = Date.parse("2035-05-06T07:08:09.000Z"); + +function readyAsset(overrides = {}) { + return { status: "ready", expiration_at: null, ...overrides }; +} + +function enabledEntry(overrides = {}) { + return { + enabled: 1, + schedule_start_at: null, + schedule_end_at: null, + ...overrides, + }; +} + +test("an enabled, ready, permanent, unscheduled entry is playable", () => { + assert.deepEqual( + evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW), + { playable: true, reason: null }, + ); +}); + +test("disabled entries are not playable", () => { + assert.deepEqual( + evaluateEntryEligibility(enabledEntry({ enabled: 0 }), readyAsset(), NOW), + { playable: false, reason: ELIGIBILITY_REASONS.DISABLED }, + ); +}); + +test("schedule start is inclusive", () => { + const entry = enabledEntry({ schedule_start_at: NOW }); + + assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), { + playable: false, + reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE, + }); + assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), { + playable: true, + reason: null, + }); +}); + +test("schedule end is exclusive", () => { + const entry = enabledEntry({ schedule_end_at: NOW }); + + assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), { + playable: true, + reason: null, + }); + assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), { + playable: false, + reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED, + }); +}); + +test("start-only, end-only, and bounded schedules use UTC epoch milliseconds", () => { + assert.equal( + evaluateEntryEligibility( + enabledEntry({ schedule_start_at: NOW - 1 }), + readyAsset(), + NOW, + ).playable, + true, + ); + assert.equal( + evaluateEntryEligibility( + enabledEntry({ schedule_end_at: NOW + 1 }), + readyAsset(), + NOW, + ).playable, + true, + ); + assert.equal( + evaluateEntryEligibility( + enabledEntry({ schedule_start_at: NOW, schedule_end_at: NOW + 1 }), + readyAsset(), + NOW, + ).playable, + true, + ); +}); + +test("only ready assets are playable", () => { + for (const status of ["processing", "failed", "deleting"]) { + assert.deepEqual( + evaluateEntryEligibility(enabledEntry(), readyAsset({ status }), NOW), + { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY }, + ); + } +}); + +test("permanent assets stay playable and expiration is exclusive", () => { + assert.equal( + evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW).playable, + true, + ); + assert.equal( + evaluateEntryEligibility( + enabledEntry(), + readyAsset({ expiration_at: NOW + 1 }), + NOW, + ).playable, + true, + ); + assert.deepEqual( + evaluateEntryEligibility( + enabledEntry(), + readyAsset({ expiration_at: NOW }), + NOW, + ), + { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED }, + ); +}); + +test("not-playable reasons follow the documented precedence", () => { + const asset = readyAsset({ status: "failed", expiration_at: NOW }); + + assert.equal( + evaluateEntryEligibility( + enabledEntry({ enabled: 0, schedule_start_at: NOW + 1 }), + asset, + NOW, + ).reason, + ELIGIBILITY_REASONS.DISABLED, + ); + assert.equal( + evaluateEntryEligibility( + enabledEntry({ schedule_start_at: NOW + 1, schedule_end_at: NOW }), + asset, + NOW, + ).reason, + ELIGIBILITY_REASONS.SCHEDULED_FUTURE, + ); + assert.equal( + evaluateEntryEligibility(enabledEntry({ schedule_end_at: NOW }), asset, NOW) + .reason, + ELIGIBILITY_REASONS.SCHEDULED_ENDED, + ); + assert.equal( + evaluateEntryEligibility(enabledEntry(), asset, NOW).reason, + ELIGIBILITY_REASONS.ASSET_NOT_READY, + ); +}); + +test("mapped camel-case domain values are supported", () => { + assert.deepEqual( + evaluateEntryEligibility( + { enabled: true, scheduleStartAt: NOW, scheduleEndAt: NOW + 1 }, + { status: "ready", expirationAt: NOW + 1 }, + NOW, + ), + { playable: true, reason: null }, + ); +}); + +test("eligibility rejects invalid evaluation clocks", () => { + for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "2035-01-01"]) { + assert.throws( + () => evaluateEntryEligibility(enabledEntry(), readyAsset(), invalid), + /nowMs must be a non-negative, safe epoch-millisecond integer/, + ); + } +}); + +test("resolved settings inherit global duration and presentation", () => { + const settings = { + default_duration_ms: 7_000, + default_background_mode: "blurred", + default_solid_background_color: "#123456", + default_background_darkened: 1, + default_blur_px: 20, + }; + + assert.deepEqual(resolveEntrySettings({}, settings), { + durationMs: 7_000, + presentation: { + backgroundMode: "blurred", + backgroundColor: "#123456", + backgroundDarkened: true, + blurPx: 20, + }, + }); +}); + +test("entry overrides are independent and blur always remains global", () => { + const settings = { + defaultDurationMs: 7_000, + defaultBackgroundMode: "blurred", + defaultSolidBackgroundColor: "#123456", + defaultBackgroundDarkened: true, + defaultBlurPx: 20, + }; + const entry = { + durationOverrideMs: 9_000, + backgroundModeOverride: "solid", + backgroundColorOverride: "#abcdef", + backgroundDarkenedOverride: false, + blurPx: 999, + }; + + assert.deepEqual(resolveEntrySettings(entry, settings), { + durationMs: 9_000, + presentation: { + backgroundMode: "solid", + backgroundColor: "#abcdef", + backgroundDarkened: false, + blurPx: 20, + }, + }); +}); diff --git a/server/test/playlists-api.test.js b/server/test/playlists-api.test.js new file mode 100644 index 0000000..caeb423 --- /dev/null +++ b/server/test/playlists-api.test.js @@ -0,0 +1,389 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createApp } = require("../src/app"); +const { loadConfig } = require("../src/config"); +const { openDatabase } = require("../src/database"); +const { runMigrations } = require("../src/database/migrate"); + +const NOW = Date.parse("2032-04-05T12:00:00.000Z"); + +const silentLogger = { + info() {}, + warn() {}, + error() {}, +}; + +function testConfig() { + return loadConfig({ + NODE_ENV: "test", + HOST: "127.0.0.1", + PORT: "0", + DATABASE_PATH: ":memory:", + APP_TIMEZONE: "America/Chicago", + S3_ENDPOINT: "http://seaweedfs:8333", + S3_BUCKET: "announcements-test", + }); +} + +function testStorage() { + return { + health() { + return { + status: "healthy", + checkedAt: new Date(NOW).toISOString(), + lastSuccessAt: new Date(NOW).toISOString(), + }; + }, + }; +} + +async function createHarness(t, { onPlaylistsChanged = () => {} } = {}) { + const config = testConfig(); + const database = openDatabase({ path: ":memory:" }); + const migrations = runMigrations(database); + const app = createApp({ + config, + database, + imageProcessor: {}, + lifecycle: { ready: true, stopping: false }, + logger: silentLogger, + migrations, + now: () => NOW, + onPlaylistsChanged, + storage: testStorage(), + }); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + t.after(async () => { + await new Promise((resolve) => server.close(resolve)); + database.close(); + }); + return { + baseUrl: `http://127.0.0.1:${server.address().port}`, + database, + }; +} + +function insertAsset(database, { id, name, status = "ready", expirationAt = null }) { + const hashCharacter = id.slice(-1).toLowerCase().replace(/[^0-9a-f]/g, "a"); + database + .prepare( + `INSERT INTO assets ( + id, source_sha256, display_name, original_filename, status, + expiration_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + hashCharacter.repeat(64), + name, + `${id}.png`, + status, + expirationAt, + NOW, + NOW, + ); +} + +async function jsonRequest(baseUrl, route, { body, ...init } = {}) { + const response = await fetch(`${baseUrl}${route}`, { + ...init, + ...(body === undefined + ? {} + : { + headers: { "content-type": "application/json", ...init.headers }, + body: JSON.stringify(body), + }), + }); + return { response, body: await response.json() }; +} + +test("playlist REST routes expose resolved entries, complete ordering, and structured errors", async (t) => { + const changes = []; + const harness = await createHarness(t, { + onPlaylistsChanged: (event) => changes.push(event), + }); + insertAsset(harness.database, { id: "asset-a", name: "Welcome" }); + insertAsset(harness.database, { id: "asset-b", name: "Events" }); + insertAsset(harness.database, { + id: "asset-f", + name: "Failed upload", + status: "failed", + }); + + const created = await jsonRequest(harness.baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Sunday" }, + }); + assert.equal(created.response.status, 201); + assert.match(created.body.id, /^[0-9a-f-]{36}$/); + assert.equal(created.body.revision, 1); + const playlistId = created.body.id; + + const listed = await jsonRequest(harness.baseUrl, "/api/playlists"); + assert.equal(listed.response.status, 200); + assert.deepEqual(listed.body.playlists.map((playlist) => playlist.id), [ + playlistId, + ]); + assert.equal(changes.length, 1, "GET must not publish invalidation events"); + + const first = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries`, + { + method: "POST", + body: { + assetId: "asset-a", + durationOverrideMs: 9_000, + backgroundModeOverride: "solid", + backgroundColorOverride: "#123abc", + backgroundDarkenedOverride: true, + }, + }, + ); + assert.equal(first.response.status, 201); + assert.equal(first.body.position, 0); + assert.equal(first.body.effectiveDurationMs, 9_000); + assert.equal(first.body.backgroundColorOverride, "#123ABC"); + assert.deepEqual(first.body.eligibility, { playable: true, reason: null }); + + const second = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries`, + { method: "POST", body: { assetId: "asset-b" } }, + ); + assert.equal(second.response.status, 201); + assert.equal(second.body.position, 1); + assert.equal(second.body.effectiveDurationMs, 7_000); + + const ordered = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries/order`, + { + method: "PUT", + body: { entryIds: [second.body.id, first.body.id] }, + }, + ); + assert.equal(ordered.response.status, 200); + assert.deepEqual( + ordered.body.entries.map((entry) => [entry.id, entry.position]), + [ + [second.body.id, 0], + [first.body.id, 1], + ], + ); + + const incomplete = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries/order`, + { method: "PUT", body: { entryIds: [first.body.id] } }, + ); + assert.equal(incomplete.response.status, 400); + assert.equal(incomplete.body.error.code, "PLAYLIST_ORDER_INCOMPLETE"); + const unexpectedOrderField = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries/order`, + { + method: "PUT", + body: { + entryIds: [second.body.id, first.body.id], + appendMissing: false, + }, + }, + ); + assert.equal(unexpectedOrderField.response.status, 400); + assert.equal( + unexpectedOrderField.body.error.code, + "INVALID_PLAYLIST_ORDER", + ); + const missingOrderField = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries/order`, + { method: "PUT", body: {} }, + ); + assert.equal(missingOrderField.response.status, 400); + assert.equal(missingOrderField.body.error.code, "INVALID_PLAYLIST_ORDER"); + const revisionAfterOrder = ordered.body.revision; + + const duplicate = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries`, + { method: "POST", body: { assetId: "asset-a" } }, + ); + assert.equal(duplicate.response.status, 409); + assert.equal(duplicate.body.error.code, "PLAYLIST_ENTRY_EXISTS"); + + const failed = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries`, + { method: "POST", body: { assetId: "asset-f" } }, + ); + assert.equal(failed.response.status, 409); + assert.equal(failed.body.error.code, "ASSET_NOT_READY"); + + const missing = await jsonRequest( + harness.baseUrl, + "/api/playlists/not-a-playlist", + ); + assert.equal(missing.response.status, 404); + assert.equal(missing.body.error.code, "PLAYLIST_NOT_FOUND"); + + const detail = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}`, + ); + assert.equal(detail.body.revision, revisionAfterOrder); + assert.equal(detail.body.entries.length, 2); + + const patchedEntry = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries/${first.body.id}`, + { + method: "PATCH", + body: { enabled: false, durationOverrideMs: null }, + }, + ); + assert.equal(patchedEntry.response.status, 200); + assert.equal(patchedEntry.body.enabled, false); + assert.equal(patchedEntry.body.effectiveDurationMs, 7_000); + assert.equal(patchedEntry.body.notPlayableReason, "disabled"); + + const removedEntry = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries/${second.body.id}`, + { method: "DELETE" }, + ); + assert.equal(removedEntry.response.status, 200); + assert.deepEqual(removedEntry.body, { id: second.body.id, deleted: true }); + const compacted = await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}`, + ); + assert.deepEqual( + compacted.body.entries.map((entry) => entry.position), + [0], + ); + + const temporary = await jsonRequest(harness.baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Temporary" }, + }); + const deletedPlaylist = await jsonRequest( + harness.baseUrl, + `/api/playlists/${temporary.body.id}`, + { method: "DELETE" }, + ); + assert.equal(deletedPlaylist.response.status, 200); + assert.deepEqual(deletedPlaylist.body, { + id: temporary.body.id, + deleted: true, + }); +}); + +test("playlist data invalidation fires once for semantic changes and not for reads, failures, or no-ops", async (t) => { + const changes = []; + const harness = await createHarness(t, { + onPlaylistsChanged: (event) => changes.push(event), + }); + insertAsset(harness.database, { id: "asset-a", name: "Welcome" }); + insertAsset(harness.database, { id: "asset-b", name: "Unused" }); + + const created = await jsonRequest(harness.baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Sunday" }, + }); + const playlistId = created.body.id; + assert.equal(changes.length, 1); + assert.equal(changes[0].reason, "created"); + + await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`); + await jsonRequest(harness.baseUrl, "/api/playlists", { + method: "POST", + body: { name: "sUNDAY" }, + }); + await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, { + method: "PATCH", + body: { name: "Sunday" }, + }); + assert.equal(changes.length, 1); + + await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, { + method: "PATCH", + body: { name: "Weekend" }, + }); + assert.equal(changes.length, 2); + assert.equal(changes[1].reason, "renamed"); + + await jsonRequest(harness.baseUrl, "/api/settings", { + method: "PATCH", + body: { defaultDurationMs: 7_000 }, + }); + assert.equal(changes.length, 2); + await jsonRequest(harness.baseUrl, "/api/settings", { + method: "PATCH", + body: { defaultDurationMs: 8_000 }, + }); + assert.equal(changes.length, 3); + assert.deepEqual(changes[2], { + reason: "settings_updated", + playlistIds: null, + settingsRevision: 2, + }); + + await jsonRequest(harness.baseUrl, "/api/assets/asset-b", { + method: "PATCH", + body: { displayName: "Still unused" }, + }); + assert.equal(changes.length, 3); + + await jsonRequest( + harness.baseUrl, + `/api/playlists/${playlistId}/entries`, + { method: "POST", body: { assetId: "asset-a" } }, + ); + assert.equal(changes.length, 4); + await jsonRequest(harness.baseUrl, "/api/assets/asset-a", { + method: "PATCH", + body: { expirationAt: NOW + 60_000 }, + }); + assert.deepEqual(changes.at(-1), { + reason: "asset_updated", + playlistIds: [playlistId], + assetId: "asset-a", + }); + const countAfterAssetUpdate = changes.length; + await jsonRequest(harness.baseUrl, "/api/assets/asset-a", { + method: "PATCH", + body: { expirationAt: NOW + 60_000 }, + }); + assert.equal(changes.length, countAfterAssetUpdate); +}); + +test("a failing invalidation listener cannot turn a committed mutation into HTTP 500", async (t) => { + const harness = await createHarness(t, { + onPlaylistsChanged() { + throw new Error("listener unavailable"); + }, + }); + + const settings = await jsonRequest(harness.baseUrl, "/api/settings", { + method: "PATCH", + body: { defaultDurationMs: 8_000 }, + }); + assert.equal(settings.response.status, 200); + assert.equal(settings.body.revision, 2); + + const playlist = await jsonRequest(harness.baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Sunday" }, + }); + assert.equal(playlist.response.status, 201); + assert.equal( + harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get() + .count, + 1, + ); +}); diff --git a/server/test/playlists-service.test.js b/server/test/playlists-service.test.js new file mode 100644 index 0000000..7ef0253 --- /dev/null +++ b/server/test/playlists-service.test.js @@ -0,0 +1,675 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const { openDatabase } = require("../src/database"); +const { runMigrations } = require("../src/database/migrate"); +const { createAssetService } = require("../src/assets/service"); +const { + MAX_DURATION_MS, + MIN_DURATION_MS, + createPlaylistService, +} = require("../src/playlists/service"); + +const BASE_TIME = 2_000_000_000_000; + +function createHarness() { + const database = openDatabase({ path: ":memory:" }); + runMigrations(database); + let clock = BASE_TIME; + let idCounter = 0; + let assetCounter = 0; + const changes = []; + const service = createPlaylistService({ + database, + now: () => clock, + createId: () => + `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`, + onChanged: (change) => changes.push(change), + }); + + function setNow(value) { + clock = value; + } + + function seedAsset(options = {}) { + const sequence = ++assetCounter; + const id = options.id || `asset-${sequence}`; + const name = options.name || id; + const status = options.status || "ready"; + const expirationAt = options.expirationAt ?? null; + const hash = crypto + .createHash("sha256") + .update(`${sequence}:${id}`) + .digest("hex"); + database + .prepare( + `INSERT INTO assets ( + id, source_sha256, display_name, original_filename, status, + expiration_at, display_sha256, display_width, display_height, + thumbnail_sha256, thumbnail_width, thumbnail_height, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1920, 1080, ?, 480, 270, ?, ?)`, + ) + .run( + id, + hash, + name, + `${name}.png`, + status, + expirationAt, + hash, + hash, + clock, + clock, + ); + return id; + } + + function playback() { + return database + .prepare("SELECT * FROM playback_state WHERE output_id = 'global'") + .get(); + } + + return { changes, database, playback, seedAsset, service, setNow }; +} + +function expectError(action, code, statusCode) { + assert.throws(action, (error) => { + assert.equal(error.code, code); + if (statusCode !== undefined) assert.equal(error.statusCode, statusCode); + return true; + }); +} + +test("playlist CRUD uses UUIDs, case-insensitive names, revisions, and post-commit events", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + + const sunday = harness.service.create({ name: " Sunday Service " }); + const midweek = harness.service.create({ name: "Midweek" }); + assert.match(sunday.id, /^[0-9a-f-]{36}$/); + assert.equal(sunday.name, "Sunday Service"); + assert.equal(sunday.revision, 1); + assert.deepEqual( + harness.service.list().map((playlist) => playlist.name), + ["Midweek", "Sunday Service"], + ); + + const revisionBeforeRead = harness.service.list()[1].revision; + const changesBeforeRead = harness.changes.length; + assert.equal(harness.service.get(sunday.id).entries.length, 0); + assert.equal(harness.service.list()[1].revision, revisionBeforeRead); + assert.equal(harness.changes.length, changesBeforeRead); + + const renamed = harness.service.update(sunday.id, { name: "Sunday Worship" }); + assert.equal(renamed.revision, 2); + assert.equal(harness.service.update(sunday.id, { name: "Sunday Worship" }).revision, 2); + expectError( + () => harness.service.create({ name: "sUnDaY wOrShIp" }), + "PLAYLIST_NAME_CONFLICT", + 409, + ); + expectError( + () => harness.service.update(midweek.id, { name: "SUNDAY WORSHIP" }), + "PLAYLIST_NAME_CONFLICT", + 409, + ); + assert.equal(harness.service.get(midweek.id).revision, 1); + + assert.deepEqual(harness.service.remove(midweek.id), { + id: midweek.id, + deleted: true, + }); + expectError( + () => harness.service.get(midweek.id), + "PLAYLIST_NOT_FOUND", + 404, + ); + assert.deepEqual( + harness.changes.map((change) => change.reason), + ["created", "created", "renamed", "deleted"], + ); +}); + +test("active and pending playlists are conservatively protected from deletion", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const active = harness.service.create({ name: "Active" }); + const pending = harness.service.create({ name: "Pending" }); + + harness.database + .prepare( + `UPDATE playback_state + SET active_playlist_id = ?, pending_playlist_id = ?, + pending_playlist_requested_at = ?, updated_at = ? + WHERE output_id = 'global'`, + ) + .run(active.id, pending.id, BASE_TIME, BASE_TIME); + + expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409); + expectError( + () => harness.service.remove(pending.id), + "PLAYLIST_PENDING_ACTIVATION", + 409, + ); + assert.equal(harness.service.list().length, 2); +}); + +test("entry adds require a ready, unexpired asset and enforce one occurrence per playlist", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const first = harness.service.create({ name: "First" }); + const second = harness.service.create({ name: "Second" }); + const ready = harness.seedAsset({ id: "ready", name: "Ready" }); + for (const status of ["processing", "failed", "deleting"]) { + harness.seedAsset({ id: status, name: status, status }); + } + harness.seedAsset({ + id: "expired", + name: "Expired", + expirationAt: BASE_TIME, + }); + + const firstEntry = harness.service.addEntry(first.id, { assetId: ready }); + const secondEntry = harness.service.addEntry(second.id, { assetId: ready }); + assert.equal(firstEntry.position, 0); + assert.equal(secondEntry.position, 0); + assert.notEqual(firstEntry.id, secondEntry.id); + expectError( + () => harness.service.addEntry(first.id, { assetId: ready }), + "PLAYLIST_ENTRY_EXISTS", + 409, + ); + for (const status of ["processing", "failed", "deleting"]) { + expectError( + () => harness.service.addEntry(first.id, { assetId: status }), + "ASSET_NOT_READY", + 409, + ); + } + expectError( + () => harness.service.addEntry(first.id, { assetId: "missing" }), + "ASSET_NOT_FOUND", + 404, + ); + expectError( + () => harness.service.addEntry(first.id, { assetId: "expired" }), + "ASSET_EXPIRED", + 409, + ); + assert.equal(harness.service.get(first.id).revision, 2); +}); + +test("entry enablement, duration, and presentation overrides are playlist-local", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const a = harness.service.create({ name: "A" }); + const b = harness.service.create({ name: "B" }); + const assetId = harness.seedAsset({ id: "shared", name: "Shared" }); + const entryA = harness.service.addEntry(a.id, { assetId }); + const entryB = harness.service.addEntry(b.id, { assetId }); + + assert.equal(entryA.durationOverrideMs, null); + assert.equal(entryA.effectiveDurationMs, 7000); + assert.deepEqual(entryA.effectivePresentation, { + backgroundMode: "blurred", + backgroundColor: "#000000", + backgroundDarkened: false, + blurPx: 20, + }); + + const patched = harness.service.updateEntry(a.id, entryA.id, { + enabled: false, + durationOverrideMs: 4000, + backgroundModeOverride: "solid", + backgroundColorOverride: "#a1b2c3", + backgroundDarkenedOverride: true, + }); + assert.equal(patched.enabled, false); + assert.equal(patched.effectiveDurationMs, 4000); + assert.deepEqual(patched.effectivePresentation, { + backgroundMode: "solid", + backgroundColor: "#A1B2C3", + backgroundDarkened: true, + blurPx: 20, + }); + assert.equal(harness.service.get(b.id).entries[0].enabled, true); + assert.equal(harness.service.get(b.id).entries[0].effectiveDurationMs, 7000); + assert.equal( + harness.database.prepare("SELECT status FROM assets WHERE id = ?").get(assetId) + .status, + "ready", + ); + + const inheritedAgain = harness.service.updateEntry(a.id, entryA.id, { + durationOverrideMs: null, + backgroundModeOverride: null, + backgroundColorOverride: null, + backgroundDarkenedOverride: null, + }); + assert.equal(inheritedAgain.effectiveDurationMs, 7000); + assert.equal(inheritedAgain.backgroundDarkenedOverride, null); + + for (const invalid of [MIN_DURATION_MS - 1, MAX_DURATION_MS + 1, 1.5]) { + expectError( + () => + harness.service.updateEntry(a.id, entryA.id, { + durationOverrideMs: invalid, + }), + "INVALID_PLAYLIST_ENTRY", + 400, + ); + } + for (const patch of [ + { backgroundModeOverride: "gradient" }, + { backgroundColorOverride: "red" }, + { backgroundDarkenedOverride: 1 }, + ]) { + expectError( + () => harness.service.updateEntry(a.id, entryA.id, patch), + "INVALID_PLAYLIST_ENTRY", + 400, + ); + } + assert.equal(harness.service.get(a.id).revision, 4); + assert.equal(harness.service.get(b.id).revision, 2); + assert.equal(entryB.enabled, true); +}); + +test("resolved entries centralize schedule and expiration eligibility at exact boundaries", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const playlist = harness.service.create({ name: "Scheduled" }); + const permanent = harness.seedAsset({ id: "permanent", name: "Permanent" }); + const futureExpiry = harness.seedAsset({ + id: "future-expiry", + name: "Future expiry", + expirationAt: BASE_TIME + 20_000, + }); + const noSchedule = harness.service.addEntry(playlist.id, { assetId: permanent }); + const bounded = harness.service.addEntry(playlist.id, { + assetId: futureExpiry, + scheduleStartAt: BASE_TIME + 10_000, + scheduleEndAt: BASE_TIME + 30_000, + }); + + let detail = harness.service.get(playlist.id); + assert.equal(detail.evaluatedAt, BASE_TIME); + assert.equal(detail.settingsRevision, 1); + assert.deepEqual( + detail.entries.map((entry) => [entry.id, entry.playable, entry.notPlayableReason]), + [ + [noSchedule.id, true, null], + [bounded.id, false, "scheduled_future"], + ], + ); + + harness.setNow(BASE_TIME + 10_000); + assert.equal(harness.service.get(playlist.id).entries[1].playable, true); + harness.setNow(BASE_TIME + 20_000); + assert.equal( + harness.service.get(playlist.id).entries[1].notPlayableReason, + "asset_expired", + ); + harness.setNow(BASE_TIME + 30_000); + assert.equal( + harness.service.get(playlist.id).entries[1].notPlayableReason, + "scheduled_ended", + ); + + harness.setNow(BASE_TIME); + const endOnlyAsset = harness.seedAsset({ id: "end-only", name: "End only" }); + const endOnly = harness.service.addEntry(playlist.id, { + assetId: endOnlyAsset, + scheduleEndAt: BASE_TIME + 1, + }); + assert.equal(endOnly.playable, true); + harness.setNow(BASE_TIME + 1); + assert.equal( + harness.service.get(playlist.id).entries.find((entry) => entry.id === endOnly.id) + .notPlayableReason, + "scheduled_ended", + ); + + expectError( + () => + harness.service.updateEntry(playlist.id, noSchedule.id, { + scheduleStartAt: BASE_TIME + 2, + scheduleEndAt: BASE_TIME + 1, + }), + "INVALID_PLAYLIST_ENTRY_SCHEDULE", + 400, + ); + expectError( + () => + harness.service.updateEntry(playlist.id, noSchedule.id, { + scheduleStartAt: "2033-05-18T03:33:20", + }), + "INVALID_PLAYLIST_ENTRY", + 400, + ); + const iso = harness.service.updateEntry(playlist.id, noSchedule.id, { + scheduleStartAt: "2033-05-18T03:33:20Z", + }); + assert.equal(iso.scheduleStartAt, 2_000_000_000_000); +}); + +test("full-list reorder validates membership and persists contiguous zero-based positions", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const playlist = harness.service.create({ name: "Ordered" }); + const other = harness.service.create({ name: "Other" }); + const entries = ["one", "two", "three"].map((id) => + harness.service.addEntry(playlist.id, { + assetId: harness.seedAsset({ id, name: id }), + }), + ); + const foreign = harness.service.addEntry(other.id, { + assetId: harness.seedAsset({ id: "foreign", name: "foreign" }), + }); + const revision = harness.service.get(playlist.id).revision; + + const reordered = harness.service.reorderEntries( + playlist.id, + [entries[2].id, entries[0].id, entries[1].id], + ); + assert.deepEqual( + reordered.entries.map((entry) => [entry.id, entry.position]), + [ + [entries[2].id, 0], + [entries[0].id, 1], + [entries[1].id, 2], + ], + ); + assert.equal(reordered.revision, revision + 1); + + const failedRevision = reordered.revision; + expectError( + () => + harness.service.reorderEntries(playlist.id, [ + entries[2].id, + entries[2].id, + entries[1].id, + ]), + "PLAYLIST_ORDER_DUPLICATE_IDS", + ); + expectError( + () => harness.service.reorderEntries(playlist.id, [entries[2].id]), + "PLAYLIST_ORDER_INCOMPLETE", + ); + expectError( + () => + harness.service.reorderEntries(playlist.id, [ + entries[2].id, + entries[0].id, + foreign.id, + ]), + "PLAYLIST_ORDER_FOREIGN_ENTRY", + ); + expectError( + () => + harness.service.reorderEntries(playlist.id, [ + entries[2].id, + entries[0].id, + "missing-entry", + ]), + "PLAYLIST_ORDER_UNKNOWN_ENTRY", + ); + assert.equal(harness.service.get(playlist.id).revision, failedRevision); + + harness.service.removeEntry(playlist.id, entries[0].id); + assert.deepEqual( + harness.service.get(playlist.id).entries.map((entry) => entry.position), + [0, 1], + ); +}); + +test("no-op and failed mutations do not increment revisions or emit invalidations", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const playlist = harness.service.create({ name: "Stable" }); + const entry = harness.service.addEntry(playlist.id, { + assetId: harness.seedAsset({ id: "stable-asset", name: "Stable asset" }), + }); + const baseline = harness.service.get(playlist.id).revision; + const eventCount = harness.changes.length; + + harness.service.update(playlist.id, { name: "Stable" }); + harness.service.updateEntry(playlist.id, entry.id, { enabled: true }); + harness.service.reorderEntries(playlist.id, [entry.id]); + expectError( + () => harness.service.updateEntry(playlist.id, "missing", { enabled: false }), + "PLAYLIST_ENTRY_NOT_FOUND", + 404, + ); + assert.equal(harness.service.get(playlist.id).revision, baseline); + assert.equal(harness.changes.length, eventCount); +}); + +test("entry references protect assets and final removal restores delete eligibility", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const playlist = harness.service.create({ name: "References" }); + const assetId = harness.seedAsset({ id: "referenced", name: "Referenced" }); + const entry = harness.service.addEntry(playlist.id, { assetId, enabled: false }); + const assetService = createAssetService({ + database: harness.database, + storage: {}, + imageProcessor: {}, + }); + + const referenced = assetService.references(assetId); + assert.equal(referenced.canDelete, false); + assert.equal(referenced.playlists[0].playlistEntryId, entry.id); + harness.service.removeEntry(playlist.id, entry.id); + assert.equal(assetService.references(assetId).canDelete, true); +}); + +test("activation queues at a live boundary, is idempotent, and mode off is only a gate", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const oldPlaylist = harness.service.create({ name: "Old" }); + const newPlaylist = harness.service.create({ name: "New" }); + const oldEntry = harness.service.addEntry(oldPlaylist.id, { + assetId: harness.seedAsset({ id: "old-asset", name: "Old asset" }), + }); + const newEntry = harness.service.addEntry(newPlaylist.id, { + assetId: harness.seedAsset({ id: "new-asset", name: "New asset" }), + durationOverrideMs: 4000, + }); + + const initial = harness.service.activate(oldPlaylist.id); + assert.equal(initial.activation, "active"); + assert.equal(initial.playback.currentEntryId, oldEntry.id); + assert.equal(initial.playback.slideStartedAt, BASE_TIME); + assert.equal(initial.playback.nextSlideAt, BASE_TIME + 7000); + + harness.database + .prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'") + .run(); + harness.setNow(BASE_TIME + 1000); + const pending = harness.service.activate(newPlaylist.id); + assert.equal(pending.activation, "pending"); + assert.equal(pending.playback.mode, "off"); + assert.equal(pending.playback.activePlaylistId, oldPlaylist.id); + assert.equal(pending.playback.pendingPlaylistId, newPlaylist.id); + assert.equal(pending.playback.currentEntryId, oldEntry.id); + const pendingRevision = pending.playback.revision; + const requestedAt = pending.playback.pendingPlaylistRequestedAt; + + const repeated = harness.service.activate(newPlaylist.id); + assert.equal(repeated.activation, "pending"); + assert.equal(repeated.playback.revision, pendingRevision); + assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt); + + const cancel = harness.service.activate(oldPlaylist.id); + assert.equal(cancel.activation, "already_active"); + assert.equal(cancel.playback.pendingPlaylistId, null); + assert.equal(cancel.playback.currentEntryId, oldEntry.id); + + harness.setNow(BASE_TIME + 7000); + const immediate = harness.service.activate(newPlaylist.id); + assert.equal(immediate.activation, "active"); + assert.equal(immediate.playback.activePlaylistId, newPlaylist.id); + assert.equal(immediate.playback.pendingPlaylistId, null); + assert.equal(immediate.playback.currentEntryId, newEntry.id); + assert.equal(immediate.playback.slideStartedAt, BASE_TIME + 7000); + assert.equal(immediate.playback.nextSlideAt, BASE_TIME + 11_000); + assert.equal(immediate.playback.mode, "off"); +}); + +test("paused normal playback queues and can replace a pending activation", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const current = harness.service.create({ name: "Paused current" }); + const firstTarget = harness.service.create({ name: "First target" }); + const secondTarget = harness.service.create({ name: "Second target" }); + harness.service.addEntry(current.id, { + assetId: harness.seedAsset({ id: "paused-current", name: "Paused current" }), + }); + harness.service.addEntry(firstTarget.id, { + assetId: harness.seedAsset({ id: "first-target", name: "First target" }), + }); + harness.service.addEntry(secondTarget.id, { + assetId: harness.seedAsset({ id: "second-target", name: "Second target" }), + }); + harness.service.activate(current.id); + harness.database + .prepare( + `UPDATE playback_state + SET rotation_paused = 1, paused_remaining_ms = 5000, + slide_started_at = NULL, next_slide_at = NULL + WHERE output_id = 'global'`, + ) + .run(); + + harness.setNow(BASE_TIME + 1000); + const first = harness.service.activate(firstTarget.id); + assert.equal(first.activation, "pending"); + assert.equal(first.playback.activePlaylistId, current.id); + assert.equal(first.playback.pendingPlaylistId, firstTarget.id); + assert.equal(first.playback.pausedRemainingMs, 5000); + + harness.setNow(BASE_TIME + 2000); + const replacement = harness.service.activate(secondTarget.id); + assert.equal(replacement.activation, "pending"); + assert.equal(replacement.playback.activePlaylistId, current.id); + assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id); + assert.equal(replacement.playback.pendingPlaylistRequestedAt, BASE_TIME + 2000); + assert.equal(replacement.playback.revision, first.playback.revision + 1); + assert.equal(replacement.playback.pausedRemainingMs, 5000); +}); + +test("static and paused activation prepare the first playable normal target without breaking hold", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const oldPlaylist = harness.service.create({ name: "Old static" }); + const target = harness.service.create({ name: "Target static" }); + const staticAssetId = harness.seedAsset({ id: "static", name: "Static" }); + harness.service.addEntry(oldPlaylist.id, { + assetId: harness.seedAsset({ id: "old-normal", name: "Old normal" }), + }); + const skipped = harness.service.addEntry(target.id, { + assetId: harness.seedAsset({ id: "disabled", name: "Disabled" }), + enabled: false, + }); + const firstPlayable = harness.service.addEntry(target.id, { + assetId: harness.seedAsset({ id: "playable", name: "Playable" }), + durationOverrideMs: 9000, + }); + assert.equal(skipped.position, 0); + harness.service.activate(oldPlaylist.id); + harness.database + .prepare( + `UPDATE playback_state + SET static_asset_id = ?, rotation_paused = 1, + paused_remaining_ms = 1234, slide_started_at = NULL, + next_slide_at = NULL + WHERE output_id = 'global'`, + ) + .run(staticAssetId); + + const result = harness.service.activate(target.id); + assert.equal(result.activation, "active"); + assert.equal(result.playback.activePlaylistId, target.id); + assert.equal(result.playback.staticAssetId, staticAssetId); + assert.equal(result.playback.rotationPaused, true); + assert.equal(result.playback.currentEntryId, firstPlayable.id); + assert.equal(result.playback.pausedRemainingMs, 9000); + assert.equal(result.playback.slideStartedAt, null); + assert.equal(result.playback.nextSlideAt, null); +}); + +test("empty activation is immediate and current-entry deletion coordinates playback revision safely", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const populated = harness.service.create({ name: "Populated" }); + const empty = harness.service.create({ name: "Empty" }); + const entries = ["first", "second"].map((id) => + harness.service.addEntry(populated.id, { + assetId: harness.seedAsset({ id: `asset-${id}`, name: id }), + }), + ); + harness.service.activate(populated.id); + const futurePlaybackTimestamp = BASE_TIME + 100_000; + harness.database + .prepare( + `UPDATE playback_state + SET updated_at = ?, mode = 'off', static_asset_id = ?, rotation_paused = 1 + WHERE output_id = 'global'`, + ) + .run(futurePlaybackTimestamp, "asset-second"); + const beforeDelete = harness.playback(); + + harness.service.removeEntry(populated.id, entries[0].id); + const afterDelete = harness.playback(); + assert.equal(afterDelete.current_entry_id, null); + assert.equal(afterDelete.slide_started_at, null); + assert.equal(afterDelete.next_slide_at, null); + assert.equal(afterDelete.paused_remaining_ms, null); + assert.equal(afterDelete.active_playlist_id, populated.id); + assert.equal(afterDelete.mode, "off"); + assert.equal(afterDelete.static_asset_id, "asset-second"); + assert.equal(afterDelete.rotation_paused, 1); + assert.equal(afterDelete.revision, beforeDelete.revision + 1); + assert.equal(afterDelete.updated_at, futurePlaybackTimestamp + 1); + assert.deepEqual( + harness.service.get(populated.id).entries.map((entry) => entry.position), + [0], + ); + + harness.database + .prepare( + `UPDATE playback_state + SET static_asset_id = NULL, rotation_paused = 0 + WHERE output_id = 'global'`, + ) + .run(); + const result = harness.service.activate(empty.id); + assert.equal(result.activation, "active"); + assert.equal(result.playback.activePlaylistId, empty.id); + assert.equal(result.playback.currentEntryId, null); + assert.equal(result.playback.slideStartedAt, null); + assert.equal(result.playback.nextSlideAt, null); +}); + +test("inactive playlist edits do not mutate authoritative playback state", (t) => { + const harness = createHarness(); + t.after(() => harness.database.close()); + const active = harness.service.create({ name: "Live" }); + const inactive = harness.service.create({ name: "Draft" }); + harness.service.addEntry(active.id, { + assetId: harness.seedAsset({ id: "live", name: "Live asset" }), + }); + const draftEntry = harness.service.addEntry(inactive.id, { + assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }), + }); + harness.service.activate(active.id); + const before = harness.playback(); + + harness.service.update(inactive.id, { name: "Draft edited" }); + harness.service.updateEntry(inactive.id, draftEntry.id, { enabled: false }); + harness.service.reorderEntries(inactive.id, [draftEntry.id]); + assert.deepEqual(harness.playback(), before); +}); diff --git a/server/test/socket-invalidation.test.js b/server/test/socket-invalidation.test.js new file mode 100644 index 0000000..7137389 --- /dev/null +++ b/server/test/socket-invalidation.test.js @@ -0,0 +1,210 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { loadConfig } = require("../src/config"); +const { createRuntime } = require("../src/runtime"); + +const silentLogger = { + info() {}, + warn() {}, + error() {}, +}; + +function testConfig() { + return loadConfig({ + NODE_ENV: "test", + HOST: "127.0.0.1", + PORT: "0", + DATABASE_PATH: ":memory:", + APP_TIMEZONE: "America/Chicago", + SHUTDOWN_TIMEOUT_MS: "1000", + S3_ENDPOINT: "http://seaweedfs:8333", + S3_BUCKET: "announcements-test", + }); +} + +function testStorage() { + return { + async start() {}, + stop() {}, + health() { + return { + status: "healthy", + checkedAt: "2032-04-05T12:00:00.000Z", + lastSuccessAt: "2032-04-05T12:00:00.000Z", + }; + }, + }; +} + +async function jsonRequest(baseUrl, route, { body, ...init } = {}) { + const response = await fetch(`${baseUrl}${route}`, { + ...init, + ...(body === undefined + ? {} + : { + headers: { "content-type": "application/json", ...init.headers }, + body: JSON.stringify(body), + }), + }); + return { response, body: await response.json() }; +} + +function splitEnginePackets(payload) { + return payload.split("\x1e").filter(Boolean); +} + +function socketEvents(payload) { + return splitEnginePackets(payload) + .filter((packet) => packet.startsWith("42")) + .map((packet) => JSON.parse(packet.slice(2))); +} + +async function connectPollingSocket(baseUrl) { + let requestNumber = 0; + const endpoint = (sid) => { + const parameters = new URLSearchParams({ + EIO: "4", + transport: "polling", + t: String(requestNumber++), + }); + if (sid) parameters.set("sid", sid); + return `${baseUrl}/socket.io/?${parameters}`; + }; + + const openingResponse = await fetch(endpoint()); + assert.equal(openingResponse.status, 200); + const openingPackets = splitEnginePackets(await openingResponse.text()); + const openPacket = openingPackets.find((packet) => packet.startsWith("0")); + assert.ok(openPacket, "Engine.IO must return an open packet"); + const { sid } = JSON.parse(openPacket.slice(1)); + + const connectResponse = await fetch(endpoint(sid), { + method: "POST", + headers: { "content-type": "text/plain;charset=UTF-8" }, + body: "40", + }); + assert.equal(connectResponse.status, 200); + await connectResponse.text(); + + const connectedResponse = await fetch(endpoint(sid)); + assert.equal(connectedResponse.status, 200); + const connectedPayload = await connectedResponse.text(); + assert.ok( + splitEnginePackets(connectedPayload).some((packet) => + packet.startsWith("40"), + ), + "Socket.IO must acknowledge the namespace connection", + ); + + return { + async poll() { + const response = await fetch(endpoint(sid), { + signal: AbortSignal.timeout(3_000), + }); + assert.equal(response.status, 200); + return response.text(); + }, + async close() { + const response = await fetch(endpoint(sid), { + method: "POST", + headers: { "content-type": "text/plain;charset=UTF-8" }, + body: "41", + }); + await response.text(); + }, + }; +} + +test("Socket.IO publishes playlists:changed only after successful semantic mutations", async (t) => { + const runtime = createRuntime({ + config: testConfig(), + logger: silentLogger, + storage: testStorage(), + }); + await runtime.start(); + t.after(() => runtime.stop("test cleanup")); + const baseUrl = `http://127.0.0.1:${runtime.address.port}`; + const socket = await connectPollingSocket(baseUrl); + t.after(() => socket.close().catch(() => {})); + + await jsonRequest(baseUrl, "/api/playlists"); + const invalid = await jsonRequest(baseUrl, "/api/playlists", { + method: "POST", + body: {}, + }); + assert.equal(invalid.response.status, 400); + await jsonRequest(baseUrl, "/api/settings", { + method: "PATCH", + body: { defaultDurationMs: 7_000 }, + }); + + const created = await jsonRequest(baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Sunday" }, + }); + assert.equal(created.response.status, 201); + let events = socketEvents(await socket.poll()); + assert.equal(events.length, 1); + assert.equal(events[0][0], "playlists:changed"); + assert.equal(events[0][1].reason, "created"); + assert.equal(events[0][1].playlistId, created.body.id); + + const duplicate = await jsonRequest(baseUrl, "/api/playlists", { + method: "POST", + body: { name: "SUNDAY" }, + }); + assert.equal(duplicate.response.status, 409); + await jsonRequest(baseUrl, `/api/playlists/${created.body.id}`, { + method: "PATCH", + body: { name: "Sunday" }, + }); + await jsonRequest(baseUrl, "/api/settings", { + method: "PATCH", + body: { defaultDurationMs: 8_000 }, + }); + events = socketEvents(await socket.poll()); + assert.deepEqual(events, [ + [ + "playlists:changed", + { + reason: "settings_updated", + playlistIds: null, + settingsRevision: 2, + }, + ], + ]); + + const second = await jsonRequest(baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Midweek" }, + }); + assert.equal(second.response.status, 201); + events = socketEvents(await socket.poll()); + assert.equal(events.length, 1); + assert.equal(events[0][1].reason, "created"); + + const activated = await jsonRequest( + baseUrl, + `/api/playlists/${created.body.id}/activate`, + { method: "POST" }, + ); + assert.equal(activated.response.status, 200); + assert.equal(activated.body.activation, "active"); + events = socketEvents(await socket.poll()); + assert.equal(events.length, 1); + assert.equal(events[0][0], "playlists:changed"); + assert.equal(events[0][1].reason, "activated"); + assert.equal(events[0][1].playlistId, created.body.id); + assert.equal( + events[0][1].playbackRevision, + activated.body.playback.revision, + ); + + const activeDelete = await jsonRequest( + baseUrl, + `/api/playlists/${created.body.id}`, + { method: "DELETE" }, + ); + assert.equal(activeDelete.response.status, 409); + assert.equal(activeDelete.body.error.code, "PLAYLIST_ACTIVE"); +});