Implement Phase 3A playlist management

This commit is contained in:
Media
2026-08-16 20:34:36 -05:00
parent 722c6cf77d
commit e2ecdc97e2
15 changed files with 2955 additions and 7 deletions
+42
View File
@@ -79,6 +79,48 @@ display and thumbnail routes stream private S3 objects through the API with a
one-year immutable cache policy. Original downloads use private, no-store one-year immutable cache policy. Original downloads use private, no-store
caching. caching.
## Playlists
Playlist and entry metadata is authoritative in SQLite. The API surface is:
- `GET /api/playlists`
- `POST /api/playlists`
- `GET /api/playlists/:playlistId`
- `PATCH /api/playlists/:playlistId`
- `DELETE /api/playlists/:playlistId`
- `POST /api/playlists/:playlistId/entries`
- `PATCH /api/playlists/:playlistId/entries/:entryId`
- `DELETE /api/playlists/:playlistId/entries/:entryId`
- `PUT /api/playlists/:playlistId/entries/order`
- `POST /api/playlists/:playlistId/activate`
Entry positions are zero-based and contiguous. Reordering requires the complete
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
playlist, but the same asset can be used independently by any number of other
playlists. Only ready, unexpired assets can be newly added.
Entry duration overrides may be null (inherit the global setting) or 1,000 to
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
inherit/off/on boolean and blur always comes from the global setting. Schedule
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
UTC offset, and intervals are evaluated as `[start, end)`.
Playlist detail responses include effective duration and presentation,
server-evaluated eligibility, immutable display/thumbnail URLs, and the
settings revision and evaluation time used. Playlist revisions increment once
per semantic content change. Exact no-ops and reads do not increment them.
Activation is explicit. A request is held in the existing pending-activation
state while a playable normal slide is mid-duration; otherwise the target
playlist is prepared immediately at its first currently playable entry. The
output-off flag remains only a visual gate, and static hold is preserved while
the hidden normal playlist changes. Phase 3A does not include the advancing
playback timer that will consume pending activation at a slide boundary.
Socket.IO publishes `playlists:changed` as a refetch invalidation after
committed playlist changes and after settings or referenced-asset edits that
affect resolved playlist data. Socket.IO is not a mutation API.
## Storage and health ## Storage and health
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`, The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
+43 -2
View File
@@ -6,24 +6,55 @@ const {
const { createAssetRouter } = require("./assets/routes"); const { createAssetRouter } = require("./assets/routes");
const { createMediaRouter } = require("./assets/media-routes"); const { createMediaRouter } = require("./assets/media-routes");
const { createAssetService } = require("./assets/service"); const { createAssetService } = require("./assets/service");
const {
createPlaylistRouter,
createPlaylistService,
} = require("./playlists");
const { createSettingsRouter } = require("./settings/routes"); const { createSettingsRouter } = require("./settings/routes");
const { createSettingsService } = require("./settings/service"); const { createSettingsService } = require("./settings/service");
function safeNotifier(notifier, logger) {
const warn = (error) => {
try {
logger?.warn?.("Playlist invalidation notification failed", error);
} catch {
// A best-effort notification or warning must not undo a database commit.
}
};
return (event) => {
try {
const result = notifier(event);
result?.catch?.(warn);
} catch (error) {
warn(error);
}
};
}
function createApp({ function createApp({
config, config,
database, database,
imageProcessor, imageProcessor,
lifecycle, lifecycle,
logger = console,
migrations, migrations,
now = Date.now,
onPlaylistsChanged = () => {},
storage, storage,
}) { }) {
const app = express(); const app = express();
const notifyPlaylistsChanged = safeNotifier(onPlaylistsChanged, logger);
const settingsService = createSettingsService(database); const settingsService = createSettingsService(database);
const assetService = createAssetService({ const assetService = createAssetService({
database, database,
imageProcessor, imageProcessor,
storage, storage,
}); });
const playlistService = createPlaylistService({
database,
now,
onChanged: notifyPlaylistsChanged,
});
app.disable("x-powered-by"); app.disable("x-powered-by");
app.set("trust proxy", config.trustProxy); app.set("trust proxy", config.trustProxy);
@@ -51,11 +82,21 @@ function createApp({
"/api/status", "/api/status",
createDetailedHealthRouter(detailedHealthRouterOptions), createDetailedHealthRouter(detailedHealthRouterOptions),
); );
app.use("/api/settings", createSettingsRouter(settingsService)); app.use(
"/api/settings",
createSettingsRouter(settingsService, {
onPlaylistsChanged: notifyPlaylistsChanged,
}),
);
app.use( app.use(
"/api/assets", "/api/assets",
createAssetRouter({ assetService, processingConfig: config.processing }), createAssetRouter({
assetService,
onPlaylistsChanged: notifyPlaylistsChanged,
processingConfig: config.processing,
}),
); );
app.use("/api/playlists", createPlaylistRouter(playlistService));
app.use("/media", createMediaRouter(assetService)); app.use("/media", createMediaRouter(assetService));
app.use((request, response) => { app.use((request, response) => {
+22 -2
View File
@@ -6,7 +6,11 @@ const {
} = require("./multipart"); } = require("./multipart");
const { sendOriginal } = require("./streaming"); const { sendOriginal } = require("./streaming");
function createAssetRouter({ assetService, processingConfig }) { function createAssetRouter({
assetService,
onPlaylistsChanged = () => {},
processingConfig,
}) {
const router = express.Router(); const router = express.Router();
const receiveUpload = createMultipartUpload(processingConfig); const receiveUpload = createMultipartUpload(processingConfig);
@@ -80,7 +84,23 @@ function createAssetRouter({ assetService, processingConfig }) {
router.patch("/:assetId", (request, response, next) => { router.patch("/:assetId", (request, response, next) => {
try { try {
response.json(assetService.update(request.params.assetId, request.body)); const previous = assetService.get(request.params.assetId);
const playlistIds = [
...new Set(
assetService
.references(request.params.assetId)
.playlists.map((reference) => reference.playlistId),
),
];
const updated = assetService.update(request.params.assetId, request.body);
if (updated.updatedAt !== previous.updatedAt && playlistIds.length > 0) {
onPlaylistsChanged({
reason: "asset_updated",
playlistIds,
assetId: updated.id,
});
}
response.json(updated);
} catch (error) { } catch (error) {
next(error); next(error);
} }
+106
View File
@@ -0,0 +1,106 @@
const ELIGIBILITY_REASONS = Object.freeze({
DISABLED: "disabled",
SCHEDULED_FUTURE: "scheduled_future",
SCHEDULED_ENDED: "scheduled_ended",
ASSET_NOT_READY: "asset_not_ready",
ASSET_EXPIRED: "asset_expired",
});
function read(object, snakeCaseName, camelCaseName) {
if (object && Object.hasOwn(object, snakeCaseName)) {
return object[snakeCaseName];
}
return object?.[camelCaseName];
}
function validateNow(nowMs) {
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
throw new TypeError("nowMs must be a non-negative, safe epoch-millisecond integer");
}
}
function evaluateEntryEligibility(entry, asset, nowMs) {
validateNow(nowMs);
const enabled = read(entry, "enabled", "enabled");
if (enabled !== true && enabled !== 1) {
return { playable: false, reason: ELIGIBILITY_REASONS.DISABLED };
}
const scheduleStartAt = read(entry, "schedule_start_at", "scheduleStartAt");
if (scheduleStartAt != null && nowMs < scheduleStartAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE };
}
const scheduleEndAt = read(entry, "schedule_end_at", "scheduleEndAt");
if (scheduleEndAt != null && nowMs >= scheduleEndAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED };
}
if (read(asset, "status", "status") !== "ready") {
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY };
}
const expirationAt = read(asset, "expiration_at", "expirationAt");
if (expirationAt != null && nowMs >= expirationAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED };
}
return { playable: true, reason: null };
}
function resolveEntrySettings(entry, appSettings) {
const durationOverrideMs = read(
entry,
"duration_override_ms",
"durationOverrideMs",
);
const backgroundModeOverride = read(
entry,
"background_mode_override",
"backgroundModeOverride",
);
const backgroundColorOverride = read(
entry,
"background_color_override",
"backgroundColorOverride",
);
const backgroundDarkenedOverride = read(
entry,
"background_darkened_override",
"backgroundDarkenedOverride",
);
return {
durationMs:
durationOverrideMs ??
read(appSettings, "default_duration_ms", "defaultDurationMs"),
presentation: {
backgroundMode:
backgroundModeOverride ??
read(appSettings, "default_background_mode", "defaultBackgroundMode"),
backgroundColor:
backgroundColorOverride ??
read(
appSettings,
"default_solid_background_color",
"defaultSolidBackgroundColor",
),
backgroundDarkened: Boolean(
backgroundDarkenedOverride ??
read(
appSettings,
"default_background_darkened",
"defaultBackgroundDarkened",
),
),
blurPx: read(appSettings, "default_blur_px", "defaultBlurPx"),
},
};
}
module.exports = {
ELIGIBILITY_REASONS,
evaluateEntryEligibility,
resolveEntrySettings,
};
+1
View File
@@ -0,0 +1 @@
module.exports = require("./eligibility");
+31
View File
@@ -0,0 +1,31 @@
class PlaylistError extends Error {
constructor(code, message, { statusCode = 400, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "PlaylistError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function playlistNotFound(playlistId) {
return new PlaylistError("PLAYLIST_NOT_FOUND", "Playlist not found", {
statusCode: 404,
details: { playlistId },
});
}
function playlistEntryNotFound(playlistId, entryId) {
return new PlaylistError("PLAYLIST_ENTRY_NOT_FOUND", "Playlist entry not found", {
statusCode: 404,
details: { playlistId, entryId },
});
}
module.exports = {
PlaylistError,
playlistEntryNotFound,
playlistNotFound,
};
+7
View File
@@ -0,0 +1,7 @@
const { createPlaylistRouter } = require("./routes");
const { createPlaylistService } = require("./service");
module.exports = {
createPlaylistRouter,
createPlaylistService,
};
+136
View File
@@ -0,0 +1,136 @@
const express = require("express");
const { PlaylistError } = require("./errors");
function orderedEntryIds(body) {
if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must be an object containing entryIds",
);
}
const fields = Object.keys(body);
if (fields.length !== 1 || fields[0] !== "entryIds") {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must contain only entryIds",
{
details: {
fields,
requiredFields: ["entryIds"],
},
},
);
}
return body.entryIds;
}
function createPlaylistRouter(playlistService) {
const router = express.Router();
router.get("/", (request, response, next) => {
try {
response.json({ playlists: playlistService.list() });
} catch (error) {
next(error);
}
});
router.post("/", (request, response, next) => {
try {
response.status(201).json(playlistService.create(request.body));
} catch (error) {
next(error);
}
});
router.get("/:playlistId", (request, response, next) => {
try {
response.json(playlistService.get(request.params.playlistId));
} catch (error) {
next(error);
}
});
router.patch("/:playlistId", (request, response, next) => {
try {
response.json(
playlistService.update(request.params.playlistId, request.body),
);
} catch (error) {
next(error);
}
});
router.delete("/:playlistId", (request, response, next) => {
try {
response.json(playlistService.remove(request.params.playlistId));
} catch (error) {
next(error);
}
});
router.post("/:playlistId/entries", (request, response, next) => {
try {
response
.status(201)
.json(playlistService.addEntry(request.params.playlistId, request.body));
} catch (error) {
next(error);
}
});
router.patch("/:playlistId/entries/:entryId", (request, response, next) => {
try {
response.json(
playlistService.updateEntry(
request.params.playlistId,
request.params.entryId,
request.body,
),
);
} catch (error) {
next(error);
}
});
router.delete("/:playlistId/entries/:entryId", (request, response, next) => {
try {
response.json(
playlistService.removeEntry(
request.params.playlistId,
request.params.entryId,
),
);
} catch (error) {
next(error);
}
});
router.put("/:playlistId/entries/order", (request, response, next) => {
try {
response.json(
playlistService.reorderEntries(
request.params.playlistId,
orderedEntryIds(request.body),
),
);
} catch (error) {
next(error);
}
});
router.post("/:playlistId/activate", (request, response, next) => {
try {
response.json(playlistService.activate(request.params.playlistId));
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createPlaylistRouter,
orderedEntryIds,
};
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -53,13 +53,18 @@ function createRuntime({
throw error; throw error;
} }
let app; let app;
let io = null;
try { try {
app = createApp({ app = createApp({
config, config,
database, database,
imageProcessor, imageProcessor,
lifecycle, lifecycle,
logger,
migrations, migrations,
onPlaylistsChanged(event) {
io?.emit("playlists:changed", event);
},
storage, storage,
}); });
} catch (error) { } catch (error) {
@@ -68,7 +73,7 @@ function createRuntime({
throw error; throw error;
} }
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
const io = new SocketServer(httpServer, { io = new SocketServer(httpServer, {
serveClient: false, serveClient: false,
}); });
+14 -2
View File
@@ -1,6 +1,9 @@
const express = require("express"); const express = require("express");
function createSettingsRouter(settingsService) { function createSettingsRouter(
settingsService,
{ onPlaylistsChanged = () => {} } = {},
) {
const router = express.Router(); const router = express.Router();
router.get("/", (request, response) => { router.get("/", (request, response) => {
@@ -9,7 +12,16 @@ function createSettingsRouter(settingsService) {
router.patch("/", (request, response, next) => { router.patch("/", (request, response, next) => {
try { try {
response.json(settingsService.update(request.body)); const previous = settingsService.get();
const updated = settingsService.update(request.body);
if (updated.revision !== previous.revision) {
onPlaylistsChanged({
reason: "settings_updated",
playlistIds: null,
settingsRevision: updated.revision,
});
}
response.json(updated);
} catch (error) { } catch (error) {
next(error); next(error);
} }
+218
View File
@@ -0,0 +1,218 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
ELIGIBILITY_REASONS,
evaluateEntryEligibility,
resolveEntrySettings,
} = require("../src/lifecycle");
const NOW = Date.parse("2035-05-06T07:08:09.000Z");
function readyAsset(overrides = {}) {
return { status: "ready", expiration_at: null, ...overrides };
}
function enabledEntry(overrides = {}) {
return {
enabled: 1,
schedule_start_at: null,
schedule_end_at: null,
...overrides,
};
}
test("an enabled, ready, permanent, unscheduled entry is playable", () => {
assert.deepEqual(
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW),
{ playable: true, reason: null },
);
});
test("disabled entries are not playable", () => {
assert.deepEqual(
evaluateEntryEligibility(enabledEntry({ enabled: 0 }), readyAsset(), NOW),
{ playable: false, reason: ELIGIBILITY_REASONS.DISABLED },
);
});
test("schedule start is inclusive", () => {
const entry = enabledEntry({ schedule_start_at: NOW });
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
playable: false,
reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
});
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
playable: true,
reason: null,
});
});
test("schedule end is exclusive", () => {
const entry = enabledEntry({ schedule_end_at: NOW });
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
playable: true,
reason: null,
});
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
playable: false,
reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED,
});
});
test("start-only, end-only, and bounded schedules use UTC epoch milliseconds", () => {
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_start_at: NOW - 1 }),
readyAsset(),
NOW,
).playable,
true,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_end_at: NOW + 1 }),
readyAsset(),
NOW,
).playable,
true,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_start_at: NOW, schedule_end_at: NOW + 1 }),
readyAsset(),
NOW,
).playable,
true,
);
});
test("only ready assets are playable", () => {
for (const status of ["processing", "failed", "deleting"]) {
assert.deepEqual(
evaluateEntryEligibility(enabledEntry(), readyAsset({ status }), NOW),
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY },
);
}
});
test("permanent assets stay playable and expiration is exclusive", () => {
assert.equal(
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW).playable,
true,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry(),
readyAsset({ expiration_at: NOW + 1 }),
NOW,
).playable,
true,
);
assert.deepEqual(
evaluateEntryEligibility(
enabledEntry(),
readyAsset({ expiration_at: NOW }),
NOW,
),
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED },
);
});
test("not-playable reasons follow the documented precedence", () => {
const asset = readyAsset({ status: "failed", expiration_at: NOW });
assert.equal(
evaluateEntryEligibility(
enabledEntry({ enabled: 0, schedule_start_at: NOW + 1 }),
asset,
NOW,
).reason,
ELIGIBILITY_REASONS.DISABLED,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_start_at: NOW + 1, schedule_end_at: NOW }),
asset,
NOW,
).reason,
ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
);
assert.equal(
evaluateEntryEligibility(enabledEntry({ schedule_end_at: NOW }), asset, NOW)
.reason,
ELIGIBILITY_REASONS.SCHEDULED_ENDED,
);
assert.equal(
evaluateEntryEligibility(enabledEntry(), asset, NOW).reason,
ELIGIBILITY_REASONS.ASSET_NOT_READY,
);
});
test("mapped camel-case domain values are supported", () => {
assert.deepEqual(
evaluateEntryEligibility(
{ enabled: true, scheduleStartAt: NOW, scheduleEndAt: NOW + 1 },
{ status: "ready", expirationAt: NOW + 1 },
NOW,
),
{ playable: true, reason: null },
);
});
test("eligibility rejects invalid evaluation clocks", () => {
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "2035-01-01"]) {
assert.throws(
() => evaluateEntryEligibility(enabledEntry(), readyAsset(), invalid),
/nowMs must be a non-negative, safe epoch-millisecond integer/,
);
}
});
test("resolved settings inherit global duration and presentation", () => {
const settings = {
default_duration_ms: 7_000,
default_background_mode: "blurred",
default_solid_background_color: "#123456",
default_background_darkened: 1,
default_blur_px: 20,
};
assert.deepEqual(resolveEntrySettings({}, settings), {
durationMs: 7_000,
presentation: {
backgroundMode: "blurred",
backgroundColor: "#123456",
backgroundDarkened: true,
blurPx: 20,
},
});
});
test("entry overrides are independent and blur always remains global", () => {
const settings = {
defaultDurationMs: 7_000,
defaultBackgroundMode: "blurred",
defaultSolidBackgroundColor: "#123456",
defaultBackgroundDarkened: true,
defaultBlurPx: 20,
};
const entry = {
durationOverrideMs: 9_000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#abcdef",
backgroundDarkenedOverride: false,
blurPx: 999,
};
assert.deepEqual(resolveEntrySettings(entry, settings), {
durationMs: 9_000,
presentation: {
backgroundMode: "solid",
backgroundColor: "#abcdef",
backgroundDarkened: false,
blurPx: 20,
},
});
});
+389
View File
@@ -0,0 +1,389 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createApp } = require("../src/app");
const { loadConfig } = require("../src/config");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const NOW = Date.parse("2032-04-05T12:00:00.000Z");
const silentLogger = {
info() {},
warn() {},
error() {},
};
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
});
}
function testStorage() {
return {
health() {
return {
status: "healthy",
checkedAt: new Date(NOW).toISOString(),
lastSuccessAt: new Date(NOW).toISOString(),
};
},
};
}
async function createHarness(t, { onPlaylistsChanged = () => {} } = {}) {
const config = testConfig();
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
const app = createApp({
config,
database,
imageProcessor: {},
lifecycle: { ready: true, stopping: false },
logger: silentLogger,
migrations,
now: () => NOW,
onPlaylistsChanged,
storage: testStorage(),
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
database.close();
});
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
database,
};
}
function insertAsset(database, { id, name, status = "ready", expirationAt = null }) {
const hashCharacter = id.slice(-1).toLowerCase().replace(/[^0-9a-f]/g, "a");
database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
expiration_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
id,
hashCharacter.repeat(64),
name,
`${id}.png`,
status,
expirationAt,
NOW,
NOW,
);
}
async function jsonRequest(baseUrl, route, { body, ...init } = {}) {
const response = await fetch(`${baseUrl}${route}`, {
...init,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json", ...init.headers },
body: JSON.stringify(body),
}),
});
return { response, body: await response.json() };
}
test("playlist REST routes expose resolved entries, complete ordering, and structured errors", async (t) => {
const changes = [];
const harness = await createHarness(t, {
onPlaylistsChanged: (event) => changes.push(event),
});
insertAsset(harness.database, { id: "asset-a", name: "Welcome" });
insertAsset(harness.database, { id: "asset-b", name: "Events" });
insertAsset(harness.database, {
id: "asset-f",
name: "Failed upload",
status: "failed",
});
const created = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(created.response.status, 201);
assert.match(created.body.id, /^[0-9a-f-]{36}$/);
assert.equal(created.body.revision, 1);
const playlistId = created.body.id;
const listed = await jsonRequest(harness.baseUrl, "/api/playlists");
assert.equal(listed.response.status, 200);
assert.deepEqual(listed.body.playlists.map((playlist) => playlist.id), [
playlistId,
]);
assert.equal(changes.length, 1, "GET must not publish invalidation events");
const first = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{
method: "POST",
body: {
assetId: "asset-a",
durationOverrideMs: 9_000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#123abc",
backgroundDarkenedOverride: true,
},
},
);
assert.equal(first.response.status, 201);
assert.equal(first.body.position, 0);
assert.equal(first.body.effectiveDurationMs, 9_000);
assert.equal(first.body.backgroundColorOverride, "#123ABC");
assert.deepEqual(first.body.eligibility, { playable: true, reason: null });
const second = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-b" } },
);
assert.equal(second.response.status, 201);
assert.equal(second.body.position, 1);
assert.equal(second.body.effectiveDurationMs, 7_000);
const ordered = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{
method: "PUT",
body: { entryIds: [second.body.id, first.body.id] },
},
);
assert.equal(ordered.response.status, 200);
assert.deepEqual(
ordered.body.entries.map((entry) => [entry.id, entry.position]),
[
[second.body.id, 0],
[first.body.id, 1],
],
);
const incomplete = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{ method: "PUT", body: { entryIds: [first.body.id] } },
);
assert.equal(incomplete.response.status, 400);
assert.equal(incomplete.body.error.code, "PLAYLIST_ORDER_INCOMPLETE");
const unexpectedOrderField = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{
method: "PUT",
body: {
entryIds: [second.body.id, first.body.id],
appendMissing: false,
},
},
);
assert.equal(unexpectedOrderField.response.status, 400);
assert.equal(
unexpectedOrderField.body.error.code,
"INVALID_PLAYLIST_ORDER",
);
const missingOrderField = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{ method: "PUT", body: {} },
);
assert.equal(missingOrderField.response.status, 400);
assert.equal(missingOrderField.body.error.code, "INVALID_PLAYLIST_ORDER");
const revisionAfterOrder = ordered.body.revision;
const duplicate = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-a" } },
);
assert.equal(duplicate.response.status, 409);
assert.equal(duplicate.body.error.code, "PLAYLIST_ENTRY_EXISTS");
const failed = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-f" } },
);
assert.equal(failed.response.status, 409);
assert.equal(failed.body.error.code, "ASSET_NOT_READY");
const missing = await jsonRequest(
harness.baseUrl,
"/api/playlists/not-a-playlist",
);
assert.equal(missing.response.status, 404);
assert.equal(missing.body.error.code, "PLAYLIST_NOT_FOUND");
const detail = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}`,
);
assert.equal(detail.body.revision, revisionAfterOrder);
assert.equal(detail.body.entries.length, 2);
const patchedEntry = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/${first.body.id}`,
{
method: "PATCH",
body: { enabled: false, durationOverrideMs: null },
},
);
assert.equal(patchedEntry.response.status, 200);
assert.equal(patchedEntry.body.enabled, false);
assert.equal(patchedEntry.body.effectiveDurationMs, 7_000);
assert.equal(patchedEntry.body.notPlayableReason, "disabled");
const removedEntry = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/${second.body.id}`,
{ method: "DELETE" },
);
assert.equal(removedEntry.response.status, 200);
assert.deepEqual(removedEntry.body, { id: second.body.id, deleted: true });
const compacted = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}`,
);
assert.deepEqual(
compacted.body.entries.map((entry) => entry.position),
[0],
);
const temporary = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Temporary" },
});
const deletedPlaylist = await jsonRequest(
harness.baseUrl,
`/api/playlists/${temporary.body.id}`,
{ method: "DELETE" },
);
assert.equal(deletedPlaylist.response.status, 200);
assert.deepEqual(deletedPlaylist.body, {
id: temporary.body.id,
deleted: true,
});
});
test("playlist data invalidation fires once for semantic changes and not for reads, failures, or no-ops", async (t) => {
const changes = [];
const harness = await createHarness(t, {
onPlaylistsChanged: (event) => changes.push(event),
});
insertAsset(harness.database, { id: "asset-a", name: "Welcome" });
insertAsset(harness.database, { id: "asset-b", name: "Unused" });
const created = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
const playlistId = created.body.id;
assert.equal(changes.length, 1);
assert.equal(changes[0].reason, "created");
await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`);
await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "sUNDAY" },
});
await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, {
method: "PATCH",
body: { name: "Sunday" },
});
assert.equal(changes.length, 1);
await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, {
method: "PATCH",
body: { name: "Weekend" },
});
assert.equal(changes.length, 2);
assert.equal(changes[1].reason, "renamed");
await jsonRequest(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 7_000 },
});
assert.equal(changes.length, 2);
await jsonRequest(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
assert.equal(changes.length, 3);
assert.deepEqual(changes[2], {
reason: "settings_updated",
playlistIds: null,
settingsRevision: 2,
});
await jsonRequest(harness.baseUrl, "/api/assets/asset-b", {
method: "PATCH",
body: { displayName: "Still unused" },
});
assert.equal(changes.length, 3);
await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-a" } },
);
assert.equal(changes.length, 4);
await jsonRequest(harness.baseUrl, "/api/assets/asset-a", {
method: "PATCH",
body: { expirationAt: NOW + 60_000 },
});
assert.deepEqual(changes.at(-1), {
reason: "asset_updated",
playlistIds: [playlistId],
assetId: "asset-a",
});
const countAfterAssetUpdate = changes.length;
await jsonRequest(harness.baseUrl, "/api/assets/asset-a", {
method: "PATCH",
body: { expirationAt: NOW + 60_000 },
});
assert.equal(changes.length, countAfterAssetUpdate);
});
test("a failing invalidation listener cannot turn a committed mutation into HTTP 500", async (t) => {
const harness = await createHarness(t, {
onPlaylistsChanged() {
throw new Error("listener unavailable");
},
});
const settings = await jsonRequest(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
assert.equal(settings.response.status, 200);
assert.equal(settings.body.revision, 2);
const playlist = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(playlist.response.status, 201);
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get()
.count,
1,
);
});
+675
View File
@@ -0,0 +1,675 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createAssetService } = require("../src/assets/service");
const {
MAX_DURATION_MS,
MIN_DURATION_MS,
createPlaylistService,
} = require("../src/playlists/service");
const BASE_TIME = 2_000_000_000_000;
function createHarness() {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
let clock = BASE_TIME;
let idCounter = 0;
let assetCounter = 0;
const changes = [];
const service = createPlaylistService({
database,
now: () => clock,
createId: () =>
`00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`,
onChanged: (change) => changes.push(change),
});
function setNow(value) {
clock = value;
}
function seedAsset(options = {}) {
const sequence = ++assetCounter;
const id = options.id || `asset-${sequence}`;
const name = options.name || id;
const status = options.status || "ready";
const expirationAt = options.expirationAt ?? null;
const hash = crypto
.createHash("sha256")
.update(`${sequence}:${id}`)
.digest("hex");
database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
expiration_at, display_sha256, display_width, display_height,
thumbnail_sha256, thumbnail_width, thumbnail_height,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1920, 1080, ?, 480, 270, ?, ?)`,
)
.run(
id,
hash,
name,
`${name}.png`,
status,
expirationAt,
hash,
hash,
clock,
clock,
);
return id;
}
function playback() {
return database
.prepare("SELECT * FROM playback_state WHERE output_id = 'global'")
.get();
}
return { changes, database, playback, seedAsset, service, setNow };
}
function expectError(action, code, statusCode) {
assert.throws(action, (error) => {
assert.equal(error.code, code);
if (statusCode !== undefined) assert.equal(error.statusCode, statusCode);
return true;
});
}
test("playlist CRUD uses UUIDs, case-insensitive names, revisions, and post-commit events", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const sunday = harness.service.create({ name: " Sunday Service " });
const midweek = harness.service.create({ name: "Midweek" });
assert.match(sunday.id, /^[0-9a-f-]{36}$/);
assert.equal(sunday.name, "Sunday Service");
assert.equal(sunday.revision, 1);
assert.deepEqual(
harness.service.list().map((playlist) => playlist.name),
["Midweek", "Sunday Service"],
);
const revisionBeforeRead = harness.service.list()[1].revision;
const changesBeforeRead = harness.changes.length;
assert.equal(harness.service.get(sunday.id).entries.length, 0);
assert.equal(harness.service.list()[1].revision, revisionBeforeRead);
assert.equal(harness.changes.length, changesBeforeRead);
const renamed = harness.service.update(sunday.id, { name: "Sunday Worship" });
assert.equal(renamed.revision, 2);
assert.equal(harness.service.update(sunday.id, { name: "Sunday Worship" }).revision, 2);
expectError(
() => harness.service.create({ name: "sUnDaY wOrShIp" }),
"PLAYLIST_NAME_CONFLICT",
409,
);
expectError(
() => harness.service.update(midweek.id, { name: "SUNDAY WORSHIP" }),
"PLAYLIST_NAME_CONFLICT",
409,
);
assert.equal(harness.service.get(midweek.id).revision, 1);
assert.deepEqual(harness.service.remove(midweek.id), {
id: midweek.id,
deleted: true,
});
expectError(
() => harness.service.get(midweek.id),
"PLAYLIST_NOT_FOUND",
404,
);
assert.deepEqual(
harness.changes.map((change) => change.reason),
["created", "created", "renamed", "deleted"],
);
});
test("active and pending playlists are conservatively protected from deletion", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const active = harness.service.create({ name: "Active" });
const pending = harness.service.create({ name: "Pending" });
harness.database
.prepare(
`UPDATE playback_state
SET active_playlist_id = ?, pending_playlist_id = ?,
pending_playlist_requested_at = ?, updated_at = ?
WHERE output_id = 'global'`,
)
.run(active.id, pending.id, BASE_TIME, BASE_TIME);
expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409);
expectError(
() => harness.service.remove(pending.id),
"PLAYLIST_PENDING_ACTIVATION",
409,
);
assert.equal(harness.service.list().length, 2);
});
test("entry adds require a ready, unexpired asset and enforce one occurrence per playlist", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const first = harness.service.create({ name: "First" });
const second = harness.service.create({ name: "Second" });
const ready = harness.seedAsset({ id: "ready", name: "Ready" });
for (const status of ["processing", "failed", "deleting"]) {
harness.seedAsset({ id: status, name: status, status });
}
harness.seedAsset({
id: "expired",
name: "Expired",
expirationAt: BASE_TIME,
});
const firstEntry = harness.service.addEntry(first.id, { assetId: ready });
const secondEntry = harness.service.addEntry(second.id, { assetId: ready });
assert.equal(firstEntry.position, 0);
assert.equal(secondEntry.position, 0);
assert.notEqual(firstEntry.id, secondEntry.id);
expectError(
() => harness.service.addEntry(first.id, { assetId: ready }),
"PLAYLIST_ENTRY_EXISTS",
409,
);
for (const status of ["processing", "failed", "deleting"]) {
expectError(
() => harness.service.addEntry(first.id, { assetId: status }),
"ASSET_NOT_READY",
409,
);
}
expectError(
() => harness.service.addEntry(first.id, { assetId: "missing" }),
"ASSET_NOT_FOUND",
404,
);
expectError(
() => harness.service.addEntry(first.id, { assetId: "expired" }),
"ASSET_EXPIRED",
409,
);
assert.equal(harness.service.get(first.id).revision, 2);
});
test("entry enablement, duration, and presentation overrides are playlist-local", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const a = harness.service.create({ name: "A" });
const b = harness.service.create({ name: "B" });
const assetId = harness.seedAsset({ id: "shared", name: "Shared" });
const entryA = harness.service.addEntry(a.id, { assetId });
const entryB = harness.service.addEntry(b.id, { assetId });
assert.equal(entryA.durationOverrideMs, null);
assert.equal(entryA.effectiveDurationMs, 7000);
assert.deepEqual(entryA.effectivePresentation, {
backgroundMode: "blurred",
backgroundColor: "#000000",
backgroundDarkened: false,
blurPx: 20,
});
const patched = harness.service.updateEntry(a.id, entryA.id, {
enabled: false,
durationOverrideMs: 4000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#a1b2c3",
backgroundDarkenedOverride: true,
});
assert.equal(patched.enabled, false);
assert.equal(patched.effectiveDurationMs, 4000);
assert.deepEqual(patched.effectivePresentation, {
backgroundMode: "solid",
backgroundColor: "#A1B2C3",
backgroundDarkened: true,
blurPx: 20,
});
assert.equal(harness.service.get(b.id).entries[0].enabled, true);
assert.equal(harness.service.get(b.id).entries[0].effectiveDurationMs, 7000);
assert.equal(
harness.database.prepare("SELECT status FROM assets WHERE id = ?").get(assetId)
.status,
"ready",
);
const inheritedAgain = harness.service.updateEntry(a.id, entryA.id, {
durationOverrideMs: null,
backgroundModeOverride: null,
backgroundColorOverride: null,
backgroundDarkenedOverride: null,
});
assert.equal(inheritedAgain.effectiveDurationMs, 7000);
assert.equal(inheritedAgain.backgroundDarkenedOverride, null);
for (const invalid of [MIN_DURATION_MS - 1, MAX_DURATION_MS + 1, 1.5]) {
expectError(
() =>
harness.service.updateEntry(a.id, entryA.id, {
durationOverrideMs: invalid,
}),
"INVALID_PLAYLIST_ENTRY",
400,
);
}
for (const patch of [
{ backgroundModeOverride: "gradient" },
{ backgroundColorOverride: "red" },
{ backgroundDarkenedOverride: 1 },
]) {
expectError(
() => harness.service.updateEntry(a.id, entryA.id, patch),
"INVALID_PLAYLIST_ENTRY",
400,
);
}
assert.equal(harness.service.get(a.id).revision, 4);
assert.equal(harness.service.get(b.id).revision, 2);
assert.equal(entryB.enabled, true);
});
test("resolved entries centralize schedule and expiration eligibility at exact boundaries", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "Scheduled" });
const permanent = harness.seedAsset({ id: "permanent", name: "Permanent" });
const futureExpiry = harness.seedAsset({
id: "future-expiry",
name: "Future expiry",
expirationAt: BASE_TIME + 20_000,
});
const noSchedule = harness.service.addEntry(playlist.id, { assetId: permanent });
const bounded = harness.service.addEntry(playlist.id, {
assetId: futureExpiry,
scheduleStartAt: BASE_TIME + 10_000,
scheduleEndAt: BASE_TIME + 30_000,
});
let detail = harness.service.get(playlist.id);
assert.equal(detail.evaluatedAt, BASE_TIME);
assert.equal(detail.settingsRevision, 1);
assert.deepEqual(
detail.entries.map((entry) => [entry.id, entry.playable, entry.notPlayableReason]),
[
[noSchedule.id, true, null],
[bounded.id, false, "scheduled_future"],
],
);
harness.setNow(BASE_TIME + 10_000);
assert.equal(harness.service.get(playlist.id).entries[1].playable, true);
harness.setNow(BASE_TIME + 20_000);
assert.equal(
harness.service.get(playlist.id).entries[1].notPlayableReason,
"asset_expired",
);
harness.setNow(BASE_TIME + 30_000);
assert.equal(
harness.service.get(playlist.id).entries[1].notPlayableReason,
"scheduled_ended",
);
harness.setNow(BASE_TIME);
const endOnlyAsset = harness.seedAsset({ id: "end-only", name: "End only" });
const endOnly = harness.service.addEntry(playlist.id, {
assetId: endOnlyAsset,
scheduleEndAt: BASE_TIME + 1,
});
assert.equal(endOnly.playable, true);
harness.setNow(BASE_TIME + 1);
assert.equal(
harness.service.get(playlist.id).entries.find((entry) => entry.id === endOnly.id)
.notPlayableReason,
"scheduled_ended",
);
expectError(
() =>
harness.service.updateEntry(playlist.id, noSchedule.id, {
scheduleStartAt: BASE_TIME + 2,
scheduleEndAt: BASE_TIME + 1,
}),
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
400,
);
expectError(
() =>
harness.service.updateEntry(playlist.id, noSchedule.id, {
scheduleStartAt: "2033-05-18T03:33:20",
}),
"INVALID_PLAYLIST_ENTRY",
400,
);
const iso = harness.service.updateEntry(playlist.id, noSchedule.id, {
scheduleStartAt: "2033-05-18T03:33:20Z",
});
assert.equal(iso.scheduleStartAt, 2_000_000_000_000);
});
test("full-list reorder validates membership and persists contiguous zero-based positions", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "Ordered" });
const other = harness.service.create({ name: "Other" });
const entries = ["one", "two", "three"].map((id) =>
harness.service.addEntry(playlist.id, {
assetId: harness.seedAsset({ id, name: id }),
}),
);
const foreign = harness.service.addEntry(other.id, {
assetId: harness.seedAsset({ id: "foreign", name: "foreign" }),
});
const revision = harness.service.get(playlist.id).revision;
const reordered = harness.service.reorderEntries(
playlist.id,
[entries[2].id, entries[0].id, entries[1].id],
);
assert.deepEqual(
reordered.entries.map((entry) => [entry.id, entry.position]),
[
[entries[2].id, 0],
[entries[0].id, 1],
[entries[1].id, 2],
],
);
assert.equal(reordered.revision, revision + 1);
const failedRevision = reordered.revision;
expectError(
() =>
harness.service.reorderEntries(playlist.id, [
entries[2].id,
entries[2].id,
entries[1].id,
]),
"PLAYLIST_ORDER_DUPLICATE_IDS",
);
expectError(
() => harness.service.reorderEntries(playlist.id, [entries[2].id]),
"PLAYLIST_ORDER_INCOMPLETE",
);
expectError(
() =>
harness.service.reorderEntries(playlist.id, [
entries[2].id,
entries[0].id,
foreign.id,
]),
"PLAYLIST_ORDER_FOREIGN_ENTRY",
);
expectError(
() =>
harness.service.reorderEntries(playlist.id, [
entries[2].id,
entries[0].id,
"missing-entry",
]),
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
);
assert.equal(harness.service.get(playlist.id).revision, failedRevision);
harness.service.removeEntry(playlist.id, entries[0].id);
assert.deepEqual(
harness.service.get(playlist.id).entries.map((entry) => entry.position),
[0, 1],
);
});
test("no-op and failed mutations do not increment revisions or emit invalidations", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "Stable" });
const entry = harness.service.addEntry(playlist.id, {
assetId: harness.seedAsset({ id: "stable-asset", name: "Stable asset" }),
});
const baseline = harness.service.get(playlist.id).revision;
const eventCount = harness.changes.length;
harness.service.update(playlist.id, { name: "Stable" });
harness.service.updateEntry(playlist.id, entry.id, { enabled: true });
harness.service.reorderEntries(playlist.id, [entry.id]);
expectError(
() => harness.service.updateEntry(playlist.id, "missing", { enabled: false }),
"PLAYLIST_ENTRY_NOT_FOUND",
404,
);
assert.equal(harness.service.get(playlist.id).revision, baseline);
assert.equal(harness.changes.length, eventCount);
});
test("entry references protect assets and final removal restores delete eligibility", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "References" });
const assetId = harness.seedAsset({ id: "referenced", name: "Referenced" });
const entry = harness.service.addEntry(playlist.id, { assetId, enabled: false });
const assetService = createAssetService({
database: harness.database,
storage: {},
imageProcessor: {},
});
const referenced = assetService.references(assetId);
assert.equal(referenced.canDelete, false);
assert.equal(referenced.playlists[0].playlistEntryId, entry.id);
harness.service.removeEntry(playlist.id, entry.id);
assert.equal(assetService.references(assetId).canDelete, true);
});
test("activation queues at a live boundary, is idempotent, and mode off is only a gate", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const oldPlaylist = harness.service.create({ name: "Old" });
const newPlaylist = harness.service.create({ name: "New" });
const oldEntry = harness.service.addEntry(oldPlaylist.id, {
assetId: harness.seedAsset({ id: "old-asset", name: "Old asset" }),
});
const newEntry = harness.service.addEntry(newPlaylist.id, {
assetId: harness.seedAsset({ id: "new-asset", name: "New asset" }),
durationOverrideMs: 4000,
});
const initial = harness.service.activate(oldPlaylist.id);
assert.equal(initial.activation, "active");
assert.equal(initial.playback.currentEntryId, oldEntry.id);
assert.equal(initial.playback.slideStartedAt, BASE_TIME);
assert.equal(initial.playback.nextSlideAt, BASE_TIME + 7000);
harness.database
.prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'")
.run();
harness.setNow(BASE_TIME + 1000);
const pending = harness.service.activate(newPlaylist.id);
assert.equal(pending.activation, "pending");
assert.equal(pending.playback.mode, "off");
assert.equal(pending.playback.activePlaylistId, oldPlaylist.id);
assert.equal(pending.playback.pendingPlaylistId, newPlaylist.id);
assert.equal(pending.playback.currentEntryId, oldEntry.id);
const pendingRevision = pending.playback.revision;
const requestedAt = pending.playback.pendingPlaylistRequestedAt;
const repeated = harness.service.activate(newPlaylist.id);
assert.equal(repeated.activation, "pending");
assert.equal(repeated.playback.revision, pendingRevision);
assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt);
const cancel = harness.service.activate(oldPlaylist.id);
assert.equal(cancel.activation, "already_active");
assert.equal(cancel.playback.pendingPlaylistId, null);
assert.equal(cancel.playback.currentEntryId, oldEntry.id);
harness.setNow(BASE_TIME + 7000);
const immediate = harness.service.activate(newPlaylist.id);
assert.equal(immediate.activation, "active");
assert.equal(immediate.playback.activePlaylistId, newPlaylist.id);
assert.equal(immediate.playback.pendingPlaylistId, null);
assert.equal(immediate.playback.currentEntryId, newEntry.id);
assert.equal(immediate.playback.slideStartedAt, BASE_TIME + 7000);
assert.equal(immediate.playback.nextSlideAt, BASE_TIME + 11_000);
assert.equal(immediate.playback.mode, "off");
});
test("paused normal playback queues and can replace a pending activation", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const current = harness.service.create({ name: "Paused current" });
const firstTarget = harness.service.create({ name: "First target" });
const secondTarget = harness.service.create({ name: "Second target" });
harness.service.addEntry(current.id, {
assetId: harness.seedAsset({ id: "paused-current", name: "Paused current" }),
});
harness.service.addEntry(firstTarget.id, {
assetId: harness.seedAsset({ id: "first-target", name: "First target" }),
});
harness.service.addEntry(secondTarget.id, {
assetId: harness.seedAsset({ id: "second-target", name: "Second target" }),
});
harness.service.activate(current.id);
harness.database
.prepare(
`UPDATE playback_state
SET rotation_paused = 1, paused_remaining_ms = 5000,
slide_started_at = NULL, next_slide_at = NULL
WHERE output_id = 'global'`,
)
.run();
harness.setNow(BASE_TIME + 1000);
const first = harness.service.activate(firstTarget.id);
assert.equal(first.activation, "pending");
assert.equal(first.playback.activePlaylistId, current.id);
assert.equal(first.playback.pendingPlaylistId, firstTarget.id);
assert.equal(first.playback.pausedRemainingMs, 5000);
harness.setNow(BASE_TIME + 2000);
const replacement = harness.service.activate(secondTarget.id);
assert.equal(replacement.activation, "pending");
assert.equal(replacement.playback.activePlaylistId, current.id);
assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id);
assert.equal(replacement.playback.pendingPlaylistRequestedAt, BASE_TIME + 2000);
assert.equal(replacement.playback.revision, first.playback.revision + 1);
assert.equal(replacement.playback.pausedRemainingMs, 5000);
});
test("static and paused activation prepare the first playable normal target without breaking hold", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const oldPlaylist = harness.service.create({ name: "Old static" });
const target = harness.service.create({ name: "Target static" });
const staticAssetId = harness.seedAsset({ id: "static", name: "Static" });
harness.service.addEntry(oldPlaylist.id, {
assetId: harness.seedAsset({ id: "old-normal", name: "Old normal" }),
});
const skipped = harness.service.addEntry(target.id, {
assetId: harness.seedAsset({ id: "disabled", name: "Disabled" }),
enabled: false,
});
const firstPlayable = harness.service.addEntry(target.id, {
assetId: harness.seedAsset({ id: "playable", name: "Playable" }),
durationOverrideMs: 9000,
});
assert.equal(skipped.position, 0);
harness.service.activate(oldPlaylist.id);
harness.database
.prepare(
`UPDATE playback_state
SET static_asset_id = ?, rotation_paused = 1,
paused_remaining_ms = 1234, slide_started_at = NULL,
next_slide_at = NULL
WHERE output_id = 'global'`,
)
.run(staticAssetId);
const result = harness.service.activate(target.id);
assert.equal(result.activation, "active");
assert.equal(result.playback.activePlaylistId, target.id);
assert.equal(result.playback.staticAssetId, staticAssetId);
assert.equal(result.playback.rotationPaused, true);
assert.equal(result.playback.currentEntryId, firstPlayable.id);
assert.equal(result.playback.pausedRemainingMs, 9000);
assert.equal(result.playback.slideStartedAt, null);
assert.equal(result.playback.nextSlideAt, null);
});
test("empty activation is immediate and current-entry deletion coordinates playback revision safely", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const populated = harness.service.create({ name: "Populated" });
const empty = harness.service.create({ name: "Empty" });
const entries = ["first", "second"].map((id) =>
harness.service.addEntry(populated.id, {
assetId: harness.seedAsset({ id: `asset-${id}`, name: id }),
}),
);
harness.service.activate(populated.id);
const futurePlaybackTimestamp = BASE_TIME + 100_000;
harness.database
.prepare(
`UPDATE playback_state
SET updated_at = ?, mode = 'off', static_asset_id = ?, rotation_paused = 1
WHERE output_id = 'global'`,
)
.run(futurePlaybackTimestamp, "asset-second");
const beforeDelete = harness.playback();
harness.service.removeEntry(populated.id, entries[0].id);
const afterDelete = harness.playback();
assert.equal(afterDelete.current_entry_id, null);
assert.equal(afterDelete.slide_started_at, null);
assert.equal(afterDelete.next_slide_at, null);
assert.equal(afterDelete.paused_remaining_ms, null);
assert.equal(afterDelete.active_playlist_id, populated.id);
assert.equal(afterDelete.mode, "off");
assert.equal(afterDelete.static_asset_id, "asset-second");
assert.equal(afterDelete.rotation_paused, 1);
assert.equal(afterDelete.revision, beforeDelete.revision + 1);
assert.equal(afterDelete.updated_at, futurePlaybackTimestamp + 1);
assert.deepEqual(
harness.service.get(populated.id).entries.map((entry) => entry.position),
[0],
);
harness.database
.prepare(
`UPDATE playback_state
SET static_asset_id = NULL, rotation_paused = 0
WHERE output_id = 'global'`,
)
.run();
const result = harness.service.activate(empty.id);
assert.equal(result.activation, "active");
assert.equal(result.playback.activePlaylistId, empty.id);
assert.equal(result.playback.currentEntryId, null);
assert.equal(result.playback.slideStartedAt, null);
assert.equal(result.playback.nextSlideAt, null);
});
test("inactive playlist edits do not mutate authoritative playback state", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const active = harness.service.create({ name: "Live" });
const inactive = harness.service.create({ name: "Draft" });
harness.service.addEntry(active.id, {
assetId: harness.seedAsset({ id: "live", name: "Live asset" }),
});
const draftEntry = harness.service.addEntry(inactive.id, {
assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }),
});
harness.service.activate(active.id);
const before = harness.playback();
harness.service.update(inactive.id, { name: "Draft edited" });
harness.service.updateEntry(inactive.id, draftEntry.id, { enabled: false });
harness.service.reorderEntries(inactive.id, [draftEntry.id]);
assert.deepEqual(harness.playback(), before);
});
+210
View File
@@ -0,0 +1,210 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
const silentLogger = {
info() {},
warn() {},
error() {},
};
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
});
}
function testStorage() {
return {
async start() {},
stop() {},
health() {
return {
status: "healthy",
checkedAt: "2032-04-05T12:00:00.000Z",
lastSuccessAt: "2032-04-05T12:00:00.000Z",
};
},
};
}
async function jsonRequest(baseUrl, route, { body, ...init } = {}) {
const response = await fetch(`${baseUrl}${route}`, {
...init,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json", ...init.headers },
body: JSON.stringify(body),
}),
});
return { response, body: await response.json() };
}
function splitEnginePackets(payload) {
return payload.split("\x1e").filter(Boolean);
}
function socketEvents(payload) {
return splitEnginePackets(payload)
.filter((packet) => packet.startsWith("42"))
.map((packet) => JSON.parse(packet.slice(2)));
}
async function connectPollingSocket(baseUrl) {
let requestNumber = 0;
const endpoint = (sid) => {
const parameters = new URLSearchParams({
EIO: "4",
transport: "polling",
t: String(requestNumber++),
});
if (sid) parameters.set("sid", sid);
return `${baseUrl}/socket.io/?${parameters}`;
};
const openingResponse = await fetch(endpoint());
assert.equal(openingResponse.status, 200);
const openingPackets = splitEnginePackets(await openingResponse.text());
const openPacket = openingPackets.find((packet) => packet.startsWith("0"));
assert.ok(openPacket, "Engine.IO must return an open packet");
const { sid } = JSON.parse(openPacket.slice(1));
const connectResponse = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "40",
});
assert.equal(connectResponse.status, 200);
await connectResponse.text();
const connectedResponse = await fetch(endpoint(sid));
assert.equal(connectedResponse.status, 200);
const connectedPayload = await connectedResponse.text();
assert.ok(
splitEnginePackets(connectedPayload).some((packet) =>
packet.startsWith("40"),
),
"Socket.IO must acknowledge the namespace connection",
);
return {
async poll() {
const response = await fetch(endpoint(sid), {
signal: AbortSignal.timeout(3_000),
});
assert.equal(response.status, 200);
return response.text();
},
async close() {
const response = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "41",
});
await response.text();
},
};
}
test("Socket.IO publishes playlists:changed only after successful semantic mutations", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const socket = await connectPollingSocket(baseUrl);
t.after(() => socket.close().catch(() => {}));
await jsonRequest(baseUrl, "/api/playlists");
const invalid = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: {},
});
assert.equal(invalid.response.status, 400);
await jsonRequest(baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 7_000 },
});
const created = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(created.response.status, 201);
let events = socketEvents(await socket.poll());
assert.equal(events.length, 1);
assert.equal(events[0][0], "playlists:changed");
assert.equal(events[0][1].reason, "created");
assert.equal(events[0][1].playlistId, created.body.id);
const duplicate = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "SUNDAY" },
});
assert.equal(duplicate.response.status, 409);
await jsonRequest(baseUrl, `/api/playlists/${created.body.id}`, {
method: "PATCH",
body: { name: "Sunday" },
});
await jsonRequest(baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
events = socketEvents(await socket.poll());
assert.deepEqual(events, [
[
"playlists:changed",
{
reason: "settings_updated",
playlistIds: null,
settingsRevision: 2,
},
],
]);
const second = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Midweek" },
});
assert.equal(second.response.status, 201);
events = socketEvents(await socket.poll());
assert.equal(events.length, 1);
assert.equal(events[0][1].reason, "created");
const activated = await jsonRequest(
baseUrl,
`/api/playlists/${created.body.id}/activate`,
{ method: "POST" },
);
assert.equal(activated.response.status, 200);
assert.equal(activated.body.activation, "active");
events = socketEvents(await socket.poll());
assert.equal(events.length, 1);
assert.equal(events[0][0], "playlists:changed");
assert.equal(events[0][1].reason, "activated");
assert.equal(events[0][1].playlistId, created.body.id);
assert.equal(
events[0][1].playbackRevision,
activated.body.playback.revision,
);
const activeDelete = await jsonRequest(
baseUrl,
`/api/playlists/${created.body.id}`,
{ method: "DELETE" },
);
assert.equal(activeDelete.response.status, 409);
assert.equal(activeDelete.body.error.code, "PLAYLIST_ACTIVE");
});