From 68273d457f8a2ac6d9125bd63ac9b96b815e1d20 Mon Sep 17 00:00:00 2001 From: Media Date: Sun, 16 Aug 2026 21:50:19 -0500 Subject: [PATCH] WIP: Phase 3B playback engine handoff --- README.md | 57 +- server/src/app.js | 49 +- server/src/assets/routes.js | 21 +- server/src/playback/errors.js | 29 + server/src/playback/index.js | 7 + server/src/playback/routes.js | 75 ++ server/src/playback/service.js | 1269 ++++++++++++++++++++ server/src/playlists/routes.js | 11 +- server/src/playlists/service.js | 223 +--- server/src/runtime.js | 39 +- server/src/settings/routes.js | 13 +- server/test/playback-api.test.js | 249 ++++ server/test/playback-service.test.js | 1430 +++++++++++++++++++++++ server/test/playlists-service.test.js | 52 +- server/test/runtime.test.js | 29 +- server/test/socket-invalidation.test.js | 197 +++- 16 files changed, 3506 insertions(+), 244 deletions(-) create mode 100644 server/src/playback/errors.js create mode 100644 server/src/playback/index.js create mode 100644 server/src/playback/routes.js create mode 100644 server/src/playback/service.js create mode 100644 server/test/playback-api.test.js create mode 100644 server/test/playback-service.test.js diff --git a/README.md b/README.md index 562ce28..884b2c9 100644 --- a/README.md +++ b/README.md @@ -110,17 +110,70 @@ 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. +Before a playback-relevant playlist, settings, or asset mutation, the playback +service catches up using the old metadata. The domain transaction then commits +and playback synchronously reconciles the new values at the mutation instant. +This prevents a late timer or restart boundary from being rewritten by a later +edit. If the post-commit reconciliation unexpectedly fails, the API returns an +error even though the preceding domain commit may already be durable; clients +must refetch current state before deciding whether to retry. + 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. +the hidden normal playlist changes. The authoritative playback service consumes +a pending activation at the current slide boundary and never merges the old and +new playlists. 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. +## Authoritative playback + +One serialized service owns persisted global playback, timer reconciliation, +and every playback control path. Its REST surface is: + +- `GET /api/playback` +- `POST /api/playback/pause` +- `POST /api/playback/resume` +- `POST /api/playback/next` +- `POST /api/playback/previous` +- `POST /api/playback/static` +- `PUT /api/playback/mode` +- `POST /api/playback/reload` + +Visual output precedence is mode off (black), a valid static asset, the current +normal entry, then black when no playable target exists. Mode off is only a +visual gate: normal playback continues underneath unless it is independently +paused or held by static mode. Pause and static both preserve the normal cursor +and remaining duration. Resume clears both static and paused state together and +continues the normal entry with its preserved remaining time. Next and previous +wrap across currently playable entries; they change the held entry without +unpausing when paused and are no-ops while static. + +Normal slides use persisted absolute start times and deadlines. Chained +timeouts reconcile from the prior authoritative deadline, including late +callbacks and restart catch-up, so delays do not accumulate as timing drift. +The service also arms exact schedule and asset-expiration boundaries. It skips +disabled, out-of-schedule, expired, and non-ready entries, and safely returns to +an eligible entry when one becomes available. Pending playlist activation is +applied at the current normal slide boundary; an empty target playlist still +becomes active with black normal output. + +Socket.IO sends `playback:state` immediately on connection and after meaningful +committed playback changes. The authoritative snapshot includes server time and +revisions, mode and transport state, active playlist and timeline data, current +and static targets, crossfade settings, and a display-cache manifest with +effective duration, presentation, scheduling, expiration, and immutable media +metadata. Enabled future-scheduled entries remain in the manifest; disabled +entries and private S3 details do not. Reads and connections do not increment +the playback revision. + +Phase 3B exposes this state and behavior through REST and Socket.IO only. MQTT +command wiring and the browser display renderer remain later phases. + ## 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 08fce48..f2b77b8 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -6,6 +6,8 @@ const { const { createAssetRouter } = require("./assets/routes"); const { createMediaRouter } = require("./assets/media-routes"); const { createAssetService } = require("./assets/service"); +const { createPlaybackService } = require("./playback"); +const { createPlaybackRouter } = require("./playback/routes"); const { createPlaylistRouter, createPlaylistService, @@ -13,10 +15,10 @@ const { const { createSettingsRouter } = require("./settings/routes"); const { createSettingsService } = require("./settings/service"); -function safeNotifier(notifier, logger) { +function safeNotifier(notifier, logger, failureMessage) { const warn = (error) => { try { - logger?.warn?.("Playlist invalidation notification failed", error); + logger?.warn?.(failureMessage, error); } catch { // A best-effort notification or warning must not undo a database commit. } @@ -40,10 +42,35 @@ function createApp({ migrations, now = Date.now, onPlaylistsChanged = () => {}, + playbackService: suppliedPlaybackService, storage, }) { const app = express(); - const notifyPlaylistsChanged = safeNotifier(onPlaylistsChanged, logger); + const notifyPlaylistsChanged = safeNotifier( + onPlaylistsChanged, + logger, + "Playlist invalidation notification failed", + ); + const playbackService = + suppliedPlaybackService || + createPlaybackService({ + database, + now, + onActivationChanged: notifyPlaylistsChanged, + }); + if (!suppliedPlaybackService) { + playbackService.initialize(); + } + const reconcilePlaybackDomain = (change) => + playbackService.domainChanged(change); + const reconcileBeforeDomainChange = () => + playbackService.reconcile("before_domain_change"); + const notifyPlaylistMutation = (change) => { + reconcilePlaybackDomain(change); + const publicChange = { ...change }; + delete publicChange.invalidBoundaryChanged; + notifyPlaylistsChanged(publicChange); + }; const settingsService = createSettingsService(database); const assetService = createAssetService({ database, @@ -53,8 +80,10 @@ function createApp({ const playlistService = createPlaylistService({ database, now, - onChanged: notifyPlaylistsChanged, + onBeforeChange: reconcileBeforeDomainChange, + onChanged: notifyPlaylistMutation, }); + app.locals.playbackService = playbackService; app.disable("x-powered-by"); app.set("trust proxy", config.trustProxy); @@ -85,18 +114,28 @@ function createApp({ app.use( "/api/settings", createSettingsRouter(settingsService, { + onBeforePlaybackDomainChange: reconcileBeforeDomainChange, onPlaylistsChanged: notifyPlaylistsChanged, + onPlaybackDomainChanged: reconcilePlaybackDomain, }), ); app.use( "/api/assets", createAssetRouter({ assetService, + onBeforePlaybackDomainChange: reconcileBeforeDomainChange, onPlaylistsChanged: notifyPlaylistsChanged, + onPlaybackDomainChanged: reconcilePlaybackDomain, processingConfig: config.processing, }), ); - app.use("/api/playlists", createPlaylistRouter(playlistService)); + app.use( + "/api/playlists", + createPlaylistRouter(playlistService, { + playbackService, + }), + ); + app.use("/api/playback", createPlaybackRouter(playbackService)); app.use("/media", createMediaRouter(assetService)); app.use((request, response) => { diff --git a/server/src/assets/routes.js b/server/src/assets/routes.js index e55ac8b..16f18bd 100644 --- a/server/src/assets/routes.js +++ b/server/src/assets/routes.js @@ -8,7 +8,9 @@ const { sendOriginal } = require("./streaming"); function createAssetRouter({ assetService, + onBeforePlaybackDomainChange = () => {}, onPlaylistsChanged = () => {}, + onPlaybackDomainChanged = () => {}, processingConfig, }) { const router = express.Router(); @@ -92,13 +94,26 @@ function createAssetRouter({ .playlists.map((reference) => reference.playlistId), ), ]; + onBeforePlaybackDomainChange({ + reason: "before_asset_update", + assetId: previous.id, + }); const updated = assetService.update(request.params.assetId, request.body); - if (updated.updatedAt !== previous.updatedAt && playlistIds.length > 0) { - onPlaylistsChanged({ + if (updated.updatedAt !== previous.updatedAt) { + const change = { reason: "asset_updated", playlistIds, assetId: updated.id, - }); + ...(updated.expirationAt !== previous.expirationAt + ? { invalidBoundaryChanged: true } + : {}), + }; + onPlaybackDomainChanged(change); + if (playlistIds.length > 0) { + const publicChange = { ...change }; + delete publicChange.invalidBoundaryChanged; + onPlaylistsChanged(publicChange); + } } response.json(updated); } catch (error) { diff --git a/server/src/playback/errors.js b/server/src/playback/errors.js new file mode 100644 index 0000000..6235384 --- /dev/null +++ b/server/src/playback/errors.js @@ -0,0 +1,29 @@ +class PlaybackError extends Error { + constructor(code, message, { statusCode = 400, details } = {}) { + super(message); + this.name = "PlaybackError"; + this.code = code; + this.statusCode = statusCode; + if (details !== undefined) this.details = details; + } +} + +function playlistNotFound(playlistId) { + return new PlaybackError("PLAYLIST_NOT_FOUND", "Playlist not found", { + statusCode: 404, + details: { playlistId }, + }); +} + +function assetNotFound(assetId) { + return new PlaybackError("ASSET_NOT_FOUND", "Library asset not found", { + statusCode: 404, + details: { assetId }, + }); +} + +module.exports = { + PlaybackError, + assetNotFound, + playlistNotFound, +}; diff --git a/server/src/playback/index.js b/server/src/playback/index.js new file mode 100644 index 0000000..2d6d4f5 --- /dev/null +++ b/server/src/playback/index.js @@ -0,0 +1,7 @@ +const { PlaybackError } = require("./errors"); +const { createPlaybackService } = require("./service"); + +module.exports = { + PlaybackError, + createPlaybackService, +}; diff --git a/server/src/playback/routes.js b/server/src/playback/routes.js new file mode 100644 index 0000000..8378c9c --- /dev/null +++ b/server/src/playback/routes.js @@ -0,0 +1,75 @@ +const express = require("express"); + +function createPlaybackRouter(playbackService) { + const router = express.Router(); + + router.get("/", (request, response, next) => { + try { + response.json(playbackService.getSnapshot()); + } catch (error) { + next(error); + } + }); + + router.post("/pause", (request, response, next) => { + try { + response.json(playbackService.pause()); + } catch (error) { + next(error); + } + }); + + router.post("/resume", (request, response, next) => { + try { + response.json(playbackService.resume()); + } catch (error) { + next(error); + } + }); + + router.post("/next", (request, response, next) => { + try { + response.json(playbackService.next()); + } catch (error) { + next(error); + } + }); + + router.post("/previous", (request, response, next) => { + try { + response.json(playbackService.previous()); + } catch (error) { + next(error); + } + }); + + router.post("/static", (request, response, next) => { + try { + response.json(playbackService.enterStatic(request.body?.assetId)); + } catch (error) { + next(error); + } + }); + + router.put("/mode", (request, response, next) => { + try { + response.json(playbackService.setMode(request.body?.mode)); + } catch (error) { + next(error); + } + }); + + router.post("/reload", (request, response, next) => { + try { + response.json(playbackService.reload()); + } catch (error) { + next(error); + } + }); + + return router; +} + +module.exports = { + createPlaybackRouter, +}; diff --git a/server/src/playback/service.js b/server/src/playback/service.js new file mode 100644 index 0000000..de53aa5 --- /dev/null +++ b/server/src/playback/service.js @@ -0,0 +1,1269 @@ +const { + evaluateEntryEligibility, + resolveEntrySettings, +} = require("../lifecycle"); +const { + PlaybackError, + assetNotFound, + playlistNotFound, +} = require("./errors"); + +const OUTPUT_ID = "global"; +const PROTOCOL_VERSION = 1; +const MAX_TIMEOUT_MS = 2_147_483_647; +const STATE_FIELDS = Object.freeze([ + "mode", + "active_playlist_id", + "pending_playlist_id", + "pending_playlist_requested_at", + "current_entry_id", + "rotation_paused", + "paused_remaining_ms", + "static_asset_id", + "slide_started_at", + "next_slide_at", +]); + +function validInstant(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +function mediaUrl(assetId, sha256) { + return sha256 + ? `/media/assets/${assetId}/display/${sha256}.webp` + : null; +} + +function createPlaybackService({ + database, + now = Date.now, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + logger = { warn() {} }, + onStateChanged = () => {}, + onActivationChanged = () => {}, +} = {}) { + if (!database) throw new TypeError("database is required"); + + const readState = database.prepare( + "SELECT * FROM playback_state WHERE output_id = 'global'", + ); + const readSettings = database.prepare("SELECT * FROM app_settings WHERE id = 1"); + const findPlaylist = database.prepare("SELECT * FROM playlists WHERE id = ?"); + const findAsset = database.prepare("SELECT * FROM assets WHERE id = ?"); + const listEntries = database.prepare(` + SELECT + pe.*, + a.display_name AS asset_display_name, + a.original_filename AS asset_original_filename, + a.status AS asset_status, + a.expiration_at AS asset_expiration_at, + a.display_sha256, + a.display_content_type, + a.display_byte_size, + a.display_width, + a.display_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 updateState = database.prepare(` + UPDATE playback_state + SET mode = @mode, + active_playlist_id = @active_playlist_id, + pending_playlist_id = @pending_playlist_id, + pending_playlist_requested_at = @pending_playlist_requested_at, + current_entry_id = @current_entry_id, + rotation_paused = @rotation_paused, + paused_remaining_ms = @paused_remaining_ms, + static_asset_id = @static_asset_id, + slide_started_at = @slide_started_at, + next_slide_at = @next_slide_at, + revision = @revision, + updated_at = @updated_at + WHERE output_id = 'global' + `); + + let initialized = false; + let running = false; + let mutationActive = false; + let timerHandle = null; + let timerGeneration = 0; + + function state() { + const row = readState.get(); + if (!row) throw new Error("The global playback_state row is missing"); + return row; + } + + function settings() { + const row = readSettings.get(); + if (!row) throw new Error("The app_settings singleton row is missing"); + return row; + } + + function assertNow() { + const value = now(); + if (!validInstant(value)) { + throw new TypeError("now() must return a non-negative safe epoch-millisecond integer"); + } + return value; + } + + function rowsFor(playlistId) { + return playlistId ? listEntries.all(playlistId) : []; + } + + function playable(entry, at) { + if (!entry) return false; + return evaluateEntryEligibility( + entry, + { + status: entry.asset_status, + expiration_at: entry.asset_expiration_at, + }, + at, + ).playable; + } + + function duration(entry, appSettings) { + return resolveEntrySettings(entry, appSettings).durationMs; + } + + function cloneState(row) { + return Object.fromEntries(STATE_FIELDS.map((field) => [field, row[field]])); + } + + function stateChanged(before, after) { + return STATE_FIELDS.some((field) => before[field] !== after[field]); + } + + function commit(before, after, at) { + if (!stateChanged(before, after)) return false; + updateState.run({ + ...after, + revision: before.revision + 1, + updated_at: Math.max(at, before.updated_at + 1), + }); + return true; + } + + function setEntry(next, entry, at, appSettings, { held = false, remaining } = {}) { + if (!entry) { + next.current_entry_id = null; + next.paused_remaining_ms = null; + next.slide_started_at = null; + next.next_slide_at = null; + return; + } + const fullDuration = duration(entry, appSettings); + next.current_entry_id = entry.id; + if (held) { + next.paused_remaining_ms = remaining ?? fullDuration; + next.slide_started_at = null; + next.next_slide_at = null; + return; + } + next.paused_remaining_ms = null; + next.slide_started_at = at; + next.next_slide_at = at + fullDuration; + } + + function firstPlayable(entries, at) { + return entries.find((entry) => playable(entry, at)) || null; + } + + function adjacentPlayable(entries, currentId, direction, at) { + if (entries.length === 0) return null; + const currentIndex = entries.findIndex((entry) => entry.id === currentId); + if (currentIndex < 0) { + return direction > 0 + ? firstPlayable(entries, at) + : [...entries].reverse().find((entry) => playable(entry, at)) || null; + } + for (let offset = 1; offset <= entries.length; offset += 1) { + const index = + (currentIndex + direction * offset + entries.length * 2) % entries.length; + if (playable(entries[index], at)) return entries[index]; + } + return null; + } + + function activateTarget(next, playlistId, at, appSettings, { held = false } = {}) { + next.active_playlist_id = playlistId; + next.pending_playlist_id = null; + next.pending_playlist_requested_at = null; + const entries = rowsFor(playlistId); + setEntry(next, firstPlayable(entries, at), at, appSettings, { held }); + return entries; + } + + function currentInvalidBoundary( + entry, + startedAt, + deadline, + through, + invalidNotBefore = null, + ) { + if (!entry) return null; + const lowerBound = validInstant(startedAt) + ? startedAt + : validInstant(deadline) + ? deadline - 1 + : 0; + const candidates = [entry.schedule_end_at, entry.asset_expiration_at] + .filter( + (value) => + validInstant(value) && value > lowerBound && value <= through, + ) + .map((value) => + validInstant(invalidNotBefore) + ? Math.max(value, invalidNotBefore) + : value, + ); + return candidates.length > 0 ? Math.min(...candidates) : null; + } + + function firstEligibilityBoundary(entries, after, through) { + const boundaries = entries.flatMap((entry) => + [ + entry.schedule_start_at, + entry.schedule_end_at, + entry.asset_expiration_at, + ].filter( + (value) => validInstant(value) && value > after && value <= through, + ), + ); + return boundaries.length > 0 ? Math.min(...boundaries) : null; + } + + function nextAvailability(entries, after, through) { + const candidates = entries + .filter( + (entry) => + (entry.enabled === 1 || entry.enabled === true) && + entry.asset_status === "ready" && + validInstant(entry.schedule_start_at) && + entry.schedule_start_at > after && + entry.schedule_start_at <= through, + ) + .map((entry) => entry.schedule_start_at) + .sort((left, right) => left - right); + return candidates.find((candidate) => + entries.some((entry) => playable(entry, candidate)), + ) ?? null; + } + + function fastForwardStable(next, entries, at, appSettings) { + if ( + next.pending_playlist_id !== null || + !validInstant(next.slide_started_at) || + !validInstant(next.next_slide_at) || + next.next_slide_at > at + ) { + return false; + } + + // Eligibility can change during a long outage. Skip complete cycles only + // as far as the millisecond before the next exact boundary, then let the + // normal transition logic process that boundary with post-boundary rules. + // This keeps catch-up bounded by the number of schedule/expiration events, + // rather than by the number of slides missed. + const eligibilityBoundary = firstEligibilityBoundary( + entries, + next.slide_started_at, + at, + ); + const through = + eligibilityBoundary === null ? at : eligibilityBoundary - 1; + if (next.next_slide_at > through) return false; + + const eligible = entries.filter((entry) => playable(entry, through)); + const currentIndex = eligible.findIndex( + (entry) => entry.id === next.current_entry_id, + ); + if (currentIndex < 0 || eligible.length === 0) return false; + + let cursor = next.next_slide_at; + let index = (currentIndex + 1) % eligible.length; + setEntry(next, eligible[index], cursor, appSettings); + const cycleDuration = eligible.reduce( + (total, entry) => total + duration(entry, appSettings), + 0, + ); + if (next.next_slide_at <= through && cycleDuration > 0) { + const cycles = Math.floor((through - cursor) / cycleDuration); + if (cycles > 0) { + cursor += cycles * cycleDuration; + setEntry(next, eligible[index], cursor, appSettings); + } + } + while (next.next_slide_at <= through) { + cursor = next.next_slide_at; + index = (index + 1) % eligible.length; + setEntry(next, eligible[index], cursor, appSettings); + } + return eligibilityBoundary === null; + } + + function reconcileHeld(next, entries, at, appSettings) { + const current = entries.find((entry) => entry.id === next.current_entry_id); + if (current && playable(current, at)) { + if (!validInstant(next.paused_remaining_ms)) { + const remaining = validInstant(next.next_slide_at) + ? Math.max(0, next.next_slide_at - at) + : duration(current, appSettings); + next.paused_remaining_ms = remaining; + } + next.slide_started_at = null; + next.next_slide_at = null; + return; + } + const replacement = adjacentPlayable( + entries, + next.current_entry_id, + 1, + at, + ); + setEntry(next, replacement, at, appSettings, { held: true }); + } + + function resumeHeldEntry(next, entries, at, appSettings) { + const current = entries.find((entry) => entry.id === next.current_entry_id); + if (!current || !playable(current, at)) { + setEntry( + next, + adjacentPlayable(entries, next.current_entry_id, 1, at), + at, + appSettings, + ); + return; + } + const fullDuration = duration(current, appSettings); + const remaining = validInstant(next.paused_remaining_ms) + ? next.paused_remaining_ms + : fullDuration; + next.paused_remaining_ms = null; + next.next_slide_at = at + remaining; + next.slide_started_at = Math.max(0, next.next_slide_at - fullDuration); + } + + function reconcileRotating( + next, + entries, + at, + appSettings, + { invalidNotBefore = null, recoveryBase = null } = {}, + ) { + let current = entries.find((entry) => entry.id === next.current_entry_id); + if (!current) { + if (next.pending_playlist_id !== null) { + entries = activateTarget( + next, + next.pending_playlist_id, + at, + appSettings, + ); + current = entries.find((entry) => entry.id === next.current_entry_id); + } else { + let selectedAt = at; + if (validInstant(recoveryBase) && recoveryBase < at) { + const availableAt = nextAvailability(entries, recoveryBase, at); + if (availableAt !== null) selectedAt = availableAt; + } + current = firstPlayable(entries, selectedAt); + if (!current && selectedAt !== at) { + selectedAt = at; + current = firstPlayable(entries, at); + } + setEntry(next, current, selectedAt, appSettings); + } + } else if ( + !validInstant(next.slide_started_at) || + !validInstant(next.next_slide_at) + ) { + resumeHeldEntry(next, entries, at, appSettings); + current = entries.find((entry) => entry.id === next.current_entry_id); + } + + for (let transitions = 0; transitions < 10_000; transitions += 1) { + current = entries.find((entry) => entry.id === next.current_entry_id); + if (!current) { + const availableAt = nextAvailability( + entries, + validInstant(next.slide_started_at) ? next.slide_started_at : at, + at, + ); + if (availableAt === null) return; + setEntry(next, firstPlayable(entries, availableAt), availableAt, appSettings); + continue; + } + + if (fastForwardStable(next, entries, at, appSettings)) return; + + const invalidAt = currentInvalidBoundary( + current, + next.slide_started_at, + next.next_slide_at, + at, + invalidNotBefore, + ); + const isPlayableNow = playable(current, at); + if ( + isPlayableNow && + validInstant(next.next_slide_at) && + next.next_slide_at > at + ) { + return; + } + + let boundary = at; + if (validInstant(next.next_slide_at) && next.next_slide_at <= at) { + boundary = next.next_slide_at; + } + if (invalidAt !== null) boundary = Math.min(boundary, invalidAt); + + if (next.pending_playlist_id !== null) { + entries = activateTarget( + next, + next.pending_playlist_id, + boundary, + appSettings, + ); + } else { + const replacement = adjacentPlayable( + entries, + next.current_entry_id, + 1, + boundary, + ); + setEntry(next, replacement, boundary, appSettings); + } + + if (next.current_entry_id === null) { + const availableAt = nextAvailability(entries, boundary, at); + if (availableAt === null) { + const availableNow = firstPlayable(entries, at); + if (availableNow) setEntry(next, availableNow, at, appSettings); + return; + } + setEntry(next, firstPlayable(entries, availableAt), availableAt, appSettings); + } + } + throw new Error("Playback reconciliation exceeded its transition safety limit"); + } + + function validStaticAsset(asset, at) { + return Boolean( + asset && + asset.status === "ready" && + (asset.expiration_at === null || asset.expiration_at > at), + ); + } + + function reconcileState( + next, + before, + at, + appSettings, + { invalidNotBefore = null, recoveryBase = before.updated_at } = {}, + ) { + if ( + next.active_playlist_id !== null && + !findPlaylist.get(next.active_playlist_id) + ) { + next.active_playlist_id = null; + setEntry(next, null, at, appSettings); + } + if ( + next.pending_playlist_id !== null && + !findPlaylist.get(next.pending_playlist_id) + ) { + next.pending_playlist_id = null; + next.pending_playlist_requested_at = null; + } + if (next.pending_playlist_id === next.active_playlist_id) { + next.pending_playlist_id = null; + next.pending_playlist_requested_at = null; + } + + let staticCleared = false; + let staticResumeAt = at; + if (next.static_asset_id !== null) { + const staticAsset = findAsset.get(next.static_asset_id); + if (validStaticAsset(staticAsset, at)) { + let entries = rowsFor(next.active_playlist_id); + const current = entries.find( + (entry) => entry.id === next.current_entry_id, + ); + if ( + next.pending_playlist_id !== null && + (!current || + !playable(current, at) || + !validInstant(next.paused_remaining_ms) || + next.paused_remaining_ms === 0) + ) { + entries = activateTarget( + next, + next.pending_playlist_id, + at, + appSettings, + { held: true }, + ); + } + reconcileHeld(next, entries, at, appSettings); + return; + } + if ( + staticAsset && + validInstant(staticAsset.expiration_at) && + staticAsset.expiration_at <= at + ) { + // A naturally reached expiration resumes from its exact boundary. If + // an operator moves expiration into the past while static is held, + // the asset update is the first instant that new boundary was known; + // do not invent playback time before that mutation. + staticResumeAt = Math.min( + at, + Math.max( + staticAsset.expiration_at, + before.updated_at, + validInstant(staticAsset.updated_at) ? staticAsset.updated_at : 0, + ), + ); + } + next.static_asset_id = null; + staticCleared = true; + } + + if (next.active_playlist_id === null && next.pending_playlist_id !== null) { + activateTarget(next, next.pending_playlist_id, at, appSettings, { + held: Boolean(next.rotation_paused), + }); + } + if (next.active_playlist_id === null) { + setEntry(next, null, at, appSettings); + return; + } + + let entries = rowsFor(next.active_playlist_id); + let current = entries.find((entry) => entry.id === next.current_entry_id); + if ( + staticCleared && + next.pending_playlist_id !== null && + (!current || + !playable(current, staticResumeAt) || + !validInstant(next.paused_remaining_ms) || + next.paused_remaining_ms === 0) + ) { + entries = activateTarget( + next, + next.pending_playlist_id, + staticResumeAt, + appSettings, + { held: Boolean(next.rotation_paused) }, + ); + current = entries.find((entry) => entry.id === next.current_entry_id); + } + if (next.rotation_paused) { + if ( + next.pending_playlist_id !== null && + (!current || + !playable(current, at) || + !validInstant(next.paused_remaining_ms) || + next.paused_remaining_ms === 0) + ) { + entries = activateTarget( + next, + next.pending_playlist_id, + at, + appSettings, + { held: true }, + ); + } + reconcileHeld(next, entries, at, appSettings); + return; + } + + if ( + staticCleared || + (next.current_entry_id !== null && + (!validInstant(next.slide_started_at) || + !validInstant(next.next_slide_at) || + next.next_slide_at <= next.slide_started_at)) + ) { + resumeHeldEntry(next, entries, staticResumeAt, appSettings); + } + reconcileRotating(next, entries, at, appSettings, { + invalidNotBefore, + // Normal transport is suspended while static is held. On automatic + // static release, never manufacture a normal start before that release + // boundary merely because playback_state.updated_at is older. + recoveryBase: staticCleared ? staticResumeAt : recoveryBase, + }); + } + + function effectivePresentation(entry, appSettings) { + const presentation = entry + ? resolveEntrySettings(entry, appSettings).presentation + : { + backgroundMode: appSettings.default_background_mode, + backgroundColor: appSettings.default_solid_background_color, + backgroundDarkened: Boolean( + appSettings.default_background_darkened, + ), + blurPx: appSettings.default_blur_px, + }; + return { + ...presentation, + backgroundDarkeningOpacity: presentation.backgroundDarkened ? 0.25 : 0, + }; + } + + function mapAsset(asset, { fromEntry = false } = {}) { + if (!asset) return null; + const id = asset.asset_id ?? asset.id; + const sha256 = asset.display_sha256; + return { + id, + displayName: fromEntry ? asset.asset_display_name : asset.display_name, + originalFilename: fromEntry + ? asset.asset_original_filename + : asset.original_filename, + expirationAt: fromEntry + ? asset.asset_expiration_at + : asset.expiration_at, + media: { + url: mediaUrl(id, sha256), + sha256, + mimeType: asset.display_content_type, + byteSize: asset.display_byte_size, + width: asset.display_width, + height: asset.display_height, + }, + }; + } + + function mapManifestEntry(entry, appSettings, at) { + const resolved = resolveEntrySettings(entry, appSettings); + return { + id: entry.id, + playlistId: entry.playlist_id, + assetId: entry.asset_id, + position: entry.position, + effectiveDurationMs: resolved.durationMs, + effectivePresentation: effectivePresentation(entry, appSettings), + schedule: { + startAt: entry.schedule_start_at, + endAt: entry.schedule_end_at, + }, + scheduleStartAt: entry.schedule_start_at, + scheduleEndAt: entry.schedule_end_at, + expirationAt: entry.asset_expiration_at, + playable: playable(entry, at), + asset: mapAsset(entry, { fromEntry: true }), + }; + } + + function publicPlayback(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 buildSnapshot(at = assertNow()) { + const row = state(); + const appSettings = settings(); + const activePlaylist = row.active_playlist_id + ? findPlaylist.get(row.active_playlist_id) + : null; + const pendingPlaylist = row.pending_playlist_id + ? findPlaylist.get(row.pending_playlist_id) + : null; + const allEntries = activePlaylist ? rowsFor(activePlaylist.id) : []; + const manifestEntries = allEntries + .filter( + (entry) => + (entry.enabled === 1 || entry.enabled === true) && + entry.asset_status === "ready" && + (entry.asset_expiration_at === null || + entry.asset_expiration_at > at) && + (entry.schedule_end_at === null || entry.schedule_end_at > at), + ) + .map((entry) => mapManifestEntry(entry, appSettings, at)); + const normalEntry = allEntries.find( + (entry) => entry.id === row.current_entry_id && playable(entry, at), + ); + const normalTarget = normalEntry + ? { + type: "normal", + ...mapManifestEntry(normalEntry, appSettings, at), + } + : null; + const staticAsset = row.static_asset_id + ? findAsset.get(row.static_asset_id) + : null; + const staticTarget = validStaticAsset(staticAsset, at) + ? { + type: "static", + assetId: staticAsset.id, + asset: mapAsset(staticAsset), + effectivePresentation: effectivePresentation(null, appSettings), + } + : null; + const outputTarget = + row.mode === "off" + ? { type: "black", reason: "mode_off" } + : staticTarget || + normalTarget || { type: "black", reason: "no_playable_content" }; + const remainingMs = + staticTarget || row.rotation_paused + ? row.paused_remaining_ms + : validInstant(row.next_slide_at) + ? Math.max(0, row.next_slide_at - at) + : null; + const transportState = staticTarget + ? "static" + : row.rotation_paused + ? "paused" + : normalTarget + ? "playing" + : "idle"; + + return { + protocolVersion: PROTOCOL_VERSION, + outputId: OUTPUT_ID, + playbackRevision: row.revision, + serverTime: at, + mode: row.mode, + transportState, + transport: { + state: transportState, + rotationPaused: Boolean(row.rotation_paused), + staticActive: Boolean(staticTarget), + }, + activePlaylist: activePlaylist + ? { + id: activePlaylist.id, + name: activePlaylist.name, + revision: activePlaylist.revision, + } + : null, + activePlaylistId: activePlaylist?.id ?? null, + pendingPlaylist: pendingPlaylist + ? { + id: pendingPlaylist.id, + name: pendingPlaylist.name, + revision: pendingPlaylist.revision, + requestedAt: row.pending_playlist_requested_at, + } + : null, + pendingPlaylistId: pendingPlaylist?.id ?? null, + settingsRevision: appSettings.revision, + crossfadeDurationMs: appSettings.crossfade_duration_ms, + timeline: { + slideStartedAt: row.slide_started_at, + nextSlideAt: row.next_slide_at, + pausedRemainingMs: row.paused_remaining_ms, + remainingMs, + }, + currentEntryId: row.current_entry_id, + currentTarget: outputTarget, + outputTarget, + normalTarget, + staticTarget, + manifest: { + playlistId: activePlaylist?.id ?? null, + playlistRevision: activePlaylist?.revision ?? null, + entries: manifestEntries, + }, + }; + } + + function nearestBoundary(row, at) { + const boundaries = []; + if (row.static_asset_id !== null) { + const asset = findAsset.get(row.static_asset_id); + if (!validStaticAsset(asset, at)) return at; + if (validInstant(asset.expiration_at) && asset.expiration_at > at) { + boundaries.push(asset.expiration_at); + } + return boundaries.length > 0 ? Math.min(...boundaries) : null; + } + + const entries = rowsFor(row.active_playlist_id); + const current = entries.find((entry) => entry.id === row.current_entry_id); + if (row.pending_playlist_id !== null && !current) return at; + if (current && !playable(current, at)) return at; + if (!current && firstPlayable(entries, at)) return at; + if ( + !row.rotation_paused && + current && + validInstant(row.next_slide_at) + ) { + boundaries.push(row.next_slide_at); + } + for (const entry of entries) { + if ( + entry.enabled !== 1 || + entry.asset_status !== "ready" || + (validInstant(entry.asset_expiration_at) && + entry.asset_expiration_at <= at) + ) { + continue; + } + for (const boundary of [ + entry.schedule_start_at, + entry.schedule_end_at, + entry.asset_expiration_at, + ]) { + if (validInstant(boundary) && boundary > at) boundaries.push(boundary); + } + } + return boundaries.length > 0 ? Math.min(...boundaries) : null; + } + + function cancelTimer() { + timerGeneration += 1; + const previousHandle = timerHandle; + timerHandle = null; + if (previousHandle !== null) clearTimeoutFn(previousHandle); + } + + function armTimer() { + cancelTimer(); + if (!running) return; + const at = assertNow(); + const deadline = nearestBoundary(state(), at); + if (deadline === null) return; + const generation = timerGeneration; + const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, deadline - at)); + timerHandle = setTimeoutFn(() => { + if (!running || generation !== timerGeneration) return; + timerHandle = null; + try { + performMutation( + "timer", + (next, before, operationAt, appSettings) => { + reconcileState(next, before, operationAt, appSettings); + }, + { forceBroadcast: assertNow() >= deadline }, + ); + } catch (error) { + scheduleTimerRetry(error, "Playback timer reconciliation failed"); + } + }, delay); + timerHandle?.unref?.(); + } + + function warn(error, message) { + try { + logger.warn?.({ error }, message); + } catch { + // Observability callbacks cannot change committed playback state. + } + } + + function scheduleTimerRetry(error, message) { + warn(error, message); + if (!running || timerHandle !== null) return; + try { + timerHandle = setTimeoutFn(() => { + timerHandle = null; + try { + armTimer(); + } catch (retryError) { + scheduleTimerRetry(retryError, "Playback timer retry failed"); + } + }, 1_000); + timerHandle?.unref?.(); + } catch (retryError) { + warn(retryError, "Playback timer retry could not be scheduled"); + } + } + + function activationChange(before, after) { + if (before.active_playlist_id !== after.active_playlist_id) { + return { + reason: "activated", + playlistId: after.active_playlist_id, + }; + } + if (before.pending_playlist_id !== after.pending_playlist_id) { + if (after.pending_playlist_id !== null) { + return { + reason: "activation_requested", + playlistId: after.pending_playlist_id, + }; + } + return { + reason: "activation_canceled", + playlistId: after.active_playlist_id, + }; + } + return null; + } + + function performMutation( + reason, + action, + { forceBroadcast = false } = {}, + ) { + if (mutationActive) { + throw new PlaybackError( + "PLAYBACK_BUSY", + "Another playback mutation is already in progress", + { statusCode: 409 }, + ); + } + mutationActive = true; + let outcome; + let before; + let changed = false; + try { + database.transaction(() => { + before = state(); + const next = cloneState(before); + const operationAt = assertNow(); + const appSettings = settings(); + outcome = action(next, before, operationAt, appSettings); + changed = commit(before, next, operationAt); + })(); + } finally { + mutationActive = false; + } + + const after = state(); + const snapshot = buildSnapshot(); + try { + armTimer(); + } catch (error) { + scheduleTimerRetry(error, "Playback timer could not be armed"); + } + if (changed || forceBroadcast) { + try { + onStateChanged(snapshot, { reason }); + } catch (error) { + warn(error, "Playback state callback failed"); + } + } + if (changed) { + const event = activationChange(before, after); + if (event) { + try { + onActivationChanged({ + ...event, + activePlaylistId: after.active_playlist_id, + pendingPlaylistId: after.pending_playlist_id, + playbackRevision: after.revision, + }); + } catch (error) { + warn(error, "Playback activation callback failed"); + } + } + } + return { after, changed, outcome, snapshot }; + } + + function reconcile(reason = "reconcile") { + return performMutation(reason, (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + }).snapshot; + } + + function initialize() { + if (initialized) return buildSnapshot(); + const result = performMutation( + "startup_recovery", + (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + }, + ); + initialized = true; + return result.snapshot; + } + + function start() { + const snapshot = initialized ? buildSnapshot() : initialize(); + running = true; + armTimer(); + return snapshot; + } + + function stop() { + running = false; + cancelTimer(); + } + + function pause() { + return performMutation("paused", (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + if (next.rotation_paused) return; + const entries = rowsFor(next.active_playlist_id); + const current = entries.find((entry) => entry.id === next.current_entry_id); + next.rotation_paused = 1; + if (current) { + const remaining = validInstant(next.next_slide_at) + ? Math.max(0, next.next_slide_at - at) + : next.paused_remaining_ms; + setEntry(next, current, at, appSettings, { + held: true, + remaining: + validInstant(remaining) ? remaining : duration(current, appSettings), + }); + } else { + setEntry(next, null, at, appSettings); + } + }).snapshot; + } + + function resume() { + return performMutation("resumed", (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + if (next.static_asset_id === null && !next.rotation_paused) return; + next.static_asset_id = null; + next.rotation_paused = 0; + const entries = rowsFor(next.active_playlist_id); + resumeHeldEntry(next, entries, at, appSettings); + reconcileRotating(next, entries, at, appSettings); + }).snapshot; + } + + function move(direction) { + return performMutation( + direction > 0 ? "next" : "previous", + (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + if (next.static_asset_id !== null) return; + const held = Boolean(next.rotation_paused); + if (next.pending_playlist_id !== null) { + activateTarget(next, next.pending_playlist_id, at, appSettings, { + held, + }); + return; + } + const entries = rowsFor(next.active_playlist_id); + setEntry( + next, + adjacentPlayable(entries, next.current_entry_id, direction, at), + at, + appSettings, + { held }, + ); + }, + ).snapshot; + } + + function enterStatic(assetId) { + if (typeof assetId !== "string" || !assetId.trim()) { + throw new PlaybackError( + "INVALID_STATIC_ASSET", + "assetId must be a non-empty LibraryAsset UUID", + { details: { field: "assetId" } }, + ); + } + const normalizedId = assetId.trim(); + return performMutation("static", (next, before, at, appSettings) => { + const asset = findAsset.get(normalizedId); + if (!asset) throw assetNotFound(normalizedId); + if (asset.status !== "ready") { + throw new PlaybackError( + "ASSET_NOT_READY", + "Static playback requires a ready library asset", + { + statusCode: 409, + details: { assetId: normalizedId, status: asset.status }, + }, + ); + } + if (asset.expiration_at !== null && asset.expiration_at <= at) { + throw new PlaybackError( + "ASSET_EXPIRED", + "Expired library assets cannot be held statically", + { + statusCode: 409, + details: { assetId: normalizedId, expirationAt: asset.expiration_at }, + }, + ); + } + reconcileState(next, before, at, appSettings); + if (next.static_asset_id === normalizedId) return; + const entries = rowsFor(next.active_playlist_id); + const current = entries.find((entry) => entry.id === next.current_entry_id); + if (current && !validInstant(next.paused_remaining_ms)) { + next.paused_remaining_ms = validInstant(next.next_slide_at) + ? Math.max(0, next.next_slide_at - at) + : duration(current, appSettings); + } + next.slide_started_at = null; + next.next_slide_at = null; + next.static_asset_id = normalizedId; + }).snapshot; + } + + function setMode(mode) { + if (mode !== "on" && mode !== "off") { + throw new PlaybackError( + "INVALID_PLAYBACK_MODE", + "mode must be on or off", + { details: { field: "mode" } }, + ); + } + return performMutation("mode_changed", (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + next.mode = mode; + }).snapshot; + } + + function activatePlaylist(playlistId) { + if (typeof playlistId !== "string" || !playlistId.trim()) { + throw playlistNotFound(playlistId); + } + const normalizedId = playlistId.trim(); + if (!findPlaylist.get(normalizedId)) throw playlistNotFound(normalizedId); + const result = performMutation( + "playlist_activation", + (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + if (next.active_playlist_id === normalizedId) { + if (next.pending_playlist_id !== null) { + next.pending_playlist_id = null; + next.pending_playlist_requested_at = null; + } + return "already_active"; + } + if (next.static_asset_id !== null) { + activateTarget(next, normalizedId, at, appSettings, { held: true }); + return "active"; + } + const entries = rowsFor(next.active_playlist_id); + const current = entries.find( + (entry) => entry.id === next.current_entry_id, + ); + // When the command and an authoritative boundary share an instant, + // the requested playlist owns that boundary. Reconciliation may have + // just prepared the old playlist's next entry at `at`; do not turn + // that freshly started entry into another full-slide delay. + const startedAtThisBoundary = Boolean( + next.slide_started_at === at && + (before.current_entry_id !== next.current_entry_id || + before.slide_started_at !== next.slide_started_at || + before.next_slide_at !== next.next_slide_at), + ); + const midDuration = Boolean( + current && + playable(current, at) && + !startedAtThisBoundary && + (next.rotation_paused + ? validInstant(next.paused_remaining_ms) && + next.paused_remaining_ms > 0 + : validInstant(next.next_slide_at) && next.next_slide_at > at), + ); + if (midDuration) { + if (next.pending_playlist_id !== normalizedId) { + next.pending_playlist_id = normalizedId; + next.pending_playlist_requested_at = at; + } + return "pending"; + } + activateTarget(next, normalizedId, at, appSettings, { + held: Boolean(next.rotation_paused), + }); + return "active"; + }, + ); + return { + activation: result.outcome, + playlistId: normalizedId, + playback: publicPlayback(result.after), + snapshot: result.snapshot, + }; + } + + function domainRelevant(change, row) { + if (change === undefined || change === null || typeof change === "string") { + return true; + } + if (change.reason?.startsWith("settings")) return true; + if (change.playlistId !== undefined) { + return change.playlistId === row.active_playlist_id; + } + if (change.assetId !== undefined) { + if (change.assetId === row.static_asset_id) return true; + return rowsFor(row.active_playlist_id).some( + (entry) => entry.asset_id === change.assetId, + ); + } + if (Array.isArray(change.playlistIds)) { + return change.playlistIds.includes(row.active_playlist_id); + } + return true; + } + + function domainChanged(change) { + if (!domainRelevant(change, state())) return buildSnapshot(); + return performMutation( + change?.reason || "domain_changed", + (next, before, at, appSettings) => { + // Domain writers reconcile once before committing. A specifically + // changed end/expiration boundary can therefore take effect now + // without rewriting the already-reconciled historical timeline. + reconcileState(next, before, at, appSettings, { + invalidNotBefore: change?.invalidBoundaryChanged ? at : null, + // New content that is already eligible starts now; it did not exist + // at playback_state.updated_at. The pre-commit reconciliation owns + // any genuinely historical empty-playlist boundary. + recoveryBase: at, + }); + }, + { forceBroadcast: true }, + ).snapshot; + } + + function reload() { + return performMutation( + "reload", + (next, before, at, appSettings) => { + reconcileState(next, before, at, appSettings); + }, + { forceBroadcast: true }, + ).snapshot; + } + + return { + activatePlaylist, + domainChanged, + enterStatic, + getSnapshot: buildSnapshot, + initialize, + next: () => move(1), + pause, + previous: () => move(-1), + reconcile, + reload, + resume, + setMode, + start, + stop, + }; +} + +module.exports = { + MAX_TIMEOUT_MS, + OUTPUT_ID, + PROTOCOL_VERSION, + createPlaybackService, +}; diff --git a/server/src/playlists/routes.js b/server/src/playlists/routes.js index 94477bd..b7ac869 100644 --- a/server/src/playlists/routes.js +++ b/server/src/playlists/routes.js @@ -24,7 +24,10 @@ function orderedEntryIds(body) { return body.entryIds; } -function createPlaylistRouter(playlistService) { +function createPlaylistRouter( + playlistService, + { playbackService } = {}, +) { const router = express.Router(); router.get("/", (request, response, next) => { @@ -121,7 +124,11 @@ function createPlaylistRouter(playlistService) { router.post("/:playlistId/activate", (request, response, next) => { try { - response.json(playlistService.activate(request.params.playlistId)); + if (!playbackService) { + throw new Error("The playback service is required for playlist activation"); + } + const result = playbackService.activatePlaylist(request.params.playlistId); + response.json(result); } catch (error) { next(error); } diff --git a/server/src/playlists/service.js b/server/src/playlists/service.js index 215cf20..2108194 100644 --- a/server/src/playlists/service.js +++ b/server/src/playlists/service.js @@ -165,24 +165,6 @@ function mapPlaylist(row) { }; } -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` @@ -238,6 +220,7 @@ function createPlaylistService({ database, now = Date.now, createId = crypto.randomUUID, + onBeforeChange = () => {}, onChanged = () => {}, }) { const playlistSelect = ` @@ -371,52 +354,6 @@ function createPlaylistService({ 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); @@ -435,11 +372,10 @@ function createPlaylistService({ } function notify(change) { - try { - onChanged(change); - } catch { - // The database commit is authoritative; invalidation must not undo it. - } + // The transaction has committed before this synchronous domain hook runs. + // A reconciliation failure is surfaced to the caller so playback cannot + // silently remain inconsistent with a successful playlist mutation. + onChanged(change); } function duplicateName(name, row = findPlaylistByName.get(name)) { @@ -628,6 +564,7 @@ function createPlaylistService({ }); function update(playlistId, input) { + onBeforeChange({ reason: "before_playlist_update", playlistId }); const result = updateTransaction(playlistId, input); if (result.changed) { notify({ @@ -662,6 +599,7 @@ function createPlaylistService({ }); function remove(playlistId) { + onBeforeChange({ reason: "before_playlist_delete", playlistId }); const playlist = removeTransaction(playlistId); notify({ reason: "deleted", playlistId, revision: playlist.revision }); return { id: playlistId, deleted: true }; @@ -743,6 +681,7 @@ function createPlaylistService({ }); function addEntry(playlistId, input) { + onBeforeChange({ reason: "before_entry_add", playlistId }); const result = addEntryTransaction(playlistId, input); notify({ reason: "entry_added", @@ -760,6 +699,7 @@ function createPlaylistService({ if (entryValuesEqual(current, fields)) { return { changed: false, + invalidBoundaryChanged: false, playlist, detail: resolvedDetail(playlistId, now()), }; @@ -774,12 +714,19 @@ function createPlaylistService({ bumpPlaylistRevision.run(timestamp, playlistId); return { changed: true, + invalidBoundaryChanged: + current.schedule_end_at !== fields.scheduleEndAt, playlist: getPlaylistRow(playlistId), detail: resolvedDetail(playlistId, now()), }; }); function updateEntryById(playlistId, entryId, input) { + onBeforeChange({ + reason: "before_entry_update", + playlistId, + entryId, + }); const result = patchEntryTransaction(playlistId, entryId, input); if (result.changed) { notify({ @@ -787,6 +734,9 @@ function createPlaylistService({ playlistId, revision: result.playlist.revision, entryId, + ...(result.invalidBoundaryChanged + ? { invalidBoundaryChanged: true } + : {}), }); } return result.detail.entries.find((entry) => entry.id === entryId); @@ -796,7 +746,6 @@ function createPlaylistService({ 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); @@ -807,18 +756,21 @@ function createPlaylistService({ }); } bumpPlaylistRevision.run(timestamp, playlistId); - return { playlist: getPlaylistRow(playlistId), playbackChanged }; + return { playlist: getPlaylistRow(playlistId) }; }); function removeEntryById(playlistId, entryId) { + onBeforeChange({ + reason: "before_entry_delete", + 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 }; } @@ -903,6 +855,7 @@ function createPlaylistService({ }); function reorderEntries(playlistId, orderedEntryIds) { + onBeforeChange({ reason: "before_entries_reorder", playlistId }); const result = reorderTransaction(playlistId, orderedEntryIds); if (result.changed) { notify({ reason: "entries_reordered", playlistId, revision: result.playlist.revision }); @@ -910,130 +863,7 @@ function createPlaylistService({ 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, @@ -1050,6 +880,5 @@ module.exports = { MAX_DURATION_MS, MIN_DURATION_MS, createPlaylistService, - mapPlaybackState, normalizePlaylistName, }; diff --git a/server/src/runtime.js b/server/src/runtime.js index a843844..c7a39a9 100644 --- a/server/src/runtime.js +++ b/server/src/runtime.js @@ -4,6 +4,7 @@ const { createApp } = require("./app"); const { openDatabase } = require("./database"); const { runMigrations } = require("./database/migrate"); const { createImageProcessor } = require("./image-processing"); +const { createPlaybackService } = require("./playback"); const { createStorageService } = require("./storage"); function listen(httpServer, port, host) { @@ -54,7 +55,29 @@ function createRuntime({ } let app; let io = null; + let playbackService; + function emitSocketEvent(eventName, payload) { + try { + io?.emit(eventName, payload); + } catch (error) { + try { + logger.warn?.(`Socket.IO ${eventName} publish failed`, error); + } catch { + // Socket invalidation remains best effort after the authoritative commit. + } + } + } try { + playbackService = createPlaybackService({ + database, + logger, + onActivationChanged(event) { + emitSocketEvent("playlists:changed", event); + }, + onStateChanged(snapshot) { + emitSocketEvent("playback:state", snapshot); + }, + }); app = createApp({ config, database, @@ -63,8 +86,9 @@ function createRuntime({ logger, migrations, onPlaylistsChanged(event) { - io?.emit("playlists:changed", event); + emitSocketEvent("playlists:changed", event); }, + playbackService, storage, }); } catch (error) { @@ -79,6 +103,11 @@ function createRuntime({ io.on("connection", (socket) => { logger.info?.(`Socket.IO client connected: ${socket.id}`); + try { + socket.emit("playback:state", playbackService.getSnapshot()); + } catch (error) { + logger.warn?.("Initial playback snapshot could not be sent", error); + } socket.on("disconnect", () => { logger.info?.(`Socket.IO client disconnected: ${socket.id}`); }); @@ -96,7 +125,10 @@ function createRuntime({ return startPromise; } - startPromise = listen(httpServer, config.port, config.host) + startPromise = Promise.resolve() + .then(() => playbackService.initialize()) + .then(() => playbackService.start()) + .then(() => listen(httpServer, config.port, config.host)) .then((listeningAddress) => { address = listeningAddress; lifecycle.ready = true; @@ -108,6 +140,7 @@ function createRuntime({ return listeningAddress; }) .catch((error) => { + playbackService.stop(); storage.stop(); database.close(); throw error; @@ -122,6 +155,7 @@ function createRuntime({ lifecycle.ready = false; lifecycle.stopping = true; + playbackService.stop(); stopPromise = new Promise((resolve) => { let settled = false; const finish = () => { @@ -163,6 +197,7 @@ function createRuntime({ io, lifecycle, migrations, + playbackService, storage, imageProcessor, get address() { diff --git a/server/src/settings/routes.js b/server/src/settings/routes.js index bc437a2..68bd918 100644 --- a/server/src/settings/routes.js +++ b/server/src/settings/routes.js @@ -2,7 +2,11 @@ const express = require("express"); function createSettingsRouter( settingsService, - { onPlaylistsChanged = () => {} } = {}, + { + onBeforePlaybackDomainChange = () => {}, + onPlaylistsChanged = () => {}, + onPlaybackDomainChanged = () => {}, + } = {}, ) { const router = express.Router(); @@ -13,13 +17,16 @@ function createSettingsRouter( router.patch("/", (request, response, next) => { try { const previous = settingsService.get(); + onBeforePlaybackDomainChange({ reason: "before_settings_update" }); const updated = settingsService.update(request.body); if (updated.revision !== previous.revision) { - onPlaylistsChanged({ + const change = { reason: "settings_updated", playlistIds: null, settingsRevision: updated.revision, - }); + }; + onPlaybackDomainChanged(change); + onPlaylistsChanged(change); } response.json(updated); } catch (error) { diff --git a/server/test/playback-api.test.js b/server/test/playback-api.test.js new file mode 100644 index 0000000..5e47f7a --- /dev/null +++ b/server/test/playback-api.test.js @@ -0,0 +1,249 @@ +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"); + +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 createPlaybackStub({ domainError = null } = {}) { + const calls = []; + let revision = 10; + const snapshot = (action = "get") => ({ + protocolVersion: 1, + outputId: "global", + playbackRevision: revision, + action, + }); + const action = (name) => () => { + calls.push([name]); + revision += 1; + return snapshot(name); + }; + return { + calls, + domainChanged(change) { + calls.push(["domainChanged", change]); + if (domainError) throw domainError; + return snapshot("domainChanged"); + }, + getSnapshot() { + calls.push(["getSnapshot"]); + return snapshot(); + }, + pause: action("pause"), + reconcile(reason) { + calls.push(["reconcile", reason]); + return snapshot("reconcile"); + }, + resume: action("resume"), + next: action("next"), + previous: action("previous"), + reload: action("reload"), + enterStatic(assetId) { + calls.push(["enterStatic", assetId]); + revision += 1; + return snapshot("static"); + }, + setMode(mode) { + calls.push(["setMode", mode]); + revision += 1; + return snapshot("mode"); + }, + activatePlaylist(playlistId) { + calls.push(["activatePlaylist", playlistId]); + revision += 1; + const current = snapshot("activate"); + return { + activation: "active", + playlistId, + playback: { revision: current.playbackRevision }, + snapshot: current, + }; + }, + }; +} + +async function createHarness(t, { domainError = null } = {}) { + const database = openDatabase({ path: ":memory:" }); + const migrations = runMigrations(database); + const playbackService = createPlaybackStub({ domainError }); + const invalidations = []; + const app = createApp({ + config: testConfig(), + database, + imageProcessor: {}, + lifecycle: { ready: true, stopping: false }, + logger: { info() {}, warn() {}, error() {} }, + migrations, + onPlaylistsChanged: (event) => invalidations.push(event), + playbackService, + storage: { + health() { + return { status: "healthy", checkedAt: null, lastSuccessAt: null }; + }, + }, + }); + 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, + invalidations, + playbackService, + }; +} + +async function requestJson(baseUrl, path, { body, ...init } = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + ...(body === undefined + ? {} + : { + headers: { "content-type": "application/json", ...init.headers }, + body: JSON.stringify(body), + }), + }); + return { response, body: await response.json() }; +} + +test("playback REST routes delegate every command to the one playback service", async (t) => { + const harness = await createHarness(t); + + const read = await requestJson(harness.baseUrl, "/api/playback"); + assert.equal(read.response.status, 200); + assert.equal(read.body.playbackRevision, 10); + + const commands = [ + ["POST", "/api/playback/pause", undefined, "pause"], + ["POST", "/api/playback/resume", undefined, "resume"], + ["POST", "/api/playback/next", undefined, "next"], + ["POST", "/api/playback/previous", undefined, "previous"], + ["POST", "/api/playback/reload", undefined, "reload"], + ["POST", "/api/playback/static", { assetId: "asset-static" }, "static"], + ["PUT", "/api/playback/mode", { mode: "off" }, "mode"], + ]; + for (const [method, path, body, action] of commands) { + const result = await requestJson(harness.baseUrl, path, { method, body }); + assert.equal(result.response.status, 200); + assert.equal(result.body.action, action); + } + + assert.deepEqual(harness.playbackService.calls, [ + ["getSnapshot"], + ["pause"], + ["resume"], + ["next"], + ["previous"], + ["reload"], + ["enterStatic", "asset-static"], + ["setMode", "off"], + ]); +}); + +test("playlist activation delegates directly to the playback authority", async (t) => { + const harness = await createHarness(t); + const result = await requestJson( + harness.baseUrl, + "/api/playlists/playlist-a/activate", + { method: "POST" }, + ); + + assert.equal(result.response.status, 200); + assert.equal(result.body.activation, "active"); + assert.equal(result.body.playlistId, "playlist-a"); + assert.deepEqual(harness.playbackService.calls, [ + ["activatePlaylist", "playlist-a"], + ]); + assert.deepEqual(harness.invalidations, []); +}); + +test("semantic domain edits reconcile playback independently of list invalidation", async (t) => { + const harness = await createHarness(t); + const timestamp = Date.now(); + harness.database + .prepare( + `INSERT INTO assets ( + id, source_sha256, display_name, original_filename, status, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'ready', ?, ?)`, + ) + .run( + "unreferenced-asset", + "a".repeat(64), + "Unreferenced", + "unreferenced.png", + timestamp, + timestamp, + ); + + const settings = await requestJson(harness.baseUrl, "/api/settings", { + method: "PATCH", + body: { defaultBlurPx: 21 }, + }); + assert.equal(settings.response.status, 200); + const asset = await requestJson( + harness.baseUrl, + "/api/assets/unreferenced-asset", + { method: "PATCH", body: { displayName: "Renamed unreferenced" } }, + ); + assert.equal(asset.response.status, 200); + + assert.deepEqual(harness.playbackService.calls, [ + ["reconcile", "before_domain_change"], + [ + "domainChanged", + { reason: "settings_updated", playlistIds: null, settingsRevision: 2 }, + ], + ["reconcile", "before_domain_change"], + [ + "domainChanged", + { + reason: "asset_updated", + playlistIds: [], + assetId: "unreferenced-asset", + }, + ], + ]); + assert.deepEqual(harness.invalidations, [ + { reason: "settings_updated", playlistIds: null, settingsRevision: 2 }, + ]); +}); + +test("a playback reconciliation failure is surfaced after the domain commit", async (t) => { + const harness = await createHarness(t, { + domainError: new Error("reconciliation failed"), + }); + const result = await requestJson(harness.baseUrl, "/api/playlists", { + method: "POST", + body: { name: "Committed before reconciliation" }, + }); + + assert.equal(result.response.status, 500); + assert.equal(result.body.error.code, "INTERNAL_ERROR"); + assert.equal( + harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get() + .count, + 1, + ); + assert.equal(harness.invalidations.length, 0); + assert.equal(harness.playbackService.calls[0][0], "domainChanged"); +}); diff --git a/server/test/playback-service.test.js b/server/test/playback-service.test.js new file mode 100644 index 0000000..6c0e970 --- /dev/null +++ b/server/test/playback-service.test.js @@ -0,0 +1,1430 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { openDatabase } = require("../src/database"); +const { runMigrations } = require("../src/database/migrate"); +const { + MAX_TIMEOUT_MS, + createPlaybackService, +} = require("../src/playback/service"); + +const BASE_TIME = 2_000_000_000_000; + +function createFakeClock(initial = BASE_TIME) { + let current = initial; + let sequence = 0; + const timers = new Set(); + + function nextTimer() { + return [...timers] + .filter((timer) => !timer.cleared && !timer.fired) + .sort((left, right) => left.at - right.at || left.id - right.id)[0] || null; + } + + return { + now: () => current, + set(value) { + assert.ok(value >= current, "fake time cannot move backwards"); + current = value; + }, + setTimeout(callback, delay) { + const timer = { + at: current + delay, + callback, + cleared: false, + delay, + fired: false, + id: ++sequence, + }; + timers.add(timer); + return timer; + }, + clearTimeout(timer) { + if (timer) timer.cleared = true; + }, + nextTimer, + runTo(value) { + assert.ok(value >= current, "fake time cannot move backwards"); + current = value; + let callbacks = 0; + while (true) { + const timer = nextTimer(); + if (!timer || timer.at > current) break; + timer.fired = true; + timer.callback(); + callbacks += 1; + assert.ok(callbacks < 1_000, "timer callback loop did not settle"); + } + return callbacks; + }, + invokeStale(timer) { + timer.callback(); + }, + }; +} + +function createHarness({ at = BASE_TIME, defaultDurationMs = 1_000 } = {}) { + const database = openDatabase({ path: ":memory:" }); + runMigrations(database); + database + .prepare( + `UPDATE app_settings + SET default_duration_ms = ?, updated_at = ? + WHERE id = 1`, + ) + .run(defaultDurationMs, at); + + const clock = createFakeClock(at); + const stateEvents = []; + const activationEvents = []; + let assetSequence = 0; + let playlistSequence = 0; + let entrySequence = 0; + let service; + + function seedAsset({ + id, + name, + status = "ready", + expirationAt = null, + contentType = "image/webp", + width = 1920, + height = 1080, + } = {}) { + const ordinal = ++assetSequence; + const assetId = id || `asset-${ordinal}`; + const assetName = name || assetId; + const sha = ordinal.toString(16).padStart(64, "0"); + database + .prepare( + `INSERT INTO assets ( + id, source_sha256, display_name, original_filename, status, + expiration_at, display_object_key, display_sha256, + display_content_type, display_byte_size, display_width, display_height, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 12345, ?, ?, ?, ?)`, + ) + .run( + assetId, + sha, + assetName, + `${assetName}.png`, + status, + expirationAt, + `private/display/${assetId}.webp`, + sha, + contentType, + width, + height, + clock.now(), + clock.now(), + ); + return assetId; + } + + function seedPlaylist({ id, name, entries = [] } = {}) { + const ordinal = ++playlistSequence; + const playlistId = id || `playlist-${ordinal}`; + database + .prepare( + `INSERT INTO playlists (id, name, revision, created_at, updated_at) + VALUES (?, ?, 1, ?, ?)`, + ) + .run( + playlistId, + name || `Playlist ${ordinal}`, + clock.now(), + clock.now(), + ); + const seededEntries = entries.map((options, position) => { + const entryId = options.id || `entry-${++entrySequence}`; + const assetId = + options.assetId || + seedAsset({ + id: options.asset?.id, + name: options.asset?.name, + status: options.asset?.status, + expirationAt: options.asset?.expirationAt, + }); + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + entryId, + playlistId, + assetId, + options.enabled === false ? 0 : 1, + options.durationMs ?? null, + options.backgroundMode ?? null, + options.backgroundColor ?? null, + options.backgroundDarkened == null + ? null + : options.backgroundDarkened + ? 1 + : 0, + options.scheduleStartAt ?? null, + options.scheduleEndAt ?? null, + position, + clock.now(), + clock.now(), + ); + return { assetId, id: entryId }; + }); + return { entries: seededEntries, id: playlistId }; + } + + function playback() { + return database + .prepare("SELECT * FROM playback_state WHERE output_id = 'global'") + .get(); + } + + function makeService({ + clearTimeoutFn = clock.clearTimeout, + initialize = true, + logger, + setTimeoutFn = clock.setTimeout, + start = false, + } = {}) { + service = createPlaybackService({ + database, + now: clock.now, + setTimeoutFn, + clearTimeoutFn, + logger, + onStateChanged: (snapshot) => stateEvents.push(snapshot), + onActivationChanged: (event) => activationEvents.push(event), + }); + if (initialize) service.initialize(); + if (start) service.start(); + return service; + } + + function close() { + service?.stop(); + database.close(); + } + + return { + activationEvents, + clock, + close, + database, + makeService, + playback, + seedAsset, + seedPlaylist, + stateEvents, + get service() { + return service; + }, + }; +} + +function expectPlaybackError(action, code, statusCode) { + assert.throws(action, (error) => { + assert.equal(error.code, code); + if (statusCode !== undefined) assert.equal(error.statusCode, statusCode); + return true; + }); +} + +test("absolute deadlines honor mixed durations, wrap, and catch up without drift", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { id: "a", durationMs: 1_000 }, + { id: "b", durationMs: 2_000 }, + { id: "c", durationMs: 3_000 }, + ], + }); + const service = harness.makeService({ start: true }); + + service.activatePlaylist(playlist.id); + assert.equal(harness.playback().current_entry_id, "a"); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 1_000); + + harness.clock.runTo(BASE_TIME + 1_000); + assert.equal(harness.playback().current_entry_id, "b"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 3_000); + + harness.clock.runTo(BASE_TIME + 6_000); + assert.equal(harness.playback().current_entry_id, "a"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 6_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 7_000); + + // The callback due at +7s executes very late. The final deadline remains on + // the original timeline rather than drifting to late-now + duration. + harness.clock.runTo(BASE_TIME + 15_500); + assert.equal(harness.playback().current_entry_id, "c"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 15_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 18_000); +}); + +test("long restart catch-up skips whole cycles across an eligibility boundary", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { id: "always" }, + { id: "joins-later", scheduleStartAt: BASE_TIME + 20_000_000 }, + ], + }); + let service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + service.stop(); + + harness.clock.set(BASE_TIME + 30_000_000); + service = harness.makeService({ start: true }); + assert.equal(harness.playback().current_entry_id, "joins-later"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 30_000_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 30_001_000); +}); + +test("startup recovers mid-slide and catches up across multiple deadlines", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { id: "one", durationMs: 1_000 }, + { id: "two", durationMs: 2_000 }, + ], + }); + let service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + const originalRevision = harness.playback().revision; + + service.stop(); + harness.clock.set(BASE_TIME + 500); + service = harness.makeService({ start: true }); + assert.equal(harness.playback().current_entry_id, "one"); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 1_000); + assert.equal(harness.playback().revision, originalRevision); + assert.equal(harness.clock.nextTimer().delay, 500); + + service.stop(); + harness.clock.set(BASE_TIME + 8_500); + service = harness.makeService({ start: true }); + assert.equal(harness.playback().current_entry_id, "two"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 7_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 9_000); + assert.equal(harness.playback().revision, originalRevision + 1); +}); + +test("startup conservatively repairs a malformed non-advancing timeline", (t) => { + const harness = createHarness({ at: BASE_TIME + 500 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ entries: [{ id: "repair-current" }] }); + harness.database + .prepare( + `UPDATE playback_state + SET active_playlist_id = ?, current_entry_id = ?, + slide_started_at = ?, next_slide_at = ?, updated_at = ? + WHERE output_id = 'global'`, + ) + .run( + playlist.id, + "repair-current", + BASE_TIME, + BASE_TIME, + BASE_TIME, + ); + + harness.makeService({ start: true }); + assert.equal(harness.playback().current_entry_id, "repair-current"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 500); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 1_500); +}); + +test("startup preserves pause, static hold, and off-gated transport semantics", (t) => { + const paused = createHarness(); + const staticHold = createHarness(); + const modeOff = createHarness(); + t.after(() => paused.close()); + t.after(() => staticHold.close()); + t.after(() => modeOff.close()); + + const pausedList = paused.seedPlaylist({ + entries: [{ id: "paused-one" }, { id: "paused-two" }], + }); + let service = paused.makeService({ start: true }); + service.activatePlaylist(pausedList.id); + paused.clock.set(BASE_TIME + 250); + service.pause(); + const pausedRevision = paused.playback().revision; + service.stop(); + paused.clock.set(BASE_TIME + 20_000); + service = paused.makeService({ start: true }); + assert.equal(paused.playback().current_entry_id, "paused-one"); + assert.equal(paused.playback().paused_remaining_ms, 750); + assert.equal(paused.playback().revision, pausedRevision); + assert.equal(paused.clock.nextTimer(), null); + + const staticList = staticHold.seedPlaylist({ entries: [{ id: "under-static" }] }); + const staticAsset = staticHold.seedAsset({ id: "arbitrary-static" }); + service = staticHold.makeService({ start: true }); + service.activatePlaylist(staticList.id); + staticHold.clock.set(BASE_TIME + 200); + service.enterStatic(staticAsset); + const staticRevision = staticHold.playback().revision; + service.stop(); + staticHold.clock.set(BASE_TIME + 40_000); + service = staticHold.makeService({ start: true }); + assert.equal(staticHold.playback().static_asset_id, staticAsset); + assert.equal(staticHold.playback().current_entry_id, "under-static"); + assert.equal(staticHold.playback().paused_remaining_ms, 800); + assert.equal(staticHold.playback().revision, staticRevision); + + const offList = modeOff.seedPlaylist({ + entries: [{ id: "off-one" }, { id: "off-two" }], + }); + service = modeOff.makeService({ start: true }); + service.activatePlaylist(offList.id); + service.setMode("off"); + service.stop(); + modeOff.clock.set(BASE_TIME + 3_500); + service = modeOff.makeService({ start: true }); + assert.equal(modeOff.playback().mode, "off"); + assert.equal(modeOff.playback().current_entry_id, "off-two"); + assert.equal(modeOff.playback().slide_started_at, BASE_TIME + 3_000); + assert.equal(modeOff.playback().next_slide_at, BASE_TIME + 4_000); +}); + +test("restart consumes pending activation at its old boundary and catches up in the target", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const oldPlaylist = harness.seedPlaylist({ + name: "Old", + entries: [{ id: "old", durationMs: 4_000 }], + }); + const target = harness.seedPlaylist({ + name: "Target", + entries: [ + { id: "target-one", durationMs: 1_000 }, + { id: "target-two", durationMs: 2_000 }, + ], + }); + let service = harness.makeService({ start: true }); + service.activatePlaylist(oldPlaylist.id); + harness.clock.set(BASE_TIME + 500); + assert.equal(service.activatePlaylist(target.id).activation, "pending"); + harness.activationEvents.length = 0; + service.stop(); + + harness.clock.set(BASE_TIME + 6_500); + service = harness.makeService({ start: true }); + assert.equal(harness.playback().active_playlist_id, target.id); + assert.equal(harness.playback().pending_playlist_id, null); + assert.equal(harness.playback().current_entry_id, "target-two"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 5_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 7_000); + assert.deepEqual(harness.activationEvents, [ + { + reason: "activated", + playlistId: target.id, + activePlaylistId: target.id, + pendingPlaylistId: null, + playbackRevision: harness.playback().revision, + }, + ]); +}); + +test("pending activation finishes the old slide and uses target eligibility at the boundary", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const oldPlaylist = harness.seedPlaylist({ + name: "Old", + entries: [{ id: "old", durationMs: 2_000 }], + }); + const target = harness.seedPlaylist({ + name: "New", + entries: [ + { id: "disabled", enabled: false }, + { id: "future", scheduleStartAt: BASE_TIME + 5_000 }, + { id: "removed-before-boundary" }, + { id: "first-at-boundary", durationMs: 3_000 }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(oldPlaylist.id); + service.setMode("off"); + harness.clock.set(BASE_TIME + 500); + const pending = service.activatePlaylist(target.id); + assert.equal(pending.activation, "pending"); + assert.equal(harness.playback().active_playlist_id, oldPlaylist.id); + + // The target is mutable while pending. Eligibility must be read at the + // boundary, not frozen when activation was requested. + harness.database + .prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = ?") + .run("removed-before-boundary"); + service.domainChanged({ + reason: "entry_updated", + playlistId: target.id, + }); + harness.clock.runTo(BASE_TIME + 2_000); + + const playback = harness.playback(); + assert.equal(playback.mode, "off"); + assert.equal(playback.active_playlist_id, target.id); + assert.equal(playback.pending_playlist_id, null); + assert.equal(playback.current_entry_id, "first-at-boundary"); + assert.equal(playback.slide_started_at, BASE_TIME + 2_000); + assert.equal(playback.next_slide_at, BASE_TIME + 5_000); +}); + +test("an activation command arriving exactly at a slide deadline owns that boundary", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const oldPlaylist = harness.seedPlaylist({ + name: "Boundary old", + entries: [{ id: "boundary-old" }], + }); + const target = harness.seedPlaylist({ + name: "Boundary target", + entries: [{ id: "boundary-target", durationMs: 2_000 }], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(oldPlaylist.id); + + // A freshly started slide is still a real current slide even when two REST + // commands happen to receive the same millisecond timestamp. + assert.equal(service.activatePlaylist(target.id).activation, "pending"); + assert.equal(service.activatePlaylist(oldPlaylist.id).activation, "already_active"); + assert.equal(harness.playback().pending_playlist_id, null); + + // Simulate the REST command winning the event-loop turn at the same instant + // as the queued timer. It must not start another old-playlist duration. + harness.clock.set(BASE_TIME + 1_000); + const result = service.activatePlaylist(target.id); + assert.equal(result.activation, "active"); + assert.equal(harness.playback().active_playlist_id, target.id); + assert.equal(harness.playback().pending_playlist_id, null); + assert.equal(harness.playback().current_entry_id, "boundary-target"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 3_000); +}); + +test("activation is immediate with no normal target or under static without breaking the hold", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const empty = harness.seedPlaylist({ name: "Empty" }); + const oldPlaylist = harness.seedPlaylist({ + name: "Old", + entries: [{ id: "old-normal", durationMs: 2_000 }], + }); + const target = harness.seedPlaylist({ + name: "Target", + entries: [ + { id: "target-disabled", enabled: false }, + { id: "target-normal", durationMs: 4_000 }, + ], + }); + const staticAsset = harness.seedAsset({ id: "library-only-static" }); + const service = harness.makeService({ start: true }); + + const emptyResult = service.activatePlaylist(empty.id); + assert.equal(emptyResult.activation, "active"); + assert.equal(harness.playback().active_playlist_id, empty.id); + assert.equal(harness.playback().current_entry_id, null); + assert.equal(service.getSnapshot().outputTarget.type, "black"); + + service.activatePlaylist(oldPlaylist.id); + harness.clock.set(BASE_TIME + 250); + service.enterStatic(staticAsset); + const heldRemaining = harness.playback().paused_remaining_ms; + const staticResult = service.activatePlaylist(target.id); + assert.equal(staticResult.activation, "active"); + assert.equal(harness.playback().active_playlist_id, target.id); + assert.equal(harness.playback().pending_playlist_id, null); + assert.equal(harness.playback().current_entry_id, "target-normal"); + assert.equal(harness.playback().static_asset_id, staticAsset); + assert.equal(harness.playback().paused_remaining_ms, 4_000); + assert.notEqual(harness.playback().paused_remaining_ms, heldRemaining); + assert.equal(service.getSnapshot().outputTarget.type, "static"); +}); + +test("eligibility skips invalid entries and exact schedule boundaries recover an empty playlist", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { id: "disabled", enabled: false }, + { id: "processing", asset: { status: "processing" } }, + { + id: "expired", + asset: { expirationAt: BASE_TIME }, + }, + { + id: "future", + scheduleStartAt: BASE_TIME + 2_000, + scheduleEndAt: BASE_TIME + 3_000, + }, + { id: "fallback", durationMs: 10_000 }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + assert.equal(harness.playback().current_entry_id, "fallback"); + + harness.database + .prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = 'fallback'") + .run(); + service.domainChanged({ reason: "entry_updated", playlistId: playlist.id }); + assert.equal(harness.playback().current_entry_id, null); + assert.equal(harness.playback().next_slide_at, null); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 2_000); + + harness.clock.runTo(BASE_TIME + 2_000); + assert.equal(harness.playback().current_entry_id, "future"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 2_000); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 3_000); + + harness.clock.runTo(BASE_TIME + 3_000); + assert.equal(harness.playback().current_entry_id, null); + assert.equal(harness.playback().next_slide_at, null); + assert.equal(service.getSnapshot().outputTarget.type, "black"); +}); + +test("a late empty-playlist schedule callback catches up from the exact start", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { id: "scheduled-only", scheduleStartAt: BASE_TIME + 1_000 }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + assert.equal(harness.playback().current_entry_id, null); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_000); + + harness.clock.runTo(BASE_TIME + 3_500); + assert.equal(harness.playback().current_entry_id, "scheduled-only"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 3_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 4_000); +}); + +test("schedule end and asset expiration preempt a longer normal duration", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { + id: "scheduled", + durationMs: 10_000, + scheduleEndAt: BASE_TIME + 750, + }, + { + id: "expiring", + durationMs: 10_000, + asset: { expirationAt: BASE_TIME + 1_500 }, + }, + { id: "permanent", durationMs: 10_000 }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 750); + + harness.clock.runTo(BASE_TIME + 750); + assert.equal(harness.playback().current_entry_id, "expiring"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 750); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 10_750); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_500); + + harness.clock.runTo(BASE_TIME + 1_500); + assert.equal(harness.playback().current_entry_id, "permanent"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_500); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 11_500); +}); + +test("past-dated live eligibility edits preserve old catch-up then apply now", (t) => { + const expiration = createHarness({ defaultDurationMs: 1_000 }); + const schedule = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => expiration.close()); + t.after(() => schedule.close()); + + const expirationPlaylist = expiration.seedPlaylist({ + entries: [{ id: "expires-current" }, { id: "expires-next" }], + }); + let service = expiration.makeService({ start: true }); + service.activatePlaylist(expirationPlaylist.id); + expiration.clock.set(BASE_TIME + 2_500); + service.reconcile("before_domain_change"); + assert.equal(expiration.playback().current_entry_id, "expires-current"); + assert.equal(expiration.playback().slide_started_at, BASE_TIME + 2_000); + const currentAssetId = expirationPlaylist.entries[0].assetId; + expiration.database + .prepare("UPDATE assets SET expiration_at = ?, updated_at = ? WHERE id = ?") + .run(BASE_TIME + 500, expiration.clock.now(), currentAssetId); + service.domainChanged({ + reason: "asset_updated", + assetId: currentAssetId, + playlistIds: [expirationPlaylist.id], + invalidBoundaryChanged: true, + }); + assert.equal(expiration.playback().current_entry_id, "expires-next"); + assert.equal(expiration.playback().slide_started_at, BASE_TIME + 2_500); + assert.equal(expiration.playback().next_slide_at, BASE_TIME + 3_500); + + const schedulePlaylist = schedule.seedPlaylist({ + entries: [{ id: "scheduled-current" }, { id: "scheduled-next" }], + }); + service = schedule.makeService({ start: true }); + service.activatePlaylist(schedulePlaylist.id); + schedule.clock.set(BASE_TIME + 2_600); + service.reconcile("before_domain_change"); + assert.equal(schedule.playback().current_entry_id, "scheduled-current"); + assert.equal(schedule.playback().slide_started_at, BASE_TIME + 2_000); + schedule.database + .prepare( + "UPDATE playlist_entries SET schedule_end_at = ?, updated_at = ? WHERE id = ?", + ) + .run(BASE_TIME + 500, schedule.clock.now(), "scheduled-current"); + service.domainChanged({ + reason: "entry_updated", + playlistId: schedulePlaylist.id, + invalidBoundaryChanged: true, + }); + assert.equal(schedule.playback().current_entry_id, "scheduled-next"); + assert.equal(schedule.playback().slide_started_at, BASE_TIME + 2_600); + assert.equal(schedule.playback().next_slide_at, BASE_TIME + 3_600); +}); + +test("unrelated domain edits retain a known overdue expiration boundary", (t) => { + const harness = createHarness({ defaultDurationMs: 10_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { + id: "known-expiration", + asset: { expirationAt: BASE_TIME + 1_000 }, + }, + { id: "known-expiration-next" }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + + harness.clock.set(BASE_TIME + 5_000); + service.reconcile("before_domain_change"); + assert.equal(harness.playback().current_entry_id, "known-expiration-next"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 11_000); + + harness.database + .prepare( + "UPDATE app_settings SET default_blur_px = 21, revision = revision + 1 WHERE id = 1", + ) + .run(); + service.domainChanged({ reason: "settings_updated", settingsRevision: 2 }); + assert.equal(harness.playback().current_entry_id, "known-expiration-next"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 11_000); +}); + +test("live playlist mutations preserve stable cursors and reconcile invalid current entries", (t) => { + const harness = createHarness({ defaultDurationMs: 5_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "first" }, { id: "second" }, { id: "third" }], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 500); + const originalDeadline = harness.playback().next_slide_at; + const originalRevision = harness.playback().revision; + const eventCount = harness.stateEvents.length; + + // Move the current stable ID around without replacing it. + harness.database + .prepare("UPDATE playlist_entries SET position = position + 10 WHERE playlist_id = ?") + .run(playlist.id); + for (const [position, id] of [ + [0, "second"], + [1, "third"], + [2, "first"], + ]) { + harness.database + .prepare("UPDATE playlist_entries SET position = ? WHERE id = ?") + .run(position, id); + } + harness.database + .prepare("UPDATE playlists SET revision = revision + 1 WHERE id = ?") + .run(playlist.id); + service.domainChanged({ reason: "entries_reordered", playlistId: playlist.id }); + assert.equal(harness.playback().current_entry_id, "first"); + assert.equal(harness.playback().next_slide_at, originalDeadline); + assert.equal(harness.playback().revision, originalRevision); + assert.equal(harness.stateEvents.length, eventCount + 1); + + // A newly added entry participates after the current slide's stable ID. + const newAsset = harness.seedAsset({ id: "new-live-asset" }); + harness.database + .prepare( + `INSERT INTO playlist_entries ( + id, playlist_id, asset_id, enabled, position, created_at, updated_at + ) VALUES ('new-live', ?, ?, 1, 3, ?, ?)`, + ) + .run(playlist.id, newAsset, harness.clock.now(), harness.clock.now()); + service.domainChanged({ reason: "entry_added", playlistId: playlist.id }); + harness.clock.runTo(originalDeadline); + assert.equal(harness.playback().current_entry_id, "new-live"); + + harness.database + .prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = 'new-live'") + .run(); + service.domainChanged({ reason: "entry_updated", playlistId: playlist.id }); + assert.equal(harness.playback().current_entry_id, "second"); + + harness.database.prepare("DELETE FROM playlist_entries WHERE id = 'second'").run(); + service.domainChanged({ reason: "entry_deleted", playlistId: playlist.id }); + assert.equal(harness.playback().current_entry_id, "third"); + + harness.database + .prepare("UPDATE playlist_entries SET schedule_start_at = ? WHERE id = 'third'") + .run(BASE_TIME + 50_000); + service.domainChanged({ reason: "entry_updated", playlistId: playlist.id }); + assert.equal(harness.playback().current_entry_id, "first"); +}); + +test("inactive playlist edits do not touch playback state or emit playback snapshots", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const active = harness.seedPlaylist({ entries: [{ id: "active" }] }); + const inactive = harness.seedPlaylist({ entries: [{ id: "inactive" }] }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(active.id); + const before = harness.playback(); + const eventCount = harness.stateEvents.length; + + harness.database + .prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = 'inactive'") + .run(); + service.domainChanged({ reason: "entry_updated", playlistId: inactive.id }); + assert.deepEqual(harness.playback(), before); + assert.equal(harness.stateEvents.length, eventCount); +}); + +test("pause preserves remaining time and resume continues it, including an exact-boundary race", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "first" }, { id: "second" }], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + const timerBeforePause = harness.clock.nextTimer(); + + harness.clock.set(BASE_TIME + 250); + service.pause(); + let playback = harness.playback(); + assert.equal(playback.rotation_paused, 1); + assert.equal(playback.current_entry_id, "first"); + assert.equal(playback.paused_remaining_ms, 750); + assert.equal(playback.slide_started_at, null); + assert.equal(playback.next_slide_at, null); + assert.equal(harness.clock.nextTimer(), null); + + const pausedRevision = playback.revision; + harness.clock.invokeStale(timerBeforePause); + assert.equal(harness.playback().revision, pausedRevision); + harness.clock.set(BASE_TIME + 10_000); + service.setMode("off"); + service.resume(); + playback = harness.playback(); + assert.equal(playback.mode, "off"); + assert.equal(playback.rotation_paused, 0); + assert.equal(playback.current_entry_id, "first"); + assert.equal(playback.next_slide_at, BASE_TIME + 10_750); + harness.clock.runTo(BASE_TIME + 10_750); + assert.equal(harness.playback().current_entry_id, "second"); + + // A command arriving at a due instant reconciles the boundary first, then + // applies pause to the newly authoritative slide. + harness.clock.set(BASE_TIME + 11_750); + service.pause(); + playback = harness.playback(); + assert.equal(playback.current_entry_id, "first"); + assert.equal(playback.rotation_paused, 1); + assert.equal(playback.paused_remaining_ms, 1_000); +}); + +test("resume clears static and pause together while preserving the suspended normal remainder", (t) => { + const harness = createHarness({ defaultDurationMs: 2_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ entries: [{ id: "normal" }] }); + const staticAsset = harness.seedAsset({ id: "static" }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 400); + service.enterStatic(staticAsset); + service.pause(); + assert.equal(harness.playback().static_asset_id, staticAsset); + assert.equal(harness.playback().rotation_paused, 1); + assert.equal(harness.playback().paused_remaining_ms, 1_600); + + harness.clock.set(BASE_TIME + 20_000); + service.resume(); + const playback = harness.playback(); + assert.equal(playback.static_asset_id, null); + assert.equal(playback.rotation_paused, 0); + assert.equal(playback.current_entry_id, "normal"); + assert.equal(playback.paused_remaining_ms, null); + assert.equal(playback.next_slide_at, BASE_TIME + 21_600); +}); + +test("next and previous wrap, skip invalid entries, respect pause, static, pending, and mode off", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + name: "Controls", + entries: [ + { id: "a", durationMs: 1_500 }, + { id: "disabled", enabled: false }, + { id: "c", durationMs: 2_500 }, + { id: "future", scheduleStartAt: BASE_TIME + 50_000 }, + ], + }); + const target = harness.seedPlaylist({ + name: "Pending target", + entries: [{ id: "target" }], + }); + const staticAsset = harness.seedAsset({ id: "static-control" }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + + harness.clock.set(BASE_TIME + 100); + service.next(); + assert.equal(harness.playback().current_entry_id, "c"); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 2_600); + service.next(); + assert.equal(harness.playback().current_entry_id, "a"); + service.previous(); + assert.equal(harness.playback().current_entry_id, "c"); + + service.pause(); + service.previous(); + let playback = harness.playback(); + assert.equal(playback.current_entry_id, "a"); + assert.equal(playback.rotation_paused, 1); + assert.equal(playback.paused_remaining_ms, 1_500); + + service.resume(); + service.setMode("off"); + service.next(); + playback = harness.playback(); + assert.equal(playback.mode, "off"); + assert.equal(playback.current_entry_id, "c"); + + service.activatePlaylist(target.id); + assert.equal(harness.playback().pending_playlist_id, target.id); + service.next(); + assert.equal(harness.playback().active_playlist_id, target.id); + assert.equal(harness.playback().pending_playlist_id, null); + assert.equal(harness.playback().current_entry_id, "target"); + + service.enterStatic(staticAsset); + const beforeStaticNext = harness.playback(); + service.next(); + service.previous(); + assert.deepEqual(harness.playback(), beforeStaticNext); +}); + +test("static accepts an arbitrary ready asset, rejects invalid assets, and expires safely", (t) => { + const harness = createHarness({ defaultDurationMs: 2_000 }); + t.after(() => harness.close()); + const normal = harness.seedPlaylist({ entries: [{ id: "normal" }] }); + const unrelated = harness.seedAsset({ + id: "unrelated", + expirationAt: BASE_TIME + 1_000, + }); + const disabledList = harness.seedPlaylist({ + entries: [{ id: "disabled-static-ref", assetId: unrelated, enabled: false }], + }); + assert.ok(disabledList.id); + const failed = harness.seedAsset({ id: "failed", status: "failed" }); + const expired = harness.seedAsset({ + id: "expired-static", + expirationAt: BASE_TIME, + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(normal.id); + harness.clock.set(BASE_TIME + 200); + + expectPlaybackError(() => service.enterStatic("missing"), "ASSET_NOT_FOUND", 404); + expectPlaybackError(() => service.enterStatic(failed), "ASSET_NOT_READY", 409); + expectPlaybackError(() => service.enterStatic(expired), "ASSET_EXPIRED", 409); + service.enterStatic(unrelated); + assert.equal(harness.playback().static_asset_id, unrelated); + assert.equal(harness.playback().paused_remaining_ms, 1_800); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_000); + + harness.clock.runTo(BASE_TIME + 1_000); + const playback = harness.playback(); + assert.equal(playback.static_asset_id, null); + assert.equal(playback.current_entry_id, "normal"); + assert.equal(playback.next_slide_at, BASE_TIME + 2_800); + assert.equal(service.getSnapshot().outputTarget.type, "normal"); +}); + +test("a static asset invalidated by a domain change clears immediately and honors operator pause", (t) => { + const harness = createHarness({ defaultDurationMs: 2_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ entries: [{ id: "normal" }] }); + const staticAsset = harness.seedAsset({ id: "mutable-static" }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 300); + service.enterStatic(staticAsset); + service.pause(); + harness.database + .prepare("UPDATE assets SET status = 'failed' WHERE id = ?") + .run(staticAsset); + service.domainChanged({ + reason: "asset_updated", + assetId: staticAsset, + playlistIds: [], + }); + + const playback = harness.playback(); + assert.equal(playback.static_asset_id, null); + assert.equal(playback.rotation_paused, 1); + assert.equal(playback.current_entry_id, "normal"); + assert.equal(playback.paused_remaining_ms, 1_700); + assert.equal(playback.next_slide_at, null); +}); + +test("a retroactive static expiration edit resumes at mutation time rather than time-travelling", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "retro-normal-a" }, { id: "retro-normal-b" }], + }); + const staticAsset = harness.seedAsset({ id: "retro-static" }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 200); + service.enterStatic(staticAsset); + assert.equal(harness.playback().paused_remaining_ms, 800); + + harness.clock.set(BASE_TIME + 500); + harness.database + .prepare("UPDATE assets SET expiration_at = ?, updated_at = ? WHERE id = ?") + .run(BASE_TIME + 100, harness.clock.now(), staticAsset); + service.domainChanged({ + reason: "asset_updated", + assetId: staticAsset, + playlistIds: [], + }); + + const playback = harness.playback(); + assert.equal(playback.static_asset_id, null); + assert.equal(playback.current_entry_id, "retro-normal-a"); + assert.equal(playback.next_slide_at, BASE_TIME + 1_300); +}); + +test("pending activation never continues the old playlist after static invalidation", (t) => { + const resumed = createHarness({ defaultDurationMs: 1_000 }); + const expired = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => resumed.close()); + t.after(() => expired.close()); + + function prepare(harness, staticExpirationAt = null) { + const oldPlaylist = harness.seedPlaylist({ + entries: [ + { + id: "static-old-current", + asset: { expirationAt: BASE_TIME + 500 }, + }, + { id: "static-old-next" }, + ], + }); + const target = harness.seedPlaylist({ + entries: [{ id: "static-pending-target" }], + }); + const staticAsset = harness.seedAsset({ + id: "pending-static", + expirationAt: staticExpirationAt, + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(oldPlaylist.id); + harness.clock.set(BASE_TIME + 100); + service.activatePlaylist(target.id); + harness.clock.set(BASE_TIME + 200); + service.enterStatic(staticAsset); + return { service, target }; + } + + let prepared = prepare(resumed); + resumed.clock.set(BASE_TIME + 700); + prepared.service.resume(); + assert.equal(resumed.playback().active_playlist_id, prepared.target.id); + assert.equal(resumed.playback().pending_playlist_id, null); + assert.equal(resumed.playback().current_entry_id, "static-pending-target"); + assert.equal(resumed.playback().slide_started_at, BASE_TIME + 700); + assert.equal(resumed.playback().next_slide_at, BASE_TIME + 1_700); + assert.equal(resumed.playback().static_asset_id, null); + assert.equal(resumed.activationEvents.at(-1).reason, "activated"); + + prepared = prepare(expired, BASE_TIME + 700); + expired.clock.runTo(BASE_TIME + 700); + assert.equal(expired.playback().active_playlist_id, prepared.target.id); + assert.equal(expired.playback().pending_playlist_id, null); + assert.equal(expired.playback().current_entry_id, "static-pending-target"); + assert.equal(expired.playback().slide_started_at, BASE_TIME + 700); + assert.equal(expired.playback().next_slide_at, BASE_TIME + 1_700); + assert.equal(expired.playback().static_asset_id, null); + assert.equal(expired.activationEvents.at(-1).reason, "activated"); +}); + +test("mode is a black visual gate while hidden normal transport keeps advancing", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "visible-one" }, { id: "visible-two" }], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 200); + service.setMode("off"); + const deadline = harness.playback().next_slide_at; + assert.equal(deadline, BASE_TIME + 1_000); + assert.deepEqual(service.getSnapshot().outputTarget, { + type: "black", + reason: "mode_off", + }); + + harness.clock.runTo(BASE_TIME + 1_000); + assert.equal(harness.playback().mode, "off"); + assert.equal(harness.playback().current_entry_id, "visible-two"); + const offRevision = harness.playback().revision; + service.setMode("off"); + assert.equal(harness.playback().revision, offRevision); + + service.setMode("on"); + assert.equal(service.getSnapshot().outputTarget.type, "normal"); + assert.equal(harness.playback().current_entry_id, "visible-two"); + expectPlaybackError( + () => service.setMode("dim"), + "INVALID_PLAYBACK_MODE", + 400, + ); +}); + +test("restart clears an expired static hold at the expiration instant and catches normal playback up", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "normal-a" }, { id: "normal-b" }], + }); + const staticAsset = harness.seedAsset({ + id: "restart-static", + expirationAt: BASE_TIME + 500, + }); + let service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 100); + service.enterStatic(staticAsset); + service.stop(); + + harness.clock.set(BASE_TIME + 3_000); + service = harness.makeService({ start: true }); + const playback = harness.playback(); + assert.equal(playback.static_asset_id, null); + assert.equal(playback.current_entry_id, "normal-a"); + assert.equal(playback.slide_started_at, BASE_TIME + 2_400); + assert.equal(playback.next_slide_at, BASE_TIME + 3_400); +}); + +test("paused playback still reconciles eligibility expirations without advancing time", (t) => { + const harness = createHarness({ defaultDurationMs: 5_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { + id: "expires-paused", + asset: { expirationAt: BASE_TIME + 1_000 }, + }, + { id: "held-fallback", durationMs: 3_000 }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + harness.clock.set(BASE_TIME + 100); + service.pause(); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_000); + + harness.clock.runTo(BASE_TIME + 1_000); + const playback = harness.playback(); + assert.equal(playback.rotation_paused, 1); + assert.equal(playback.current_entry_id, "held-fallback"); + assert.equal(playback.paused_remaining_ms, 3_000); + assert.equal(playback.next_slide_at, null); +}); + +test("reload and domain refresh publish fresh snapshots without meaningless playback revision churn", (t) => { + const harness = createHarness({ defaultDurationMs: 2_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "current" }, { id: "replacement" }], + }); + const unrelated = harness.seedAsset({ id: "unrelated-refresh" }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + const revision = harness.playback().revision; + const eventsAfterActivation = harness.stateEvents.length; + + service.getSnapshot(); + service.getSnapshot(); + service.setMode("on"); + assert.equal(harness.playback().revision, revision); + assert.equal(harness.stateEvents.length, eventsAfterActivation); + + service.reload(); + assert.equal(harness.playback().revision, revision); + assert.equal(harness.stateEvents.length, eventsAfterActivation + 1); + + harness.database + .prepare( + `UPDATE app_settings + SET revision = revision + 1, default_blur_px = 27 + WHERE id = 1`, + ) + .run(); + service.domainChanged({ reason: "settings_updated", settingsRevision: 2 }); + assert.equal(harness.playback().revision, revision); + assert.equal(harness.stateEvents.at(-1).settingsRevision, 2); + + const eventCount = harness.stateEvents.length; + harness.database + .prepare("UPDATE assets SET display_name = 'Unrelated renamed' WHERE id = ?") + .run(unrelated); + service.domainChanged({ + reason: "asset_updated", + assetId: unrelated, + playlistIds: [], + }); + assert.equal(harness.stateEvents.length, eventCount); + assert.equal(harness.playback().revision, revision); + + // Reload is also a reconciliation command, not merely a broadcast command. + const currentAssetId = harness.database + .prepare("SELECT asset_id FROM playlist_entries WHERE id = 'current'") + .get().asset_id; + harness.database + .prepare("UPDATE assets SET status = 'failed' WHERE id = ?") + .run(currentAssetId); + service.reload(); + assert.equal(harness.playback().current_entry_id, "replacement"); + assert.equal(harness.playback().revision, revision + 1); +}); + +test("authoritative snapshot contains cache metadata and presentation without private storage details", (t) => { + const harness = createHarness({ defaultDurationMs: 7_000 }); + t.after(() => harness.close()); + harness.database + .prepare( + `UPDATE app_settings + SET revision = 8, + default_background_mode = 'solid', + default_solid_background_color = '#123456', + default_background_darkened = 1, + default_blur_px = 22, + crossfade_duration_ms = 444 + WHERE id = 1`, + ) + .run(); + const playlist = harness.seedPlaylist({ + id: "snapshot-playlist", + name: "Sunday", + entries: [ + { + id: "snapshot-current", + durationMs: 2_500, + backgroundMode: "blurred", + backgroundColor: "#ABCDEF", + backgroundDarkened: false, + }, + { + id: "snapshot-future", + scheduleStartAt: BASE_TIME + 50_000, + }, + { id: "snapshot-disabled", enabled: false }, + { id: "snapshot-ended", scheduleEndAt: BASE_TIME }, + { + id: "snapshot-expired", + asset: { expirationAt: BASE_TIME }, + }, + { + id: "snapshot-processing", + asset: { status: "processing" }, + }, + ], + }); + const staticAsset = harness.seedAsset({ id: "snapshot-static" }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + const snapshot = service.getSnapshot(); + + assert.equal(snapshot.protocolVersion, 1); + assert.equal(snapshot.outputId, "global"); + assert.equal(snapshot.serverTime, BASE_TIME); + assert.equal(snapshot.playbackRevision, harness.playback().revision); + assert.equal(snapshot.mode, "on"); + assert.equal(snapshot.transportState, "playing"); + assert.deepEqual(snapshot.activePlaylist, { + id: playlist.id, + name: "Sunday", + revision: 1, + }); + assert.equal(snapshot.settingsRevision, 8); + assert.equal(snapshot.crossfadeDurationMs, 444); + assert.deepEqual(snapshot.timeline, { + slideStartedAt: BASE_TIME, + nextSlideAt: BASE_TIME + 2_500, + pausedRemainingMs: null, + remainingMs: 2_500, + }); + assert.deepEqual( + snapshot.manifest.entries.map((entry) => entry.id), + ["snapshot-current", "snapshot-future"], + ); + const current = snapshot.manifest.entries[0]; + assert.equal(current.effectiveDurationMs, 2_500); + assert.deepEqual(current.effectivePresentation, { + backgroundMode: "blurred", + backgroundColor: "#ABCDEF", + backgroundDarkened: false, + blurPx: 22, + backgroundDarkeningOpacity: 0, + }); + assert.equal(current.asset.media.mimeType, "image/webp"); + assert.equal(current.asset.media.byteSize, 12_345); + assert.equal(current.asset.media.width, 1920); + assert.equal(current.asset.media.height, 1080); + assert.match( + current.asset.media.url, + /^\/media\/assets\/[^/]+\/display\/[0-9a-f]{64}\.webp$/, + ); + assert.equal(current.asset.media.sha256.length, 64); + assert.equal(snapshot.outputTarget.type, "normal"); + assert.deepEqual(snapshot.currentTarget, snapshot.outputTarget); + + const serialized = JSON.stringify(snapshot); + assert.doesNotMatch(serialized, /private\/display/); + assert.doesNotMatch(serialized, /object_key|objectKey|bucket|endpoint/i); + + service.enterStatic(staticAsset); + const held = service.getSnapshot(); + assert.equal(held.transportState, "static"); + assert.equal(held.staticTarget.assetId, staticAsset); + assert.deepEqual(held.staticTarget.effectivePresentation, { + backgroundMode: "solid", + backgroundColor: "#123456", + backgroundDarkened: true, + blurPx: 22, + backgroundDarkeningOpacity: 0.25, + }); +}); + +test("schedule-only manifest changes broadcast at the boundary without bumping playback revision", (t) => { + const harness = createHarness({ defaultDurationMs: 5_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [ + { id: "long-current" }, + { id: "cache-future", scheduleStartAt: BASE_TIME + 1_000 }, + ], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + const revision = harness.playback().revision; + const eventCount = harness.stateEvents.length; + assert.equal( + service + .getSnapshot() + .manifest.entries.find((entry) => entry.id === "cache-future").playable, + false, + ); + + harness.clock.runTo(BASE_TIME + 1_000); + assert.equal(harness.playback().current_entry_id, "long-current"); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 5_000); + assert.equal(harness.playback().revision, revision); + assert.equal(harness.stateEvents.length, eventCount + 1); + assert.equal( + harness.stateEvents + .at(-1) + .manifest.entries.find((entry) => entry.id === "cache-future").playable, + true, + ); +}); + +test("long timer delays are safely chained and cleared callbacks cannot mutate state", (t) => { + const harness = createHarness(); + t.after(() => harness.close()); + const startAt = BASE_TIME + MAX_TIMEOUT_MS + 5_000; + const playlist = harness.seedPlaylist({ + entries: [{ id: "far-future", scheduleStartAt: startAt }], + }); + const service = harness.makeService({ start: true }); + service.activatePlaylist(playlist.id); + const firstTimer = harness.clock.nextTimer(); + assert.equal(firstTimer.delay, MAX_TIMEOUT_MS); + + harness.clock.runTo(BASE_TIME + MAX_TIMEOUT_MS); + assert.equal(harness.playback().current_entry_id, null); + assert.equal(harness.clock.nextTimer().delay, 5_000); + harness.clock.runTo(startAt); + assert.equal(harness.playback().current_entry_id, "far-future"); + assert.equal(harness.playback().slide_started_at, startAt); + + const advancingTimer = harness.clock.nextTimer(); + harness.clock.set(startAt + 100); + service.pause(); + const beforeStale = harness.playback(); + harness.clock.invokeStale(advancingTimer); + assert.deepEqual(harness.playback(), beforeStale); +}); + +test("a timer rearm failure preserves the committed state and schedules recovery", (t) => { + const harness = createHarness({ defaultDurationMs: 1_000 }); + t.after(() => harness.close()); + const playlist = harness.seedPlaylist({ + entries: [{ id: "rearm-a" }, { id: "rearm-b" }], + }); + let failNextArm = false; + const service = harness.makeService({ + start: true, + logger: { warn() {} }, + setTimeoutFn(callback, delay) { + if (failNextArm) { + failNextArm = false; + throw new Error("injected timer arm failure"); + } + return harness.clock.setTimeout(callback, delay); + }, + }); + service.activatePlaylist(playlist.id); + failNextArm = true; + + harness.clock.runTo(BASE_TIME + 1_000); + assert.equal(harness.playback().current_entry_id, "rearm-b"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000); + assert.equal(harness.stateEvents.at(-1).currentEntryId, "rearm-b"); + assert.equal(harness.clock.nextTimer().at, BASE_TIME + 2_000); + + harness.clock.runTo(BASE_TIME + 2_000); + assert.equal(harness.playback().current_entry_id, "rearm-a"); + assert.equal(harness.playback().slide_started_at, BASE_TIME + 2_000); + assert.equal(harness.playback().next_slide_at, BASE_TIME + 3_000); +}); diff --git a/server/test/playlists-service.test.js b/server/test/playlists-service.test.js index 7ef0253..f765cfd 100644 --- a/server/test/playlists-service.test.js +++ b/server/test/playlists-service.test.js @@ -4,6 +4,7 @@ const crypto = require("node:crypto"); const { openDatabase } = require("../src/database"); const { runMigrations } = require("../src/database/migrate"); const { createAssetService } = require("../src/assets/service"); +const { createPlaybackService } = require("../src/playback"); const { MAX_DURATION_MS, MIN_DURATION_MS, @@ -19,13 +20,22 @@ function createHarness() { let idCounter = 0; let assetCounter = 0; const changes = []; + let playbackService; const service = createPlaylistService({ database, now: () => clock, createId: () => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`, - onChanged: (change) => changes.push(change), + onBeforeChange: () => { + playbackService?.reconcile("before_domain_change"); + }, + onChanged: (change) => { + changes.push(change); + playbackService?.domainChanged(change); + }, }); + playbackService = createPlaybackService({ database, now: () => clock }); + playbackService.initialize(); function setNow(value) { clock = value; @@ -71,7 +81,15 @@ function createHarness() { .get(); } - return { changes, database, playback, seedAsset, service, setNow }; + return { + changes, + database, + playback, + playbackService, + seedAsset, + service, + setNow, + }; } function expectError(action, code, statusCode) { @@ -479,7 +497,7 @@ test("activation queues at a live boundary, is idempotent, and mode off is only durationOverrideMs: 4000, }); - const initial = harness.service.activate(oldPlaylist.id); + const initial = harness.playbackService.activatePlaylist(oldPlaylist.id); assert.equal(initial.activation, "active"); assert.equal(initial.playback.currentEntryId, oldEntry.id); assert.equal(initial.playback.slideStartedAt, BASE_TIME); @@ -489,7 +507,7 @@ test("activation queues at a live boundary, is idempotent, and mode off is only .prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'") .run(); harness.setNow(BASE_TIME + 1000); - const pending = harness.service.activate(newPlaylist.id); + const pending = harness.playbackService.activatePlaylist(newPlaylist.id); assert.equal(pending.activation, "pending"); assert.equal(pending.playback.mode, "off"); assert.equal(pending.playback.activePlaylistId, oldPlaylist.id); @@ -498,18 +516,18 @@ test("activation queues at a live boundary, is idempotent, and mode off is only const pendingRevision = pending.playback.revision; const requestedAt = pending.playback.pendingPlaylistRequestedAt; - const repeated = harness.service.activate(newPlaylist.id); + const repeated = harness.playbackService.activatePlaylist(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); + const cancel = harness.playbackService.activatePlaylist(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); + const immediate = harness.playbackService.activatePlaylist(newPlaylist.id); assert.equal(immediate.activation, "active"); assert.equal(immediate.playback.activePlaylistId, newPlaylist.id); assert.equal(immediate.playback.pendingPlaylistId, null); @@ -534,7 +552,7 @@ test("paused normal playback queues and can replace a pending activation", (t) = harness.service.addEntry(secondTarget.id, { assetId: harness.seedAsset({ id: "second-target", name: "Second target" }), }); - harness.service.activate(current.id); + harness.playbackService.activatePlaylist(current.id); harness.database .prepare( `UPDATE playback_state @@ -545,14 +563,14 @@ test("paused normal playback queues and can replace a pending activation", (t) = .run(); harness.setNow(BASE_TIME + 1000); - const first = harness.service.activate(firstTarget.id); + const first = harness.playbackService.activatePlaylist(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); + const replacement = harness.playbackService.activatePlaylist(secondTarget.id); assert.equal(replacement.activation, "pending"); assert.equal(replacement.playback.activePlaylistId, current.id); assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id); @@ -579,7 +597,7 @@ test("static and paused activation prepare the first playable normal target with durationOverrideMs: 9000, }); assert.equal(skipped.position, 0); - harness.service.activate(oldPlaylist.id); + harness.playbackService.activatePlaylist(oldPlaylist.id); harness.database .prepare( `UPDATE playback_state @@ -590,7 +608,7 @@ test("static and paused activation prepare the first playable normal target with ) .run(staticAssetId); - const result = harness.service.activate(target.id); + const result = harness.playbackService.activatePlaylist(target.id); assert.equal(result.activation, "active"); assert.equal(result.playback.activePlaylistId, target.id); assert.equal(result.playback.staticAssetId, staticAssetId); @@ -611,7 +629,7 @@ test("empty activation is immediate and current-entry deletion coordinates playb assetId: harness.seedAsset({ id: `asset-${id}`, name: id }), }), ); - harness.service.activate(populated.id); + harness.playbackService.activatePlaylist(populated.id); const futurePlaybackTimestamp = BASE_TIME + 100_000; harness.database .prepare( @@ -624,10 +642,10 @@ test("empty activation is immediate and current-entry deletion coordinates playb harness.service.removeEntry(populated.id, entries[0].id); const afterDelete = harness.playback(); - assert.equal(afterDelete.current_entry_id, null); + assert.equal(afterDelete.current_entry_id, entries[1].id); assert.equal(afterDelete.slide_started_at, null); assert.equal(afterDelete.next_slide_at, null); - assert.equal(afterDelete.paused_remaining_ms, null); + assert.equal(afterDelete.paused_remaining_ms, 7000); assert.equal(afterDelete.active_playlist_id, populated.id); assert.equal(afterDelete.mode, "off"); assert.equal(afterDelete.static_asset_id, "asset-second"); @@ -646,7 +664,7 @@ test("empty activation is immediate and current-entry deletion coordinates playb WHERE output_id = 'global'`, ) .run(); - const result = harness.service.activate(empty.id); + const result = harness.playbackService.activatePlaylist(empty.id); assert.equal(result.activation, "active"); assert.equal(result.playback.activePlaylistId, empty.id); assert.equal(result.playback.currentEntryId, null); @@ -665,7 +683,7 @@ test("inactive playlist edits do not mutate authoritative playback state", (t) = const draftEntry = harness.service.addEntry(inactive.id, { assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }), }); - harness.service.activate(active.id); + harness.playbackService.activatePlaylist(active.id); const before = harness.playback(); harness.service.update(inactive.id, { name: "Draft edited" }); diff --git a/server/test/runtime.test.js b/server/test/runtime.test.js index 934d34a..13170af 100644 --- a/server/test/runtime.test.js +++ b/server/test/runtime.test.js @@ -1,9 +1,10 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const net = require("node:net"); const { loadConfig } = require("../src/config"); const { createRuntime } = require("../src/runtime"); -function testConfig() { +function testConfig(overrides = {}) { return loadConfig({ NODE_ENV: "test", HOST: "127.0.0.1", @@ -20,6 +21,7 @@ function testConfig() { MQTT_URL: "mqtt://broker:1883", MQTT_PASSWORD: "mqtt-password-that-must-not-leak", HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/private-id", + ...overrides, }); } @@ -171,3 +173,28 @@ test("runtime startup and shutdown are idempotent", async () => { assert.equal(runtime.lifecycle.ready, false); assert.equal(runtime.database.open, false); }); + +test("a listen failure stops playback and closes startup resources", async (t) => { + const occupied = net.createServer(); + occupied.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + occupied.once("listening", resolve); + occupied.once("error", reject); + }); + t.after( + () => + new Promise((resolve) => { + occupied.close(resolve); + }), + ); + + const runtime = createRuntime({ + config: testConfig({ PORT: String(occupied.address().port) }), + logger: silentLogger, + storage: testStorage(), + }); + + await assert.rejects(runtime.start(), (error) => error.code === "EADDRINUSE"); + assert.equal(runtime.lifecycle.ready, false); + assert.equal(runtime.database.open, false); +}); diff --git a/server/test/socket-invalidation.test.js b/server/test/socket-invalidation.test.js index 7137389..c43d4d8 100644 --- a/server/test/socket-invalidation.test.js +++ b/server/test/socket-invalidation.test.js @@ -97,6 +97,7 @@ async function connectPollingSocket(baseUrl) { ); return { + initialEvents: socketEvents(connectedPayload), async poll() { const response = await fetch(endpoint(sid), { signal: AbortSignal.timeout(3_000), @@ -115,6 +116,65 @@ async function connectPollingSocket(baseUrl) { }; } +test("Socket.IO sends an authoritative playback snapshot on connect and after 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(() => {})); + + let initial = socket.initialEvents.find( + ([eventName]) => eventName === "playback:state", + ); + if (!initial) { + initial = socketEvents(await socket.poll()).find( + ([eventName]) => eventName === "playback:state", + ); + } + assert.ok(initial, "a full playback snapshot must be sent on connection"); + assert.equal(initial[1].protocolVersion, 1); + assert.equal(initial[1].outputId, "global"); + const initialRevision = initial[1].playbackRevision; + + const read = await jsonRequest(baseUrl, "/api/playback"); + assert.equal(read.response.status, 200); + assert.equal(read.body.playbackRevision, initialRevision); + const reread = await jsonRequest(baseUrl, "/api/playback"); + assert.equal(reread.body.playbackRevision, initialRevision); + + const changed = await jsonRequest(baseUrl, "/api/playback/mode", { + method: "PUT", + body: { mode: "off" }, + }); + assert.equal(changed.response.status, 200); + assert.equal(changed.body.mode, "off"); + assert.equal(changed.body.playbackRevision, initialRevision + 1); + + const events = socketEvents(await socket.poll()); + const playbackEvents = events.filter( + ([eventName]) => eventName === "playback:state", + ); + assert.equal(playbackEvents.length, 1); + assert.deepEqual(playbackEvents[0][1], changed.body); + + const unchanged = await jsonRequest(baseUrl, "/api/playback/mode", { + method: "PUT", + body: { mode: "off" }, + }); + assert.equal(unchanged.body.playbackRevision, changed.body.playbackRevision); + assert.equal( + runtime.database + .prepare("SELECT revision FROM playback_state WHERE output_id = 'global'") + .get().revision, + changed.body.playbackRevision, + ); +}); + test("Socket.IO publishes playlists:changed only after successful semantic mutations", async (t) => { const runtime = createRuntime({ config: testConfig(), @@ -144,10 +204,12 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat }); 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); + let playlistEvents = events.filter( + ([eventName]) => eventName === "playlists:changed", + ); + assert.equal(playlistEvents.length, 1); + assert.equal(playlistEvents[0][1].reason, "created"); + assert.equal(playlistEvents[0][1].playlistId, created.body.id); const duplicate = await jsonRequest(baseUrl, "/api/playlists", { method: "POST", @@ -163,7 +225,10 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat body: { defaultDurationMs: 8_000 }, }); events = socketEvents(await socket.poll()); - assert.deepEqual(events, [ + playlistEvents = events.filter( + ([eventName]) => eventName === "playlists:changed", + ); + assert.deepEqual(playlistEvents, [ [ "playlists:changed", { @@ -180,8 +245,11 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat }); assert.equal(second.response.status, 201); events = socketEvents(await socket.poll()); - assert.equal(events.length, 1); - assert.equal(events[0][1].reason, "created"); + playlistEvents = events.filter( + ([eventName]) => eventName === "playlists:changed", + ); + assert.equal(playlistEvents.length, 1); + assert.equal(playlistEvents[0][1].reason, "created"); const activated = await jsonRequest( baseUrl, @@ -191,14 +259,20 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat 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); + playlistEvents = events.filter( + ([eventName]) => eventName === "playlists:changed", + ); + assert.equal(playlistEvents.length, 1); + assert.equal(playlistEvents[0][1].reason, "activated"); + assert.equal(playlistEvents[0][1].playlistId, created.body.id); assert.equal( - events[0][1].playbackRevision, + playlistEvents[0][1].playbackRevision, activated.body.playback.revision, ); + assert.equal( + events.filter(([eventName]) => eventName === "playback:state").length, + 1, + ); const activeDelete = await jsonRequest( baseUrl, @@ -208,3 +282,102 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat assert.equal(activeDelete.response.status, 409); assert.equal(activeDelete.body.error.code, "PLAYLIST_ACTIVE"); }); + +test("activation invalidations come from authoritative pending-state transitions", async (t) => { + const runtime = createRuntime({ + config: testConfig(), + logger: silentLogger, + storage: testStorage(), + }); + await runtime.start(); + t.after(() => runtime.stop("test cleanup")); + const timestamp = Date.now(); + const insertAsset = runtime.database.prepare(` + INSERT INTO assets ( + id, source_sha256, display_name, original_filename, status, + display_sha256, display_content_type, display_byte_size, + display_width, display_height, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'ready', ?, 'image/webp', 1, 1920, 1080, ?, ?) + `); + const insertPlaylist = runtime.database.prepare(` + INSERT INTO playlists (id, name, created_at, updated_at) + VALUES (?, ?, ?, ?) + `); + const insertEntry = runtime.database.prepare(` + INSERT INTO playlist_entries ( + id, playlist_id, asset_id, position, created_at, updated_at + ) VALUES (?, ?, ?, 0, ?, ?) + `); + insertAsset.run( + "asset-old", + "a".repeat(64), + "Old asset", + "old.png", + "c".repeat(64), + timestamp, + timestamp, + ); + insertAsset.run( + "asset-new", + "b".repeat(64), + "New asset", + "new.png", + "d".repeat(64), + timestamp, + timestamp, + ); + insertPlaylist.run("playlist-old", "Old", timestamp, timestamp); + insertPlaylist.run("playlist-new", "New", timestamp, timestamp); + insertEntry.run( + "entry-old", + "playlist-old", + "asset-old", + timestamp, + timestamp, + ); + insertEntry.run( + "entry-new", + "playlist-new", + "asset-new", + timestamp, + timestamp, + ); + + const baseUrl = `http://127.0.0.1:${runtime.address.port}`; + const socket = await connectPollingSocket(baseUrl); + t.after(() => socket.close().catch(() => {})); + + const activateOld = await jsonRequest( + baseUrl, + "/api/playlists/playlist-old/activate", + { method: "POST" }, + ); + assert.equal(activateOld.body.activation, "active"); + await socket.poll(); + + const requestNew = await jsonRequest( + baseUrl, + "/api/playlists/playlist-new/activate", + { method: "POST" }, + ); + assert.equal(requestNew.body.activation, "pending"); + let invalidations = socketEvents(await socket.poll()).filter( + ([eventName]) => eventName === "playlists:changed", + ); + assert.equal(invalidations.length, 1); + assert.equal(invalidations[0][1].reason, "activation_requested"); + assert.equal(invalidations[0][1].pendingPlaylistId, "playlist-new"); + + const cancel = await jsonRequest( + baseUrl, + "/api/playlists/playlist-old/activate", + { method: "POST" }, + ); + assert.equal(cancel.body.activation, "already_active"); + invalidations = socketEvents(await socket.poll()).filter( + ([eventName]) => eventName === "playlists:changed", + ); + assert.equal(invalidations.length, 1); + assert.equal(invalidations[0][1].reason, "activation_canceled"); + assert.equal(invalidations[0][1].pendingPlaylistId, null); +});