Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993986c25e | ||
|
|
68273d457f | ||
|
|
e2ecdc97e2 | ||
|
|
722c6cf77d | ||
|
|
eb19675a5c |
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.gitignore
|
||||
.agents
|
||||
.codex
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
**/dist
|
||||
**/*.log
|
||||
**/*.sqlite
|
||||
**/*.sqlite-shm
|
||||
**/*.sqlite-wal
|
||||
**/*.sqlite-journal
|
||||
data
|
||||
backups
|
||||
client/pnpm-lock.yaml
|
||||
@@ -0,0 +1,46 @@
|
||||
# A distinct project name and host port let v1 and v2 run side by side.
|
||||
COMPOSE_PROJECT_NAME=announcements-v2
|
||||
WEB_PORT=5175
|
||||
|
||||
# Docker Compose pins these internal values. They are shown here to document the
|
||||
# standalone API configuration contract; do not uncomment them for normal Compose use.
|
||||
# NODE_ENV=production
|
||||
# HOST=0.0.0.0
|
||||
# PORT=7532
|
||||
# DATABASE_PATH=/data/db/announcements.sqlite
|
||||
# S3_ENDPOINT=http://seaweedfs:8333
|
||||
# S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Optional local Vite development proxy target (not used by Compose).
|
||||
# VITE_API_PROXY_TARGET=http://127.0.0.1:7532
|
||||
|
||||
# Application configuration exposed by Compose
|
||||
APP_TIMEZONE=America/Chicago
|
||||
SHUTDOWN_TIMEOUT_MS=10000
|
||||
DATABASE_BUSY_TIMEOUT_MS=5000
|
||||
TRUST_PROXY=true
|
||||
|
||||
# Private SeaweedFS S3 configuration
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=announcements-v2
|
||||
S3_ACCESS_KEY_ID=replace-with-a-random-access-key
|
||||
S3_SECRET_ACCESS_KEY=replace-with-a-long-random-secret
|
||||
S3_CONNECT_TIMEOUT_MS=2000
|
||||
# Maximum socket inactivity during one S3 operation (not a total upload deadline).
|
||||
S3_REQUEST_TIMEOUT_MS=5000
|
||||
S3_MAX_ATTEMPTS=2
|
||||
S3_HEALTH_PROBE_INTERVAL_MS=30000
|
||||
|
||||
# Image upload and decode limits. The default proxy body limit is 512 MiB,
|
||||
# which accommodates the default twenty 25 MiB files plus multipart overhead.
|
||||
UPLOAD_MAX_FILE_BYTES=26214400
|
||||
UPLOAD_MAX_FILES=20
|
||||
IMAGE_MAX_INPUT_PIXELS=40000000
|
||||
# UPLOAD_TEMP_DIRECTORY=/tmp/announcements-uploads
|
||||
|
||||
# Optional integrations. Leave blank to disable them during Phase 2.
|
||||
MQTT_URL=
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
HA_WEBHOOK_URL=
|
||||
HA_WEBHOOK_SECRET=
|
||||
+16
-1
@@ -1 +1,16 @@
|
||||
node_modules
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local build and runtime data
|
||||
dist/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.sqlite-journal
|
||||
data/
|
||||
backups/
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Announcements v2
|
||||
|
||||
Announcements is a small Express, React, Socket.IO, MQTT, SQLite, and
|
||||
S3-compatible signage application. Version 2 is being built in phases and is
|
||||
intended to run beside the existing production version until cutover.
|
||||
|
||||
## Development
|
||||
|
||||
The repository is a pnpm workspace with one lockfile. It requires Node.js
|
||||
22.13 or newer and pnpm 10.29.3.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Only the Nginx web service publishes a host port (5175 by default). The API and
|
||||
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
|
||||
S3 credentials before starting the stack.
|
||||
|
||||
## Library assets
|
||||
|
||||
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
|
||||
field. An optional `display_name` (or `displayName`) is accepted for a
|
||||
single-file upload. Supported decoded formats are still JPEG, PNG, and WebP;
|
||||
extensions and multipart content types are not trusted. Animated, multipage,
|
||||
malformed, unsupported, oversized, and excessive-pixel inputs are rejected.
|
||||
|
||||
Default limits are:
|
||||
|
||||
- 25 MiB per file (`UPLOAD_MAX_FILE_BYTES`)
|
||||
- 20 files per request (`UPLOAD_MAX_FILES`)
|
||||
- 40 million decoded pixels per image (`IMAGE_MAX_INPUT_PIXELS`)
|
||||
|
||||
Nginx accepts up to 512 MiB per request, enough for the default aggregate plus
|
||||
multipart overhead. If the backend limits are raised, update
|
||||
`client_max_body_size` in `client/nginx.conf` as well. Proxied API requests use
|
||||
a ten-minute inactivity timeout so a valid large batch can finish processing.
|
||||
|
||||
Original bytes are archived unchanged. Content identity is the SHA-256 of
|
||||
those bytes, so uploading the same content returns the existing asset even
|
||||
when the filename differs. Display-name collisions with different content
|
||||
return `409 ASSET_NAME_CONFLICT`. Multi-file uploads validate every new image
|
||||
locally before persisting any of them. If a later storage operation fails,
|
||||
already completed items remain valid and retrying the request is idempotent.
|
||||
|
||||
Sharp produces two metadata-free, auto-oriented WebP renditions:
|
||||
|
||||
- display: inside-fit at most 1920×1080, quality 82
|
||||
- thumbnail: inside-fit at most 480×270, quality 72
|
||||
|
||||
Neither rendition crops or upscales. Transparency is flattened against black.
|
||||
Presentation effects such as blur, shadows, darkening, and framing are not
|
||||
baked into stored media.
|
||||
|
||||
## Asset and media API
|
||||
|
||||
- `GET /api/assets`
|
||||
- `POST /api/assets`
|
||||
- `GET /api/assets/:assetId`
|
||||
- `PATCH /api/assets/:assetId`
|
||||
- `GET /api/assets/:assetId/references`
|
||||
- `DELETE /api/assets/:assetId`
|
||||
- `GET /api/assets/:assetId/original`
|
||||
- `GET /media/assets/:assetId/display/:sha256.webp`
|
||||
- `GET /media/assets/:assetId/thumbnail/:sha256.webp`
|
||||
|
||||
PATCH accepts only display name and expiration. A null expiration is permanent.
|
||||
Manual deletion refuses playlist, current playback, and static references. It
|
||||
marks an asset as deleting before idempotently removing its three objects and
|
||||
removes SQLite metadata only after storage succeeds. A failed processing row
|
||||
can be inspected, safely deleted, and uploaded again; no automatic retry loop
|
||||
is run.
|
||||
|
||||
Media URLs are derived from SQLite metadata and never accept object keys. The
|
||||
display and thumbnail routes stream private S3 objects through the API with a
|
||||
one-year immutable cache policy. Original downloads use private, no-store
|
||||
caching.
|
||||
|
||||
## Playlists
|
||||
|
||||
Playlist and entry metadata is authoritative in SQLite. The API surface is:
|
||||
|
||||
- `GET /api/playlists`
|
||||
- `POST /api/playlists`
|
||||
- `GET /api/playlists/:playlistId`
|
||||
- `PATCH /api/playlists/:playlistId`
|
||||
- `DELETE /api/playlists/:playlistId`
|
||||
- `POST /api/playlists/:playlistId/entries`
|
||||
- `PATCH /api/playlists/:playlistId/entries/:entryId`
|
||||
- `DELETE /api/playlists/:playlistId/entries/:entryId`
|
||||
- `PUT /api/playlists/:playlistId/entries/order`
|
||||
- `POST /api/playlists/:playlistId/activate`
|
||||
|
||||
Entry positions are zero-based and contiguous. Reordering requires the complete
|
||||
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
|
||||
playlist, but the same asset can be used independently by any number of other
|
||||
playlists. Only ready, unexpired assets can be newly added.
|
||||
|
||||
Entry duration overrides may be null (inherit the global setting) or 1,000 to
|
||||
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
|
||||
inherit/off/on boolean and blur always comes from the global setting. Schedule
|
||||
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
|
||||
UTC offset, and intervals are evaluated as `[start, end)`.
|
||||
|
||||
Playlist detail responses include effective duration and presentation,
|
||||
server-evaluated eligibility, immutable display/thumbnail URLs, and the
|
||||
settings revision and evaluation time used. Playlist revisions increment once
|
||||
per semantic content change. Exact no-ops and reads do not increment them.
|
||||
|
||||
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. 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.
|
||||
|
||||
Deleting a library asset that is the current static target is still refused by
|
||||
the Phase 2 deletion model. That refusal is a known interim integration point,
|
||||
not the intended final behavior: deleting the statically held slide is meant to
|
||||
exit static safely, return to normal rotation, and only then delete the asset.
|
||||
Phase 3B does not depend on the refusal, so the change belongs to the later
|
||||
phase that revisits asset lifecycle.
|
||||
|
||||
## Storage and health
|
||||
|
||||
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
||||
`S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`.
|
||||
Connection timeout, socket-inactivity timeout (`S3_REQUEST_TIMEOUT_MS`),
|
||||
attempts, and background probe interval are configurable in `.env.example`.
|
||||
Startup verifies or safely creates only the configured bucket.
|
||||
|
||||
Storage outages do not stop the API or fail process readiness. `/health/ready`
|
||||
reports storage as degraded while remaining HTTP 200 when SQLite and the API
|
||||
runtime are healthy. `/api/health` and `/api/status` expose cached, redacted
|
||||
storage state and recover after a successful probe or storage operation.
|
||||
+18
-8
@@ -1,14 +1,24 @@
|
||||
FROM node:22-alpine AS build
|
||||
FROM node:22.23.2-bookworm-slim AS build
|
||||
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY client/package.json ./client/package.json
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
RUN pnpm install --frozen-lockfile --filter @announcements/client...
|
||||
|
||||
COPY client ./client
|
||||
|
||||
RUN pnpm --filter @announcements/client build
|
||||
|
||||
FROM nginx:1.30.4-alpine
|
||||
|
||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/client/dist /usr/share/nginx/html
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,50 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Matches the default API allowance of twenty 25 MiB files plus multipart
|
||||
# overhead. Keep this in sync if the backend upload limits are raised.
|
||||
client_max_body_size 512m;
|
||||
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $api_origin http://api:7532;
|
||||
|
||||
location ~ ^/(api|health|media)(/|$) {
|
||||
proxy_pass $api_origin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_connect_timeout 5s;
|
||||
# Multipart requests return only after image processing and private-S3
|
||||
# writes complete. This is an inactivity timeout, not a size limit.
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass $api_origin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 75s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "client",
|
||||
"name": "@announcements/client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
||||
Generated
-1572
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import "./App.css";
|
||||
|
||||
const socket = io("http://10.40.0.2:7532");
|
||||
const socket = io();
|
||||
|
||||
export default function App() {
|
||||
const [slides, setSlides] = useState([]);
|
||||
|
||||
+19
-6
@@ -1,7 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
export default defineConfig(({ mode }) => {
|
||||
const environment = loadEnv(mode, ".", "VITE_");
|
||||
const target = environment.VITE_API_PROXY_TARGET || "http://127.0.0.1:7532";
|
||||
const proxy = { target, changeOrigin: true };
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": proxy,
|
||||
"/health": proxy,
|
||||
"/media": proxy,
|
||||
"/socket.io": { ...proxy, ws: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+105
-14
@@ -1,20 +1,111 @@
|
||||
name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
|
||||
|
||||
services:
|
||||
announcements-server:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: client/Dockerfile
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
ports:
|
||||
- "${WEB_PORT:-5175}:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
container_name: announcements-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7532:7532"
|
||||
|
||||
announcements-client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
container_name: announcements-client
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5174:80"
|
||||
init: true
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: "7532"
|
||||
DATABASE_PATH: /data/db/announcements.sqlite
|
||||
APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
|
||||
SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
|
||||
DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
|
||||
TRUST_PROXY: "${TRUST_PROXY:-true}"
|
||||
S3_ENDPOINT: http://seaweedfs:8333
|
||||
S3_REGION: "${S3_REGION:-us-east-1}"
|
||||
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
|
||||
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
|
||||
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
S3_CONNECT_TIMEOUT_MS: "${S3_CONNECT_TIMEOUT_MS:-2000}"
|
||||
S3_REQUEST_TIMEOUT_MS: "${S3_REQUEST_TIMEOUT_MS:-5000}"
|
||||
S3_MAX_ATTEMPTS: "${S3_MAX_ATTEMPTS:-2}"
|
||||
S3_HEALTH_PROBE_INTERVAL_MS: "${S3_HEALTH_PROBE_INTERVAL_MS:-30000}"
|
||||
UPLOAD_TEMP_DIRECTORY: /tmp/announcements-uploads
|
||||
UPLOAD_MAX_FILE_BYTES: "${UPLOAD_MAX_FILE_BYTES:-26214400}"
|
||||
UPLOAD_MAX_FILES: "${UPLOAD_MAX_FILES:-20}"
|
||||
IMAGE_MAX_INPUT_PIXELS: "${IMAGE_MAX_INPUT_PIXELS:-40000000}"
|
||||
MQTT_URL: "${MQTT_URL:-}"
|
||||
MQTT_USERNAME: "${MQTT_USERNAME:-}"
|
||||
MQTT_PASSWORD: "${MQTT_PASSWORD:-}"
|
||||
HA_WEBHOOK_URL: "${HA_WEBHOOK_URL:-}"
|
||||
HA_WEBHOOK_SECRET: "${HA_WEBHOOK_SECRET:-}"
|
||||
volumes:
|
||||
- app-data:/data
|
||||
depends_on:
|
||||
- announcements-server
|
||||
seaweedfs:
|
||||
condition: service_started
|
||||
networks:
|
||||
- app
|
||||
- storage
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:7532/health/ready').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
stop_grace_period: 15s
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.39
|
||||
restart: unless-stopped
|
||||
command: ["mini", "-dir=/data", "-ip=seaweedfs"]
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
|
||||
AWS_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
|
||||
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
expose:
|
||||
- "8333"
|
||||
networks:
|
||||
- storage
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
wget -q -O /dev/null http://127.0.0.1:9333/readyz &&
|
||||
wget -q -O /dev/null http://127.0.0.1:8333/readyz
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
stop_grace_period: 30s
|
||||
|
||||
networks:
|
||||
app:
|
||||
storage:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
seaweedfs-data:
|
||||
|
||||
+14
-19
@@ -1,24 +1,19 @@
|
||||
{
|
||||
"name": "Annoucements",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"name": "announcements",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Church announcement management and signage",
|
||||
"scripts": {
|
||||
"dev": "nodemon server/index.js",
|
||||
"start": "node server/index.js"
|
||||
"dev:server": "pnpm --filter @announcements/server dev",
|
||||
"dev:client": "pnpm --filter @announcements/client dev",
|
||||
"start": "pnpm --filter @announcements/server start",
|
||||
"build": "pnpm --filter @announcements/client build",
|
||||
"lint": "pnpm --filter @announcements/client lint",
|
||||
"test": "pnpm --filter @announcements/server test",
|
||||
"check": "pnpm lint && pnpm test && pnpm build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "[email protected]",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"fast-xml-parser": "^5.8.0",
|
||||
"mqtt": "^5.15.1",
|
||||
"socket.io": "^4.8.3"
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
|
||||
Generated
+2059
-388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- client
|
||||
- server
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- sharp
|
||||
+24
-5
@@ -1,13 +1,32 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22.23.2-bookworm-slim AS dependencies
|
||||
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY client/package.json ./client/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod --filter @announcements/server...
|
||||
|
||||
COPY server ./server
|
||||
|
||||
FROM node:22.23.2-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=dependencies /app/server ./server
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 7532
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
CMD ["node", "server/src/index.js"]
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
const express = require("express");
|
||||
const http = require("http");
|
||||
const { Server } = require("socket.io");
|
||||
const cors = require("cors");
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
},
|
||||
});
|
||||
|
||||
const PORT = 7532;
|
||||
|
||||
let currentSlide = 0;
|
||||
const slideSeconds = 7;
|
||||
|
||||
const { XMLParser } = require("fast-xml-parser");
|
||||
|
||||
let slides = [];
|
||||
|
||||
async function loadSlides() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://10.40.0.2:9000/announcements?list-type=2"
|
||||
);
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
const parser = new XMLParser();
|
||||
const data = parser.parse(xml);
|
||||
|
||||
const contents = data.ListBucketResult?.Contents || [];
|
||||
|
||||
const files = Array.isArray(contents)
|
||||
? contents
|
||||
: [contents];
|
||||
|
||||
slides = files
|
||||
.map((file) => file.Key)
|
||||
.filter((name) =>
|
||||
/\.(png|jpg|jpeg|webp)$/i.test(name)
|
||||
)
|
||||
.map((name) => {
|
||||
const encodedName = name
|
||||
.split("/")
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join("/");
|
||||
|
||||
return `http://10.40.0.2:9000/announcements/${encodedName}`;
|
||||
});
|
||||
|
||||
console.log("Slides loaded:", slides.length);
|
||||
|
||||
if (currentSlide >= slides.length) {
|
||||
currentSlide = 0;
|
||||
}
|
||||
|
||||
broadcastState();
|
||||
} catch (err) {
|
||||
console.error("Failed to load slides:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const mqtt = require("mqtt");
|
||||
|
||||
let mode = "on";
|
||||
|
||||
const mqttClient = mqtt.connect("mqtt://10.40.0.2");
|
||||
|
||||
mqttClient.on("connect", () => {
|
||||
console.log("MQTT connected");
|
||||
mqttClient.subscribe("announcements/mode");
|
||||
});
|
||||
|
||||
mqttClient.on("message", (topic, message) => {
|
||||
if (topic === "announcements/mode") {
|
||||
const value = message.toString().trim().toLowerCase();
|
||||
|
||||
if (value === "on" || value === "off") {
|
||||
mode = value;
|
||||
broadcastState();
|
||||
console.log("Announcement mode:", mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function broadcastState() {
|
||||
io.emit("slideshow:state", {
|
||||
currentSlide,
|
||||
slides,
|
||||
slideSeconds,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("Client connected:", socket.id);
|
||||
|
||||
socket.emit("slideshow:state", {
|
||||
currentSlide,
|
||||
slides,
|
||||
slideSeconds,
|
||||
mode,
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (slides.length === 0) return;
|
||||
|
||||
currentSlide = (currentSlide + 1) % slides.length;
|
||||
broadcastState();
|
||||
}, slideSeconds * 1000);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.send("Announcement server is running");
|
||||
});
|
||||
|
||||
loadSlides();
|
||||
|
||||
setInterval(() => {
|
||||
loadSlides();
|
||||
}, 30000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Announcement server running on http://10.40.0.2:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@announcements/server",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Announcements v2 API and realtime server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1101.0",
|
||||
"better-sqlite3": "13.0.2",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.2.1",
|
||||
"multer": "2.0.2",
|
||||
"sharp": "0.35.3",
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
const express = require("express");
|
||||
const {
|
||||
createDetailedHealthRouter,
|
||||
createHealthRouter,
|
||||
} = require("./health/routes");
|
||||
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,
|
||||
} = require("./playlists");
|
||||
const { createSettingsRouter } = require("./settings/routes");
|
||||
const { createSettingsService } = require("./settings/service");
|
||||
|
||||
function safeNotifier(notifier, logger, failureMessage) {
|
||||
const warn = (error) => {
|
||||
try {
|
||||
logger?.warn?.(failureMessage, error);
|
||||
} catch {
|
||||
// A best-effort notification or warning must not undo a database commit.
|
||||
}
|
||||
};
|
||||
return (event) => {
|
||||
try {
|
||||
const result = notifier(event);
|
||||
result?.catch?.(warn);
|
||||
} catch (error) {
|
||||
warn(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createApp({
|
||||
config,
|
||||
database,
|
||||
imageProcessor,
|
||||
lifecycle,
|
||||
logger = console,
|
||||
migrations,
|
||||
now = Date.now,
|
||||
onPlaylistsChanged = () => {},
|
||||
playbackService: suppliedPlaybackService,
|
||||
storage,
|
||||
}) {
|
||||
const app = express();
|
||||
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,
|
||||
imageProcessor,
|
||||
storage,
|
||||
});
|
||||
const playlistService = createPlaylistService({
|
||||
database,
|
||||
now,
|
||||
onBeforeChange: reconcileBeforeDomainChange,
|
||||
onChanged: notifyPlaylistMutation,
|
||||
});
|
||||
app.locals.playbackService = playbackService;
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", config.trustProxy);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.get("/api", (request, response) => {
|
||||
response.json({ name: "Announcements API", version: 2 });
|
||||
});
|
||||
app.use(
|
||||
"/health",
|
||||
createHealthRouter({ database, lifecycle, migrations, storage }),
|
||||
);
|
||||
const detailedHealthRouterOptions = {
|
||||
config,
|
||||
database,
|
||||
lifecycle,
|
||||
migrations,
|
||||
storage,
|
||||
};
|
||||
app.use(
|
||||
"/api/health",
|
||||
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||
);
|
||||
app.use(
|
||||
"/api/status",
|
||||
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||
);
|
||||
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, {
|
||||
playbackService,
|
||||
}),
|
||||
);
|
||||
app.use("/api/playback", createPlaybackRouter(playbackService));
|
||||
app.use("/media", createMediaRouter(assetService));
|
||||
|
||||
app.use((request, response) => {
|
||||
response.status(404).json({
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: "Route not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.use((error, request, response, next) => {
|
||||
if (response.headersSent) {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof SyntaxError && error.status === 400 && "body" in error) {
|
||||
response.status(400).json({
|
||||
error: {
|
||||
code: "INVALID_JSON",
|
||||
message: "Request body must contain valid JSON",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const statusCode = error.statusCode || 500;
|
||||
response.status(statusCode).json({
|
||||
error: {
|
||||
code: error.code || "INTERNAL_ERROR",
|
||||
message:
|
||||
statusCode >= 500 ? "An internal server error occurred" : error.message,
|
||||
...(error.details ? { details: error.details } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApp,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
class AssetError extends Error {
|
||||
constructor(code, message, { statusCode = 400, details, cause } = {}) {
|
||||
super(message, cause ? { cause } : undefined);
|
||||
this.name = "AssetError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
if (details !== undefined) {
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assetNotFound(assetId) {
|
||||
return new AssetError("ASSET_NOT_FOUND", "Library asset not found", {
|
||||
statusCode: 404,
|
||||
details: { assetId },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AssetError,
|
||||
assetNotFound,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
const express = require("express");
|
||||
const { sendImmutableMedia } = require("./streaming");
|
||||
|
||||
function createMediaRouter(assetService) {
|
||||
const router = express.Router();
|
||||
|
||||
async function sendMedia(request, response, next, kind) {
|
||||
const version = request.params.version;
|
||||
if (!version.endsWith(".webp")) {
|
||||
response.status(404).json({
|
||||
error: {
|
||||
code: "MEDIA_NOT_FOUND",
|
||||
message: "Asset media version not found",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const descriptor = assetService.mediaDescriptor(
|
||||
request.params.assetId,
|
||||
kind,
|
||||
version.slice(0, -".webp".length),
|
||||
);
|
||||
await sendImmutableMedia(assetService, response, descriptor);
|
||||
} catch (error) {
|
||||
if (response.headersSent) {
|
||||
response.destroy(error);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/assets/:assetId/display/:version", (request, response, next) =>
|
||||
sendMedia(request, response, next, "display"),
|
||||
);
|
||||
router.get(
|
||||
"/assets/:assetId/thumbnail/:version",
|
||||
(request, response, next) =>
|
||||
sendMedia(request, response, next, "thumbnail"),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMediaRouter,
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const multer = require("multer");
|
||||
const { AssetError } = require("./errors");
|
||||
|
||||
const ALLOWED_FILE_FIELDS = new Set(["file", "files"]);
|
||||
const ALLOWED_TEXT_FIELDS = new Set(["display_name", "displayName"]);
|
||||
const MAX_FIELD_BYTES = 4 * 1024;
|
||||
const MAX_ORIGINAL_FILENAME_LENGTH = 255;
|
||||
const TEMPORARY_UPLOAD_PATHS = Symbol("temporaryUploadPaths");
|
||||
const TEMP_STORAGE_ERROR_CODES = new Set([
|
||||
"EACCES",
|
||||
"EEXIST",
|
||||
"EIO",
|
||||
"EMFILE",
|
||||
"ENFILE",
|
||||
"ENOSPC",
|
||||
"ENOTDIR",
|
||||
"EPERM",
|
||||
"EROFS",
|
||||
]);
|
||||
|
||||
function safeOriginalFilename(value) {
|
||||
const normalized = String(value || "")
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
const leafName = path.posix.basename(normalized);
|
||||
const bounded = [...leafName].slice(0, MAX_ORIGINAL_FILENAME_LENGTH).join("");
|
||||
|
||||
if (!bounded.trim()) {
|
||||
throw new AssetError(
|
||||
"INVALID_ORIGINAL_FILENAME",
|
||||
"Uploaded files must include a usable filename",
|
||||
);
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
|
||||
function mapMulterError(error, config) {
|
||||
if (!(error instanceof multer.MulterError)) {
|
||||
if (TEMP_STORAGE_ERROR_CODES.has(error?.code)) {
|
||||
return new AssetError(
|
||||
"UPLOAD_TEMP_STORAGE_UNAVAILABLE",
|
||||
"Temporary upload storage is unavailable",
|
||||
{ statusCode: 503, cause: error },
|
||||
);
|
||||
}
|
||||
return new AssetError(
|
||||
"INVALID_MULTIPART_UPLOAD",
|
||||
"The multipart upload could not be read",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.code === "LIMIT_FILE_SIZE") {
|
||||
return new AssetError(
|
||||
"UPLOAD_FILE_TOO_LARGE",
|
||||
"An uploaded file exceeds the configured size limit",
|
||||
{
|
||||
statusCode: 413,
|
||||
details: { maxFileBytes: config.maxFileBytes },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (error.code === "LIMIT_FILE_COUNT") {
|
||||
return new AssetError(
|
||||
"UPLOAD_FILE_COUNT_EXCEEDED",
|
||||
"The upload contains too many files",
|
||||
{
|
||||
statusCode: 413,
|
||||
details: { maxFiles: config.maxFiles },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
[
|
||||
"LIMIT_FIELD_COUNT",
|
||||
"LIMIT_FIELD_KEY",
|
||||
"LIMIT_FIELD_VALUE",
|
||||
"LIMIT_PART_COUNT",
|
||||
"LIMIT_UNEXPECTED_FILE",
|
||||
].includes(error.code)
|
||||
) {
|
||||
return new AssetError(
|
||||
"INVALID_MULTIPART_UPLOAD",
|
||||
"The multipart upload contains too many or invalid parts",
|
||||
{ details: { reason: error.code } },
|
||||
);
|
||||
}
|
||||
|
||||
return new AssetError(
|
||||
"INVALID_MULTIPART_UPLOAD",
|
||||
"The multipart upload could not be read",
|
||||
{ details: { reason: error.code } },
|
||||
);
|
||||
}
|
||||
|
||||
function createTemporaryStorage(tempDirectory) {
|
||||
return multer.diskStorage({
|
||||
destination(request, file, callback) {
|
||||
fs.mkdir(tempDirectory, { recursive: true, mode: 0o700 }, (error) => {
|
||||
callback(error, tempDirectory);
|
||||
});
|
||||
},
|
||||
filename(request, file, callback) {
|
||||
const temporaryName = crypto.randomUUID();
|
||||
if (!request[TEMPORARY_UPLOAD_PATHS]) {
|
||||
request[TEMPORARY_UPLOAD_PATHS] = [];
|
||||
}
|
||||
request[TEMPORARY_UPLOAD_PATHS].push(
|
||||
path.join(tempDirectory, temporaryName),
|
||||
);
|
||||
callback(null, temporaryName);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function temporaryUploadFiles(request) {
|
||||
const paths = new Set(request[TEMPORARY_UPLOAD_PATHS] || []);
|
||||
for (const file of Array.isArray(request.files) ? request.files : []) {
|
||||
if (file?.path) paths.add(file.path);
|
||||
}
|
||||
return [...paths].map((temporaryPath) => ({ path: temporaryPath }));
|
||||
}
|
||||
|
||||
function createMultipartUpload(config) {
|
||||
const middleware = multer({
|
||||
storage: createTemporaryStorage(config.tempDirectory),
|
||||
limits: {
|
||||
fileSize: config.maxFileBytes,
|
||||
files: config.maxFiles,
|
||||
fields: 2,
|
||||
parts: config.maxFiles + 2,
|
||||
fieldNameSize: 64,
|
||||
fieldSize: MAX_FIELD_BYTES,
|
||||
headerPairs: 100,
|
||||
},
|
||||
}).any();
|
||||
|
||||
return function receiveAssetUpload(request, response) {
|
||||
return new Promise((resolve, reject) => {
|
||||
middleware(request, response, (error) => {
|
||||
if (error) {
|
||||
reject(mapMulterError(error, config));
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(request.files) ? request.files : [];
|
||||
try {
|
||||
if (files.length === 0) {
|
||||
throw new AssetError(
|
||||
"UPLOAD_FILE_REQUIRED",
|
||||
"At least one image file is required",
|
||||
);
|
||||
}
|
||||
|
||||
const unexpectedFile = files.find(
|
||||
(file) => !ALLOWED_FILE_FIELDS.has(file.fieldname),
|
||||
);
|
||||
if (unexpectedFile) {
|
||||
throw new AssetError(
|
||||
"UNEXPECTED_UPLOAD_FIELD",
|
||||
"Image files must use the file or files multipart field",
|
||||
{ details: { field: unexpectedFile.fieldname } },
|
||||
);
|
||||
}
|
||||
|
||||
const body = request.body || {};
|
||||
const unknownFields = Object.keys(body).filter(
|
||||
(name) => !ALLOWED_TEXT_FIELDS.has(name),
|
||||
);
|
||||
if (unknownFields.length > 0) {
|
||||
throw new AssetError(
|
||||
"UNEXPECTED_UPLOAD_FIELD",
|
||||
"The multipart upload contains unsupported fields",
|
||||
{ details: { fields: unknownFields } },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
Object.hasOwn(body, "display_name") &&
|
||||
Object.hasOwn(body, "displayName")
|
||||
) {
|
||||
throw new AssetError(
|
||||
"DUPLICATE_DISPLAY_NAME_FIELD",
|
||||
"Provide only one display name field",
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = body.display_name ?? body.displayName ?? null;
|
||||
if (displayName !== null && files.length !== 1) {
|
||||
throw new AssetError(
|
||||
"DISPLAY_NAME_REQUIRES_SINGLE_FILE",
|
||||
"An explicit display name can be used only with one uploaded file",
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
displayName,
|
||||
files: files.map((file) => ({
|
||||
path: file.path,
|
||||
byteSize: file.size,
|
||||
originalFilename: safeOriginalFilename(file.originalname),
|
||||
suppliedContentType: file.mimetype,
|
||||
})),
|
||||
});
|
||||
} catch (validationError) {
|
||||
reject(validationError);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupTemporaryFiles(files = []) {
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
if (!file?.path) return;
|
||||
try {
|
||||
await fs.promises.rm(file.path, { force: true });
|
||||
} catch {
|
||||
// Temporary cleanup is best-effort and must not mask the API result.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanupTemporaryFiles,
|
||||
createMultipartUpload,
|
||||
mapMulterError,
|
||||
safeOriginalFilename,
|
||||
temporaryUploadFiles,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
const express = require("express");
|
||||
const {
|
||||
cleanupTemporaryFiles,
|
||||
createMultipartUpload,
|
||||
temporaryUploadFiles,
|
||||
} = require("./multipart");
|
||||
const { sendOriginal } = require("./streaming");
|
||||
|
||||
function createAssetRouter({
|
||||
assetService,
|
||||
onBeforePlaybackDomainChange = () => {},
|
||||
onPlaylistsChanged = () => {},
|
||||
onPlaybackDomainChanged = () => {},
|
||||
processingConfig,
|
||||
}) {
|
||||
const router = express.Router();
|
||||
const receiveUpload = createMultipartUpload(processingConfig);
|
||||
|
||||
router.get("/", (request, response, next) => {
|
||||
try {
|
||||
response.json({ assets: assetService.list() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (request, response, next) => {
|
||||
const cleanupAfterAbort = () => {
|
||||
void cleanupTemporaryFiles(temporaryUploadFiles(request));
|
||||
};
|
||||
request.once("aborted", cleanupAfterAbort);
|
||||
|
||||
try {
|
||||
const upload = await receiveUpload(request, response);
|
||||
const results = await assetService.upload(upload.files, {
|
||||
displayName: upload.displayName,
|
||||
});
|
||||
const createdCount = results.filter((result) => result.created).length;
|
||||
|
||||
if (results.length === 1) {
|
||||
response.status(createdCount > 0 ? 201 : 200).json(results[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(createdCount > 0 ? 201 : 200).json({
|
||||
results,
|
||||
createdCount,
|
||||
duplicateCount: results.length - createdCount,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
} finally {
|
||||
request.off("aborted", cleanupAfterAbort);
|
||||
await cleanupTemporaryFiles(temporaryUploadFiles(request));
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:assetId/references", (request, response, next) => {
|
||||
try {
|
||||
response.json(assetService.references(request.params.assetId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:assetId/original", async (request, response, next) => {
|
||||
try {
|
||||
const descriptor = assetService.originalDescriptor(request.params.assetId);
|
||||
await sendOriginal(assetService, response, descriptor);
|
||||
} catch (error) {
|
||||
if (response.headersSent) {
|
||||
response.destroy(error);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:assetId", (request, response, next) => {
|
||||
try {
|
||||
response.json(assetService.get(request.params.assetId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/:assetId", (request, response, next) => {
|
||||
try {
|
||||
const previous = assetService.get(request.params.assetId);
|
||||
const playlistIds = [
|
||||
...new Set(
|
||||
assetService
|
||||
.references(request.params.assetId)
|
||||
.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) {
|
||||
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) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:assetId", async (request, response, next) => {
|
||||
try {
|
||||
response.json(await assetService.remove(request.params.assetId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAssetRouter,
|
||||
};
|
||||
@@ -0,0 +1,835 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { AssetError, assetNotFound } = require("./errors");
|
||||
const { StorageObjectNotFoundError } = require("../storage");
|
||||
|
||||
const MAX_DISPLAY_NAME_LENGTH = 200;
|
||||
const MAX_PROCESSING_ERROR_LENGTH = 1_000;
|
||||
const CONCURRENT_UPLOAD_WAIT_MS = 15_000;
|
||||
const CONCURRENT_UPLOAD_POLL_MS = 50;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new AssetError(
|
||||
"INVALID_ASSET_DISPLAY_NAME",
|
||||
"Asset display name must be a string",
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
throw new AssetError(
|
||||
"INVALID_ASSET_DISPLAY_NAME",
|
||||
"Asset display name cannot be empty",
|
||||
);
|
||||
}
|
||||
if ([...normalized].length > MAX_DISPLAY_NAME_LENGTH) {
|
||||
throw new AssetError(
|
||||
"INVALID_ASSET_DISPLAY_NAME",
|
||||
`Asset display name cannot exceed ${MAX_DISPLAY_NAME_LENGTH} characters`,
|
||||
{ details: { maxLength: MAX_DISPLAY_NAME_LENGTH } },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function defaultDisplayName(originalFilename) {
|
||||
const extension = path.extname(originalFilename);
|
||||
const withoutExtension = extension
|
||||
? originalFilename.slice(0, -extension.length)
|
||||
: originalFilename;
|
||||
return normalizeDisplayName(withoutExtension);
|
||||
}
|
||||
|
||||
function normalizeExpiration(value) {
|
||||
if (value === null) return null;
|
||||
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isSafeInteger(timestamp) && timestamp >= 0) {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AssetError(
|
||||
"INVALID_ASSET_EXPIRATION",
|
||||
"Asset expiration must be null, an epoch-millisecond integer, or an ISO date",
|
||||
);
|
||||
}
|
||||
|
||||
function mediaUrls(row) {
|
||||
if (row.status !== "ready") {
|
||||
return {
|
||||
originalUrl: null,
|
||||
displayUrl: null,
|
||||
thumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
originalUrl: `/api/assets/${row.id}/original`,
|
||||
displayUrl: `/media/assets/${row.id}/display/${row.display_sha256}.webp`,
|
||||
thumbnailUrl: `/media/assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAsset(row) {
|
||||
if (!row) return null;
|
||||
const urls = mediaUrls(row);
|
||||
return {
|
||||
id: row.id,
|
||||
displayName: row.display_name,
|
||||
originalFilename: row.original_filename,
|
||||
status: row.status,
|
||||
processingError: row.processing_error || null,
|
||||
sourceSha256: row.source_sha256,
|
||||
expirationAt: row.expiration_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
referenceCount: Number(row.reference_count || 0),
|
||||
original: row.original_content_type
|
||||
? {
|
||||
contentType: row.original_content_type,
|
||||
byteSize: row.original_byte_size,
|
||||
width: row.original_width,
|
||||
height: row.original_height,
|
||||
url: urls.originalUrl,
|
||||
}
|
||||
: null,
|
||||
display: row.display_content_type
|
||||
? {
|
||||
sha256: row.display_sha256,
|
||||
contentType: row.display_content_type,
|
||||
byteSize: row.display_byte_size,
|
||||
width: row.display_width,
|
||||
height: row.display_height,
|
||||
url: urls.displayUrl,
|
||||
}
|
||||
: null,
|
||||
thumbnail: row.thumbnail_content_type
|
||||
? {
|
||||
sha256: row.thumbnail_sha256,
|
||||
contentType: row.thumbnail_content_type,
|
||||
byteSize: row.thumbnail_byte_size,
|
||||
width: row.thumbnail_width,
|
||||
height: row.thumbnail_height,
|
||||
url: urls.thumbnailUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function hashFile(filename) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
let byteSize = 0;
|
||||
for await (const chunk of fs.createReadStream(filename)) {
|
||||
byteSize += chunk.length;
|
||||
hash.update(chunk);
|
||||
}
|
||||
return { sourceSha256: hash.digest("hex"), byteSize };
|
||||
}
|
||||
|
||||
function objectKeys(assetId, processed) {
|
||||
return {
|
||||
original: `assets/${assetId}/original/${processed.original.sha256}`,
|
||||
display: `assets/${assetId}/display/${processed.display.sha256}.webp`,
|
||||
thumbnail: `assets/${assetId}/thumbnail/${processed.thumbnail.sha256}.webp`,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedProcessingError(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : "PROCESSING_FAILED";
|
||||
const message =
|
||||
typeof error?.message === "string" && error.message
|
||||
? error.message
|
||||
: "Asset processing failed";
|
||||
return `${code}: ${message}`.slice(0, MAX_PROCESSING_ERROR_LENGTH);
|
||||
}
|
||||
|
||||
function createAssetService({
|
||||
database,
|
||||
storage,
|
||||
imageProcessor,
|
||||
now = Date.now,
|
||||
createId = crypto.randomUUID,
|
||||
wait = delay,
|
||||
concurrentUploadWaitMs = CONCURRENT_UPLOAD_WAIT_MS,
|
||||
}) {
|
||||
const assetSelect = `
|
||||
SELECT a.*,
|
||||
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.asset_id = a.id)
|
||||
AS reference_count
|
||||
FROM assets a
|
||||
`;
|
||||
const listAssets = database.prepare(`${assetSelect} ORDER BY a.created_at DESC, a.id`);
|
||||
const findById = database.prepare(`${assetSelect} WHERE a.id = ?`);
|
||||
const findByHash = database.prepare(`${assetSelect} WHERE a.source_sha256 = ?`);
|
||||
const findByName = database.prepare(`${assetSelect} WHERE a.display_name = ? COLLATE NOCASE`);
|
||||
const insertProcessing = database.prepare(`
|
||||
INSERT INTO assets (
|
||||
id, source_sha256, display_name, original_filename, status,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @sourceSha256, @displayName, @originalFilename, 'processing',
|
||||
@createdAt, @updatedAt
|
||||
)
|
||||
`);
|
||||
const markReady = database.prepare(`
|
||||
UPDATE assets
|
||||
SET status = 'ready',
|
||||
processing_error = NULL,
|
||||
original_object_key = @originalObjectKey,
|
||||
original_content_type = @originalContentType,
|
||||
original_byte_size = @originalByteSize,
|
||||
original_width = @originalWidth,
|
||||
original_height = @originalHeight,
|
||||
display_object_key = @displayObjectKey,
|
||||
display_sha256 = @displaySha256,
|
||||
display_content_type = @displayContentType,
|
||||
display_byte_size = @displayByteSize,
|
||||
display_width = @displayWidth,
|
||||
display_height = @displayHeight,
|
||||
thumbnail_object_key = @thumbnailObjectKey,
|
||||
thumbnail_sha256 = @thumbnailSha256,
|
||||
thumbnail_content_type = @thumbnailContentType,
|
||||
thumbnail_byte_size = @thumbnailByteSize,
|
||||
thumbnail_width = @thumbnailWidth,
|
||||
thumbnail_height = @thumbnailHeight,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id AND status = 'processing'
|
||||
`);
|
||||
const markFailed = database.prepare(`
|
||||
UPDATE assets
|
||||
SET status = 'failed',
|
||||
processing_error = @processingError,
|
||||
original_object_key = @originalObjectKey,
|
||||
original_content_type = @originalContentType,
|
||||
original_byte_size = @originalByteSize,
|
||||
original_width = @originalWidth,
|
||||
original_height = @originalHeight,
|
||||
display_object_key = @displayObjectKey,
|
||||
display_sha256 = @displaySha256,
|
||||
display_content_type = @displayContentType,
|
||||
display_byte_size = @displayByteSize,
|
||||
display_width = @displayWidth,
|
||||
display_height = @displayHeight,
|
||||
thumbnail_object_key = @thumbnailObjectKey,
|
||||
thumbnail_sha256 = @thumbnailSha256,
|
||||
thumbnail_content_type = @thumbnailContentType,
|
||||
thumbnail_byte_size = @thumbnailByteSize,
|
||||
thumbnail_width = @thumbnailWidth,
|
||||
thumbnail_height = @thumbnailHeight,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id AND status = 'processing'
|
||||
`);
|
||||
const updateEditable = database.prepare(`
|
||||
UPDATE assets
|
||||
SET display_name = @displayName,
|
||||
expiration_at = @expirationAt,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id AND status != 'deleting'
|
||||
`);
|
||||
const playlistReferences = database.prepare(`
|
||||
SELECT
|
||||
pe.id AS entry_id,
|
||||
pe.playlist_id,
|
||||
p.name AS playlist_name,
|
||||
pe.enabled,
|
||||
pe.position,
|
||||
EXISTS (
|
||||
SELECT 1 FROM playback_state active
|
||||
WHERE active.active_playlist_id = pe.playlist_id
|
||||
) AS is_active_playlist,
|
||||
EXISTS (
|
||||
SELECT 1 FROM playback_state current
|
||||
WHERE current.current_entry_id = pe.id
|
||||
) AS is_current_normal_target
|
||||
FROM playlist_entries pe
|
||||
JOIN playlists p ON p.id = pe.playlist_id
|
||||
WHERE pe.asset_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE, pe.position, pe.id
|
||||
`);
|
||||
const playbackReferences = database.prepare(`
|
||||
SELECT
|
||||
output_id,
|
||||
mode,
|
||||
active_playlist_id,
|
||||
current_entry_id,
|
||||
static_asset_id
|
||||
FROM playback_state
|
||||
WHERE static_asset_id = ?
|
||||
OR current_entry_id IN (
|
||||
SELECT id FROM playlist_entries WHERE asset_id = ?
|
||||
)
|
||||
ORDER BY output_id
|
||||
`);
|
||||
const markDeleting = database.prepare(`
|
||||
UPDATE assets
|
||||
SET status = 'deleting', updated_at = ?
|
||||
WHERE id = ? AND status != 'processing'
|
||||
`);
|
||||
const deleteAssetRow = database.prepare(
|
||||
"DELETE FROM assets WHERE id = ? AND status = 'deleting'",
|
||||
);
|
||||
|
||||
const completeAsset = database.transaction((metadata) => {
|
||||
const result = markReady.run(metadata);
|
||||
if (result.changes !== 1) {
|
||||
throw new Error("Asset processing state changed before completion");
|
||||
}
|
||||
return findById.get(metadata.id);
|
||||
});
|
||||
|
||||
function getRaw(assetId) {
|
||||
const row = findById.get(assetId);
|
||||
if (!row) throw assetNotFound(assetId);
|
||||
return row;
|
||||
}
|
||||
|
||||
function duplicateStateError(row) {
|
||||
if (row.status === "failed") {
|
||||
return new AssetError(
|
||||
"ASSET_PROCESSING_FAILED",
|
||||
"These bytes match an asset whose processing previously failed",
|
||||
{ statusCode: 409, details: { asset: mapAsset(row) } },
|
||||
);
|
||||
}
|
||||
if (row.status === "deleting") {
|
||||
return new AssetError(
|
||||
"ASSET_DELETING",
|
||||
"These bytes match an asset that is being deleted",
|
||||
{ statusCode: 409, details: { assetId: row.id } },
|
||||
);
|
||||
}
|
||||
return new AssetError(
|
||||
"ASSET_PROCESSING",
|
||||
"These bytes are already being processed",
|
||||
{ statusCode: 409, details: { assetId: row.id } },
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveExistingHash(row) {
|
||||
if (row.status === "ready") {
|
||||
return { created: false, duplicate: true, asset: mapAsset(row) };
|
||||
}
|
||||
if (row.status !== "processing") {
|
||||
throw duplicateStateError(row);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + concurrentUploadWaitMs;
|
||||
while (Date.now() < deadline) {
|
||||
await wait(CONCURRENT_UPLOAD_POLL_MS);
|
||||
const latest = findByHash.get(row.source_sha256);
|
||||
if (!latest) break;
|
||||
if (latest.status === "ready") {
|
||||
return { created: false, duplicate: true, asset: mapAsset(latest) };
|
||||
}
|
||||
if (latest.status !== "processing") {
|
||||
throw duplicateStateError(latest);
|
||||
}
|
||||
}
|
||||
throw duplicateStateError(findByHash.get(row.source_sha256) || row);
|
||||
}
|
||||
|
||||
function nameConflict(displayName, row = findByName.get(displayName)) {
|
||||
return new AssetError(
|
||||
"ASSET_NAME_CONFLICT",
|
||||
"Another library asset already uses that display name",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: row ? { asset: mapAsset(row) } : { displayName },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function processingMetadata(id, processed, keys, error) {
|
||||
return {
|
||||
id,
|
||||
processingError: error ? boundedProcessingError(error) : null,
|
||||
originalObjectKey: keys.original,
|
||||
originalContentType: processed.original.contentType,
|
||||
originalByteSize: processed.original.byteSize,
|
||||
originalWidth: processed.original.width,
|
||||
originalHeight: processed.original.height,
|
||||
displayObjectKey: keys.display,
|
||||
displaySha256: processed.display.sha256,
|
||||
displayContentType: processed.display.contentType,
|
||||
displayByteSize: processed.display.byteSize,
|
||||
displayWidth: processed.display.width,
|
||||
displayHeight: processed.display.height,
|
||||
thumbnailObjectKey: keys.thumbnail,
|
||||
thumbnailSha256: processed.thumbnail.sha256,
|
||||
thumbnailContentType: processed.thumbnail.contentType,
|
||||
thumbnailByteSize: processed.thumbnail.byteSize,
|
||||
thumbnailWidth: processed.thumbnail.width,
|
||||
thumbnailHeight: processed.thumbnail.height,
|
||||
updatedAt: now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanObjects(keys) {
|
||||
const results = await Promise.allSettled(
|
||||
[...new Set(Object.values(keys).filter(Boolean))].map((key) =>
|
||||
storage.deleteObject(key),
|
||||
),
|
||||
);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
if (rejected) throw rejected.reason;
|
||||
}
|
||||
|
||||
async function createProcessedAsset(plan) {
|
||||
const id = createId();
|
||||
const timestamp = now();
|
||||
try {
|
||||
insertProcessing.run({
|
||||
id,
|
||||
sourceSha256: plan.sourceSha256,
|
||||
displayName: plan.displayName,
|
||||
originalFilename: plan.file.originalFilename,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
const hashMatch = findByHash.get(plan.sourceSha256);
|
||||
if (hashMatch) return resolveExistingHash(hashMatch);
|
||||
const nameMatch = findByName.get(plan.displayName);
|
||||
if (nameMatch) throw nameConflict(plan.displayName, nameMatch);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const keys = objectKeys(id, plan.processed);
|
||||
try {
|
||||
await storage.putObject({
|
||||
key: keys.original,
|
||||
body: fs.createReadStream(plan.file.path),
|
||||
contentType: plan.processed.original.contentType,
|
||||
contentLength: plan.processed.original.byteSize,
|
||||
metadata: { sha256: plan.processed.original.sha256 },
|
||||
});
|
||||
await storage.putObject({
|
||||
key: keys.display,
|
||||
body: plan.processed.display.buffer,
|
||||
contentType: plan.processed.display.contentType,
|
||||
contentLength: plan.processed.display.byteSize,
|
||||
metadata: { sha256: plan.processed.display.sha256 },
|
||||
});
|
||||
await storage.putObject({
|
||||
key: keys.thumbnail,
|
||||
body: plan.processed.thumbnail.buffer,
|
||||
contentType: plan.processed.thumbnail.contentType,
|
||||
contentLength: plan.processed.thumbnail.byteSize,
|
||||
metadata: { sha256: plan.processed.thumbnail.sha256 },
|
||||
});
|
||||
|
||||
const row = completeAsset(processingMetadata(id, plan.processed, keys));
|
||||
return { created: true, duplicate: false, asset: mapAsset(row) };
|
||||
} catch (error) {
|
||||
const failure = processingMetadata(id, plan.processed, keys, error);
|
||||
try {
|
||||
markFailed.run(failure);
|
||||
} catch {
|
||||
// Preserve the original failure; the processing row remains diagnosable.
|
||||
}
|
||||
try {
|
||||
await cleanObjects(keys);
|
||||
} catch {
|
||||
// The failed row retains deterministic keys so DELETE can retry cleanup.
|
||||
}
|
||||
|
||||
if (error?.statusCode) {
|
||||
error.details = { ...(error.details || {}), assetId: id, retryable: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(files, { displayName = null } = {}) {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
throw new AssetError(
|
||||
"UPLOAD_FILE_REQUIRED",
|
||||
"At least one image file is required",
|
||||
);
|
||||
}
|
||||
|
||||
const plans = [];
|
||||
const firstPlanByHash = new Map();
|
||||
const plannedNames = new Map();
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
const identity = await hashFile(file.path);
|
||||
if (identity.byteSize !== file.byteSize) {
|
||||
throw new AssetError(
|
||||
"UPLOAD_CHANGED_DURING_PROCESSING",
|
||||
"An uploaded temporary file changed before processing",
|
||||
{ statusCode: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const existing = findByHash.get(identity.sourceSha256);
|
||||
if (existing) {
|
||||
plans.push({ index, existing });
|
||||
continue;
|
||||
}
|
||||
|
||||
const priorPlan = firstPlanByHash.get(identity.sourceSha256);
|
||||
if (priorPlan) {
|
||||
plans.push({ index, duplicateOf: priorPlan });
|
||||
continue;
|
||||
}
|
||||
|
||||
const proposedName = normalizeDisplayName(
|
||||
displayName ?? defaultDisplayName(file.originalFilename),
|
||||
);
|
||||
const existingName = findByName.get(proposedName);
|
||||
if (existingName) throw nameConflict(proposedName, existingName);
|
||||
|
||||
const normalizedNameKey = proposedName.toLowerCase();
|
||||
const batchNameMatch = plannedNames.get(normalizedNameKey);
|
||||
if (batchNameMatch) throw nameConflict(proposedName);
|
||||
|
||||
const plan = {
|
||||
index,
|
||||
file,
|
||||
displayName: proposedName,
|
||||
sourceSha256: identity.sourceSha256,
|
||||
};
|
||||
firstPlanByHash.set(identity.sourceSha256, plan);
|
||||
plannedNames.set(normalizedNameKey, plan);
|
||||
plans.push(plan);
|
||||
}
|
||||
|
||||
// Decode every new image before any database or object-storage mutation.
|
||||
for (const plan of plans) {
|
||||
if (plan.existing || plan.duplicateOf) continue;
|
||||
plan.processed = await imageProcessor.process(plan.file.path);
|
||||
if (plan.processed.original.sha256 !== plan.sourceSha256) {
|
||||
throw new AssetError(
|
||||
"UPLOAD_CHANGED_DURING_PROCESSING",
|
||||
"An uploaded temporary file changed before processing",
|
||||
{ statusCode: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resultByPlan = new Map();
|
||||
const results = new Array(files.length);
|
||||
for (const plan of plans) {
|
||||
let result;
|
||||
if (plan.existing) {
|
||||
result = await resolveExistingHash(plan.existing);
|
||||
} else if (plan.duplicateOf) {
|
||||
const originalResult = resultByPlan.get(plan.duplicateOf);
|
||||
result = {
|
||||
created: false,
|
||||
duplicate: true,
|
||||
asset: originalResult.asset,
|
||||
};
|
||||
} else {
|
||||
result = await createProcessedAsset(plan);
|
||||
resultByPlan.set(plan, result);
|
||||
}
|
||||
results[plan.index] = result;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return listAssets.all().map(mapAsset);
|
||||
}
|
||||
|
||||
function get(assetId) {
|
||||
return mapAsset(getRaw(assetId));
|
||||
}
|
||||
|
||||
function update(assetId, input) {
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new AssetError("INVALID_ASSET_UPDATE", "Asset update must be an object");
|
||||
}
|
||||
|
||||
const allowed = new Set([
|
||||
"displayName",
|
||||
"display_name",
|
||||
"expirationAt",
|
||||
"expiration_at",
|
||||
]);
|
||||
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new AssetError(
|
||||
"INVALID_ASSET_UPDATE",
|
||||
"Asset update contains unsupported fields",
|
||||
{ details: { fields: unknown } },
|
||||
);
|
||||
}
|
||||
if (Object.keys(input).length === 0) {
|
||||
throw new AssetError("INVALID_ASSET_UPDATE", "At least one field is required");
|
||||
}
|
||||
if (
|
||||
Object.hasOwn(input, "displayName") &&
|
||||
Object.hasOwn(input, "display_name")
|
||||
) {
|
||||
throw new AssetError("INVALID_ASSET_UPDATE", "Display name was provided twice");
|
||||
}
|
||||
if (
|
||||
Object.hasOwn(input, "expirationAt") &&
|
||||
Object.hasOwn(input, "expiration_at")
|
||||
) {
|
||||
throw new AssetError("INVALID_ASSET_UPDATE", "Expiration was provided twice");
|
||||
}
|
||||
|
||||
const current = getRaw(assetId);
|
||||
if (current.status === "deleting") {
|
||||
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
|
||||
statusCode: 409,
|
||||
details: { assetId },
|
||||
});
|
||||
}
|
||||
const hasDisplayName =
|
||||
Object.hasOwn(input, "displayName") || Object.hasOwn(input, "display_name");
|
||||
const hasExpiration =
|
||||
Object.hasOwn(input, "expirationAt") || Object.hasOwn(input, "expiration_at");
|
||||
const nextDisplayName = hasDisplayName
|
||||
? normalizeDisplayName(
|
||||
Object.hasOwn(input, "displayName")
|
||||
? input.displayName
|
||||
: input.display_name,
|
||||
)
|
||||
: current.display_name;
|
||||
const nextExpiration = hasExpiration
|
||||
? normalizeExpiration(
|
||||
Object.hasOwn(input, "expirationAt")
|
||||
? input.expirationAt
|
||||
: input.expiration_at,
|
||||
)
|
||||
: current.expiration_at;
|
||||
|
||||
if (
|
||||
nextDisplayName === current.display_name &&
|
||||
nextExpiration === current.expiration_at
|
||||
) {
|
||||
return mapAsset(current);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = updateEditable.run({
|
||||
id: assetId,
|
||||
displayName: nextDisplayName,
|
||||
expirationAt: nextExpiration,
|
||||
updatedAt: Math.max(now(), current.updated_at + 1),
|
||||
});
|
||||
if (result.changes !== 1) {
|
||||
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
|
||||
statusCode: 409,
|
||||
details: { assetId },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const nameMatch = findByName.get(nextDisplayName);
|
||||
if (nameMatch && nameMatch.id !== assetId) {
|
||||
throw nameConflict(nextDisplayName, nameMatch);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return mapAsset(getRaw(assetId));
|
||||
}
|
||||
|
||||
function references(assetId) {
|
||||
const asset = getRaw(assetId);
|
||||
const playlists = playlistReferences.all(assetId).map((row) => ({
|
||||
playlistId: row.playlist_id,
|
||||
playlistName: row.playlist_name,
|
||||
playlistEntryId: row.entry_id,
|
||||
enabled: Boolean(row.enabled),
|
||||
position: row.position,
|
||||
isActivePlaylist: Boolean(row.is_active_playlist),
|
||||
isCurrentNormalTarget: Boolean(row.is_current_normal_target),
|
||||
}));
|
||||
const playback = playbackReferences.all(assetId, assetId).map((row) => ({
|
||||
outputId: row.output_id,
|
||||
mode: row.mode,
|
||||
activePlaylistId: row.active_playlist_id,
|
||||
currentPlaylistEntryId: row.current_entry_id,
|
||||
isCurrentNormalTarget: playlists.some(
|
||||
(entry) => entry.playlistEntryId === row.current_entry_id,
|
||||
),
|
||||
isCurrentStaticTarget: row.static_asset_id === assetId,
|
||||
}));
|
||||
const currentNormalTarget = playback.some((state) => state.isCurrentNormalTarget);
|
||||
const currentStaticTarget = playback.some((state) => state.isCurrentStaticTarget);
|
||||
const blockerCodes = [];
|
||||
if (playlists.length > 0) blockerCodes.push("PLAYLIST_REFERENCES");
|
||||
if (currentNormalTarget) blockerCodes.push("CURRENT_PLAYBACK_TARGET");
|
||||
if (currentStaticTarget) blockerCodes.push("CURRENT_STATIC_TARGET");
|
||||
if (asset.status === "processing") blockerCodes.push("ASSET_PROCESSING");
|
||||
if (asset.status === "deleting") blockerCodes.push("ASSET_DELETING");
|
||||
|
||||
return {
|
||||
assetId,
|
||||
playlists,
|
||||
playback,
|
||||
currentNormalTarget,
|
||||
currentStaticTarget,
|
||||
canDelete: blockerCodes.length === 0,
|
||||
blockerCodes,
|
||||
};
|
||||
}
|
||||
|
||||
const beginDelete = database.transaction((assetId) => {
|
||||
const asset = getRaw(assetId);
|
||||
const referenceInfo = references(assetId);
|
||||
const relationshipBlockers = referenceInfo.blockerCodes.filter((code) =>
|
||||
[
|
||||
"PLAYLIST_REFERENCES",
|
||||
"CURRENT_PLAYBACK_TARGET",
|
||||
"CURRENT_STATIC_TARGET",
|
||||
].includes(code),
|
||||
);
|
||||
if (relationshipBlockers.length > 0) {
|
||||
throw new AssetError(
|
||||
referenceInfo.playlists.length > 0 ? "ASSET_REFERENCED" : "ASSET_IN_USE",
|
||||
"The library asset is still referenced and cannot be deleted",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: { ...referenceInfo, blockerCodes: relationshipBlockers },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (asset.status === "processing") {
|
||||
throw new AssetError(
|
||||
"ASSET_PROCESSING",
|
||||
"An asset cannot be deleted while it is processing",
|
||||
{ statusCode: 409, details: { assetId } },
|
||||
);
|
||||
}
|
||||
if (asset.status !== "deleting") {
|
||||
markDeleting.run(Math.max(now(), asset.updated_at + 1), assetId);
|
||||
}
|
||||
return findById.get(assetId);
|
||||
});
|
||||
|
||||
async function remove(assetId) {
|
||||
const deleting = beginDelete(assetId);
|
||||
const keys = {
|
||||
original: deleting.original_object_key,
|
||||
display: deleting.display_object_key,
|
||||
thumbnail: deleting.thumbnail_object_key,
|
||||
};
|
||||
|
||||
try {
|
||||
await cleanObjects(keys);
|
||||
} catch (error) {
|
||||
if (error?.statusCode) {
|
||||
error.details = { ...(error.details || {}), assetId, retryable: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = deleteAssetRow.run(assetId);
|
||||
if (result.changes !== 1) throw assetNotFound(assetId);
|
||||
} catch (error) {
|
||||
if (error?.code?.startsWith?.("SQLITE_CONSTRAINT")) {
|
||||
throw new AssetError(
|
||||
"ASSET_REFERENCED",
|
||||
"The library asset became referenced before deletion completed",
|
||||
{ statusCode: 409, details: { assetId, retryable: true } },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { id: assetId, deleted: true };
|
||||
}
|
||||
|
||||
function mediaDescriptor(assetId, kind, requestedHash) {
|
||||
if (!SHA256_PATTERN.test(requestedHash)) {
|
||||
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const asset = getRaw(assetId);
|
||||
const fields =
|
||||
kind === "display"
|
||||
? {
|
||||
hash: asset.display_sha256,
|
||||
key: asset.display_object_key,
|
||||
contentType: asset.display_content_type,
|
||||
contentLength: asset.display_byte_size,
|
||||
}
|
||||
: {
|
||||
hash: asset.thumbnail_sha256,
|
||||
key: asset.thumbnail_object_key,
|
||||
contentType: asset.thumbnail_content_type,
|
||||
contentLength: asset.thumbnail_byte_size,
|
||||
};
|
||||
if (asset.status !== "ready" || fields.hash !== requestedHash || !fields.key) {
|
||||
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
return { asset, kind, ...fields };
|
||||
}
|
||||
|
||||
function originalDescriptor(assetId) {
|
||||
const asset = getRaw(assetId);
|
||||
if (asset.status !== "ready" || !asset.original_object_key) {
|
||||
throw new AssetError("ASSET_ORIGINAL_NOT_READY", "Asset original is not ready", {
|
||||
statusCode: 409,
|
||||
details: { assetId, status: asset.status },
|
||||
});
|
||||
}
|
||||
return {
|
||||
asset,
|
||||
key: asset.original_object_key,
|
||||
hash: asset.source_sha256,
|
||||
contentType: asset.original_content_type,
|
||||
contentLength: asset.original_byte_size,
|
||||
};
|
||||
}
|
||||
|
||||
async function readStoredObject(descriptor) {
|
||||
try {
|
||||
return await storage.getObject(descriptor.key);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectNotFoundError) {
|
||||
throw new AssetError(
|
||||
"STORAGE_INCONSISTENCY",
|
||||
"Asset metadata exists but its stored bytes are unavailable",
|
||||
{
|
||||
statusCode: 503,
|
||||
details: { assetId: descriptor.asset.id, retryable: true },
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
list,
|
||||
get,
|
||||
upload,
|
||||
update,
|
||||
references,
|
||||
remove,
|
||||
mediaDescriptor,
|
||||
originalDescriptor,
|
||||
readStoredObject,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAssetService,
|
||||
defaultDisplayName,
|
||||
mapAsset,
|
||||
normalizeDisplayName,
|
||||
normalizeExpiration,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
const { Readable } = require("node:stream");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
const { AssetError } = require("./errors");
|
||||
|
||||
function readableBody(body) {
|
||||
if (body && typeof body.pipe === "function") return body;
|
||||
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
||||
return Readable.from([body]);
|
||||
}
|
||||
if (body && typeof body.getReader === "function") {
|
||||
return Readable.fromWeb(body);
|
||||
}
|
||||
throw new AssetError(
|
||||
"STORAGE_INCONSISTENCY",
|
||||
"Stored asset bytes could not be streamed",
|
||||
{ statusCode: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
function validateDescriptor(descriptor) {
|
||||
if (
|
||||
typeof descriptor.contentType !== "string" ||
|
||||
!Number.isSafeInteger(descriptor.contentLength) ||
|
||||
descriptor.contentLength < 0
|
||||
) {
|
||||
throw new AssetError(
|
||||
"STORAGE_INCONSISTENCY",
|
||||
"Stored asset metadata is incomplete",
|
||||
{ statusCode: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setCommonHeaders(response, descriptor) {
|
||||
response.set("Content-Type", descriptor.contentType);
|
||||
response.set("Content-Length", String(descriptor.contentLength));
|
||||
response.set("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
|
||||
async function storedBody(assetService, descriptor) {
|
||||
const stored = await assetService.readStoredObject(descriptor);
|
||||
return readableBody(stored.body);
|
||||
}
|
||||
|
||||
async function sendImmutableMedia(assetService, response, descriptor) {
|
||||
validateDescriptor(descriptor);
|
||||
const body = await storedBody(assetService, descriptor);
|
||||
setCommonHeaders(response, descriptor);
|
||||
response.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
response.set("ETag", `"${descriptor.hash}"`);
|
||||
await pipeline(body, response);
|
||||
}
|
||||
|
||||
async function sendOriginal(assetService, response, descriptor) {
|
||||
validateDescriptor(descriptor);
|
||||
const body = await storedBody(assetService, descriptor);
|
||||
response.attachment(descriptor.asset.original_filename);
|
||||
setCommonHeaders(response, descriptor);
|
||||
response.set("Cache-Control", "private, no-store");
|
||||
await pipeline(body, response);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendImmutableMedia,
|
||||
sendOriginal,
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
const path = require("node:path");
|
||||
|
||||
class ConfigError extends Error {
|
||||
constructor(message, field) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(env, name) {
|
||||
const value = env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new ConfigError(`${name} is required`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(env, name) {
|
||||
const value = env[name]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function integer(env, name, defaultValue, { min, max } = {}) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
throw new ConfigError(`${name} must be an integer`, name);
|
||||
}
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ConfigError(`${name} must be a safe integer`, name);
|
||||
}
|
||||
if (min !== undefined && value < min) {
|
||||
throw new ConfigError(`${name} must be at least ${min}`, name);
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
throw new ConfigError(`${name} must be at most ${max}`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(env, name, defaultValue) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["false", "0", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
throw new ConfigError(`${name} must be a boolean`, name);
|
||||
}
|
||||
|
||||
function url(env, name, { required = false, protocols } = {}) {
|
||||
const raw = required ? requiredString(env, name) : optionalString(env, name);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new ConfigError(`${name} must be a valid URL`, name);
|
||||
}
|
||||
|
||||
if (protocols && !protocols.includes(parsed.protocol)) {
|
||||
throw new ConfigError(
|
||||
`${name} must use one of these protocols: ${protocols.join(", ")}`,
|
||||
name,
|
||||
);
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function bucketName(env) {
|
||||
const value = requiredString(env, "S3_BUCKET");
|
||||
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
|
||||
throw new ConfigError(
|
||||
"S3_BUCKET must be a valid lowercase S3 bucket name",
|
||||
"S3_BUCKET",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nodeEnvironment(env) {
|
||||
const value = env.NODE_ENV?.trim() || "development";
|
||||
if (!["development", "test", "production"].includes(value)) {
|
||||
throw new ConfigError(
|
||||
"NODE_ENV must be development, test, or production",
|
||||
"NODE_ENV",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timeZone(env) {
|
||||
const value = requiredString(env, "APP_TIMEZONE");
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
|
||||
} catch {
|
||||
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storageCredential(env, name, environment) {
|
||||
const value = optionalString(env, name);
|
||||
if (environment === "production" && value === null) {
|
||||
throw new ConfigError(`${name} is required in production`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadConfig(env = process.env) {
|
||||
const environment = nodeEnvironment(env);
|
||||
const databasePath = requiredString(env, "DATABASE_PATH");
|
||||
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
|
||||
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
environment,
|
||||
host: env.HOST?.trim() || "0.0.0.0",
|
||||
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
|
||||
timeZone: timeZone(env),
|
||||
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
|
||||
min: 1_000,
|
||||
max: 60_000,
|
||||
}),
|
||||
trustProxy: boolean(env, "TRUST_PROXY", true),
|
||||
database: Object.freeze({
|
||||
path: databasePath,
|
||||
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
|
||||
min: 0,
|
||||
max: 60_000,
|
||||
}),
|
||||
}),
|
||||
storage: Object.freeze({
|
||||
endpoint: url(env, "S3_ENDPOINT", {
|
||||
required: true,
|
||||
protocols: ["http:", "https:"],
|
||||
}),
|
||||
region: env.S3_REGION?.trim() || "us-east-1",
|
||||
bucket: bucketName(env),
|
||||
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
|
||||
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
|
||||
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
|
||||
healthProbeIntervalMs: integer(
|
||||
env,
|
||||
"S3_HEALTH_PROBE_INTERVAL_MS",
|
||||
30_000,
|
||||
{ min: 1_000, max: 300_000 },
|
||||
),
|
||||
connectionTimeoutMs: integer(env, "S3_CONNECT_TIMEOUT_MS", 2_000, {
|
||||
min: 250,
|
||||
max: 60_000,
|
||||
}),
|
||||
requestTimeoutMs: integer(env, "S3_REQUEST_TIMEOUT_MS", 5_000, {
|
||||
min: 1_000,
|
||||
max: 120_000,
|
||||
}),
|
||||
maxAttempts: integer(env, "S3_MAX_ATTEMPTS", 2, { min: 1, max: 5 }),
|
||||
}),
|
||||
processing: Object.freeze({
|
||||
tempDirectory: (() => {
|
||||
const value = env.UPLOAD_TEMP_DIRECTORY?.trim() || "/tmp/announcements-uploads";
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw new ConfigError(
|
||||
"UPLOAD_TEMP_DIRECTORY must be an absolute path",
|
||||
"UPLOAD_TEMP_DIRECTORY",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
})(),
|
||||
maxFileBytes: integer(env, "UPLOAD_MAX_FILE_BYTES", 25 * 1024 * 1024, {
|
||||
min: 1,
|
||||
max: 1024 * 1024 * 1024,
|
||||
}),
|
||||
maxFiles: integer(env, "UPLOAD_MAX_FILES", 20, { min: 1, max: 100 }),
|
||||
maxInputPixels: integer(env, "IMAGE_MAX_INPUT_PIXELS", 40_000_000, {
|
||||
min: 1,
|
||||
max: 500_000_000,
|
||||
}),
|
||||
}),
|
||||
mqtt: Object.freeze({
|
||||
url: url(env, "MQTT_URL", {
|
||||
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
|
||||
}),
|
||||
username: optionalString(env, "MQTT_USERNAME"),
|
||||
password: optionalString(env, "MQTT_PASSWORD"),
|
||||
}),
|
||||
notifications: Object.freeze({
|
||||
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
|
||||
protocols: ["http:", "https:"],
|
||||
}),
|
||||
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function redactedConfig(config) {
|
||||
return {
|
||||
environment: config.environment,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
timeZone: config.timeZone,
|
||||
trustProxy: config.trustProxy,
|
||||
database: {
|
||||
configured: Boolean(config.database.path),
|
||||
},
|
||||
storage: {
|
||||
configured: Boolean(
|
||||
config.storage.endpoint &&
|
||||
config.storage.bucket &&
|
||||
config.storage.accessKeyId &&
|
||||
config.storage.secretAccessKey,
|
||||
),
|
||||
region: config.storage.region,
|
||||
bucket: config.storage.bucket,
|
||||
forcePathStyle: config.storage.forcePathStyle,
|
||||
healthProbeIntervalMs: config.storage.healthProbeIntervalMs,
|
||||
connectionTimeoutMs: config.storage.connectionTimeoutMs,
|
||||
requestTimeoutMs: config.storage.requestTimeoutMs,
|
||||
maxAttempts: config.storage.maxAttempts,
|
||||
},
|
||||
processing: {
|
||||
tempDirectoryConfigured: Boolean(config.processing.tempDirectory),
|
||||
maxFileBytes: config.processing.maxFileBytes,
|
||||
maxFiles: config.processing.maxFiles,
|
||||
maxInputPixels: config.processing.maxInputPixels,
|
||||
},
|
||||
mqtt: {
|
||||
configured: Boolean(config.mqtt.url),
|
||||
},
|
||||
notifications: {
|
||||
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ConfigError,
|
||||
loadConfig,
|
||||
redactedConfig,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
function openDatabase({ path: filename, busyTimeoutMs = 5_000 }) {
|
||||
if (filename !== ":memory:") {
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
||||
}
|
||||
|
||||
const database = new Database(filename, {
|
||||
timeout: busyTimeoutMs,
|
||||
});
|
||||
|
||||
database.pragma("foreign_keys = ON");
|
||||
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
if (filename !== ":memory:") {
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.pragma("synchronous = NORMAL");
|
||||
}
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
function checkDatabase(database) {
|
||||
database.prepare("SELECT 1 AS ok").get();
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkDatabase,
|
||||
openDatabase,
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DEFAULT_MIGRATIONS_DIRECTORY = path.join(__dirname, "migrations");
|
||||
const MIGRATION_FILENAME = /^(\d+)_([a-z0-9_-]+)\.sql$/i;
|
||||
|
||||
function discoverMigrations(directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||
const migrations = fs
|
||||
.readdirSync(directory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
||||
.map((entry) => {
|
||||
const match = MIGRATION_FILENAME.exec(entry.name);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid migration filename: ${entry.name}`);
|
||||
}
|
||||
return {
|
||||
version: Number(match[1]),
|
||||
name: match[2],
|
||||
filename: entry.name,
|
||||
sql: fs.readFileSync(path.join(directory, entry.name), "utf8"),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.version - right.version);
|
||||
|
||||
const versions = new Set();
|
||||
for (const migration of migrations) {
|
||||
if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
|
||||
throw new Error(`Invalid migration version: ${migration.filename}`);
|
||||
}
|
||||
if (versions.has(migration.version)) {
|
||||
throw new Error(`Duplicate migration version: ${migration.version}`);
|
||||
}
|
||||
versions.add(migration.version);
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
function ensureMigrationsTable(database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function readAppliedMigrations(database) {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT version, name, applied_at AS appliedAt
|
||||
FROM schema_migrations
|
||||
ORDER BY version`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
function migrationStatus(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||
ensureMigrationsTable(database);
|
||||
const available = discoverMigrations(directory);
|
||||
const applied = readAppliedMigrations(database);
|
||||
const appliedVersions = new Set(applied.map((migration) => migration.version));
|
||||
|
||||
return {
|
||||
currentVersion: applied.at(-1)?.version ?? 0,
|
||||
latestAvailableVersion: available.at(-1)?.version ?? 0,
|
||||
pendingVersions: available
|
||||
.filter((migration) => !appliedVersions.has(migration.version))
|
||||
.map((migration) => migration.version),
|
||||
applied,
|
||||
};
|
||||
}
|
||||
|
||||
function runMigrations(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||
ensureMigrationsTable(database);
|
||||
const migrations = discoverMigrations(directory);
|
||||
const applied = new Map(
|
||||
readAppliedMigrations(database).map((migration) => [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]),
|
||||
);
|
||||
const availableVersions = new Set(migrations.map((migration) => migration.version));
|
||||
|
||||
for (const [version] of applied) {
|
||||
if (!availableVersions.has(version)) {
|
||||
throw new Error(`Database contains unknown migration version ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
const insertMigration = database.prepare(
|
||||
`INSERT INTO schema_migrations (version, name, applied_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
);
|
||||
const applyMigration = database.transaction((migration) => {
|
||||
database.exec(migration.sql);
|
||||
insertMigration.run(migration.version, migration.name, Date.now());
|
||||
});
|
||||
|
||||
for (const migration of migrations) {
|
||||
const appliedName = applied.get(migration.version);
|
||||
if (appliedName !== undefined) {
|
||||
if (appliedName !== migration.name) {
|
||||
throw new Error(
|
||||
`Migration ${migration.version} was applied as ${appliedName}, not ${migration.name}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
applyMigration(migration);
|
||||
}
|
||||
|
||||
return migrationStatus(database, directory);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MIGRATIONS_DIRECTORY,
|
||||
discoverMigrations,
|
||||
migrationStatus,
|
||||
runMigrations,
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_sha256 TEXT NOT NULL UNIQUE
|
||||
CHECK (length(source_sha256) = 64),
|
||||
display_name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||
CHECK (length(trim(display_name)) > 0),
|
||||
original_filename TEXT NOT NULL
|
||||
CHECK (length(trim(original_filename)) > 0),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('processing', 'ready', 'failed', 'deleting')),
|
||||
processing_error TEXT,
|
||||
expiration_at INTEGER,
|
||||
|
||||
original_object_key TEXT,
|
||||
original_content_type TEXT,
|
||||
original_byte_size INTEGER CHECK (original_byte_size IS NULL OR original_byte_size >= 0),
|
||||
original_width INTEGER CHECK (original_width IS NULL OR original_width > 0),
|
||||
original_height INTEGER CHECK (original_height IS NULL OR original_height > 0),
|
||||
|
||||
display_object_key TEXT,
|
||||
display_sha256 TEXT CHECK (display_sha256 IS NULL OR length(display_sha256) = 64),
|
||||
display_content_type TEXT,
|
||||
display_byte_size INTEGER CHECK (display_byte_size IS NULL OR display_byte_size >= 0),
|
||||
display_width INTEGER CHECK (display_width IS NULL OR display_width > 0),
|
||||
display_height INTEGER CHECK (display_height IS NULL OR display_height > 0),
|
||||
|
||||
thumbnail_object_key TEXT,
|
||||
thumbnail_sha256 TEXT CHECK (thumbnail_sha256 IS NULL OR length(thumbnail_sha256) = 64),
|
||||
thumbnail_content_type TEXT,
|
||||
thumbnail_byte_size INTEGER CHECK (thumbnail_byte_size IS NULL OR thumbnail_byte_size >= 0),
|
||||
thumbnail_width INTEGER CHECK (thumbnail_width IS NULL OR thumbnail_width > 0),
|
||||
thumbnail_height INTEGER CHECK (thumbnail_height IS NULL OR thumbnail_height > 0),
|
||||
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX assets_lifecycle_idx ON assets (status, expiration_at);
|
||||
|
||||
CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||
CHECK (length(trim(name)) > 0),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
playlist_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
duration_override_ms INTEGER
|
||||
CHECK (duration_override_ms IS NULL OR duration_override_ms > 0),
|
||||
background_mode_override TEXT
|
||||
CHECK (
|
||||
background_mode_override IS NULL OR
|
||||
background_mode_override IN ('blurred', 'solid', 'black')
|
||||
),
|
||||
background_color_override TEXT,
|
||||
background_darkened_override INTEGER
|
||||
CHECK (
|
||||
background_darkened_override IS NULL OR
|
||||
background_darkened_override IN (0, 1)
|
||||
),
|
||||
schedule_start_at INTEGER,
|
||||
schedule_end_at INTEGER,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
|
||||
UNIQUE (playlist_id, position),
|
||||
UNIQUE (playlist_id, asset_id),
|
||||
CHECK (
|
||||
schedule_start_at IS NULL OR
|
||||
schedule_end_at IS NULL OR
|
||||
schedule_start_at < schedule_end_at
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX playlist_entries_asset_idx ON playlist_entries (asset_id);
|
||||
CREATE INDEX playlist_entries_schedule_idx
|
||||
ON playlist_entries (playlist_id, enabled, schedule_start_at, schedule_end_at);
|
||||
|
||||
CREATE TABLE app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
default_duration_ms INTEGER NOT NULL DEFAULT 7000
|
||||
CHECK (default_duration_ms > 0),
|
||||
default_background_mode TEXT NOT NULL DEFAULT 'blurred'
|
||||
CHECK (default_background_mode IN ('blurred', 'solid', 'black')),
|
||||
default_solid_background_color TEXT NOT NULL DEFAULT '#000000'
|
||||
CHECK (length(trim(default_solid_background_color)) > 0),
|
||||
default_background_darkened INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (default_background_darkened IN (0, 1)),
|
||||
default_blur_px INTEGER NOT NULL DEFAULT 20
|
||||
CHECK (default_blur_px >= 0),
|
||||
crossfade_duration_ms INTEGER NOT NULL DEFAULT 1000
|
||||
CHECK (crossfade_duration_ms >= 0),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO app_settings (id, updated_at)
|
||||
VALUES (1, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
|
||||
|
||||
CREATE TABLE playback_state (
|
||||
output_id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL DEFAULT 'on' CHECK (mode IN ('on', 'off')),
|
||||
active_playlist_id TEXT,
|
||||
pending_playlist_id TEXT,
|
||||
pending_playlist_requested_at INTEGER,
|
||||
current_entry_id TEXT,
|
||||
rotation_paused INTEGER NOT NULL DEFAULT 0 CHECK (rotation_paused IN (0, 1)),
|
||||
paused_remaining_ms INTEGER
|
||||
CHECK (paused_remaining_ms IS NULL OR paused_remaining_ms >= 0),
|
||||
static_asset_id TEXT,
|
||||
slide_started_at INTEGER,
|
||||
next_slide_at INTEGER,
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (active_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (pending_playlist_id) REFERENCES playlists(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (current_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (static_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
|
||||
CHECK (
|
||||
(pending_playlist_id IS NULL AND pending_playlist_requested_at IS NULL) OR
|
||||
(pending_playlist_id IS NOT NULL AND pending_playlist_requested_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO playback_state (output_id, updated_at)
|
||||
VALUES ('global', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
|
||||
|
||||
CREATE TABLE asset_import_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_system TEXT NOT NULL,
|
||||
source_bucket TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_etag TEXT,
|
||||
source_byte_size INTEGER CHECK (source_byte_size IS NULL OR source_byte_size >= 0),
|
||||
source_sha256 TEXT CHECK (source_sha256 IS NULL OR length(source_sha256) = 64),
|
||||
asset_id TEXT,
|
||||
initial_playlist_entry_id TEXT,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('pending', 'processing', 'imported', 'failed')),
|
||||
error TEXT,
|
||||
imported_at INTEGER,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (initial_playlist_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
|
||||
UNIQUE (source_system, source_bucket, source_key)
|
||||
);
|
||||
|
||||
CREATE INDEX asset_import_sources_status_idx ON asset_import_sources (status);
|
||||
|
||||
CREATE TABLE notification_incidents (
|
||||
id TEXT PRIMARY KEY,
|
||||
condition_key TEXT NOT NULL,
|
||||
episode INTEGER NOT NULL CHECK (episode > 0),
|
||||
event TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('open', 'recovered')),
|
||||
message TEXT NOT NULL,
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
opened_at INTEGER NOT NULL,
|
||||
last_observed_at INTEGER NOT NULL,
|
||||
recovered_at INTEGER,
|
||||
UNIQUE (condition_key, episode),
|
||||
CHECK (
|
||||
(state = 'open' AND recovered_at IS NULL) OR
|
||||
(state = 'recovered' AND recovered_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX notification_incidents_one_open_idx
|
||||
ON notification_incidents (condition_key)
|
||||
WHERE state = 'open';
|
||||
|
||||
CREATE TABLE notification_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
incident_id TEXT NOT NULL,
|
||||
transition TEXT NOT NULL CHECK (transition IN ('failure', 'recovery')),
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_attempt_at INTEGER NOT NULL,
|
||||
delivered_at INTEGER,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (incident_id) REFERENCES notification_incidents(id) ON DELETE CASCADE,
|
||||
UNIQUE (incident_id, transition)
|
||||
);
|
||||
|
||||
CREATE INDEX notification_outbox_due_idx
|
||||
ON notification_outbox (next_attempt_at)
|
||||
WHERE delivered_at IS NULL;
|
||||
|
||||
CREATE TABLE display_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
output_id TEXT NOT NULL DEFAULT 'global',
|
||||
first_seen_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
active_playlist_id TEXT,
|
||||
playlist_revision INTEGER,
|
||||
playback_revision INTEGER,
|
||||
current_entry_id TEXT,
|
||||
current_asset_id TEXT,
|
||||
cached_asset_count INTEGER CHECK (cached_asset_count IS NULL OR cached_asset_count >= 0),
|
||||
playable_asset_count INTEGER CHECK (playable_asset_count IS NULL OR playable_asset_count >= 0),
|
||||
last_error TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX display_clients_last_seen_idx ON display_clients (last_seen_at);
|
||||
@@ -0,0 +1,143 @@
|
||||
const express = require("express");
|
||||
const { checkDatabase } = require("../database");
|
||||
const { redactedConfig } = require("../config");
|
||||
|
||||
function databaseHealth(database) {
|
||||
try {
|
||||
checkDatabase(database);
|
||||
return { status: "ok" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "SQLite is unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function storageHealth(storage) {
|
||||
if (!storage) {
|
||||
return {
|
||||
status: "degraded",
|
||||
checkedAt: null,
|
||||
lastSuccessAt: null,
|
||||
code: "STORAGE_NOT_INITIALIZED",
|
||||
message: "Object storage has not been initialized",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = storage.health();
|
||||
if (result?.status === "healthy") {
|
||||
return {
|
||||
status: "healthy",
|
||||
checkedAt: result.checkedAt || null,
|
||||
lastSuccessAt: result.lastSuccessAt || result.checkedAt || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "degraded",
|
||||
checkedAt: result?.checkedAt || null,
|
||||
lastSuccessAt: result?.lastSuccessAt || null,
|
||||
code:
|
||||
result?.code === "STORAGE_NOT_CHECKED"
|
||||
? "STORAGE_NOT_CHECKED"
|
||||
: "STORAGE_UNAVAILABLE",
|
||||
message:
|
||||
result?.code === "STORAGE_NOT_CHECKED"
|
||||
? "Object storage has not been checked"
|
||||
: "Object storage is unavailable",
|
||||
};
|
||||
} catch {
|
||||
// A health endpoint must remain available when its dependency is not.
|
||||
}
|
||||
|
||||
return {
|
||||
status: "degraded",
|
||||
checkedAt: null,
|
||||
lastSuccessAt: null,
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
message: "Object storage is unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function createHealthRouter({ database, lifecycle, migrations, storage }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/live", (request, response) => {
|
||||
response.json({ status: "ok" });
|
||||
});
|
||||
|
||||
router.get("/ready", async (request, response) => {
|
||||
const databaseCheck = databaseHealth(database);
|
||||
const storageCheck = storageHealth(storage);
|
||||
const migrationsCurrent = migrations.pendingVersions.length === 0;
|
||||
const ready =
|
||||
lifecycle.ready && databaseCheck.status === "ok" && migrationsCurrent;
|
||||
|
||||
response.status(ready ? 200 : 503).json({
|
||||
status: ready ? "ready" : "not_ready",
|
||||
checks: {
|
||||
database: databaseCheck.status,
|
||||
migrations: migrationsCurrent ? "current" : "pending",
|
||||
storage: storageCheck.status,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function createDetailedHealthRouter({ config, database, lifecycle, migrations, storage }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (request, response) => {
|
||||
const databaseCheck = databaseHealth(database);
|
||||
const storageCheck = storageHealth(storage);
|
||||
const ready =
|
||||
lifecycle.ready &&
|
||||
databaseCheck.status === "ok" &&
|
||||
migrations.pendingVersions.length === 0;
|
||||
|
||||
const configuration = redactedConfig(config);
|
||||
const status = !ready
|
||||
? "not_ready"
|
||||
: storageCheck.status === "healthy"
|
||||
? "ok"
|
||||
: "degraded";
|
||||
|
||||
response.status(ready ? 200 : 503).json({
|
||||
status,
|
||||
configuration,
|
||||
services: {
|
||||
database: {
|
||||
...databaseCheck,
|
||||
currentSchemaVersion: migrations.currentVersion,
|
||||
latestSchemaVersion: migrations.latestAvailableVersion,
|
||||
},
|
||||
storage: {
|
||||
...storageCheck,
|
||||
configured: configuration.storage.configured,
|
||||
},
|
||||
mqtt: {
|
||||
status: "not_started",
|
||||
configured: configuration.mqtt.configured,
|
||||
},
|
||||
notifications: {
|
||||
status: "not_started",
|
||||
configured: configuration.notifications.configured,
|
||||
},
|
||||
playback: { status: "not_started" },
|
||||
displays: { status: "not_started" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDetailedHealthRouter,
|
||||
createHealthRouter,
|
||||
databaseHealth,
|
||||
storageHealth,
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const SUPPORTED_FORMATS = Object.freeze(new Set(["jpeg", "png", "webp"]));
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
});
|
||||
const BLACK = Object.freeze({ r: 0, g: 0, b: 0, alpha: 1 });
|
||||
const DEFAULT_IMAGE_DERIVATIVE_OPTIONS = Object.freeze({
|
||||
display: Object.freeze({ maxWidth: 1920, maxHeight: 1080, quality: 82 }),
|
||||
thumbnail: Object.freeze({ maxWidth: 480, maxHeight: 270, quality: 72 }),
|
||||
});
|
||||
|
||||
class ImageProcessingError extends Error {
|
||||
constructor(code, message, { statusCode = 422, details, cause } = {}) {
|
||||
super(message, cause ? { cause } : undefined);
|
||||
this.name = "ImageProcessingError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
if (details !== undefined) {
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireInteger(value, name, { min, max } = {}) {
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new TypeError(`${name} must be an integer`);
|
||||
}
|
||||
if (min !== undefined && value < min) {
|
||||
throw new RangeError(`${name} must be at least ${min}`);
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
throw new RangeError(`${name} must be at most ${max}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTargetOptions(target, name, defaults) {
|
||||
if (
|
||||
target !== undefined &&
|
||||
(target === null || typeof target !== "object" || Array.isArray(target))
|
||||
) {
|
||||
throw new TypeError(`${name} image options must be an object`);
|
||||
}
|
||||
|
||||
const configured = { ...defaults, ...target };
|
||||
|
||||
return Object.freeze({
|
||||
maxWidth: requireInteger(configured.maxWidth, `${name}.maxWidth`, { min: 1 }),
|
||||
maxHeight: requireInteger(configured.maxHeight, `${name}.maxHeight`, { min: 1 }),
|
||||
quality: requireInteger(configured.quality, `${name}.quality`, {
|
||||
min: 1,
|
||||
max: 100,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Image processing options are required");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
maxInputPixels: requireInteger(
|
||||
options.maxInputPixels,
|
||||
"maxInputPixels",
|
||||
{ min: 1 },
|
||||
),
|
||||
display: normalizeTargetOptions(
|
||||
options.display,
|
||||
"display",
|
||||
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.display,
|
||||
),
|
||||
thumbnail: normalizeTargetOptions(
|
||||
options.thumbnail,
|
||||
"thumbnail",
|
||||
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.thumbnail,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(buffer) {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function hashFile(filename) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
let byteSize = 0;
|
||||
|
||||
try {
|
||||
for await (const chunk of fs.createReadStream(filename)) {
|
||||
byteSize += chunk.length;
|
||||
hash.update(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new ImageProcessingError(
|
||||
"IMAGE_INPUT_UNREADABLE",
|
||||
"The uploaded image could not be read",
|
||||
{ statusCode: 422, cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return { byteSize, sha256: hash.digest("hex") };
|
||||
}
|
||||
|
||||
async function describeInput(input) {
|
||||
if (Buffer.isBuffer(input)) {
|
||||
return {
|
||||
sharpInput: input,
|
||||
byteSize: input.length,
|
||||
sha256: sha256(input),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input !== "string" || input.length === 0) {
|
||||
throw new ImageProcessingError(
|
||||
"INVALID_IMAGE_INPUT",
|
||||
"Image input must be a Buffer or temporary file path",
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.promises.stat(input);
|
||||
} catch (error) {
|
||||
throw new ImageProcessingError(
|
||||
"IMAGE_INPUT_UNREADABLE",
|
||||
"The uploaded image could not be read",
|
||||
{ statusCode: 422, cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new ImageProcessingError(
|
||||
"INVALID_IMAGE_INPUT",
|
||||
"Image input path must identify a regular file",
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sharpInput: input,
|
||||
...(await hashFile(input)),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeError(error, maxInputPixels) {
|
||||
if (error instanceof ImageProcessingError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (/pixel limit/i.test(error?.message || "")) {
|
||||
return new ImageProcessingError(
|
||||
"IMAGE_PIXEL_LIMIT_EXCEEDED",
|
||||
"The uploaded image exceeds the configured decoded-pixel limit",
|
||||
{
|
||||
statusCode: 413,
|
||||
details: { maxInputPixels },
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new ImageProcessingError(
|
||||
"MALFORMED_IMAGE",
|
||||
"The uploaded file is not a valid decodable image",
|
||||
{ statusCode: 422, cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
async function readMetadata(input, maxInputPixels) {
|
||||
try {
|
||||
return await sharp(input, {
|
||||
animated: true,
|
||||
failOn: "error",
|
||||
limitInputPixels: maxInputPixels,
|
||||
}).metadata();
|
||||
} catch (error) {
|
||||
throw decodeError(error, maxInputPixels);
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(metadata) {
|
||||
const pages = metadata.pages ?? 1;
|
||||
|
||||
if (pages > 1) {
|
||||
const hasAnimationTiming =
|
||||
metadata.loop !== undefined ||
|
||||
(Array.isArray(metadata.delay) && metadata.delay.length > 0);
|
||||
throw new ImageProcessingError(
|
||||
hasAnimationTiming
|
||||
? "ANIMATED_IMAGE_NOT_SUPPORTED"
|
||||
: "MULTIPAGE_IMAGE_NOT_SUPPORTED",
|
||||
hasAnimationTiming
|
||||
? "Animated images are not supported"
|
||||
: "Multipage images are not supported",
|
||||
{ statusCode: 422, details: { pages } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_FORMATS.has(metadata.format)) {
|
||||
throw new ImageProcessingError(
|
||||
"UNSUPPORTED_IMAGE_FORMAT",
|
||||
"Only still JPEG, PNG, and WebP images are supported",
|
||||
{
|
||||
statusCode: 415,
|
||||
details: {
|
||||
detectedFormat: metadata.format || null,
|
||||
supportedFormats: [...SUPPORTED_FORMATS],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(metadata.width) ||
|
||||
metadata.width < 1 ||
|
||||
!Number.isSafeInteger(metadata.height) ||
|
||||
metadata.height < 1
|
||||
) {
|
||||
throw new ImageProcessingError(
|
||||
"MALFORMED_IMAGE",
|
||||
"The uploaded image does not contain valid dimensions",
|
||||
{ statusCode: 422 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateDerivative(input, target, maxInputPixels) {
|
||||
let output;
|
||||
try {
|
||||
output = await sharp(input, {
|
||||
failOn: "error",
|
||||
limitInputPixels: maxInputPixels,
|
||||
})
|
||||
.autoOrient()
|
||||
.flatten({ background: BLACK })
|
||||
.resize({
|
||||
width: target.maxWidth,
|
||||
height: target.maxHeight,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toColourspace("srgb")
|
||||
.webp({ quality: target.quality, effort: 4 })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
} catch (error) {
|
||||
throw decodeError(error, maxInputPixels);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: output.data,
|
||||
sha256: sha256(output.data),
|
||||
format: "webp",
|
||||
contentType: CONTENT_TYPES.webp,
|
||||
byteSize: output.data.length,
|
||||
width: output.info.width,
|
||||
height: output.info.height,
|
||||
};
|
||||
}
|
||||
|
||||
function originalMetadata(metadata, inputDescription) {
|
||||
const orientedDimensions = metadata.autoOrient || {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
};
|
||||
|
||||
return {
|
||||
sha256: inputDescription.sha256,
|
||||
format: metadata.format,
|
||||
contentType: CONTENT_TYPES[metadata.format],
|
||||
byteSize: inputDescription.byteSize,
|
||||
// Width and height describe how the original should appear after applying
|
||||
// its source orientation. The encoded dimensions remain available for
|
||||
// diagnostics without implying that the archived bytes were rewritten.
|
||||
width: orientedDimensions.width,
|
||||
height: orientedDimensions.height,
|
||||
encodedWidth: metadata.width,
|
||||
encodedHeight: metadata.height,
|
||||
orientation: metadata.orientation ?? null,
|
||||
pages: metadata.pages ?? 1,
|
||||
channels: metadata.channels,
|
||||
hasAlpha: Boolean(metadata.hasAlpha),
|
||||
};
|
||||
}
|
||||
|
||||
function createImageProcessor(options) {
|
||||
const config = normalizeOptions(options);
|
||||
|
||||
async function process(input) {
|
||||
const inputDescription = await describeInput(input);
|
||||
const metadata = await readMetadata(
|
||||
inputDescription.sharpInput,
|
||||
config.maxInputPixels,
|
||||
);
|
||||
validateMetadata(metadata);
|
||||
|
||||
const [display, thumbnail] = await Promise.all([
|
||||
generateDerivative(
|
||||
inputDescription.sharpInput,
|
||||
config.display,
|
||||
config.maxInputPixels,
|
||||
),
|
||||
generateDerivative(
|
||||
inputDescription.sharpInput,
|
||||
config.thumbnail,
|
||||
config.maxInputPixels,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
original: originalMetadata(metadata, inputDescription),
|
||||
display,
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.freeze({ process });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
|
||||
ImageProcessingError,
|
||||
createImageProcessor,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
const path = require("node:path");
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "../../.env") });
|
||||
|
||||
const { loadConfig, redactedConfig } = require("./config");
|
||||
const { createRuntime } = require("./runtime");
|
||||
|
||||
async function startApplication({ env = process.env, logger = console } = {}) {
|
||||
const config = loadConfig(env);
|
||||
const runtime = createRuntime({ config, logger });
|
||||
const address = await runtime.start();
|
||||
const displayedHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
|
||||
|
||||
logger.info?.(
|
||||
`Announcements API listening on http://${displayedHost}:${address.port}`,
|
||||
);
|
||||
logger.info?.("Configuration loaded", redactedConfig(config));
|
||||
|
||||
let shutdownStarted = false;
|
||||
const shutdown = async (signal) => {
|
||||
if (shutdownStarted) return;
|
||||
shutdownStarted = true;
|
||||
try {
|
||||
await runtime.stop(signal);
|
||||
} catch (error) {
|
||||
logger.error?.("Graceful shutdown failed", error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
process.once("SIGTERM", shutdown);
|
||||
process.once("SIGINT", shutdown);
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await startApplication();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startApplication,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("./eligibility");
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const { PlaybackError } = require("./errors");
|
||||
const { createPlaybackService } = require("./service");
|
||||
|
||||
module.exports = {
|
||||
PlaybackError,
|
||||
createPlaybackService,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const { createPlaylistRouter } = require("./routes");
|
||||
const { createPlaylistService } = require("./service");
|
||||
|
||||
module.exports = {
|
||||
createPlaylistRouter,
|
||||
createPlaylistService,
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
{ playbackService } = {},
|
||||
) {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlaylistRouter,
|
||||
orderedEntryIds,
|
||||
};
|
||||
@@ -0,0 +1,884 @@
|
||||
const crypto = require("node:crypto");
|
||||
const {
|
||||
evaluateEntryEligibility,
|
||||
resolveEntrySettings,
|
||||
} = require("../lifecycle");
|
||||
const {
|
||||
PlaylistError,
|
||||
playlistEntryNotFound,
|
||||
playlistNotFound,
|
||||
} = require("./errors");
|
||||
|
||||
const MIN_DURATION_MS = 1_000;
|
||||
const MAX_DURATION_MS = 3_600_000;
|
||||
const MAX_PLAYLIST_NAME_LENGTH = 200;
|
||||
const BACKGROUND_MODES = new Set(["blurred", "solid", "black"]);
|
||||
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
const ENTRY_PATCH_FIELDS = new Set([
|
||||
"enabled",
|
||||
"durationOverrideMs",
|
||||
"backgroundModeOverride",
|
||||
"backgroundColorOverride",
|
||||
"backgroundDarkenedOverride",
|
||||
"scheduleStartAt",
|
||||
"scheduleEndAt",
|
||||
]);
|
||||
|
||||
function normalizeObject(input, code, message) {
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new PlaylistError(code, message);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizePlaylistName(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_NAME",
|
||||
"Playlist name must be a string",
|
||||
);
|
||||
}
|
||||
const normalized = value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_NAME",
|
||||
"Playlist name cannot be empty",
|
||||
);
|
||||
}
|
||||
if ([...normalized].length > MAX_PLAYLIST_NAME_LENGTH) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_NAME",
|
||||
`Playlist name cannot exceed ${MAX_PLAYLIST_NAME_LENGTH} characters`,
|
||||
{ details: { maxLength: MAX_PLAYLIST_NAME_LENGTH } },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, field) {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
`${field} must be a boolean`,
|
||||
{ details: { field } },
|
||||
);
|
||||
}
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
function normalizeNullableDuration(value) {
|
||||
if (value === null) return null;
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < MIN_DURATION_MS ||
|
||||
value > MAX_DURATION_MS
|
||||
) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
`durationOverrideMs must be null or an integer from ${MIN_DURATION_MS} through ${MAX_DURATION_MS}`,
|
||||
{
|
||||
details: {
|
||||
field: "durationOverrideMs",
|
||||
min: MIN_DURATION_MS,
|
||||
max: MAX_DURATION_MS,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeNullableBackgroundMode(value) {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string" || !BACKGROUND_MODES.has(value)) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
"backgroundModeOverride must be null, blurred, solid, or black",
|
||||
{ details: { field: "backgroundModeOverride" } },
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeNullableColor(value) {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string" || !HEX_COLOR_PATTERN.test(value)) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
"backgroundColorOverride must be null or a six-digit hex color",
|
||||
{ details: { field: "backgroundColorOverride" } },
|
||||
);
|
||||
}
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeNullableDarkening(value) {
|
||||
if (value === null) return null;
|
||||
return normalizeBoolean(value, "backgroundDarkenedOverride");
|
||||
}
|
||||
|
||||
function normalizeNullableInstant(value, field) {
|
||||
if (value === null) return null;
|
||||
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const instant = value.trim();
|
||||
if (!/([zZ]|[+-]\d{2}:\d{2})$/.test(instant)) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
`${field} ISO dates must include Z or a numeric UTC offset`,
|
||||
{ details: { field } },
|
||||
);
|
||||
}
|
||||
const timestamp = Date.parse(instant);
|
||||
if (Number.isSafeInteger(timestamp) && timestamp >= 0) return timestamp;
|
||||
}
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
`${field} must be null, an epoch-millisecond integer, or an ISO date`,
|
||||
{ details: { field } },
|
||||
);
|
||||
}
|
||||
|
||||
function validateSchedule(startAt, endAt) {
|
||||
if (startAt !== null && endAt !== null && startAt >= endAt) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
|
||||
"scheduleStartAt must be earlier than scheduleEndAt",
|
||||
{ details: { scheduleStartAt: startAt, scheduleEndAt: endAt } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapPlaylist(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
revision: row.revision,
|
||||
entryCount: Number(row.entry_count || 0),
|
||||
isActive: Boolean(row.is_active),
|
||||
isPendingActivation: Boolean(row.is_pending),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mediaUrl(assetId, kind, sha256) {
|
||||
return sha256
|
||||
? `/media/assets/${assetId}/${kind}/${sha256}.webp`
|
||||
: null;
|
||||
}
|
||||
|
||||
function mapResolvedEntry(row, settings, nowMs) {
|
||||
const eligibility = evaluateEntryEligibility(row, row, nowMs);
|
||||
const resolved = resolveEntrySettings(row, settings);
|
||||
return {
|
||||
id: row.entry_id,
|
||||
playlistId: row.playlist_id,
|
||||
assetId: row.asset_id,
|
||||
enabled: Boolean(row.enabled),
|
||||
position: row.position,
|
||||
durationOverrideMs: row.duration_override_ms,
|
||||
effectiveDurationMs: resolved.durationMs,
|
||||
backgroundModeOverride: row.background_mode_override,
|
||||
backgroundColorOverride: row.background_color_override,
|
||||
backgroundDarkenedOverride:
|
||||
row.background_darkened_override === null
|
||||
? null
|
||||
: Boolean(row.background_darkened_override),
|
||||
effectivePresentation: resolved.presentation,
|
||||
scheduleStartAt: row.schedule_start_at,
|
||||
scheduleEndAt: row.schedule_end_at,
|
||||
playable: eligibility.playable,
|
||||
notPlayableReason: eligibility.reason,
|
||||
eligibility,
|
||||
asset: {
|
||||
id: row.asset_id,
|
||||
displayName: row.asset_display_name,
|
||||
originalFilename: row.asset_original_filename,
|
||||
status: row.status,
|
||||
expirationAt: row.expiration_at,
|
||||
display: {
|
||||
width: row.display_width,
|
||||
height: row.display_height,
|
||||
url: mediaUrl(row.asset_id, "display", row.display_sha256),
|
||||
},
|
||||
thumbnail: {
|
||||
width: row.thumbnail_width,
|
||||
height: row.thumbnail_height,
|
||||
url: mediaUrl(row.asset_id, "thumbnail", row.thumbnail_sha256),
|
||||
},
|
||||
},
|
||||
createdAt: row.entry_created_at,
|
||||
updatedAt: row.entry_updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function createPlaylistService({
|
||||
database,
|
||||
now = Date.now,
|
||||
createId = crypto.randomUUID,
|
||||
onBeforeChange = () => {},
|
||||
onChanged = () => {},
|
||||
}) {
|
||||
const playlistSelect = `
|
||||
SELECT p.*,
|
||||
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.playlist_id = p.id)
|
||||
AS entry_count,
|
||||
EXISTS (
|
||||
SELECT 1 FROM playback_state ps WHERE ps.active_playlist_id = p.id
|
||||
) AS is_active,
|
||||
EXISTS (
|
||||
SELECT 1 FROM playback_state ps WHERE ps.pending_playlist_id = p.id
|
||||
) AS is_pending
|
||||
FROM playlists p
|
||||
`;
|
||||
const listPlaylists = database.prepare(
|
||||
`${playlistSelect} ORDER BY p.name COLLATE NOCASE, p.id`,
|
||||
);
|
||||
const findPlaylist = database.prepare(`${playlistSelect} WHERE p.id = ?`);
|
||||
const findPlaylistByName = database.prepare(
|
||||
`${playlistSelect} WHERE p.name = ? COLLATE NOCASE`,
|
||||
);
|
||||
const insertPlaylist = database.prepare(`
|
||||
INSERT INTO playlists (id, name, revision, created_at, updated_at)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
`);
|
||||
const renamePlaylist = database.prepare(`
|
||||
UPDATE playlists
|
||||
SET name = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
const deletePlaylist = database.prepare("DELETE FROM playlists WHERE id = ?");
|
||||
const bumpPlaylistRevision = database.prepare(`
|
||||
UPDATE playlists
|
||||
SET revision = revision + 1, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
const readSettings = database.prepare("SELECT * FROM app_settings WHERE id = 1");
|
||||
const readPlayback = database.prepare(
|
||||
"SELECT * FROM playback_state WHERE output_id = 'global'",
|
||||
);
|
||||
const resolvedEntries = database.prepare(`
|
||||
SELECT
|
||||
pe.id AS entry_id,
|
||||
pe.playlist_id,
|
||||
pe.asset_id,
|
||||
pe.enabled,
|
||||
pe.duration_override_ms,
|
||||
pe.background_mode_override,
|
||||
pe.background_color_override,
|
||||
pe.background_darkened_override,
|
||||
pe.schedule_start_at,
|
||||
pe.schedule_end_at,
|
||||
pe.position,
|
||||
pe.created_at AS entry_created_at,
|
||||
pe.updated_at AS entry_updated_at,
|
||||
a.display_name AS asset_display_name,
|
||||
a.original_filename AS asset_original_filename,
|
||||
a.status,
|
||||
a.expiration_at,
|
||||
a.display_sha256,
|
||||
a.display_width,
|
||||
a.display_height,
|
||||
a.thumbnail_sha256,
|
||||
a.thumbnail_width,
|
||||
a.thumbnail_height
|
||||
FROM playlist_entries pe
|
||||
JOIN assets a ON a.id = pe.asset_id
|
||||
WHERE pe.playlist_id = ?
|
||||
ORDER BY pe.position, pe.id
|
||||
`);
|
||||
const findEntry = database.prepare(`
|
||||
SELECT pe.*
|
||||
FROM playlist_entries pe
|
||||
WHERE pe.playlist_id = ? AND pe.id = ?
|
||||
`);
|
||||
const findEntryByAsset = database.prepare(`
|
||||
SELECT pe.*
|
||||
FROM playlist_entries pe
|
||||
WHERE pe.playlist_id = ? AND pe.asset_id = ?
|
||||
`);
|
||||
const findEntryOwner = database.prepare(
|
||||
"SELECT playlist_id FROM playlist_entries WHERE id = ?",
|
||||
);
|
||||
const listEntryIds = database.prepare(`
|
||||
SELECT id, position
|
||||
FROM playlist_entries
|
||||
WHERE playlist_id = ?
|
||||
ORDER BY position, id
|
||||
`);
|
||||
const findAsset = database.prepare("SELECT * FROM assets WHERE id = ?");
|
||||
const nextPosition = database.prepare(`
|
||||
SELECT COALESCE(MAX(position), -1) + 1 AS position
|
||||
FROM playlist_entries
|
||||
WHERE playlist_id = ?
|
||||
`);
|
||||
const insertEntry = database.prepare(`
|
||||
INSERT INTO playlist_entries (
|
||||
id, playlist_id, asset_id, enabled, duration_override_ms,
|
||||
background_mode_override, background_color_override,
|
||||
background_darkened_override, schedule_start_at, schedule_end_at,
|
||||
position, created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @playlistId, @assetId, @enabled, @durationOverrideMs,
|
||||
@backgroundModeOverride, @backgroundColorOverride,
|
||||
@backgroundDarkenedOverride, @scheduleStartAt, @scheduleEndAt,
|
||||
@position, @createdAt, @updatedAt
|
||||
)
|
||||
`);
|
||||
const updateEntry = database.prepare(`
|
||||
UPDATE playlist_entries
|
||||
SET enabled = @enabled,
|
||||
duration_override_ms = @durationOverrideMs,
|
||||
background_mode_override = @backgroundModeOverride,
|
||||
background_color_override = @backgroundColorOverride,
|
||||
background_darkened_override = @backgroundDarkenedOverride,
|
||||
schedule_start_at = @scheduleStartAt,
|
||||
schedule_end_at = @scheduleEndAt,
|
||||
updated_at = @updatedAt
|
||||
WHERE playlist_id = @playlistId AND id = @entryId
|
||||
`);
|
||||
const deleteEntry = database.prepare(
|
||||
"DELETE FROM playlist_entries WHERE playlist_id = ? AND id = ?",
|
||||
);
|
||||
const moveEntriesOutOfRange = database.prepare(`
|
||||
UPDATE playlist_entries
|
||||
SET position = position + ?
|
||||
WHERE playlist_id = ?
|
||||
`);
|
||||
const setEntryPosition = database.prepare(`
|
||||
UPDATE playlist_entries
|
||||
SET position = ?, updated_at = ?
|
||||
WHERE playlist_id = ? AND id = ?
|
||||
`);
|
||||
function getPlaylistRow(playlistId) {
|
||||
const row = findPlaylist.get(playlistId);
|
||||
if (!row) throw playlistNotFound(playlistId);
|
||||
return row;
|
||||
}
|
||||
|
||||
function getEntryRow(playlistId, entryId) {
|
||||
getPlaylistRow(playlistId);
|
||||
const row = findEntry.get(playlistId, entryId);
|
||||
if (!row) throw playlistEntryNotFound(playlistId, entryId);
|
||||
return row;
|
||||
}
|
||||
|
||||
function nextTimestamp(row) {
|
||||
return Math.max(now(), row.updated_at + 1);
|
||||
}
|
||||
|
||||
function notify(change) {
|
||||
// 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)) {
|
||||
return new PlaylistError(
|
||||
"PLAYLIST_NAME_CONFLICT",
|
||||
"Another playlist already uses that name",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: row ? { playlist: mapPlaylist(row) } : { name },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function validateCreate(input) {
|
||||
normalizeObject(input, "INVALID_PLAYLIST", "Playlist must be an object");
|
||||
const unknown = Object.keys(input).filter((key) => key !== "name");
|
||||
if (unknown.length > 0) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST",
|
||||
"Playlist contains unsupported fields",
|
||||
{ details: { fields: unknown } },
|
||||
);
|
||||
}
|
||||
if (!Object.hasOwn(input, "name")) {
|
||||
throw new PlaylistError("INVALID_PLAYLIST", "Playlist name is required", {
|
||||
details: { field: "name" },
|
||||
});
|
||||
}
|
||||
return normalizePlaylistName(input.name);
|
||||
}
|
||||
|
||||
function validateEntryFields(input, { creating = false, current = null } = {}) {
|
||||
normalizeObject(
|
||||
input,
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
"Playlist entry must be an object",
|
||||
);
|
||||
const allowed = creating
|
||||
? new Set(["assetId", ...ENTRY_PATCH_FIELDS])
|
||||
: ENTRY_PATCH_FIELDS;
|
||||
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
"Playlist entry contains unsupported fields",
|
||||
{ details: { fields: unknown } },
|
||||
);
|
||||
}
|
||||
if (!creating && Object.keys(input).length === 0) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
"At least one playlist entry field is required",
|
||||
);
|
||||
}
|
||||
if (creating && (typeof input.assetId !== "string" || !input.assetId.trim())) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
"assetId is required",
|
||||
{ details: { field: "assetId" } },
|
||||
);
|
||||
}
|
||||
|
||||
const value = (field, fallback) =>
|
||||
Object.hasOwn(input, field) ? input[field] : fallback;
|
||||
const normalized = {
|
||||
assetId: creating ? input.assetId.trim() : current.asset_id,
|
||||
enabled: Object.hasOwn(input, "enabled")
|
||||
? normalizeBoolean(input.enabled, "enabled")
|
||||
: (current?.enabled ?? 1),
|
||||
durationOverrideMs: Object.hasOwn(input, "durationOverrideMs")
|
||||
? normalizeNullableDuration(input.durationOverrideMs)
|
||||
: (current?.duration_override_ms ?? null),
|
||||
backgroundModeOverride: Object.hasOwn(input, "backgroundModeOverride")
|
||||
? normalizeNullableBackgroundMode(input.backgroundModeOverride)
|
||||
: (current?.background_mode_override ?? null),
|
||||
backgroundColorOverride: Object.hasOwn(input, "backgroundColorOverride")
|
||||
? normalizeNullableColor(input.backgroundColorOverride)
|
||||
: (current?.background_color_override ?? null),
|
||||
backgroundDarkenedOverride: Object.hasOwn(
|
||||
input,
|
||||
"backgroundDarkenedOverride",
|
||||
)
|
||||
? normalizeNullableDarkening(input.backgroundDarkenedOverride)
|
||||
: (current?.background_darkened_override ?? null),
|
||||
scheduleStartAt: normalizeNullableInstant(
|
||||
value("scheduleStartAt", current?.schedule_start_at ?? null),
|
||||
"scheduleStartAt",
|
||||
),
|
||||
scheduleEndAt: normalizeNullableInstant(
|
||||
value("scheduleEndAt", current?.schedule_end_at ?? null),
|
||||
"scheduleEndAt",
|
||||
),
|
||||
};
|
||||
validateSchedule(normalized.scheduleStartAt, normalized.scheduleEndAt);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function entryValuesEqual(current, next) {
|
||||
return (
|
||||
current.enabled === next.enabled &&
|
||||
current.duration_override_ms === next.durationOverrideMs &&
|
||||
current.background_mode_override === next.backgroundModeOverride &&
|
||||
current.background_color_override === next.backgroundColorOverride &&
|
||||
current.background_darkened_override === next.backgroundDarkenedOverride &&
|
||||
current.schedule_start_at === next.scheduleStartAt &&
|
||||
current.schedule_end_at === next.scheduleEndAt
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedDetail(playlistId, nowMs = now()) {
|
||||
const playlist = mapPlaylist(getPlaylistRow(playlistId));
|
||||
const settings = readSettings.get();
|
||||
if (!settings) throw new Error("The app_settings singleton row is missing");
|
||||
return {
|
||||
...playlist,
|
||||
evaluatedAt: nowMs,
|
||||
settingsRevision: settings.revision,
|
||||
entries: resolvedEntries
|
||||
.all(playlistId)
|
||||
.map((row) => mapResolvedEntry(row, settings, nowMs)),
|
||||
};
|
||||
}
|
||||
|
||||
const createTransaction = database.transaction((input) => {
|
||||
const name = validateCreate(input);
|
||||
const existing = findPlaylistByName.get(name);
|
||||
if (existing) throw duplicateName(name, existing);
|
||||
const timestamp = now();
|
||||
const id = createId();
|
||||
try {
|
||||
insertPlaylist.run(id, name, timestamp, timestamp);
|
||||
} catch (error) {
|
||||
const conflict = findPlaylistByName.get(name);
|
||||
if (conflict) throw duplicateName(name, conflict);
|
||||
throw error;
|
||||
}
|
||||
return mapPlaylist(getPlaylistRow(id));
|
||||
});
|
||||
|
||||
function create(input) {
|
||||
const playlist = createTransaction(input);
|
||||
notify({ reason: "created", playlistId: playlist.id, revision: playlist.revision });
|
||||
return playlist;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return listPlaylists.all().map(mapPlaylist);
|
||||
}
|
||||
|
||||
function get(playlistId) {
|
||||
return resolvedDetail(playlistId);
|
||||
}
|
||||
|
||||
const updateTransaction = database.transaction((playlistId, input) => {
|
||||
normalizeObject(input, "INVALID_PLAYLIST", "Playlist update must be an object");
|
||||
const unknown = Object.keys(input).filter((key) => key !== "name");
|
||||
if (unknown.length > 0) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST",
|
||||
"Playlist update contains unsupported fields",
|
||||
{ details: { fields: unknown } },
|
||||
);
|
||||
}
|
||||
if (!Object.hasOwn(input, "name") || Object.keys(input).length !== 1) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST",
|
||||
"Playlist update requires a name",
|
||||
{ details: { field: "name" } },
|
||||
);
|
||||
}
|
||||
const current = getPlaylistRow(playlistId);
|
||||
const name = normalizePlaylistName(input.name);
|
||||
if (current.name === name) {
|
||||
return { changed: false, playlist: mapPlaylist(current) };
|
||||
}
|
||||
const conflict = findPlaylistByName.get(name);
|
||||
if (conflict && conflict.id !== playlistId) throw duplicateName(name, conflict);
|
||||
try {
|
||||
renamePlaylist.run(name, nextTimestamp(current), playlistId);
|
||||
} catch (error) {
|
||||
const raced = findPlaylistByName.get(name);
|
||||
if (raced && raced.id !== playlistId) throw duplicateName(name, raced);
|
||||
throw error;
|
||||
}
|
||||
return { changed: true, playlist: mapPlaylist(getPlaylistRow(playlistId)) };
|
||||
});
|
||||
|
||||
function update(playlistId, input) {
|
||||
onBeforeChange({ reason: "before_playlist_update", playlistId });
|
||||
const result = updateTransaction(playlistId, input);
|
||||
if (result.changed) {
|
||||
notify({
|
||||
reason: "renamed",
|
||||
playlistId,
|
||||
revision: result.playlist.revision,
|
||||
});
|
||||
}
|
||||
return result.playlist;
|
||||
}
|
||||
|
||||
const removeTransaction = database.transaction((playlistId) => {
|
||||
const playlist = getPlaylistRow(playlistId);
|
||||
const state = readPlayback.get();
|
||||
if (!state) throw new Error("The global playback_state row is missing");
|
||||
if (state.active_playlist_id === playlistId) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ACTIVE",
|
||||
"The active playlist cannot be deleted",
|
||||
{ statusCode: 409, details: { playlistId } },
|
||||
);
|
||||
}
|
||||
if (state.pending_playlist_id === playlistId) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_PENDING_ACTIVATION",
|
||||
"A playlist pending activation cannot be deleted",
|
||||
{ statusCode: 409, details: { playlistId } },
|
||||
);
|
||||
}
|
||||
deletePlaylist.run(playlistId);
|
||||
return playlist;
|
||||
});
|
||||
|
||||
function remove(playlistId) {
|
||||
onBeforeChange({ reason: "before_playlist_delete", playlistId });
|
||||
const playlist = removeTransaction(playlistId);
|
||||
notify({ reason: "deleted", playlistId, revision: playlist.revision });
|
||||
return { id: playlistId, deleted: true };
|
||||
}
|
||||
|
||||
const addEntryTransaction = database.transaction((playlistId, input) => {
|
||||
const playlist = getPlaylistRow(playlistId);
|
||||
const fields = validateEntryFields(input, { creating: true });
|
||||
const operationNow = now();
|
||||
const asset = findAsset.get(fields.assetId);
|
||||
if (!asset) {
|
||||
throw new PlaylistError("ASSET_NOT_FOUND", "Library asset not found", {
|
||||
statusCode: 404,
|
||||
details: { assetId: fields.assetId },
|
||||
});
|
||||
}
|
||||
if (asset.status !== "ready") {
|
||||
throw new PlaylistError(
|
||||
"ASSET_NOT_READY",
|
||||
"Only ready library assets can be added to playlists",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: { assetId: asset.id, status: asset.status },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (asset.expiration_at !== null && asset.expiration_at <= operationNow) {
|
||||
throw new PlaylistError(
|
||||
"ASSET_EXPIRED",
|
||||
"Expired library assets cannot be added to playlists",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: { assetId: asset.id, expirationAt: asset.expiration_at },
|
||||
},
|
||||
);
|
||||
}
|
||||
const existing = findEntryByAsset.get(playlistId, fields.assetId);
|
||||
if (existing) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ENTRY_EXISTS",
|
||||
"This asset already appears in the playlist",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: { playlistId, assetId: fields.assetId, entryId: existing.id },
|
||||
},
|
||||
);
|
||||
}
|
||||
const timestamp = Math.max(operationNow, playlist.updated_at + 1);
|
||||
const id = createId();
|
||||
try {
|
||||
insertEntry.run({
|
||||
id,
|
||||
playlistId,
|
||||
...fields,
|
||||
position: nextPosition.get(playlistId).position,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
const raced = findEntryByAsset.get(playlistId, fields.assetId);
|
||||
if (raced) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ENTRY_EXISTS",
|
||||
"This asset already appears in the playlist",
|
||||
{
|
||||
statusCode: 409,
|
||||
details: { playlistId, assetId: fields.assetId, entryId: raced.id },
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||
return {
|
||||
playlist: getPlaylistRow(playlistId),
|
||||
entryId: id,
|
||||
detail: resolvedDetail(playlistId, now()),
|
||||
};
|
||||
});
|
||||
|
||||
function addEntry(playlistId, input) {
|
||||
onBeforeChange({ reason: "before_entry_add", playlistId });
|
||||
const result = addEntryTransaction(playlistId, input);
|
||||
notify({
|
||||
reason: "entry_added",
|
||||
playlistId,
|
||||
revision: result.playlist.revision,
|
||||
entryId: result.entryId,
|
||||
});
|
||||
return result.detail.entries.find((entry) => entry.id === result.entryId);
|
||||
}
|
||||
|
||||
const patchEntryTransaction = database.transaction((playlistId, entryId, input) => {
|
||||
const playlist = getPlaylistRow(playlistId);
|
||||
const current = getEntryRow(playlistId, entryId);
|
||||
const fields = validateEntryFields(input, { current });
|
||||
if (entryValuesEqual(current, fields)) {
|
||||
return {
|
||||
changed: false,
|
||||
invalidBoundaryChanged: false,
|
||||
playlist,
|
||||
detail: resolvedDetail(playlistId, now()),
|
||||
};
|
||||
}
|
||||
const timestamp = nextTimestamp(playlist);
|
||||
updateEntry.run({
|
||||
playlistId,
|
||||
entryId,
|
||||
...fields,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
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({
|
||||
reason: "entry_updated",
|
||||
playlistId,
|
||||
revision: result.playlist.revision,
|
||||
entryId,
|
||||
...(result.invalidBoundaryChanged
|
||||
? { invalidBoundaryChanged: true }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return result.detail.entries.find((entry) => entry.id === entryId);
|
||||
}
|
||||
|
||||
const removeEntryTransaction = database.transaction((playlistId, entryId) => {
|
||||
const playlist = getPlaylistRow(playlistId);
|
||||
getEntryRow(playlistId, entryId);
|
||||
const timestamp = nextTimestamp(playlist);
|
||||
const result = deleteEntry.run(playlistId, entryId);
|
||||
if (result.changes !== 1) throw playlistEntryNotFound(playlistId, entryId);
|
||||
const remaining = listEntryIds.all(playlistId);
|
||||
if (remaining.some((entry, index) => entry.position !== index)) {
|
||||
moveEntriesOutOfRange.run(remaining.length + 1, playlistId);
|
||||
remaining.forEach((entry, position) => {
|
||||
setEntryPosition.run(position, timestamp, playlistId, entry.id);
|
||||
});
|
||||
}
|
||||
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||
return { playlist: getPlaylistRow(playlistId) };
|
||||
});
|
||||
|
||||
function removeEntryById(playlistId, entryId) {
|
||||
onBeforeChange({
|
||||
reason: "before_entry_delete",
|
||||
playlistId,
|
||||
entryId,
|
||||
});
|
||||
const result = removeEntryTransaction(playlistId, entryId);
|
||||
notify({
|
||||
reason: "entry_deleted",
|
||||
playlistId,
|
||||
revision: result.playlist.revision,
|
||||
entryId,
|
||||
});
|
||||
return { id: entryId, deleted: true };
|
||||
}
|
||||
|
||||
function validateOrder(playlistId, orderedEntryIds) {
|
||||
if (!Array.isArray(orderedEntryIds)) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ORDER",
|
||||
"Playlist order must be a complete array of entry IDs",
|
||||
);
|
||||
}
|
||||
if (orderedEntryIds.some((id) => typeof id !== "string" || !id)) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ORDER",
|
||||
"Every ordered entry ID must be a non-empty string",
|
||||
);
|
||||
}
|
||||
const duplicates = orderedEntryIds.filter(
|
||||
(id, index) => orderedEntryIds.indexOf(id) !== index,
|
||||
);
|
||||
if (duplicates.length > 0) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ORDER_DUPLICATE_IDS",
|
||||
"Playlist order contains duplicate entry IDs",
|
||||
{ details: { entryIds: [...new Set(duplicates)] } },
|
||||
);
|
||||
}
|
||||
|
||||
const current = listEntryIds.all(playlistId);
|
||||
const currentIds = new Set(current.map((entry) => entry.id));
|
||||
const suppliedIds = new Set(orderedEntryIds);
|
||||
const extra = orderedEntryIds.filter((id) => !currentIds.has(id));
|
||||
if (extra.length > 0) {
|
||||
const foreign = extra.filter((id) => {
|
||||
const owner = findEntryOwner.get(id);
|
||||
return owner && owner.playlist_id !== playlistId;
|
||||
});
|
||||
if (foreign.length > 0) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ORDER_FOREIGN_ENTRY",
|
||||
"Playlist order contains an entry from another playlist",
|
||||
{ details: { entryIds: foreign } },
|
||||
);
|
||||
}
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
|
||||
"Playlist order contains an unknown entry ID",
|
||||
{ details: { entryIds: extra } },
|
||||
);
|
||||
}
|
||||
const missing = current
|
||||
.map((entry) => entry.id)
|
||||
.filter((id) => !suppliedIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new PlaylistError(
|
||||
"PLAYLIST_ORDER_INCOMPLETE",
|
||||
"Playlist order is missing current entries",
|
||||
{ details: { entryIds: missing } },
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
const reorderTransaction = database.transaction((playlistId, orderedEntryIds) => {
|
||||
const playlist = getPlaylistRow(playlistId);
|
||||
const current = validateOrder(playlistId, orderedEntryIds);
|
||||
if (current.every((entry, index) => entry.id === orderedEntryIds[index])) {
|
||||
return { changed: false, playlist, detail: resolvedDetail(playlistId, now()) };
|
||||
}
|
||||
const timestamp = nextTimestamp(playlist);
|
||||
const offset = Math.max(current.length, 1);
|
||||
moveEntriesOutOfRange.run(offset, playlistId);
|
||||
orderedEntryIds.forEach((entryId, position) => {
|
||||
setEntryPosition.run(position, timestamp, playlistId, entryId);
|
||||
});
|
||||
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||
return {
|
||||
changed: true,
|
||||
playlist: getPlaylistRow(playlistId),
|
||||
detail: resolvedDetail(playlistId, now()),
|
||||
};
|
||||
});
|
||||
|
||||
function reorderEntries(playlistId, orderedEntryIds) {
|
||||
onBeforeChange({ reason: "before_entries_reorder", playlistId });
|
||||
const result = reorderTransaction(playlistId, orderedEntryIds);
|
||||
if (result.changed) {
|
||||
notify({ reason: "entries_reordered", playlistId, revision: result.playlist.revision });
|
||||
}
|
||||
return result.detail;
|
||||
}
|
||||
|
||||
return {
|
||||
addEntry,
|
||||
create,
|
||||
get,
|
||||
list,
|
||||
remove,
|
||||
removeEntry: removeEntryById,
|
||||
reorderEntries,
|
||||
update,
|
||||
updateEntry: updateEntryById,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_DURATION_MS,
|
||||
MIN_DURATION_MS,
|
||||
createPlaylistService,
|
||||
normalizePlaylistName,
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
const http = require("node:http");
|
||||
const { Server: SocketServer } = require("socket.io");
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function onError(error) {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(error);
|
||||
}
|
||||
function onListening() {
|
||||
httpServer.off("error", onError);
|
||||
resolve(httpServer.address());
|
||||
}
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
httpServer.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function createRuntime({
|
||||
config,
|
||||
imageProcessor: suppliedImageProcessor,
|
||||
logger = console,
|
||||
storage: suppliedStorage,
|
||||
}) {
|
||||
const database = openDatabase(config.database);
|
||||
let migrations;
|
||||
try {
|
||||
migrations = runMigrations(database);
|
||||
} catch (error) {
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lifecycle = {
|
||||
ready: false,
|
||||
stopping: false,
|
||||
};
|
||||
let storage;
|
||||
let imageProcessor;
|
||||
try {
|
||||
storage = suppliedStorage || createStorageService(config.storage, { logger });
|
||||
imageProcessor =
|
||||
suppliedImageProcessor ||
|
||||
createImageProcessor({ maxInputPixels: config.processing.maxInputPixels });
|
||||
} catch (error) {
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
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,
|
||||
imageProcessor,
|
||||
lifecycle,
|
||||
logger,
|
||||
migrations,
|
||||
onPlaylistsChanged(event) {
|
||||
emitSocketEvent("playlists:changed", event);
|
||||
},
|
||||
playbackService,
|
||||
storage,
|
||||
});
|
||||
} catch (error) {
|
||||
storage.stop();
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
const httpServer = http.createServer(app);
|
||||
io = new SocketServer(httpServer, {
|
||||
serveClient: false,
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
let address = null;
|
||||
let startPromise = null;
|
||||
let stopPromise = null;
|
||||
|
||||
async function start() {
|
||||
if (stopPromise) {
|
||||
throw new Error("Cannot start a runtime after shutdown has begun");
|
||||
}
|
||||
if (startPromise) {
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
startPromise = Promise.resolve()
|
||||
.then(() => playbackService.initialize())
|
||||
.then(() => playbackService.start())
|
||||
.then(() => listen(httpServer, config.port, config.host))
|
||||
.then((listeningAddress) => {
|
||||
address = listeningAddress;
|
||||
lifecycle.ready = true;
|
||||
Promise.resolve()
|
||||
.then(() => storage.start())
|
||||
.catch(() => {
|
||||
logger.warn?.("Object storage health checks could not be started");
|
||||
});
|
||||
return listeningAddress;
|
||||
})
|
||||
.catch((error) => {
|
||||
playbackService.stop();
|
||||
storage.stop();
|
||||
database.close();
|
||||
throw error;
|
||||
});
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
async function stop(reason = "shutdown") {
|
||||
if (stopPromise) {
|
||||
return stopPromise;
|
||||
}
|
||||
|
||||
lifecycle.ready = false;
|
||||
lifecycle.stopping = true;
|
||||
playbackService.stop();
|
||||
stopPromise = new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
storage.stop();
|
||||
if (database.open) {
|
||||
database.close();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
logger.warn?.(
|
||||
`Graceful shutdown timed out after ${config.shutdownTimeoutMs}ms`,
|
||||
);
|
||||
httpServer.closeAllConnections?.();
|
||||
finish();
|
||||
}, config.shutdownTimeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
if (!httpServer.listening) {
|
||||
io.close();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info?.(`Stopping Announcements API (${reason})`);
|
||||
io.close(finish);
|
||||
});
|
||||
return stopPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
config,
|
||||
database,
|
||||
httpServer,
|
||||
io,
|
||||
lifecycle,
|
||||
migrations,
|
||||
playbackService,
|
||||
storage,
|
||||
imageProcessor,
|
||||
get address() {
|
||||
return address;
|
||||
},
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createRuntime,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const express = require("express");
|
||||
|
||||
function createSettingsRouter(
|
||||
settingsService,
|
||||
{
|
||||
onBeforePlaybackDomainChange = () => {},
|
||||
onPlaylistsChanged = () => {},
|
||||
onPlaybackDomainChanged = () => {},
|
||||
} = {},
|
||||
) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (request, response) => {
|
||||
response.json(settingsService.get());
|
||||
});
|
||||
|
||||
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) {
|
||||
const change = {
|
||||
reason: "settings_updated",
|
||||
playlistIds: null,
|
||||
settingsRevision: updated.revision,
|
||||
};
|
||||
onPlaybackDomainChanged(change);
|
||||
onPlaylistsChanged(change);
|
||||
}
|
||||
response.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSettingsRouter,
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
const SETTING_COLUMNS = Object.freeze({
|
||||
defaultDurationMs: "default_duration_ms",
|
||||
defaultBackgroundMode: "default_background_mode",
|
||||
defaultSolidBackgroundColor: "default_solid_background_color",
|
||||
defaultBackgroundDarkened: "default_background_darkened",
|
||||
defaultBlurPx: "default_blur_px",
|
||||
crossfadeDurationMs: "crossfade_duration_ms",
|
||||
});
|
||||
|
||||
class SettingsValidationError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SettingsValidationError";
|
||||
this.statusCode = 400;
|
||||
this.code = "INVALID_SETTINGS";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function mapSettings(row) {
|
||||
return {
|
||||
revision: row.revision,
|
||||
defaultDurationMs: row.default_duration_ms,
|
||||
defaultBackgroundMode: row.default_background_mode,
|
||||
defaultSolidBackgroundColor: row.default_solid_background_color,
|
||||
defaultBackgroundDarkened: Boolean(row.default_background_darkened),
|
||||
defaultBlurPx: row.default_blur_px,
|
||||
crossfadeDurationMs: row.crossfade_duration_ms,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSafeInteger(name, value, { positive = false } = {}) {
|
||||
if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) {
|
||||
const qualifier = positive ? "a positive" : "a non-negative";
|
||||
throw new SettingsValidationError(`${name} must be ${qualifier} integer`, {
|
||||
field: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validatePatch(input) {
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new SettingsValidationError("Settings payload must be a JSON object");
|
||||
}
|
||||
|
||||
const keys = Object.keys(input);
|
||||
const unknown = keys.filter((key) => !Object.hasOwn(SETTING_COLUMNS, key));
|
||||
if (unknown.length > 0) {
|
||||
throw new SettingsValidationError("Settings payload contains unknown fields", {
|
||||
fields: unknown,
|
||||
});
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
throw new SettingsValidationError("At least one setting is required");
|
||||
}
|
||||
|
||||
if ("defaultDurationMs" in input) {
|
||||
validateSafeInteger("defaultDurationMs", input.defaultDurationMs, {
|
||||
positive: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
"defaultBackgroundMode" in input &&
|
||||
!["blurred", "solid", "black"].includes(input.defaultBackgroundMode)
|
||||
) {
|
||||
throw new SettingsValidationError(
|
||||
"defaultBackgroundMode must be blurred, solid, or black",
|
||||
{ field: "defaultBackgroundMode" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
"defaultSolidBackgroundColor" in input &&
|
||||
(typeof input.defaultSolidBackgroundColor !== "string" ||
|
||||
!/^#[0-9a-f]{6}$/i.test(input.defaultSolidBackgroundColor))
|
||||
) {
|
||||
throw new SettingsValidationError(
|
||||
"defaultSolidBackgroundColor must be a six-digit hex color",
|
||||
{ field: "defaultSolidBackgroundColor" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
"defaultBackgroundDarkened" in input &&
|
||||
typeof input.defaultBackgroundDarkened !== "boolean"
|
||||
) {
|
||||
throw new SettingsValidationError(
|
||||
"defaultBackgroundDarkened must be a boolean",
|
||||
{ field: "defaultBackgroundDarkened" },
|
||||
);
|
||||
}
|
||||
if ("defaultBlurPx" in input) {
|
||||
validateSafeInteger("defaultBlurPx", input.defaultBlurPx);
|
||||
}
|
||||
if ("crossfadeDurationMs" in input) {
|
||||
validateSafeInteger("crossfadeDurationMs", input.crossfadeDurationMs);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function createSettingsService(database, { now = Date.now } = {}) {
|
||||
const read = database.prepare("SELECT * FROM app_settings WHERE id = 1");
|
||||
const update = database.prepare(`
|
||||
UPDATE app_settings
|
||||
SET revision = revision + 1,
|
||||
default_duration_ms = @defaultDurationMs,
|
||||
default_background_mode = @defaultBackgroundMode,
|
||||
default_solid_background_color = @defaultSolidBackgroundColor,
|
||||
default_background_darkened = @defaultBackgroundDarkened,
|
||||
default_blur_px = @defaultBlurPx,
|
||||
crossfade_duration_ms = @crossfadeDurationMs,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
function get() {
|
||||
const row = read.get();
|
||||
if (!row) {
|
||||
throw new Error("The app_settings singleton row is missing");
|
||||
}
|
||||
return mapSettings(row);
|
||||
}
|
||||
|
||||
const updateTransaction = database.transaction((patch) => {
|
||||
validatePatch(patch);
|
||||
const current = get();
|
||||
const next = { ...current, ...patch };
|
||||
const changed = Object.keys(patch).some((key) => current[key] !== next[key]);
|
||||
|
||||
if (!changed) {
|
||||
return current;
|
||||
}
|
||||
|
||||
update.run({
|
||||
defaultDurationMs: next.defaultDurationMs,
|
||||
defaultBackgroundMode: next.defaultBackgroundMode,
|
||||
defaultSolidBackgroundColor: next.defaultSolidBackgroundColor,
|
||||
defaultBackgroundDarkened: next.defaultBackgroundDarkened ? 1 : 0,
|
||||
defaultBlurPx: next.defaultBlurPx,
|
||||
crossfadeDurationMs: next.crossfadeDurationMs,
|
||||
updatedAt: Math.max(now(), current.updatedAt + 1),
|
||||
});
|
||||
return get();
|
||||
});
|
||||
|
||||
return {
|
||||
get,
|
||||
update: updateTransaction,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SettingsValidationError,
|
||||
createSettingsService,
|
||||
mapSettings,
|
||||
validatePatch,
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
const {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} = require("@aws-sdk/client-s3");
|
||||
|
||||
class StorageError extends Error {
|
||||
constructor(message, { code, statusCode, cause } = {}) {
|
||||
super(message, { cause });
|
||||
this.name = "StorageError";
|
||||
this.code = code || "STORAGE_ERROR";
|
||||
this.statusCode = statusCode || 500;
|
||||
}
|
||||
}
|
||||
|
||||
class StorageUnavailableError extends StorageError {
|
||||
constructor(cause) {
|
||||
super("Object storage is unavailable", {
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
statusCode: 503,
|
||||
cause,
|
||||
});
|
||||
this.name = "StorageUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
class StorageObjectNotFoundError extends StorageError {
|
||||
constructor(key, cause) {
|
||||
super("Stored object was not found", {
|
||||
code: "STORAGE_OBJECT_NOT_FOUND",
|
||||
statusCode: 404,
|
||||
cause,
|
||||
});
|
||||
this.name = "StorageObjectNotFoundError";
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
function errorStatus(error) {
|
||||
return error?.$metadata?.httpStatusCode || error?.statusCode;
|
||||
}
|
||||
|
||||
function isMissingBucket(error) {
|
||||
return (
|
||||
error?.name === "NoSuchBucket" ||
|
||||
error?.Code === "NoSuchBucket" ||
|
||||
error?.code === "NoSuchBucket" ||
|
||||
errorStatus(error) === 404
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingObject(error) {
|
||||
if (
|
||||
error?.name === "NoSuchBucket" ||
|
||||
error?.Code === "NoSuchBucket" ||
|
||||
error?.code === "NoSuchBucket"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
error?.name === "NoSuchKey" ||
|
||||
error?.name === "NotFound" ||
|
||||
error?.Code === "NoSuchKey" ||
|
||||
error?.code === "NoSuchKey" ||
|
||||
errorStatus(error) === 404
|
||||
);
|
||||
}
|
||||
|
||||
function isBucketCreationConflict(error) {
|
||||
return (
|
||||
error?.name === "BucketAlreadyOwnedByYou" ||
|
||||
error?.name === "BucketAlreadyExists" ||
|
||||
error?.Code === "BucketAlreadyOwnedByYou" ||
|
||||
error?.Code === "BucketAlreadyExists" ||
|
||||
error?.code === "BucketAlreadyOwnedByYou" ||
|
||||
error?.code === "BucketAlreadyExists"
|
||||
);
|
||||
}
|
||||
|
||||
function isConsumerCancellation(error) {
|
||||
return (
|
||||
error?.name === "AbortError" ||
|
||||
error?.code === "ABORT_ERR" ||
|
||||
error?.code === "ERR_STREAM_PREMATURE_CLOSE"
|
||||
);
|
||||
}
|
||||
|
||||
function assertObjectKey(key) {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new TypeError("Storage object key must be a non-empty string");
|
||||
}
|
||||
|
||||
const segments = key.split("/");
|
||||
if (
|
||||
key.startsWith("/") ||
|
||||
key.includes("\\") ||
|
||||
key.includes("\0") ||
|
||||
segments.some((segment) => segment === "." || segment === "..")
|
||||
) {
|
||||
throw new TypeError("Storage object key is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function publicHealth(health) {
|
||||
return Object.freeze({ ...health });
|
||||
}
|
||||
|
||||
function createS3Client(config) {
|
||||
const options = {
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
maxAttempts: config.maxAttempts,
|
||||
requestHandler: {
|
||||
connectionTimeout: config.connectionTimeoutMs,
|
||||
// Use an inactivity timeout rather than an absolute request deadline so
|
||||
// a large but continuously progressing upload is not aborted.
|
||||
socketTimeout: config.requestTimeoutMs,
|
||||
},
|
||||
// Optional AWS checksums are not uniformly implemented by S3-compatible
|
||||
// providers. Source and derivative SHA-256 hashes are verified by the app.
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
};
|
||||
|
||||
if (config.accessKeyId && config.secretAccessKey) {
|
||||
options.credentials = {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
};
|
||||
}
|
||||
|
||||
return new S3Client(options);
|
||||
}
|
||||
|
||||
function createStorageService(config, { client, logger = console, now = () => new Date() } = {}) {
|
||||
const s3 = client || createS3Client(config);
|
||||
let ensurePromise = null;
|
||||
let healthTimer = null;
|
||||
let bucketReady = false;
|
||||
let issuedHealthSequence = 0;
|
||||
let appliedHealthSequence = 0;
|
||||
let currentHealth = {
|
||||
status: "degraded",
|
||||
checkedAt: null,
|
||||
lastSuccessAt: null,
|
||||
code: "STORAGE_NOT_CHECKED",
|
||||
message: "Object storage has not been checked",
|
||||
};
|
||||
|
||||
function nextHealthSequence() {
|
||||
issuedHealthSequence += 1;
|
||||
return issuedHealthSequence;
|
||||
}
|
||||
|
||||
function markHealthy(sequence) {
|
||||
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
|
||||
appliedHealthSequence = sequence;
|
||||
bucketReady = true;
|
||||
const checkedAt = now().toISOString();
|
||||
currentHealth = {
|
||||
status: "healthy",
|
||||
checkedAt,
|
||||
lastSuccessAt: checkedAt,
|
||||
};
|
||||
return publicHealth(currentHealth);
|
||||
}
|
||||
|
||||
function markDegraded(sequence) {
|
||||
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
|
||||
appliedHealthSequence = sequence;
|
||||
bucketReady = false;
|
||||
currentHealth = {
|
||||
status: "degraded",
|
||||
checkedAt: now().toISOString(),
|
||||
lastSuccessAt: currentHealth.lastSuccessAt,
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
message: "Object storage is unavailable",
|
||||
};
|
||||
return publicHealth(currentHealth);
|
||||
}
|
||||
|
||||
function unavailable(error, sequence) {
|
||||
markDegraded(sequence);
|
||||
return error instanceof StorageUnavailableError
|
||||
? error
|
||||
: new StorageUnavailableError(error);
|
||||
}
|
||||
|
||||
async function ensureBucketOnce() {
|
||||
const sequence = nextHealthSequence();
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
||||
markHealthy(sequence);
|
||||
return { created: false };
|
||||
} catch (error) {
|
||||
if (!isMissingBucket(error)) {
|
||||
throw unavailable(error, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: config.bucket }));
|
||||
markHealthy(sequence);
|
||||
return { created: true };
|
||||
} catch (error) {
|
||||
if (isBucketCreationConflict(error)) {
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
||||
markHealthy(sequence);
|
||||
return { created: false };
|
||||
} catch (verificationError) {
|
||||
throw unavailable(verificationError, sequence);
|
||||
}
|
||||
}
|
||||
throw unavailable(error, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBucket() {
|
||||
if (!ensurePromise) {
|
||||
ensurePromise = ensureBucketOnce().finally(() => {
|
||||
ensurePromise = null;
|
||||
});
|
||||
}
|
||||
return ensurePromise;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
await ensureBucket();
|
||||
} catch (error) {
|
||||
logger.warn?.("Object storage is unavailable during initialization");
|
||||
}
|
||||
return publicHealth(currentHealth);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!healthTimer) {
|
||||
healthTimer = setInterval(() => {
|
||||
ensureBucket().catch(() => {});
|
||||
}, config.healthProbeIntervalMs || 30_000);
|
||||
healthTimer.unref?.();
|
||||
}
|
||||
return initialize();
|
||||
}
|
||||
|
||||
async function probe() {
|
||||
const sequence = nextHealthSequence();
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
||||
return markHealthy(sequence);
|
||||
} catch (error) {
|
||||
return markDegraded(sequence);
|
||||
}
|
||||
}
|
||||
|
||||
async function putObject({ key, body, contentType, contentLength, metadata }) {
|
||||
assertObjectKey(key);
|
||||
if (!bucketReady) await ensureBucket();
|
||||
const sequence = nextHealthSequence();
|
||||
const input = {
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
...(contentType ? { ContentType: contentType } : {}),
|
||||
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
|
||||
...(metadata ? { Metadata: metadata } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await s3.send(new PutObjectCommand(input));
|
||||
markHealthy(sequence);
|
||||
return {
|
||||
key,
|
||||
etag: result.ETag || null,
|
||||
versionId: result.VersionId || null,
|
||||
};
|
||||
} catch (error) {
|
||||
throw unavailable(error, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
function objectMetadata(key, result) {
|
||||
return {
|
||||
key,
|
||||
contentType: result.ContentType || null,
|
||||
contentLength:
|
||||
result.ContentLength === undefined ? null : Number(result.ContentLength),
|
||||
etag: result.ETag || null,
|
||||
lastModified: result.LastModified || null,
|
||||
metadata: result.Metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
async function getObject(key) {
|
||||
assertObjectKey(key);
|
||||
if (!bucketReady) await ensureBucket();
|
||||
const sequence = nextHealthSequence();
|
||||
try {
|
||||
const result = await s3.send(
|
||||
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
||||
);
|
||||
markHealthy(sequence);
|
||||
result.Body?.once?.("error", (error) => {
|
||||
if (!isConsumerCancellation(error)) {
|
||||
markDegraded(nextHealthSequence());
|
||||
}
|
||||
});
|
||||
return {
|
||||
...objectMetadata(key, result),
|
||||
body: result.Body,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isMissingObject(error)) {
|
||||
markHealthy(sequence);
|
||||
throw new StorageObjectNotFoundError(key, error);
|
||||
}
|
||||
throw unavailable(error, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
async function headObject(key) {
|
||||
assertObjectKey(key);
|
||||
if (!bucketReady) await ensureBucket();
|
||||
const sequence = nextHealthSequence();
|
||||
try {
|
||||
const result = await s3.send(
|
||||
new HeadObjectCommand({ Bucket: config.bucket, Key: key }),
|
||||
);
|
||||
markHealthy(sequence);
|
||||
return objectMetadata(key, result);
|
||||
} catch (error) {
|
||||
if (isMissingObject(error)) {
|
||||
markHealthy(sequence);
|
||||
return null;
|
||||
}
|
||||
throw unavailable(error, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
async function objectExists(key) {
|
||||
return (await headObject(key)) !== null;
|
||||
}
|
||||
|
||||
async function deleteObject(key) {
|
||||
assertObjectKey(key);
|
||||
if (!bucketReady) await ensureBucket();
|
||||
const sequence = nextHealthSequence();
|
||||
try {
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
|
||||
);
|
||||
markHealthy(sequence);
|
||||
} catch (error) {
|
||||
if (isMissingObject(error)) {
|
||||
markHealthy(sequence);
|
||||
return;
|
||||
}
|
||||
throw unavailable(error, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (healthTimer) {
|
||||
clearInterval(healthTimer);
|
||||
healthTimer = null;
|
||||
}
|
||||
s3.destroy?.();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
initialize,
|
||||
start,
|
||||
stop,
|
||||
ensureBucket,
|
||||
probe,
|
||||
health: () => publicHealth(currentHealth),
|
||||
putObject,
|
||||
getObject,
|
||||
headObject,
|
||||
objectExists,
|
||||
deleteObject,
|
||||
destroy: stop,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
StorageError,
|
||||
StorageObjectNotFoundError,
|
||||
StorageUnavailableError,
|
||||
createS3Client,
|
||||
createStorageService,
|
||||
};
|
||||
@@ -0,0 +1,442 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Readable } = require("node:stream");
|
||||
const sharp = require("sharp");
|
||||
const { createApp } = require("../src/app");
|
||||
const { loadConfig } = require("../src/config");
|
||||
const { openDatabase } = require("../src/database");
|
||||
const { runMigrations } = require("../src/database/migrate");
|
||||
const { createImageProcessor } = require("../src/image-processing");
|
||||
const { StorageObjectNotFoundError } = require("../src/storage");
|
||||
|
||||
async function readBody(body) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
const chunks = [];
|
||||
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function memoryStorage() {
|
||||
const objects = new Map();
|
||||
return {
|
||||
objects,
|
||||
health() {
|
||||
return {
|
||||
status: "healthy",
|
||||
checkedAt: "2033-05-18T03:33:20.000Z",
|
||||
lastSuccessAt: "2033-05-18T03:33:20.000Z",
|
||||
};
|
||||
},
|
||||
async putObject(input) {
|
||||
objects.set(input.key, {
|
||||
body: await readBody(input.body),
|
||||
contentType: input.contentType,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return { key: input.key };
|
||||
},
|
||||
async getObject(key) {
|
||||
const object = objects.get(key);
|
||||
if (!object) throw new StorageObjectNotFoundError(key);
|
||||
return {
|
||||
body: Readable.from([object.body]),
|
||||
contentLength: object.body.length,
|
||||
contentType: object.contentType,
|
||||
};
|
||||
},
|
||||
async deleteObject(key) {
|
||||
objects.delete(key);
|
||||
return { key, deleted: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createHarness(t, { maxFileBytes = 1024 * 1024 } = {}) {
|
||||
const tempDirectory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "announcements-api-uploads-"),
|
||||
);
|
||||
const config = loadConfig({
|
||||
NODE_ENV: "test",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "0",
|
||||
DATABASE_PATH: ":memory:",
|
||||
APP_TIMEZONE: "America/Chicago",
|
||||
S3_ENDPOINT: "http://private-seaweedfs:8333",
|
||||
S3_BUCKET: "announcements-test",
|
||||
UPLOAD_TEMP_DIRECTORY: tempDirectory,
|
||||
UPLOAD_MAX_FILE_BYTES: String(maxFileBytes),
|
||||
UPLOAD_MAX_FILES: "5",
|
||||
IMAGE_MAX_INPUT_PIXELS: "1000000",
|
||||
});
|
||||
const database = openDatabase({ path: ":memory:" });
|
||||
const migrations = runMigrations(database);
|
||||
const storage = memoryStorage();
|
||||
const app = createApp({
|
||||
config,
|
||||
database,
|
||||
imageProcessor: createImageProcessor({
|
||||
maxInputPixels: config.processing.maxInputPixels,
|
||||
}),
|
||||
lifecycle: { ready: true },
|
||||
migrations,
|
||||
storage,
|
||||
});
|
||||
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();
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
||||
database,
|
||||
storage,
|
||||
tempDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
function appendFile(form, field, buffer, filename, contentType) {
|
||||
form.append(field, new Blob([buffer], { type: contentType }), filename);
|
||||
}
|
||||
|
||||
async function jsonResponse(baseUrl, route, init) {
|
||||
const response = await fetch(`${baseUrl}${route}`, init);
|
||||
return { response, body: await response.json() };
|
||||
}
|
||||
|
||||
async function eventuallyEmpty(directory) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (fs.readdirSync(directory).length === 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(directory), []);
|
||||
}
|
||||
|
||||
async function eventuallyContainsFile(directory) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (fs.readdirSync(directory).length > 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail("The upload did not create its temporary file");
|
||||
}
|
||||
|
||||
async function sampleImages() {
|
||||
const source = sharp({
|
||||
create: {
|
||||
width: 80,
|
||||
height: 60,
|
||||
channels: 4,
|
||||
background: { r: 31, g: 72, b: 120, alpha: 0.5 },
|
||||
},
|
||||
});
|
||||
return {
|
||||
jpeg: await source.clone().jpeg().toBuffer(),
|
||||
png: await source.clone().png().toBuffer(),
|
||||
webp: await source.clone().webp().toBuffer(),
|
||||
};
|
||||
}
|
||||
|
||||
test("asset HTTP API accepts JPEG, PNG, and WebP and serves private immutable media", async (t) => {
|
||||
const harness = await createHarness(t);
|
||||
const images = await sampleImages();
|
||||
const form = new FormData();
|
||||
appendFile(form, "files", images.jpeg, "welcome.jpg", "image/jpeg");
|
||||
appendFile(form, "files", images.png, "../../bulletin.png", "image/png");
|
||||
appendFile(form, "files", images.webp, "events.webp", "image/webp");
|
||||
|
||||
const uploaded = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
assert.equal(uploaded.response.status, 201);
|
||||
assert.equal(uploaded.body.createdCount, 3);
|
||||
assert.equal(uploaded.body.duplicateCount, 0);
|
||||
assert.equal(uploaded.body.results.length, 3);
|
||||
assert.deepEqual(
|
||||
uploaded.body.results.map((result) => result.asset.original.contentType),
|
||||
["image/jpeg", "image/png", "image/webp"],
|
||||
);
|
||||
assert.equal(uploaded.body.results[1].asset.originalFilename, "bulletin.png");
|
||||
assert.doesNotMatch(JSON.stringify(uploaded.body), /objectKey|object_key|private-seaweedfs/);
|
||||
assert.equal(harness.storage.objects.size, 9);
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
|
||||
const asset = uploaded.body.results[0].asset;
|
||||
const listed = await jsonResponse(harness.baseUrl, "/api/assets");
|
||||
assert.equal(listed.response.status, 200);
|
||||
assert.equal(listed.body.assets.length, 3);
|
||||
assert.deepEqual(
|
||||
(await jsonResponse(harness.baseUrl, `/api/assets/${asset.id}`)).body,
|
||||
asset,
|
||||
);
|
||||
|
||||
for (const media of [asset.display, asset.thumbnail]) {
|
||||
const response = await fetch(`${harness.baseUrl}${media.url}`);
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("content-type"), "image/webp");
|
||||
assert.equal(
|
||||
response.headers.get("cache-control"),
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
assert.equal(response.headers.get("content-length"), String(body.length));
|
||||
assert.equal(response.headers.get("etag"), `"${media.sha256}"`);
|
||||
assert.equal(crypto.createHash("sha256").update(body).digest("hex"), media.sha256);
|
||||
}
|
||||
|
||||
const original = await fetch(`${harness.baseUrl}${asset.original.url}`);
|
||||
assert.equal(original.status, 200);
|
||||
assert.equal(original.headers.get("content-type"), "image/jpeg");
|
||||
assert.equal(original.headers.get("cache-control"), "private, no-store");
|
||||
assert.match(original.headers.get("content-disposition"), /^attachment;/);
|
||||
assert.deepEqual(Buffer.from(await original.arrayBuffer()), images.jpeg);
|
||||
|
||||
const wrongVersion = await jsonResponse(
|
||||
harness.baseUrl,
|
||||
`/media/assets/${asset.id}/display/${"a".repeat(64)}.webp`,
|
||||
);
|
||||
assert.equal(wrongVersion.response.status, 404);
|
||||
assert.equal(wrongVersion.body.error.code, "MEDIA_NOT_FOUND");
|
||||
const arbitraryKey = await jsonResponse(
|
||||
harness.baseUrl,
|
||||
`/media/assets/${asset.id}/display/${encodeURIComponent("../../original/key")}.webp`,
|
||||
);
|
||||
assert.equal(arbitraryKey.response.status, 404);
|
||||
assert.equal(arbitraryKey.body.error.code, "MEDIA_NOT_FOUND");
|
||||
|
||||
const displayKey = [...harness.storage.objects.keys()].find(
|
||||
(key) => key.includes(`assets/${asset.id}/display/`),
|
||||
);
|
||||
harness.storage.objects.delete(displayKey);
|
||||
const missingStoredBytes = await jsonResponse(
|
||||
harness.baseUrl,
|
||||
asset.display.url,
|
||||
);
|
||||
assert.equal(missingStoredBytes.response.status, 503);
|
||||
assert.equal(missingStoredBytes.body.error.code, "STORAGE_INCONSISTENCY");
|
||||
assert.equal(
|
||||
(await jsonResponse(harness.baseUrl, "/health/live")).response.status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("asset HTTP API reports duplicates, name conflicts, and persists editable metadata", async (t) => {
|
||||
const harness = await createHarness(t);
|
||||
const { png, jpeg } = await sampleImages();
|
||||
const firstForm = new FormData();
|
||||
appendFile(firstForm, "file", png, "weekly.png", "application/octet-stream");
|
||||
firstForm.append("display_name", "Weekly Announcement");
|
||||
const first = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: firstForm,
|
||||
});
|
||||
assert.equal(first.response.status, 201);
|
||||
assert.equal(first.body.created, true);
|
||||
const assetId = first.body.asset.id;
|
||||
|
||||
const duplicateForm = new FormData();
|
||||
appendFile(duplicateForm, "file", png, "unrelated-name.png", "image/png");
|
||||
duplicateForm.append("display_name", "Ignored duplicate proposal");
|
||||
const duplicate = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: duplicateForm,
|
||||
});
|
||||
assert.equal(duplicate.response.status, 200);
|
||||
assert.equal(duplicate.body.created, false);
|
||||
assert.equal(duplicate.body.duplicate, true);
|
||||
assert.equal(duplicate.body.asset.id, assetId);
|
||||
assert.equal(harness.storage.objects.size, 3);
|
||||
|
||||
const conflictForm = new FormData();
|
||||
appendFile(conflictForm, "file", jpeg, "different.jpg", "image/jpeg");
|
||||
conflictForm.append("displayName", "weekly announcement");
|
||||
const conflict = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: conflictForm,
|
||||
});
|
||||
assert.equal(conflict.response.status, 409);
|
||||
assert.equal(conflict.body.error.code, "ASSET_NAME_CONFLICT");
|
||||
|
||||
const expiresAt = Date.parse("2034-01-02T03:04:05.000Z");
|
||||
const edited = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
display_name: "Renamed announcement",
|
||||
expiration_at: expiresAt,
|
||||
}),
|
||||
});
|
||||
assert.equal(edited.response.status, 200);
|
||||
assert.equal(edited.body.displayName, "Renamed announcement");
|
||||
assert.equal(edited.body.expirationAt, expiresAt);
|
||||
const permanent = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ expiration_at: null }),
|
||||
});
|
||||
assert.equal(permanent.body.expirationAt, null);
|
||||
assert.equal(harness.storage.objects.size, 3);
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
|
||||
const timestamp = Date.now();
|
||||
harness.database
|
||||
.prepare(
|
||||
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("api-playlist", "API playlist", timestamp, timestamp);
|
||||
harness.database
|
||||
.prepare(
|
||||
`INSERT INTO playlist_entries
|
||||
(id, playlist_id, asset_id, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 0, ?, ?)`,
|
||||
)
|
||||
.run("api-entry", "api-playlist", assetId, timestamp, timestamp);
|
||||
const references = await jsonResponse(
|
||||
harness.baseUrl,
|
||||
`/api/assets/${assetId}/references`,
|
||||
);
|
||||
assert.equal(references.response.status, 200);
|
||||
assert.equal(references.body.canDelete, false);
|
||||
assert.equal(references.body.playlists[0].playlistEntryId, "api-entry");
|
||||
const blockedDelete = await jsonResponse(
|
||||
harness.baseUrl,
|
||||
`/api/assets/${assetId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
assert.equal(blockedDelete.response.status, 409);
|
||||
assert.equal(blockedDelete.body.error.code, "ASSET_REFERENCED");
|
||||
assert.equal(harness.storage.objects.size, 3);
|
||||
harness.database
|
||||
.prepare("DELETE FROM playlist_entries WHERE id = ?")
|
||||
.run("api-entry");
|
||||
|
||||
const removed = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
assert.equal(removed.response.status, 200);
|
||||
assert.deepEqual(removed.body, { id: assetId, deleted: true });
|
||||
assert.equal(harness.storage.objects.size, 0);
|
||||
assert.equal(
|
||||
(await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`)).response.status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("upload validation returns structured errors and always cleans temporary files", async (t) => {
|
||||
const harness = await createHarness(t, { maxFileBytes: 1024 });
|
||||
|
||||
const malformed = new FormData();
|
||||
appendFile(malformed, "file", Buffer.from("not an image"), "broken.png", "image/png");
|
||||
const malformedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: malformed,
|
||||
});
|
||||
assert.equal(malformedResult.response.status, 422);
|
||||
assert.equal(malformedResult.body.error.code, "MALFORMED_IMAGE");
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
|
||||
const unsupportedBuffer = await sharp({
|
||||
create: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
channels: 3,
|
||||
background: "red",
|
||||
},
|
||||
})
|
||||
.gif()
|
||||
.toBuffer();
|
||||
const unsupported = new FormData();
|
||||
appendFile(unsupported, "file", unsupportedBuffer, "still.gif", "image/png");
|
||||
const unsupportedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: unsupported,
|
||||
});
|
||||
assert.equal(unsupportedResult.response.status, 415);
|
||||
assert.equal(unsupportedResult.body.error.code, "UNSUPPORTED_IMAGE_FORMAT");
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
|
||||
const oversized = new FormData();
|
||||
appendFile(
|
||||
oversized,
|
||||
"file",
|
||||
crypto.randomBytes(1025),
|
||||
"too-large.png",
|
||||
"image/png",
|
||||
);
|
||||
const oversizedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: oversized,
|
||||
});
|
||||
assert.equal(oversizedResult.response.status, 413);
|
||||
assert.equal(oversizedResult.body.error.code, "UPLOAD_FILE_TOO_LARGE");
|
||||
assert.deepEqual(oversizedResult.body.error.details, { maxFileBytes: 1024 });
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
|
||||
const tooMany = new FormData();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
appendFile(tooMany, "files", Buffer.from("x"), `${index}.png`, "image/png");
|
||||
}
|
||||
const tooManyResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: tooMany,
|
||||
});
|
||||
assert.equal(tooManyResult.response.status, 413);
|
||||
assert.equal(tooManyResult.body.error.code, "UPLOAD_FILE_COUNT_EXCEEDED");
|
||||
assert.deepEqual(tooManyResult.body.error.details, { maxFiles: 5 });
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
|
||||
const absent = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||
method: "POST",
|
||||
body: new FormData(),
|
||||
});
|
||||
assert.equal(absent.response.status, 400);
|
||||
assert.equal(absent.body.error.code, "UPLOAD_FILE_REQUIRED");
|
||||
assert.equal(harness.storage.objects.size, 0);
|
||||
assert.equal(
|
||||
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("an aborted multipart request removes its in-flight temporary file", async (t) => {
|
||||
const harness = await createHarness(t);
|
||||
const boundary = `announcements-${crypto.randomUUID()}`;
|
||||
const preamble = Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
'Content-Disposition: form-data; name="file"; filename="aborted.png"\r\n' +
|
||||
"Content-Type: image/png\r\n\r\n",
|
||||
);
|
||||
const declaredLength = preamble.length + 512 * 1024;
|
||||
const request = http.request(`${harness.baseUrl}/api/assets`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
"content-length": String(declaredLength),
|
||||
},
|
||||
});
|
||||
request.on("error", () => {});
|
||||
request.write(preamble);
|
||||
request.write(Buffer.alloc(64 * 1024, 1));
|
||||
|
||||
await eventuallyContainsFile(harness.tempDirectory);
|
||||
const closed = new Promise((resolve) => request.once("close", resolve));
|
||||
request.destroy();
|
||||
await closed;
|
||||
await eventuallyEmpty(harness.tempDirectory);
|
||||
assert.equal(harness.storage.objects.size, 0);
|
||||
assert.equal(
|
||||
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
mapMulterError,
|
||||
safeOriginalFilename,
|
||||
} = require("../src/assets/multipart");
|
||||
|
||||
test("original filename metadata keeps a safe leaf without path traversal", () => {
|
||||
assert.equal(safeOriginalFilename("../../Sunday/welcome.png"), "welcome.png");
|
||||
assert.equal(safeOriginalFilename("..\\..\\Sunday\\welcome.png"), "welcome.png");
|
||||
assert.equal(safeOriginalFilename(" welcome sign.png "), " welcome sign.png ");
|
||||
});
|
||||
|
||||
test("original filename metadata removes controls and remains bounded", () => {
|
||||
assert.equal(safeOriginalFilename("wel\u0000come\n.png"), "welcome.png");
|
||||
assert.equal([...safeOriginalFilename(`${"a".repeat(300)}.png`)].length, 255);
|
||||
assert.throws(
|
||||
() => safeOriginalFilename(" \u0000\n "),
|
||||
(error) => error.code === "INVALID_ORIGINAL_FILENAME",
|
||||
);
|
||||
});
|
||||
|
||||
test("temporary-storage failures are server errors, not invalid client uploads", () => {
|
||||
const storageFailure = mapMulterError(
|
||||
Object.assign(new Error("disk full at a private path"), { code: "ENOSPC" }),
|
||||
{},
|
||||
);
|
||||
assert.equal(storageFailure.code, "UPLOAD_TEMP_STORAGE_UNAVAILABLE");
|
||||
assert.equal(storageFailure.statusCode, 503);
|
||||
assert.doesNotMatch(storageFailure.message, /private path/);
|
||||
|
||||
const malformed = mapMulterError(new Error("Unexpected end of form"), {});
|
||||
assert.equal(malformed.code, "INVALID_MULTIPART_UPLOAD");
|
||||
assert.equal(malformed.statusCode, 400);
|
||||
});
|
||||
@@ -0,0 +1,608 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Readable } = require("node:stream");
|
||||
const { openDatabase } = require("../src/database");
|
||||
const { runMigrations } = require("../src/database/migrate");
|
||||
const { createAssetService } = require("../src/assets/service");
|
||||
const {
|
||||
StorageObjectNotFoundError,
|
||||
StorageUnavailableError,
|
||||
} = require("../src/storage");
|
||||
|
||||
async function bufferBody(body) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
const chunks = [];
|
||||
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function createMemoryStorage() {
|
||||
const objects = new Map();
|
||||
const calls = [];
|
||||
let failNextPut = null;
|
||||
let successfulPutsBeforeFailure = 0;
|
||||
let failNextDelete = null;
|
||||
let putDelayMs = 0;
|
||||
|
||||
return {
|
||||
objects,
|
||||
calls,
|
||||
setPutFailure(error, putsBeforeFailure = 0) {
|
||||
failNextPut = error;
|
||||
successfulPutsBeforeFailure = putsBeforeFailure;
|
||||
},
|
||||
setDeleteFailure(error) {
|
||||
failNextDelete = error;
|
||||
},
|
||||
setPutDelay(milliseconds) {
|
||||
putDelayMs = milliseconds;
|
||||
},
|
||||
async putObject(input) {
|
||||
calls.push({ operation: "put", ...input, body: undefined });
|
||||
if (putDelayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, putDelayMs));
|
||||
}
|
||||
if (failNextPut) {
|
||||
if (successfulPutsBeforeFailure === 0) {
|
||||
const error = failNextPut;
|
||||
failNextPut = null;
|
||||
throw error;
|
||||
}
|
||||
successfulPutsBeforeFailure -= 1;
|
||||
}
|
||||
objects.set(input.key, {
|
||||
body: await bufferBody(input.body),
|
||||
contentType: input.contentType,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return { key: input.key };
|
||||
},
|
||||
async getObject(key) {
|
||||
calls.push({ operation: "get", key });
|
||||
const object = objects.get(key);
|
||||
if (!object) throw new StorageObjectNotFoundError(key);
|
||||
return {
|
||||
body: Readable.from(object.body),
|
||||
contentLength: object.body.length,
|
||||
contentType: object.contentType,
|
||||
};
|
||||
},
|
||||
async deleteObject(key) {
|
||||
calls.push({ operation: "delete", key });
|
||||
if (failNextDelete) {
|
||||
const error = failNextDelete;
|
||||
failNextDelete = null;
|
||||
throw error;
|
||||
}
|
||||
objects.delete(key);
|
||||
return { key, deleted: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeImageProcessor({ reject } = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async process(filename) {
|
||||
calls.push(filename);
|
||||
if (reject) throw reject;
|
||||
const original = await fs.promises.readFile(filename);
|
||||
const sourceSha256 = crypto.createHash("sha256").update(original).digest("hex");
|
||||
const displayBuffer = Buffer.from(`display:${sourceSha256}`);
|
||||
const thumbnailBuffer = Buffer.from(`thumbnail:${sourceSha256}`);
|
||||
return {
|
||||
original: {
|
||||
sha256: sourceSha256,
|
||||
contentType: "image/png",
|
||||
byteSize: original.length,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
display: {
|
||||
buffer: displayBuffer,
|
||||
sha256: crypto.createHash("sha256").update(displayBuffer).digest("hex"),
|
||||
contentType: "image/webp",
|
||||
byteSize: displayBuffer.length,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
thumbnail: {
|
||||
buffer: thumbnailBuffer,
|
||||
sha256: crypto.createHash("sha256").update(thumbnailBuffer).digest("hex"),
|
||||
contentType: "image/webp",
|
||||
byteSize: thumbnailBuffer.length,
|
||||
width: 405,
|
||||
height: 270,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(options = {}) {
|
||||
const database = openDatabase({ path: ":memory:" });
|
||||
runMigrations(database);
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-assets-"));
|
||||
const storage = options.storage || createMemoryStorage();
|
||||
const imageProcessor =
|
||||
options.imageProcessor || createFakeImageProcessor(options.processorOptions);
|
||||
let idCounter = 0;
|
||||
let clock = 2_000_000_000_000;
|
||||
const service = createAssetService({
|
||||
database,
|
||||
storage,
|
||||
imageProcessor,
|
||||
now: () => ++clock,
|
||||
createId:
|
||||
options.createId ||
|
||||
(() => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`),
|
||||
concurrentUploadWaitMs: options.concurrentUploadWaitMs ?? 2_000,
|
||||
});
|
||||
|
||||
function uploadFile(originalFilename, contents) {
|
||||
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
||||
const filename = path.join(directory, crypto.randomUUID());
|
||||
fs.writeFileSync(filename, buffer);
|
||||
return { path: filename, byteSize: buffer.length, originalFilename };
|
||||
}
|
||||
|
||||
return {
|
||||
database,
|
||||
directory,
|
||||
imageProcessor,
|
||||
service,
|
||||
storage,
|
||||
uploadFile,
|
||||
close() {
|
||||
database.close();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawAsset(database, assetId) {
|
||||
return database.prepare("SELECT * FROM assets WHERE id = ?").get(assetId);
|
||||
}
|
||||
|
||||
test("upload stores exact bytes under content-addressed keys without leaking keys", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
const original = Buffer.from("exact uploaded bytes");
|
||||
|
||||
const [result] = await harness.service.upload([
|
||||
harness.uploadFile(" Sunday Welcome.PNG ", original),
|
||||
]);
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.duplicate, false);
|
||||
assert.match(result.asset.id, /^[0-9a-f-]{36}$/);
|
||||
assert.equal(result.asset.displayName, "Sunday Welcome");
|
||||
assert.equal(result.asset.originalFilename, " Sunday Welcome.PNG ");
|
||||
assert.equal(result.asset.expirationAt, null);
|
||||
assert.equal(result.asset.status, "ready");
|
||||
assert.equal(result.asset.original.byteSize, original.length);
|
||||
assert.equal(result.asset.display.contentType, "image/webp");
|
||||
assert.equal(result.asset.thumbnail.width, 405);
|
||||
assert.match(result.asset.display.url, /^\/media\/assets\//);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result.asset),
|
||||
/object_key|originalObjectKey|displayObjectKey|thumbnailObjectKey|seaweed/i,
|
||||
);
|
||||
|
||||
const row = rawAsset(harness.database, result.asset.id);
|
||||
const expectedSourceHash = crypto.createHash("sha256").update(original).digest("hex");
|
||||
assert.equal(row.source_sha256, expectedSourceHash);
|
||||
assert.equal(row.original_object_key, `assets/${row.id}/original/${expectedSourceHash}`);
|
||||
assert.equal(
|
||||
row.display_object_key,
|
||||
`assets/${row.id}/display/${row.display_sha256}.webp`,
|
||||
);
|
||||
assert.equal(
|
||||
row.thumbnail_object_key,
|
||||
`assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
|
||||
);
|
||||
assert.deepEqual(harness.storage.objects.get(row.original_object_key).body, original);
|
||||
assert.equal(harness.storage.objects.size, 3);
|
||||
|
||||
assert.deepEqual(harness.service.list(), [result.asset]);
|
||||
assert.deepEqual(harness.service.get(result.asset.id), result.asset);
|
||||
});
|
||||
|
||||
test("exact duplicate detection takes precedence over filename and display name", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
const contents = Buffer.from("same image bytes");
|
||||
|
||||
const [created] = await harness.service.upload([
|
||||
harness.uploadFile("first.png", contents),
|
||||
]);
|
||||
const [sameFilename] = await harness.service.upload([
|
||||
harness.uploadFile("first.png", contents),
|
||||
]);
|
||||
const [differentFilename] = await harness.service.upload(
|
||||
[harness.uploadFile("completely different name.webp", contents)],
|
||||
{ displayName: "A conflicting requested name is ignored for an exact duplicate" },
|
||||
);
|
||||
|
||||
for (const duplicate of [sameFilename, differentFilename]) {
|
||||
assert.equal(duplicate.created, false);
|
||||
assert.equal(duplicate.duplicate, true);
|
||||
assert.equal(duplicate.asset.id, created.asset.id);
|
||||
}
|
||||
assert.equal(harness.service.list().length, 1);
|
||||
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
|
||||
assert.equal(harness.imageProcessor.calls.length, 1);
|
||||
});
|
||||
|
||||
test("different content with the same proposed name is a conflict, not a duplicate", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
await harness.service.upload([harness.uploadFile("bulletin.png", "image one")]);
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.upload([harness.uploadFile("bulletin.jpg", "image two")]),
|
||||
(error) => {
|
||||
assert.equal(error.statusCode, 409);
|
||||
assert.equal(error.code, "ASSET_NAME_CONFLICT");
|
||||
assert.equal(error.details.asset.displayName, "bulletin");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(harness.service.list().length, 1);
|
||||
assert.equal(harness.imageProcessor.calls.length, 1);
|
||||
|
||||
const [created] = await harness.service.upload(
|
||||
[harness.uploadFile("bulletin.jpg", "image two")],
|
||||
{ displayName: "Second bulletin" },
|
||||
);
|
||||
assert.equal(created.created, true);
|
||||
assert.notEqual(created.asset.sourceSha256, harness.service.list()[1].sourceSha256);
|
||||
assert.equal(harness.service.list().length, 2);
|
||||
});
|
||||
|
||||
test("multiple-file upload preserves order and deduplicates repeated bytes in the batch", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
|
||||
const results = await harness.service.upload([
|
||||
harness.uploadFile("alpha.png", "alpha bytes"),
|
||||
harness.uploadFile("beta.png", "beta bytes"),
|
||||
harness.uploadFile("alpha-copy.png", "alpha bytes"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
results.map(({ created, duplicate }) => ({ created, duplicate })),
|
||||
[
|
||||
{ created: true, duplicate: false },
|
||||
{ created: true, duplicate: false },
|
||||
{ created: false, duplicate: true },
|
||||
],
|
||||
);
|
||||
assert.equal(results[0].asset.id, results[2].asset.id);
|
||||
assert.equal(harness.service.list().length, 2);
|
||||
assert.equal(harness.imageProcessor.calls.length, 2);
|
||||
assert.equal(harness.storage.objects.size, 6);
|
||||
});
|
||||
|
||||
test("all new images are decoded before the batch mutates SQLite or storage", async (t) => {
|
||||
const expected = Object.assign(new Error("bad second image"), {
|
||||
code: "MALFORMED_IMAGE",
|
||||
statusCode: 422,
|
||||
});
|
||||
let processCount = 0;
|
||||
const regularProcessor = createFakeImageProcessor();
|
||||
const harness = createHarness({
|
||||
imageProcessor: {
|
||||
async process(filename) {
|
||||
processCount += 1;
|
||||
if (processCount === 2) throw expected;
|
||||
return regularProcessor.process(filename);
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => harness.close());
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.upload([
|
||||
harness.uploadFile("valid.png", "valid"),
|
||||
harness.uploadFile("broken.png", "broken"),
|
||||
]),
|
||||
expected,
|
||||
);
|
||||
assert.equal(harness.service.list().length, 0);
|
||||
assert.equal(harness.storage.objects.size, 0);
|
||||
assert.equal(harness.storage.calls.length, 0);
|
||||
});
|
||||
|
||||
test("concurrent identical uploads converge on one ready asset", async (t) => {
|
||||
const storage = createMemoryStorage();
|
||||
storage.setPutDelay(30);
|
||||
const harness = createHarness({ storage });
|
||||
t.after(() => harness.close());
|
||||
const bytes = Buffer.from("racing identical upload");
|
||||
|
||||
const [[left], [right]] = await Promise.all([
|
||||
harness.service.upload([harness.uploadFile("race.png", bytes)]),
|
||||
harness.service.upload([harness.uploadFile("other-name.png", bytes)]),
|
||||
]);
|
||||
|
||||
assert.equal(harness.service.list().length, 1);
|
||||
assert.equal(left.asset.id, right.asset.id);
|
||||
assert.deepEqual([left.created, right.created].sort(), [false, true]);
|
||||
assert.deepEqual([left.duplicate, right.duplicate].sort(), [false, true]);
|
||||
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 3);
|
||||
});
|
||||
|
||||
test("a partial storage failure leaves a diagnosable failed row and no ready URLs", async (t) => {
|
||||
const storage = createMemoryStorage();
|
||||
storage.setPutFailure(new StorageUnavailableError(new Error("offline")), 1);
|
||||
const harness = createHarness({ storage });
|
||||
t.after(() => harness.close());
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.upload([harness.uploadFile("failure.png", "failure bytes")]),
|
||||
(error) => {
|
||||
assert.equal(error.statusCode, 503);
|
||||
assert.equal(error.details.retryable, true);
|
||||
assert.match(error.details.assetId, /^[0-9a-f-]{36}$/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const [failed] = harness.service.list();
|
||||
assert.equal(failed.status, "failed");
|
||||
assert.match(failed.processingError, /^STORAGE_UNAVAILABLE:/);
|
||||
assert.equal(failed.display.url, null);
|
||||
assert.equal(failed.thumbnail.url, null);
|
||||
assert.equal(failed.original.url, null);
|
||||
assert.equal(storage.objects.size, 0);
|
||||
const row = rawAsset(harness.database, failed.id);
|
||||
assert.match(
|
||||
row.original_object_key,
|
||||
new RegExp(`^assets/${failed.id}/original/[0-9a-f]{64}$`),
|
||||
);
|
||||
assert.equal(
|
||||
row.display_object_key,
|
||||
`assets/${failed.id}/display/${row.display_sha256}.webp`,
|
||||
);
|
||||
assert.equal(
|
||||
row.thumbnail_object_key,
|
||||
`assets/${failed.id}/thumbnail/${row.thumbnail_sha256}.webp`,
|
||||
);
|
||||
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 2);
|
||||
assert.deepEqual(
|
||||
new Set(
|
||||
storage.calls
|
||||
.filter((call) => call.operation === "delete")
|
||||
.map((call) => call.key),
|
||||
),
|
||||
new Set([
|
||||
row.original_object_key,
|
||||
row.display_object_key,
|
||||
row.thumbnail_object_key,
|
||||
]),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.upload([harness.uploadFile("retry.png", "failure bytes")]),
|
||||
(error) => error.code === "ASSET_PROCESSING_FAILED" && error.statusCode === 409,
|
||||
);
|
||||
|
||||
assert.deepEqual(await harness.service.remove(failed.id), {
|
||||
id: failed.id,
|
||||
deleted: true,
|
||||
});
|
||||
assert.equal(harness.service.list().length, 0);
|
||||
});
|
||||
|
||||
test("rename and expiration edits change only approved SQLite metadata", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
const [upload] = await harness.service.upload([
|
||||
harness.uploadFile("editable.png", "editable bytes"),
|
||||
]);
|
||||
const before = rawAsset(harness.database, upload.asset.id);
|
||||
|
||||
const expiring = harness.service.update(upload.asset.id, {
|
||||
display_name: " New display name ",
|
||||
expiration_at: "2035-05-06T07:08:09.000Z",
|
||||
});
|
||||
assert.equal(expiring.displayName, "New display name");
|
||||
assert.equal(expiring.expirationAt, Date.parse("2035-05-06T07:08:09.000Z"));
|
||||
|
||||
const permanent = harness.service.update(upload.asset.id, { expirationAt: null });
|
||||
assert.equal(permanent.expirationAt, null);
|
||||
const after = rawAsset(harness.database, upload.asset.id);
|
||||
assert.equal(after.original_object_key, before.original_object_key);
|
||||
assert.equal(after.display_object_key, before.display_object_key);
|
||||
assert.equal(after.thumbnail_object_key, before.thumbnail_object_key);
|
||||
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
|
||||
|
||||
await assert.rejects(
|
||||
async () => harness.service.update(upload.asset.id, { status: "failed" }),
|
||||
(error) => error.code === "INVALID_ASSET_UPDATE",
|
||||
);
|
||||
assert.throws(
|
||||
() => harness.service.update(upload.asset.id, { expirationAt: -1 }),
|
||||
(error) => error.code === "INVALID_ASSET_EXPIRATION",
|
||||
);
|
||||
});
|
||||
|
||||
test("references describe playlist, normal, and static use and block deletion", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
const [upload] = await harness.service.upload([
|
||||
harness.uploadFile("referenced.png", "referenced bytes"),
|
||||
]);
|
||||
const id = upload.asset.id;
|
||||
const now = 2_100_000_000_000;
|
||||
harness.database
|
||||
.prepare(
|
||||
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("playlist-1", "Sunday", now, now);
|
||||
harness.database
|
||||
.prepare(
|
||||
`INSERT INTO playlist_entries
|
||||
(id, playlist_id, asset_id, enabled, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, 0, ?, ?)`,
|
||||
)
|
||||
.run("entry-1", "playlist-1", id, now, now);
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET active_playlist_id = ?, current_entry_id = ?, static_asset_id = ?, updated_at = ?
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run("playlist-1", "entry-1", id, now);
|
||||
|
||||
const references = harness.service.references(id);
|
||||
assert.equal(references.canDelete, false);
|
||||
assert.deepEqual(references.blockerCodes, [
|
||||
"PLAYLIST_REFERENCES",
|
||||
"CURRENT_PLAYBACK_TARGET",
|
||||
"CURRENT_STATIC_TARGET",
|
||||
]);
|
||||
assert.deepEqual(references.playlists[0], {
|
||||
playlistId: "playlist-1",
|
||||
playlistName: "Sunday",
|
||||
playlistEntryId: "entry-1",
|
||||
enabled: true,
|
||||
position: 0,
|
||||
isActivePlaylist: true,
|
||||
isCurrentNormalTarget: true,
|
||||
});
|
||||
assert.equal(references.playback[0].isCurrentNormalTarget, true);
|
||||
assert.equal(references.playback[0].isCurrentStaticTarget, true);
|
||||
|
||||
const callsBeforeDelete = harness.storage.calls.length;
|
||||
await assert.rejects(
|
||||
harness.service.remove(id),
|
||||
(error) => {
|
||||
assert.equal(error.code, "ASSET_REFERENCED");
|
||||
assert.equal(error.statusCode, 409);
|
||||
assert.deepEqual(error.details.blockerCodes, [
|
||||
"PLAYLIST_REFERENCES",
|
||||
"CURRENT_PLAYBACK_TARGET",
|
||||
"CURRENT_STATIC_TARGET",
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(harness.storage.calls.length, callsBeforeDelete);
|
||||
assert.equal(rawAsset(harness.database, id).status, "ready");
|
||||
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET active_playlist_id = NULL, current_entry_id = NULL, static_asset_id = NULL
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run();
|
||||
harness.database.prepare("DELETE FROM playlist_entries WHERE id = ?").run("entry-1");
|
||||
assert.equal(harness.service.references(id).canDelete, true);
|
||||
await harness.service.remove(id);
|
||||
assert.equal(harness.storage.objects.size, 0);
|
||||
assert.equal(rawAsset(harness.database, id), undefined);
|
||||
});
|
||||
|
||||
test("failed storage deletion retains a retryable deleting row", async (t) => {
|
||||
const storage = createMemoryStorage();
|
||||
const harness = createHarness({ storage });
|
||||
t.after(() => harness.close());
|
||||
const [upload] = await harness.service.upload([
|
||||
harness.uploadFile("delete-me.png", "delete me"),
|
||||
]);
|
||||
storage.setDeleteFailure(new StorageUnavailableError(new Error("offline")));
|
||||
|
||||
await assert.rejects(harness.service.remove(upload.asset.id), (error) => {
|
||||
assert.equal(error.statusCode, 503);
|
||||
assert.equal(error.details.assetId, upload.asset.id);
|
||||
assert.equal(error.details.retryable, true);
|
||||
return true;
|
||||
});
|
||||
assert.equal(rawAsset(harness.database, upload.asset.id).status, "deleting");
|
||||
assert.throws(
|
||||
() => harness.service.update(upload.asset.id, { displayName: "Cannot edit" }),
|
||||
(error) => error.code === "ASSET_DELETING",
|
||||
);
|
||||
|
||||
await harness.service.remove(upload.asset.id);
|
||||
assert.equal(rawAsset(harness.database, upload.asset.id), undefined);
|
||||
assert.equal(storage.objects.size, 0);
|
||||
});
|
||||
|
||||
test("processing assets cannot be deleted and storage is not touched", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
const timestamp = Date.now();
|
||||
harness.database
|
||||
.prepare(
|
||||
`INSERT INTO assets
|
||||
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'processing', ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
"00000000-0000-4000-8000-999999999999",
|
||||
"f".repeat(64),
|
||||
"Still processing",
|
||||
"processing.png",
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
harness.service.references("00000000-0000-4000-8000-999999999999")
|
||||
.canDelete,
|
||||
false,
|
||||
);
|
||||
await assert.rejects(
|
||||
harness.service.remove("00000000-0000-4000-8000-999999999999"),
|
||||
(error) => error.code === "ASSET_PROCESSING" && error.statusCode === 409,
|
||||
);
|
||||
assert.equal(harness.storage.calls.length, 0);
|
||||
assert.equal(
|
||||
rawAsset(harness.database, "00000000-0000-4000-8000-999999999999").status,
|
||||
"processing",
|
||||
);
|
||||
});
|
||||
|
||||
test("media descriptors only allow the recorded version and map missing bytes to 503", async (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.close());
|
||||
const [upload] = await harness.service.upload([
|
||||
harness.uploadFile("media.png", "media bytes"),
|
||||
]);
|
||||
const { asset } = upload;
|
||||
|
||||
const display = harness.service.mediaDescriptor(
|
||||
asset.id,
|
||||
"display",
|
||||
asset.display.sha256,
|
||||
);
|
||||
assert.equal(display.contentType, "image/webp");
|
||||
assert.equal(display.contentLength, asset.display.byteSize);
|
||||
const stored = await harness.service.readStoredObject(display);
|
||||
assert.deepEqual(await bufferBody(stored.body), harness.storage.objects.get(display.key).body);
|
||||
|
||||
for (const requestedHash of ["a".repeat(64), "../original/key", "not-a-hash"]) {
|
||||
assert.throws(
|
||||
() => harness.service.mediaDescriptor(asset.id, "display", requestedHash),
|
||||
(error) => error.code === "MEDIA_NOT_FOUND" && error.statusCode === 404,
|
||||
);
|
||||
}
|
||||
|
||||
harness.storage.objects.delete(display.key);
|
||||
await assert.rejects(harness.service.readStoredObject(display), (error) => {
|
||||
assert.equal(error.code, "STORAGE_INCONSISTENCY");
|
||||
assert.equal(error.statusCode, 503);
|
||||
assert.deepEqual(error.details, { assetId: asset.id, retryable: true });
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { ConfigError, loadConfig, redactedConfig } = require("../src/config");
|
||||
|
||||
function validEnvironment(overrides = {}) {
|
||||
return {
|
||||
NODE_ENV: "production",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "7532",
|
||||
DATABASE_PATH: "/data/db/announcements.sqlite",
|
||||
APP_TIMEZONE: "America/Chicago",
|
||||
S3_ENDPOINT: "http://seaweedfs:8333",
|
||||
S3_REGION: "us-east-1",
|
||||
S3_BUCKET: "announcements",
|
||||
S3_ACCESS_KEY_ID: "test-access-key",
|
||||
S3_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
S3_FORCE_PATH_STYLE: "true",
|
||||
MQTT_URL: "mqtt://broker:1883",
|
||||
MQTT_USERNAME: "mqtt-user",
|
||||
MQTT_PASSWORD: "mqtt-password",
|
||||
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/example",
|
||||
HA_WEBHOOK_SECRET: "webhook-secret",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("loadConfig validates and normalizes the supported environment", () => {
|
||||
const config = loadConfig(validEnvironment());
|
||||
|
||||
assert.equal(config.environment, "production");
|
||||
assert.equal(config.port, 7532);
|
||||
assert.equal(config.timeZone, "America/Chicago");
|
||||
assert.equal(config.storage.endpoint, "http://seaweedfs:8333");
|
||||
assert.equal(config.storage.forcePathStyle, true);
|
||||
assert.equal(config.storage.healthProbeIntervalMs, 30_000);
|
||||
assert.equal(config.storage.connectionTimeoutMs, 2_000);
|
||||
assert.equal(config.storage.requestTimeoutMs, 5_000);
|
||||
assert.equal(config.storage.maxAttempts, 2);
|
||||
assert.equal(config.processing.tempDirectory, "/tmp/announcements-uploads");
|
||||
assert.equal(config.processing.maxFileBytes, 25 * 1024 * 1024);
|
||||
assert.equal(config.processing.maxFiles, 20);
|
||||
assert.equal(config.processing.maxInputPixels, 40_000_000);
|
||||
});
|
||||
|
||||
test("production requires private S3 credentials", () => {
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ S3_ACCESS_KEY_ID: "" })),
|
||||
(error) =>
|
||||
error instanceof ConfigError && error.field === "S3_ACCESS_KEY_ID",
|
||||
);
|
||||
|
||||
const development = loadConfig(
|
||||
validEnvironment({
|
||||
NODE_ENV: "development",
|
||||
S3_ACCESS_KEY_ID: "",
|
||||
S3_SECRET_ACCESS_KEY: "",
|
||||
}),
|
||||
);
|
||||
assert.equal(development.storage.accessKeyId, null);
|
||||
assert.equal(development.storage.secretAccessKey, null);
|
||||
});
|
||||
|
||||
test("loadConfig rejects invalid typed values", () => {
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ PORT: "7532.5" })),
|
||||
/PORT must be an integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ APP_TIMEZONE: "Church/Local" })),
|
||||
/valid IANA time zone/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ S3_FORCE_PATH_STYLE: "sometimes" })),
|
||||
/must be a boolean/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ S3_HEALTH_PROBE_INTERVAL_MS: "999" })),
|
||||
/S3_HEALTH_PROBE_INTERVAL_MS must be at least 1000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ S3_REQUEST_TIMEOUT_MS: "999" })),
|
||||
/S3_REQUEST_TIMEOUT_MS must be at least 1000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ S3_CONNECT_TIMEOUT_MS: "249" })),
|
||||
/S3_CONNECT_TIMEOUT_MS must be at least 250/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ S3_MAX_ATTEMPTS: "6" })),
|
||||
/S3_MAX_ATTEMPTS must be at most 5/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ DATABASE_PATH: "relative.sqlite" })),
|
||||
/must be an absolute path/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ UPLOAD_TEMP_DIRECTORY: "relative" })),
|
||||
/UPLOAD_TEMP_DIRECTORY must be an absolute path/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILE_BYTES: "0" })),
|
||||
/UPLOAD_MAX_FILE_BYTES must be at least 1/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILES: "101" })),
|
||||
/UPLOAD_MAX_FILES must be at most 100/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig(validEnvironment({ IMAGE_MAX_INPUT_PIXELS: "no-limit" })),
|
||||
/IMAGE_MAX_INPUT_PIXELS must be an integer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("redactedConfig never returns credentials, passwords, paths, or webhook URLs", () => {
|
||||
const summary = redactedConfig(loadConfig(validEnvironment()));
|
||||
const serialized = JSON.stringify(summary);
|
||||
|
||||
assert.equal(summary.storage.configured, true);
|
||||
assert.equal(summary.mqtt.configured, true);
|
||||
assert.equal(summary.notifications.configured, true);
|
||||
assert.doesNotMatch(serialized, /test-access-key/);
|
||||
assert.doesNotMatch(serialized, /test-secret-key/);
|
||||
assert.doesNotMatch(serialized, /mqtt-password/);
|
||||
assert.doesNotMatch(serialized, /webhook-secret/);
|
||||
assert.doesNotMatch(serialized, /announcements\.sqlite/);
|
||||
assert.doesNotMatch(serialized, /seaweedfs/);
|
||||
assert.doesNotMatch(serialized, /home-assistant/);
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { openDatabase } = require("../src/database");
|
||||
const { migrationStatus, runMigrations } = require("../src/database/migrate");
|
||||
|
||||
function migratedDatabase() {
|
||||
const database = openDatabase({ path: ":memory:" });
|
||||
const migrations = runMigrations(database);
|
||||
return { database, migrations };
|
||||
}
|
||||
|
||||
function columnNames(database, table) {
|
||||
return database.pragma(`table_info(${table})`).map((column) => column.name);
|
||||
}
|
||||
|
||||
test("initial migration creates every approved Phase 0 table and seed row", (t) => {
|
||||
const { database, migrations } = migratedDatabase();
|
||||
t.after(() => database.close());
|
||||
|
||||
const tables = database
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name`,
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
|
||||
assert.deepEqual(tables, [
|
||||
"app_settings",
|
||||
"asset_import_sources",
|
||||
"assets",
|
||||
"display_clients",
|
||||
"notification_incidents",
|
||||
"notification_outbox",
|
||||
"playback_state",
|
||||
"playlist_entries",
|
||||
"playlists",
|
||||
"schema_migrations",
|
||||
]);
|
||||
assert.equal(migrations.currentVersion, 1);
|
||||
assert.equal(migrations.latestAvailableVersion, 1);
|
||||
assert.deepEqual(migrations.pendingVersions, []);
|
||||
|
||||
assert.deepEqual(database.prepare("SELECT * FROM app_settings").get(), {
|
||||
id: 1,
|
||||
revision: 1,
|
||||
default_duration_ms: 7000,
|
||||
default_background_mode: "blurred",
|
||||
default_solid_background_color: "#000000",
|
||||
default_background_darkened: 0,
|
||||
default_blur_px: 20,
|
||||
crossfade_duration_ms: 1000,
|
||||
updated_at: database.prepare("SELECT updated_at FROM app_settings").get()
|
||||
.updated_at,
|
||||
});
|
||||
|
||||
const playback = database.prepare("SELECT * FROM playback_state").get();
|
||||
assert.equal(playback.output_id, "global");
|
||||
assert.equal(playback.mode, "on");
|
||||
assert.equal(playback.pending_playlist_id, null);
|
||||
assert.equal(playback.pending_playlist_requested_at, null);
|
||||
assert.ok(columnNames(database, "playlist_entries").includes(
|
||||
"background_darkened_override",
|
||||
));
|
||||
});
|
||||
|
||||
test("migrations are idempotent and report their status", (t) => {
|
||||
const { database } = migratedDatabase();
|
||||
t.after(() => database.close());
|
||||
|
||||
const rerun = runMigrations(database);
|
||||
assert.equal(rerun.applied.length, 1);
|
||||
assert.deepEqual(rerun.pendingVersions, []);
|
||||
assert.deepEqual(migrationStatus(database), rerun);
|
||||
});
|
||||
|
||||
test("a failed migration rolls back its schema and version record", (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-migrations-"));
|
||||
const migrationPath = path.join(directory, "001_test.sql");
|
||||
fs.writeFileSync(
|
||||
migrationPath,
|
||||
"CREATE TABLE should_roll_back (id INTEGER); THIS IS NOT SQL;",
|
||||
);
|
||||
const database = openDatabase({ path: ":memory:" });
|
||||
t.after(() => {
|
||||
database.close();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
assert.throws(() => runMigrations(database, directory));
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'should_roll_back'",
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get().count,
|
||||
0,
|
||||
);
|
||||
|
||||
fs.writeFileSync(migrationPath, "CREATE TABLE recovered (id INTEGER);");
|
||||
const status = runMigrations(database, directory);
|
||||
assert.equal(status.currentVersion, 1);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'recovered'")
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("a library asset can occur only once in a given playlist", (t) => {
|
||||
const { database } = migratedDatabase();
|
||||
t.after(() => database.close());
|
||||
|
||||
const now = Date.now();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO assets
|
||||
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
|
||||
)
|
||||
.run("asset-1", "a".repeat(64), "Welcome", "welcome.png", now, now);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run("playlist-1", "Sunday", now, now);
|
||||
|
||||
const insert = database.prepare(
|
||||
`INSERT INTO playlist_entries
|
||||
(id, playlist_id, asset_id, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
insert.run("entry-1", "playlist-1", "asset-1", 0, now, now);
|
||||
|
||||
assert.throws(
|
||||
() => insert.run("entry-2", "playlist-1", "asset-1", 1, now, now),
|
||||
/UNIQUE constraint failed/,
|
||||
);
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run("playlist-2", "Midweek", now, now);
|
||||
assert.doesNotThrow(() =>
|
||||
insert.run("entry-3", "playlist-2", "asset-1", 0, now, now),
|
||||
);
|
||||
assert.throws(
|
||||
() => database.prepare("DELETE FROM assets WHERE id = 'asset-1'").run(),
|
||||
/FOREIGN KEY constraint failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("darkening overrides accept inherited, off, and on only", (t) => {
|
||||
const { database } = migratedDatabase();
|
||||
t.after(() => database.close());
|
||||
|
||||
const now = Date.now();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||
VALUES ('playlist-1', 'Sunday', ?, ?)`,
|
||||
)
|
||||
.run(now, now);
|
||||
const insertAsset = database.prepare(
|
||||
`INSERT INTO assets
|
||||
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
|
||||
);
|
||||
const insertEntry = database.prepare(
|
||||
`INSERT INTO playlist_entries
|
||||
(id, playlist_id, asset_id, background_darkened_override, position, created_at, updated_at)
|
||||
VALUES (?, 'playlist-1', ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
for (const [index, override] of [null, 0, 1].entries()) {
|
||||
const assetId = `asset-${index}`;
|
||||
insertAsset.run(
|
||||
assetId,
|
||||
String(index).repeat(64),
|
||||
`Asset ${index}`,
|
||||
`asset-${index}.png`,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
insertEntry.run(
|
||||
`entry-${index}`,
|
||||
assetId,
|
||||
override,
|
||||
index,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
insertAsset.run(
|
||||
"asset-invalid",
|
||||
"f".repeat(64),
|
||||
"Invalid override",
|
||||
"invalid.png",
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.throws(
|
||||
() => insertEntry.run("entry-invalid", "asset-invalid", 2, 3, now, now),
|
||||
/CHECK constraint failed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
database
|
||||
.prepare("UPDATE app_settings SET default_background_darkened = 2 WHERE id = 1")
|
||||
.run(),
|
||||
/CHECK constraint failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("pending playlist activation requires a playlist and request time together", (t) => {
|
||||
const { database } = migratedDatabase();
|
||||
t.after(() => database.close());
|
||||
|
||||
const now = Date.now();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||
VALUES ('playlist-1', 'Sunday', ?, ?)`,
|
||||
)
|
||||
.run(now, now);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE playback_state SET pending_playlist_id = 'playlist-1' WHERE output_id = 'global'",
|
||||
)
|
||||
.run(),
|
||||
/CHECK constraint failed/,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET pending_playlist_id = 'playlist-1', pending_playlist_requested_at = ?
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run(now),
|
||||
);
|
||||
assert.throws(
|
||||
() => database.prepare("DELETE FROM playlists WHERE id = 'playlist-1'").run(),
|
||||
/FOREIGN KEY constraint failed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET pending_playlist_id = 'missing', pending_playlist_requested_at = ?
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run(now),
|
||||
/FOREIGN KEY constraint failed/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const sharp = require("sharp");
|
||||
const {
|
||||
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
|
||||
ImageProcessingError,
|
||||
createImageProcessor,
|
||||
} = require("../src/image-processing");
|
||||
|
||||
const MAX_INPUT_PIXELS = 12_000_000;
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
});
|
||||
|
||||
function createProcessor(overrides = {}) {
|
||||
return createImageProcessor({
|
||||
maxInputPixels: MAX_INPUT_PIXELS,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function hash(buffer) {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function solidImage(format, width, height, background = "#4a76b8") {
|
||||
return sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background,
|
||||
},
|
||||
})
|
||||
.toFormat(format)
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
async function twoFrameImage(format) {
|
||||
const width = 20;
|
||||
const frameHeight = 10;
|
||||
const channels = 4;
|
||||
const firstFrame = Buffer.alloc(width * frameHeight * channels);
|
||||
const secondFrame = Buffer.alloc(width * frameHeight * channels);
|
||||
|
||||
for (let offset = 0; offset < firstFrame.length; offset += channels) {
|
||||
firstFrame.set([255, 0, 0, 255], offset);
|
||||
secondFrame.set([0, 0, 255, 255], offset);
|
||||
}
|
||||
|
||||
const image = sharp(Buffer.concat([firstFrame, secondFrame]), {
|
||||
raw: {
|
||||
width,
|
||||
height: frameHeight * 2,
|
||||
channels,
|
||||
pageHeight: frameHeight,
|
||||
},
|
||||
});
|
||||
|
||||
if (format === "tiff") {
|
||||
return image.tiff().toBuffer();
|
||||
}
|
||||
return image[format]({ delay: [100, 100], loop: 0 }).toBuffer();
|
||||
}
|
||||
|
||||
test("accepts decoded still JPEG, PNG, and WebP content", async (t) => {
|
||||
const processor = createProcessor();
|
||||
|
||||
for (const format of ["jpeg", "png", "webp"]) {
|
||||
await t.test(format, async () => {
|
||||
const source = await solidImage(format, 640, 360);
|
||||
const result = await processor.process(source);
|
||||
|
||||
assert.equal(result.original.format, format);
|
||||
assert.equal(result.original.contentType, CONTENT_TYPES[format]);
|
||||
assert.equal(result.original.byteSize, source.length);
|
||||
assert.equal(result.original.sha256, hash(source));
|
||||
assert.equal(result.original.width, 640);
|
||||
assert.equal(result.original.height, 360);
|
||||
assert.equal(result.original.pages, 1);
|
||||
|
||||
for (const derivative of [result.display, result.thumbnail]) {
|
||||
assert.ok(Buffer.isBuffer(derivative.buffer));
|
||||
assert.equal(derivative.format, "webp");
|
||||
assert.equal(derivative.contentType, "image/webp");
|
||||
assert.equal(derivative.byteSize, derivative.buffer.length);
|
||||
assert.equal(derivative.sha256, hash(derivative.buffer));
|
||||
assert.match(derivative.sha256, /^[0-9a-f]{64}$/);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("uses bounding boxes without cropping or changing source aspect ratio", async () => {
|
||||
const source = await solidImage("png", 3000, 1000);
|
||||
const result = await createProcessor().process(source);
|
||||
|
||||
assert.deepEqual(
|
||||
{ width: result.display.width, height: result.display.height },
|
||||
{ width: 1920, height: 640 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ width: result.thumbnail.width, height: result.thumbnail.height },
|
||||
{ width: 480, height: 160 },
|
||||
);
|
||||
assert.equal(result.display.width / result.display.height, 3);
|
||||
assert.equal(result.thumbnail.width / result.thumbnail.height, 3);
|
||||
});
|
||||
|
||||
test("does not enlarge a source that fits within both derivative bounds", async () => {
|
||||
const source = await solidImage("jpeg", 320, 180);
|
||||
const result = await createProcessor().process(source);
|
||||
|
||||
assert.deepEqual(
|
||||
{ width: result.display.width, height: result.display.height },
|
||||
{ width: 320, height: 180 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ width: result.thumbnail.width, height: result.thumbnail.height },
|
||||
{ width: 320, height: 180 },
|
||||
);
|
||||
});
|
||||
|
||||
test("applies EXIF orientation and strips it from derivative metadata", async () => {
|
||||
const source = await sharp({
|
||||
create: {
|
||||
width: 120,
|
||||
height: 80,
|
||||
channels: 3,
|
||||
background: "#cb7235",
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.withMetadata({ orientation: 6 })
|
||||
.toBuffer();
|
||||
|
||||
const result = await createProcessor().process(source);
|
||||
const outputMetadata = await sharp(result.display.buffer).metadata();
|
||||
|
||||
assert.equal(result.original.orientation, 6);
|
||||
assert.equal(result.original.encodedWidth, 120);
|
||||
assert.equal(result.original.encodedHeight, 80);
|
||||
assert.equal(result.original.width, 80);
|
||||
assert.equal(result.original.height, 120);
|
||||
assert.equal(result.display.width, 80);
|
||||
assert.equal(result.display.height, 120);
|
||||
assert.equal(outputMetadata.orientation, undefined);
|
||||
assert.equal(outputMetadata.exif, undefined);
|
||||
});
|
||||
|
||||
test("flattens transparent pixels against black in both derivatives", async () => {
|
||||
const source = await solidImage("png", 40, 30, {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
});
|
||||
const result = await createProcessor().process(source);
|
||||
|
||||
assert.equal(result.original.hasAlpha, true);
|
||||
for (const derivative of [result.display, result.thumbnail]) {
|
||||
const metadata = await sharp(derivative.buffer).metadata();
|
||||
const pixels = await sharp(derivative.buffer).raw().toBuffer();
|
||||
|
||||
assert.equal(metadata.hasAlpha, false);
|
||||
assert.ok(
|
||||
pixels.every((channel) => channel <= 2),
|
||||
"fully transparent source pixels should become black",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("supports a temporary file path without changing source identity", async (t) => {
|
||||
const temporaryDirectory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "announcements-image-processing-"),
|
||||
);
|
||||
t.after(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true }));
|
||||
|
||||
const source = await solidImage("webp", 800, 600);
|
||||
const filename = path.join(temporaryDirectory, "upload.tmp");
|
||||
fs.writeFileSync(filename, source);
|
||||
|
||||
const result = await createProcessor().process(filename);
|
||||
assert.equal(result.original.sha256, hash(source));
|
||||
assert.equal(result.original.byteSize, source.length);
|
||||
assert.equal(result.original.format, "webp");
|
||||
});
|
||||
|
||||
test("rejects malformed and unsupported decoded content with typed errors", async () => {
|
||||
const processor = createProcessor();
|
||||
const gif = await solidImage("gif", 64, 64);
|
||||
|
||||
await assert.rejects(
|
||||
processor.process(Buffer.from("not an image")),
|
||||
(error) =>
|
||||
error instanceof ImageProcessingError &&
|
||||
error.code === "MALFORMED_IMAGE" &&
|
||||
error.statusCode === 422,
|
||||
);
|
||||
await assert.rejects(
|
||||
processor.process(gif),
|
||||
(error) =>
|
||||
error instanceof ImageProcessingError &&
|
||||
error.code === "UNSUPPORTED_IMAGE_FORMAT" &&
|
||||
error.statusCode === 415 &&
|
||||
error.details.detectedFormat === "gif",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects animated WebP and GIF instead of selecting the first frame", async (t) => {
|
||||
for (const format of ["webp", "gif"]) {
|
||||
await t.test(format, async () => {
|
||||
const animated = await twoFrameImage(format);
|
||||
const metadata = await sharp(animated, { animated: true }).metadata();
|
||||
assert.equal(metadata.pages, 2, "test fixture must contain two frames");
|
||||
await assert.rejects(
|
||||
createProcessor().process(animated),
|
||||
(error) =>
|
||||
error instanceof ImageProcessingError &&
|
||||
error.code === "ANIMATED_IMAGE_NOT_SUPPORTED" &&
|
||||
error.details.pages === 2,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unexpected multipage inputs with a distinct typed error", async () => {
|
||||
const multipageTiff = await twoFrameImage("tiff");
|
||||
const metadata = await sharp(multipageTiff, { animated: true }).metadata();
|
||||
assert.equal(metadata.pages, 2, "test fixture must contain two pages");
|
||||
|
||||
await assert.rejects(
|
||||
createProcessor().process(multipageTiff),
|
||||
(error) =>
|
||||
error instanceof ImageProcessingError &&
|
||||
error.code === "MULTIPAGE_IMAGE_NOT_SUPPORTED" &&
|
||||
error.details.pages === 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("enforces the injected decoded-pixel limit", async () => {
|
||||
const source = await solidImage("png", 100, 100);
|
||||
const processor = createImageProcessor({ maxInputPixels: 9_999 });
|
||||
|
||||
await assert.rejects(
|
||||
processor.process(source),
|
||||
(error) =>
|
||||
error instanceof ImageProcessingError &&
|
||||
error.code === "IMAGE_PIXEL_LIMIT_EXCEEDED" &&
|
||||
error.statusCode === 413 &&
|
||||
error.details.maxInputPixels === 9_999,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses documented defaults while allowing validated target overrides", async () => {
|
||||
assert.deepEqual(DEFAULT_IMAGE_DERIVATIVE_OPTIONS, {
|
||||
display: { maxWidth: 1920, maxHeight: 1080, quality: 82 },
|
||||
thumbnail: { maxWidth: 480, maxHeight: 270, quality: 72 },
|
||||
});
|
||||
|
||||
const source = await solidImage("png", 900, 600);
|
||||
const processor = createProcessor({
|
||||
display: { maxWidth: 300, maxHeight: 300, quality: 90 },
|
||||
thumbnail: { maxWidth: 100, maxHeight: 50, quality: 65 },
|
||||
});
|
||||
const result = await processor.process(source);
|
||||
|
||||
assert.deepEqual(
|
||||
{ width: result.display.width, height: result.display.height },
|
||||
{ width: 300, height: 200 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ width: result.thumbnail.width, height: result.thumbnail.height },
|
||||
{ width: 75, height: 50 },
|
||||
);
|
||||
assert.throws(
|
||||
() => createImageProcessor({ maxInputPixels: 0 }),
|
||||
/maxInputPixels must be at least 1/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createImageProcessor({
|
||||
maxInputPixels: 10_000,
|
||||
display: { quality: 101 },
|
||||
}),
|
||||
/display\.quality must be at most 100/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid input interfaces with structured errors", async () => {
|
||||
await assert.rejects(
|
||||
createProcessor().process({ path: "/tmp/not-an-interface" }),
|
||||
(error) =>
|
||||
error instanceof ImageProcessingError &&
|
||||
error.code === "INVALID_IMAGE_INPUT" &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,706 @@
|
||||
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 { createPlaybackService } = require("../src/playback");
|
||||
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 = [];
|
||||
let playbackService;
|
||||
const service = createPlaylistService({
|
||||
database,
|
||||
now: () => clock,
|
||||
createId: () =>
|
||||
`00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`,
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
playbackService,
|
||||
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.service.addEntry(active.id, {
|
||||
assetId: harness.seedAsset({ id: "held", name: "Held" }),
|
||||
});
|
||||
harness.service.addEntry(pending.id, {
|
||||
assetId: harness.seedAsset({ id: "queued", name: "Queued" }),
|
||||
});
|
||||
|
||||
// A mid-duration normal slide is what keeps an activation genuinely pending.
|
||||
// With no playable normal target the authority promotes the request straight
|
||||
// away, so an activation cannot be left queued behind an empty playlist.
|
||||
harness.playbackService.activatePlaylist(active.id);
|
||||
assert.equal(
|
||||
harness.playbackService.activatePlaylist(pending.id).activation,
|
||||
"pending",
|
||||
);
|
||||
|
||||
expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409);
|
||||
expectError(
|
||||
() => harness.service.remove(pending.id),
|
||||
"PLAYLIST_PENDING_ACTIVATION",
|
||||
409,
|
||||
);
|
||||
assert.equal(harness.playback().active_playlist_id, active.id);
|
||||
assert.equal(harness.playback().pending_playlist_id, pending.id);
|
||||
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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(newPlaylist.id);
|
||||
assert.equal(repeated.activation, "pending");
|
||||
assert.equal(repeated.playback.revision, pendingRevision);
|
||||
assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt);
|
||||
|
||||
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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(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.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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(populated.id);
|
||||
const futurePlaybackTimestamp = BASE_TIME + 100_000;
|
||||
// Static and pause suspend the normal timeline. Persist the same shape the
|
||||
// playback authority itself writes, so the pre-change reconciliation has no
|
||||
// malformed timeline to repair and only the deletion moves the revision.
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET updated_at = ?, mode = 'off', static_asset_id = ?,
|
||||
rotation_paused = 1, paused_remaining_ms = 7000,
|
||||
slide_started_at = NULL, next_slide_at = NULL
|
||||
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, entries[1].id);
|
||||
assert.equal(afterDelete.slide_started_at, null);
|
||||
assert.equal(afterDelete.next_slide_at, 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");
|
||||
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.playbackService.activatePlaylist(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.playbackService.activatePlaylist(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);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
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(overrides = {}) {
|
||||
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_REGION: "us-east-1",
|
||||
S3_BUCKET: "announcements",
|
||||
S3_ACCESS_KEY_ID: "access-key-that-must-not-leak",
|
||||
S3_SECRET_ACCESS_KEY: "secret-key-that-must-not-leak",
|
||||
S3_FORCE_PATH_STYLE: "true",
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const silentLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function testStorage() {
|
||||
return {
|
||||
async start() {},
|
||||
stop() {},
|
||||
health() {
|
||||
return {
|
||||
status: "degraded",
|
||||
checkedAt: "2026-08-16T00:00:00.000Z",
|
||||
lastSuccessAt: null,
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
message: "Object storage is unavailable",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(baseUrl, path, init) {
|
||||
const response = await fetch(`${baseUrl}${path}`, init);
|
||||
return { response, body: await response.json() };
|
||||
}
|
||||
|
||||
test("HTTP foundation exposes live, ready, detailed health, and persisted settings", 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 live = await responseJson(baseUrl, "/health/live");
|
||||
assert.equal(live.response.status, 200);
|
||||
assert.deepEqual(live.body, { status: "ok" });
|
||||
|
||||
const ready = await responseJson(baseUrl, "/health/ready");
|
||||
assert.equal(ready.response.status, 200);
|
||||
assert.equal(ready.body.status, "ready");
|
||||
|
||||
const status = await responseJson(baseUrl, "/api/health");
|
||||
assert.equal(status.response.status, 200);
|
||||
assert.equal(status.body.status, "degraded");
|
||||
assert.equal(status.body.services.database.currentSchemaVersion, 1);
|
||||
assert.equal(status.body.services.storage.status, "degraded");
|
||||
const statusText = JSON.stringify(status.body);
|
||||
assert.doesNotMatch(statusText, /access-key-that-must-not-leak/);
|
||||
assert.doesNotMatch(statusText, /secret-key-that-must-not-leak/);
|
||||
assert.doesNotMatch(statusText, /mqtt-password-that-must-not-leak/);
|
||||
assert.doesNotMatch(statusText, /private-id/);
|
||||
|
||||
const statusAlias = await responseJson(baseUrl, "/api/status");
|
||||
assert.equal(statusAlias.response.status, 200);
|
||||
assert.deepEqual(statusAlias.body, status.body);
|
||||
|
||||
const initial = await responseJson(baseUrl, "/api/settings");
|
||||
assert.equal(initial.body.defaultBackgroundDarkened, false);
|
||||
|
||||
const changed = await responseJson(baseUrl, "/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
defaultBackgroundDarkened: true,
|
||||
defaultDurationMs: 9000,
|
||||
}),
|
||||
});
|
||||
assert.equal(changed.response.status, 200);
|
||||
assert.equal(changed.body.revision, 2);
|
||||
assert.equal(changed.body.defaultBackgroundDarkened, true);
|
||||
|
||||
const reread = await responseJson(baseUrl, "/api/settings");
|
||||
assert.equal(reread.body.defaultDurationMs, 9000);
|
||||
});
|
||||
|
||||
test("invalid settings receive a structured client error", 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 result = await responseJson(baseUrl, "/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ defaultBackgroundDarkened: 0.25 }),
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 400);
|
||||
assert.equal(result.body.error.code, "INVALID_SETTINGS");
|
||||
});
|
||||
|
||||
test("liveness stays up while critical readiness failures return 503", 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}`;
|
||||
|
||||
runtime.lifecycle.ready = false;
|
||||
let live = await responseJson(baseUrl, "/health/live");
|
||||
let ready = await responseJson(baseUrl, "/health/ready");
|
||||
assert.equal(live.response.status, 200);
|
||||
assert.equal(ready.response.status, 503);
|
||||
assert.equal(ready.body.status, "not_ready");
|
||||
|
||||
runtime.lifecycle.ready = true;
|
||||
runtime.migrations.pendingVersions.push(2);
|
||||
ready = await responseJson(baseUrl, "/health/ready");
|
||||
assert.equal(ready.response.status, 503);
|
||||
assert.equal(ready.body.checks.migrations, "pending");
|
||||
|
||||
runtime.migrations.pendingVersions.length = 0;
|
||||
runtime.database.close();
|
||||
live = await responseJson(baseUrl, "/health/live");
|
||||
ready = await responseJson(baseUrl, "/health/ready");
|
||||
assert.equal(live.response.status, 200);
|
||||
assert.equal(ready.response.status, 503);
|
||||
assert.equal(ready.body.checks.database, "error");
|
||||
});
|
||||
|
||||
test("runtime startup and shutdown are idempotent", async () => {
|
||||
const runtime = createRuntime({
|
||||
config: testConfig(),
|
||||
logger: silentLogger,
|
||||
storage: testStorage(),
|
||||
});
|
||||
const [firstAddress, secondAddress] = await Promise.all([
|
||||
runtime.start(),
|
||||
runtime.start(),
|
||||
]);
|
||||
assert.deepEqual(firstAddress, secondAddress);
|
||||
assert.equal(runtime.lifecycle.ready, true);
|
||||
|
||||
await Promise.all([runtime.stop("test"), runtime.stop("test")]);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { openDatabase } = require("../src/database");
|
||||
const { runMigrations } = require("../src/database/migrate");
|
||||
const {
|
||||
SettingsValidationError,
|
||||
createSettingsService,
|
||||
} = require("../src/settings/service");
|
||||
|
||||
function createService() {
|
||||
const database = openDatabase({ path: ":memory:" });
|
||||
runMigrations(database);
|
||||
return {
|
||||
database,
|
||||
service: createSettingsService(database, { now: () => 2_000_000_000_000 }),
|
||||
};
|
||||
}
|
||||
|
||||
test("settings are persisted with the approved defaults", (t) => {
|
||||
const { database, service } = createService();
|
||||
t.after(() => database.close());
|
||||
|
||||
assert.deepEqual(service.get(), {
|
||||
revision: 1,
|
||||
defaultDurationMs: 7000,
|
||||
defaultBackgroundMode: "blurred",
|
||||
defaultSolidBackgroundColor: "#000000",
|
||||
defaultBackgroundDarkened: false,
|
||||
defaultBlurPx: 20,
|
||||
crossfadeDurationMs: 1000,
|
||||
updatedAt: database.prepare("SELECT updated_at FROM app_settings").get()
|
||||
.updated_at,
|
||||
});
|
||||
});
|
||||
|
||||
test("settings updates persist boolean darkening and increment revision", (t) => {
|
||||
const { database, service } = createService();
|
||||
t.after(() => database.close());
|
||||
|
||||
const updated = service.update({
|
||||
defaultBackgroundDarkened: true,
|
||||
defaultBlurPx: 24,
|
||||
});
|
||||
|
||||
assert.equal(updated.revision, 2);
|
||||
assert.equal(updated.defaultBackgroundDarkened, true);
|
||||
assert.equal(updated.defaultBlurPx, 24);
|
||||
assert.equal(
|
||||
database.prepare("SELECT default_background_darkened FROM app_settings").get()
|
||||
.default_background_darkened,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("settings reject strength values, unknown fields, and invalid values", (t) => {
|
||||
const { database, service } = createService();
|
||||
t.after(() => database.close());
|
||||
|
||||
const original = service.get();
|
||||
|
||||
assert.throws(
|
||||
() => service.update({ defaultBlurPx: 24, darkeningOverride: 0.25 }),
|
||||
(error) => error instanceof SettingsValidationError,
|
||||
);
|
||||
assert.deepEqual(service.get(), original);
|
||||
assert.throws(
|
||||
() => service.update({ defaultBlurPx: 24, defaultBackgroundDarkened: 1 }),
|
||||
/must be a boolean/,
|
||||
);
|
||||
assert.deepEqual(service.get(), original);
|
||||
assert.throws(
|
||||
() => service.update({ defaultDurationMs: 0 }),
|
||||
/positive integer/,
|
||||
);
|
||||
assert.deepEqual(service.get(), original);
|
||||
assert.throws(
|
||||
() => service.update({ toString: "not a setting" }),
|
||||
/unknown fields/,
|
||||
);
|
||||
assert.deepEqual(service.get(), original);
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
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 {
|
||||
initialEvents: socketEvents(connectedPayload),
|
||||
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 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(),
|
||||
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());
|
||||
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",
|
||||
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());
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.deepEqual(playlistEvents, [
|
||||
[
|
||||
"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());
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(playlistEvents.length, 1);
|
||||
assert.equal(playlistEvents[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());
|
||||
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(
|
||||
playlistEvents[0][1].playbackRevision,
|
||||
activated.body.playback.revision,
|
||||
);
|
||||
assert.equal(
|
||||
events.filter(([eventName]) => eventName === "playback:state").length,
|
||||
1,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const express = require("express");
|
||||
const { openDatabase } = require("../src/database");
|
||||
const { runMigrations } = require("../src/database/migrate");
|
||||
const {
|
||||
createDetailedHealthRouter,
|
||||
createHealthRouter,
|
||||
} = require("../src/health/routes");
|
||||
|
||||
function config() {
|
||||
return {
|
||||
environment: "test",
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
timeZone: "America/Chicago",
|
||||
trustProxy: true,
|
||||
database: { path: ":memory:" },
|
||||
storage: {
|
||||
endpoint: "http://private-storage:8333",
|
||||
region: "us-east-1",
|
||||
bucket: "announcements-v2",
|
||||
accessKeyId: "must-not-leak",
|
||||
secretAccessKey: "must-not-leak-either",
|
||||
forcePathStyle: true,
|
||||
},
|
||||
processing: {
|
||||
tempDirectory: "/tmp/announcements-uploads",
|
||||
maxFileBytes: 1024,
|
||||
maxFiles: 2,
|
||||
maxInputPixels: 1000,
|
||||
},
|
||||
mqtt: { url: null },
|
||||
notifications: { homeAssistantWebhookUrl: null },
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(storage, callback) {
|
||||
const database = openDatabase({ path: ":memory:", busyTimeoutMs: 1000 });
|
||||
const migrations = runMigrations(database);
|
||||
const lifecycle = { ready: true };
|
||||
const app = express();
|
||||
const options = { config: config(), database, lifecycle, migrations, storage };
|
||||
app.use("/health", createHealthRouter(options));
|
||||
app.use("/api/health", createDetailedHealthRouter(options));
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
|
||||
try {
|
||||
await callback(`http://127.0.0.1:${server.address().port}`);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("storage degradation is reported but does not fail API readiness", async () => {
|
||||
const storage = {
|
||||
health() {
|
||||
return {
|
||||
status: "degraded",
|
||||
checkedAt: "2026-08-16T12:00:00.000Z",
|
||||
lastSuccessAt: "2026-08-16T11:59:00.000Z",
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
await withServer(storage, async (baseUrl) => {
|
||||
const ready = await fetch(`${baseUrl}/health/ready`);
|
||||
const readyBody = await ready.json();
|
||||
assert.equal(ready.status, 200);
|
||||
assert.equal(readyBody.status, "ready");
|
||||
assert.equal(readyBody.checks.storage, "degraded");
|
||||
|
||||
const detailed = await fetch(`${baseUrl}/api/health`);
|
||||
const detailedBody = await detailed.json();
|
||||
assert.equal(detailed.status, 200);
|
||||
assert.equal(detailedBody.status, "degraded");
|
||||
assert.equal(detailedBody.services.storage.status, "degraded");
|
||||
assert.equal(
|
||||
detailedBody.services.storage.lastSuccessAt,
|
||||
"2026-08-16T11:59:00.000Z",
|
||||
);
|
||||
assert.equal(detailedBody.services.storage.code, "STORAGE_UNAVAILABLE");
|
||||
assert.doesNotMatch(JSON.stringify(detailedBody), /must-not-leak/);
|
||||
assert.doesNotMatch(JSON.stringify(detailedBody), /private-storage/);
|
||||
});
|
||||
});
|
||||
|
||||
test("healthy storage produces healthy detailed status", async () => {
|
||||
const storage = {
|
||||
health() {
|
||||
return { status: "healthy", checkedAt: "2026-08-16T12:00:00.000Z" };
|
||||
},
|
||||
};
|
||||
|
||||
await withServer(storage, async (baseUrl) => {
|
||||
const detailed = await fetch(`${baseUrl}/api/health`);
|
||||
const body = await detailed.json();
|
||||
assert.equal(detailed.status, 200);
|
||||
assert.equal(body.status, "ok");
|
||||
assert.equal(body.services.storage.status, "healthy");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const { PassThrough, Readable } = require("node:stream");
|
||||
const {
|
||||
StorageObjectNotFoundError,
|
||||
StorageUnavailableError,
|
||||
createStorageService,
|
||||
} = require("../src/storage");
|
||||
|
||||
const storageConfig = {
|
||||
endpoint: "http://seaweedfs:8333",
|
||||
region: "us-east-1",
|
||||
bucket: "announcements-v2",
|
||||
accessKeyId: "test-key",
|
||||
secretAccessKey: "test-secret",
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
function fakeClient(handler) {
|
||||
const commands = [];
|
||||
return {
|
||||
commands,
|
||||
async send(command) {
|
||||
commands.push(command);
|
||||
return handler(command, commands.length - 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commandName(command) {
|
||||
return command.constructor.name;
|
||||
}
|
||||
|
||||
test("storage initialization probes an existing bucket without creating it", async () => {
|
||||
const client = fakeClient(async () => ({}));
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
|
||||
const health = await service.initialize();
|
||||
assert.equal(health.status, "healthy");
|
||||
assert.match(health.checkedAt, /T/);
|
||||
assert.equal(health.lastSuccessAt, health.checkedAt);
|
||||
assert.deepEqual(client.commands.map(commandName), ["HeadBucketCommand"]);
|
||||
});
|
||||
|
||||
test("storage initialization creates only a missing bucket", async () => {
|
||||
let exists = false;
|
||||
const missing = Object.assign(new Error("missing"), {
|
||||
name: "NoSuchBucket",
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand" && !exists) throw missing;
|
||||
if (commandName(command) === "CreateBucketCommand") exists = true;
|
||||
return {};
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
|
||||
assert.deepEqual(await service.ensureBucket(), { created: true });
|
||||
assert.deepEqual(client.commands.map(commandName), [
|
||||
"HeadBucketCommand",
|
||||
"CreateBucketCommand",
|
||||
]);
|
||||
assert.equal(client.commands[1].input.Bucket, storageConfig.bucket);
|
||||
|
||||
assert.deepEqual(await service.ensureBucket(), { created: false });
|
||||
assert.deepEqual(client.commands.map(commandName), [
|
||||
"HeadBucketCommand",
|
||||
"CreateBucketCommand",
|
||||
"HeadBucketCommand",
|
||||
]);
|
||||
});
|
||||
|
||||
test("storage start performs a nonfatal immediate check and stop releases its client", async () => {
|
||||
let destroyed = false;
|
||||
const client = fakeClient(async () => ({}));
|
||||
client.destroy = () => {
|
||||
destroyed = true;
|
||||
};
|
||||
const service = createStorageService(
|
||||
{ ...storageConfig, healthProbeIntervalMs: 1_000 },
|
||||
{ client },
|
||||
);
|
||||
|
||||
const health = await service.start();
|
||||
assert.equal(health.status, "healthy");
|
||||
assert.deepEqual(client.commands.map(commandName), ["HeadBucketCommand"]);
|
||||
service.stop();
|
||||
assert.equal(destroyed, true);
|
||||
});
|
||||
|
||||
test("a create-bucket race is healthy only after the bucket can be verified", async () => {
|
||||
const missing = Object.assign(new Error("missing"), {
|
||||
name: "NoSuchBucket",
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
const conflict = Object.assign(new Error("race"), {
|
||||
name: "BucketAlreadyOwnedByYou",
|
||||
$metadata: { httpStatusCode: 409 },
|
||||
});
|
||||
let headCalls = 0;
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand") {
|
||||
headCalls += 1;
|
||||
if (headCalls === 1) throw missing;
|
||||
return {};
|
||||
}
|
||||
if (commandName(command) === "CreateBucketCommand") throw conflict;
|
||||
throw new Error(`Unexpected ${commandName(command)}`);
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
|
||||
assert.deepEqual(await service.ensureBucket(), { created: false });
|
||||
assert.equal(service.health().status, "healthy");
|
||||
assert.deepEqual(client.commands.map(commandName), [
|
||||
"HeadBucketCommand",
|
||||
"CreateBucketCommand",
|
||||
"HeadBucketCommand",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an older failed probe cannot overwrite a newer successful operation", async () => {
|
||||
let rejectProbe;
|
||||
let notifyProbeStarted;
|
||||
const probeStarted = new Promise((resolve) => {
|
||||
notifyProbeStarted = resolve;
|
||||
});
|
||||
let headCalls = 0;
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand") {
|
||||
headCalls += 1;
|
||||
if (headCalls === 1) return {};
|
||||
notifyProbeStarted();
|
||||
return new Promise((resolve, reject) => {
|
||||
rejectProbe = reject;
|
||||
});
|
||||
}
|
||||
if (commandName(command) === "PutObjectCommand") return {};
|
||||
throw new Error(`Unexpected ${commandName(command)}`);
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
await service.initialize();
|
||||
|
||||
const staleProbe = service.probe();
|
||||
await probeStarted;
|
||||
await service.putObject({
|
||||
key: "assets/id/original/hash",
|
||||
body: Buffer.from("bytes"),
|
||||
contentLength: 5,
|
||||
});
|
||||
const successfulState = service.health();
|
||||
rejectProbe(new Error("older request failed"));
|
||||
await staleProbe;
|
||||
|
||||
assert.equal(successfulState.status, "healthy");
|
||||
assert.deepEqual(service.health(), successfulState);
|
||||
});
|
||||
|
||||
test("temporary storage failure degrades without throwing during initialization and recovers", async () => {
|
||||
let available = false;
|
||||
const warnings = [];
|
||||
const client = fakeClient(async () => {
|
||||
if (!available) throw new Error("connection refused at a private address");
|
||||
return {};
|
||||
});
|
||||
const service = createStorageService(storageConfig, {
|
||||
client,
|
||||
logger: { warn(message) { warnings.push(message); } },
|
||||
});
|
||||
|
||||
const initial = await service.initialize();
|
||||
assert.equal(initial.status, "degraded");
|
||||
assert.equal(initial.code, "STORAGE_UNAVAILABLE");
|
||||
assert.equal(initial.lastSuccessAt, null);
|
||||
assert.deepEqual(warnings, ["Object storage is unavailable during initialization"]);
|
||||
|
||||
available = true;
|
||||
const recovered = await service.probe();
|
||||
assert.equal(recovered.status, "healthy");
|
||||
assert.equal(recovered.lastSuccessAt, recovered.checkedAt);
|
||||
assert.equal(service.health().status, "healthy");
|
||||
|
||||
available = false;
|
||||
const degradedAgain = await service.probe();
|
||||
assert.equal(degradedAgain.status, "degraded");
|
||||
assert.equal(degradedAgain.lastSuccessAt, recovered.lastSuccessAt);
|
||||
});
|
||||
|
||||
test("put, get, head, existence, and delete use the configured private bucket", async () => {
|
||||
const body = Readable.from([Buffer.from("image bytes")]);
|
||||
const client = fakeClient(async (command) => {
|
||||
switch (commandName(command)) {
|
||||
case "PutObjectCommand":
|
||||
return { ETag: '"etag"' };
|
||||
case "GetObjectCommand":
|
||||
return {
|
||||
Body: body,
|
||||
ContentType: "image/webp",
|
||||
ContentLength: 11,
|
||||
ETag: '"etag"',
|
||||
};
|
||||
case "HeadObjectCommand":
|
||||
return {
|
||||
ContentType: "image/webp",
|
||||
ContentLength: 11,
|
||||
ETag: '"etag"',
|
||||
};
|
||||
case "DeleteObjectCommand":
|
||||
return {};
|
||||
case "HeadBucketCommand":
|
||||
return {};
|
||||
default:
|
||||
throw new Error(`Unexpected ${commandName(command)}`);
|
||||
}
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
const key = "assets/id/display/hash.webp";
|
||||
await service.initialize();
|
||||
client.commands.length = 0;
|
||||
|
||||
const put = await service.putObject({
|
||||
key,
|
||||
body: Buffer.from("image bytes"),
|
||||
contentType: "image/webp",
|
||||
contentLength: 11,
|
||||
});
|
||||
assert.deepEqual(put, { key, etag: '"etag"', versionId: null });
|
||||
|
||||
const fetched = await service.getObject(key);
|
||||
assert.equal(fetched.body, body);
|
||||
assert.equal(fetched.contentType, "image/webp");
|
||||
assert.equal(fetched.contentLength, 11);
|
||||
|
||||
assert.equal((await service.headObject(key)).etag, '"etag"');
|
||||
assert.equal(await service.objectExists(key), true);
|
||||
await service.deleteObject(key);
|
||||
|
||||
for (const command of client.commands) {
|
||||
assert.equal(command.input.Bucket, storageConfig.bucket);
|
||||
assert.equal(command.input.Key, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("missing objects are distinct from unavailable storage", async () => {
|
||||
let missing = true;
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand") return {};
|
||||
if (missing) {
|
||||
throw Object.assign(new Error("missing"), {
|
||||
name: "NoSuchKey",
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
}
|
||||
throw new Error("network offline");
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
|
||||
await service.initialize();
|
||||
client.commands.length = 0;
|
||||
|
||||
assert.equal(await service.headObject("assets/id/original/hash"), null);
|
||||
assert.equal(await service.objectExists("assets/id/original/hash"), false);
|
||||
await assert.rejects(
|
||||
service.getObject("assets/id/original/hash"),
|
||||
StorageObjectNotFoundError,
|
||||
);
|
||||
assert.equal(service.health().status, "healthy");
|
||||
|
||||
missing = false;
|
||||
await assert.rejects(
|
||||
service.putObject({ key: "assets/id/original/hash", body: Buffer.alloc(0) }),
|
||||
(error) =>
|
||||
error instanceof StorageUnavailableError &&
|
||||
error.code === "STORAGE_UNAVAILABLE" &&
|
||||
error.statusCode === 503,
|
||||
);
|
||||
assert.equal(service.health().status, "degraded");
|
||||
});
|
||||
|
||||
test("a missing bucket during object access is a storage outage, not a missing object", async () => {
|
||||
let initialized = false;
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand" && !initialized) {
|
||||
initialized = true;
|
||||
return {};
|
||||
}
|
||||
throw Object.assign(new Error("bucket missing"), {
|
||||
name: "NoSuchBucket",
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
await service.initialize();
|
||||
|
||||
await assert.rejects(
|
||||
service.getObject("assets/id/original/hash"),
|
||||
StorageUnavailableError,
|
||||
);
|
||||
assert.equal(service.health().status, "degraded");
|
||||
});
|
||||
|
||||
test("storage rejects unsafe internal object keys before contacting S3", async () => {
|
||||
const client = fakeClient(async () => ({}));
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
|
||||
await assert.rejects(
|
||||
service.getObject("../private"),
|
||||
/Storage object key is invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.deleteObject("/absolute"),
|
||||
/Storage object key is invalid/,
|
||||
);
|
||||
assert.equal(client.commands.length, 0);
|
||||
});
|
||||
|
||||
test("real HTTP requests time out instead of hanging indefinitely", async (t) => {
|
||||
const server = http.createServer(() => {
|
||||
// Intentionally accept the connection without sending response headers.
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
const service = createStorageService({
|
||||
...storageConfig,
|
||||
endpoint: `http://127.0.0.1:${server.address().port}`,
|
||||
connectionTimeoutMs: 100,
|
||||
requestTimeoutMs: 100,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
t.after(() => service.stop());
|
||||
|
||||
const startedAt = Date.now();
|
||||
const health = await service.probe();
|
||||
assert.equal(health.status, "degraded");
|
||||
assert.equal(health.code, "STORAGE_UNAVAILABLE");
|
||||
assert.ok(Date.now() - startedAt < 2_000);
|
||||
});
|
||||
|
||||
test("consumer stream cancellation does not falsely degrade storage", async () => {
|
||||
let body = new PassThrough();
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand") return {};
|
||||
if (commandName(command) === "GetObjectCommand") {
|
||||
return { Body: body, ContentLength: 10, ContentType: "image/webp" };
|
||||
}
|
||||
throw new Error(`Unexpected ${commandName(command)}`);
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
await service.initialize();
|
||||
|
||||
const cancelled = await service.getObject("assets/id/display/hash.webp");
|
||||
cancelled.body.destroy(
|
||||
Object.assign(new Error("consumer closed"), {
|
||||
code: "ERR_STREAM_PREMATURE_CLOSE",
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(service.health().status, "healthy");
|
||||
|
||||
body = new PassThrough();
|
||||
const failed = await service.getObject("assets/id/display/hash.webp");
|
||||
failed.body.destroy(Object.assign(new Error("upstream reset"), { code: "ECONNRESET" }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(service.health().status, "degraded");
|
||||
});
|
||||
|
||||
test("delete treats an already-missing object as an idempotent success", async () => {
|
||||
let missingBucket = false;
|
||||
const client = fakeClient(async (command) => {
|
||||
if (commandName(command) === "HeadBucketCommand") return {};
|
||||
if (commandName(command) === "DeleteObjectCommand") {
|
||||
throw Object.assign(new Error("missing"), {
|
||||
name: missingBucket ? "NoSuchBucket" : "NoSuchKey",
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected ${commandName(command)}`);
|
||||
});
|
||||
const service = createStorageService(storageConfig, { client });
|
||||
await service.initialize();
|
||||
|
||||
await assert.doesNotReject(
|
||||
service.deleteObject("assets/id/original/hash"),
|
||||
);
|
||||
assert.equal(service.health().status, "healthy");
|
||||
|
||||
missingBucket = true;
|
||||
await assert.rejects(
|
||||
service.deleteObject("assets/id/original/hash"),
|
||||
StorageUnavailableError,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user