No description
  • Swift 58.5%
  • TypeScript 37.2%
  • JavaScript 2%
  • Shell 1.5%
  • Dockerfile 0.6%
  • Other 0.2%
Find a file
2026-10-05 18:15:06 +11:00
.forgejo/workflows Add a Forgejo workflow that builds and publishes the image 2026-10-05 18:12:40 +11:00
dev Fake device: queue an appended playlist behind one that is still loading 2026-10-05 16:03:47 +11:00
docs Contracts: repeated stop/resume is a no-op for the Mac; shuffle follows a playlist sent to a stopped Mac 2026-10-05 16:04:22 +11:00
Sources/App Add stop and resume 2026-10-05 16:02:36 +11:00
Tests/AppTests Add stop and resume 2026-10-05 16:02:36 +11:00
web Web: stop and resume a Mac, with a clear Stopped state 2026-10-05 15:59:59 +11:00
.dockerignore Add test suite, Dockerfile and scan-triggered re-plan 2026-10-05 12:29:21 +11:00
.gitignore Add Vapor server, SeaweedFS dev stack and seed script 2026-10-05 12:15:47 +11:00
CLAUDE.md Add stop and resume 2026-10-05 16:02:36 +11:00
Dockerfile Add test suite, Dockerfile and scan-triggered re-plan 2026-10-05 12:29:21 +11:00
Package.resolved Add Vapor server, SeaweedFS dev stack and seed script 2026-10-05 12:15:47 +11:00
Package.swift Add Vapor server, SeaweedFS dev stack and seed script 2026-10-05 12:15:47 +11:00
README.md Add a Forgejo workflow that builds and publishes the image 2026-10-05 18:12:40 +11:00

simpleplayer-svc

The server behind SimplePlayer: it knows the video library in an S3-compatible bucket (SeaweedFS), tells each Mac ("Remote Projector" app) what to download and play, and serves the admin web page used to manage the Macs. Swift, Vapor 4, Fluent on SQLite, Soto for S3; the admin SPA lives in web/.

The two contracts this server implements are in docs/:

There is no authentication yet; TODO(auth) marks where it will go.

Quick start

Needs Swift 6, Docker (OrbStack is fine), ffmpeg and Node (for the SPA).

docker compose -f dev/compose.yaml up -d    # SeaweedFS with S3 on localhost:8333
dev/seed.sh                                 # generate and upload the test clips
(cd web && npm ci && npm run build)         # build the admin page (optional)
dev/run-server.sh                           # the server on http://localhost:8080

Open http://localhost:8080, press scan (or curl -X POST http://localhost:8080/api/admin/v1/library/scan), and the seeded folders appear. To see a Mac without the Mac app, run node dev/fake-device.mjs.

Configuration

Everything comes from environment variables. The server refuses to start, listing every problem, when a required variable is missing or a value is malformed.

Variable Default Notes
PORT 8080 Listens on 0.0.0.0
LOG_LEVEL info trace, debug, info, notice, warning, error or critical
DATABASE_PATH data/simpleplayer.sqlite SQLite file; the directory is created. The image sets /data/simpleplayer.sqlite
PUBLIC_DIR Public Directory holding the built SPA. The image sets /app/Public
S3_ENDPOINT required How the server reaches S3, e.g. http://seaweedfs:8333
S3_PUBLIC_ENDPOINT = S3_ENDPOINT How the Macs reach S3. Used only inside presigned download URLs. Must be https in production (see below)
S3_REGION us-east-1
S3_BUCKET required
S3_PREFIX empty Only objects under this prefix are scanned; keys and folder paths in the API are relative to it
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY required From a Secret. Read access (list and get) is enough
S3_PATH_STYLE true http://host/bucket/key addressing, which SeaweedFS wants; false gives http://bucket.host/key
S3_PRESIGN_TTL_SECONDS 43200 Lifetime of a download URL (60 to 604800). Long, because the files are huge and downloads slow

Deploying

The image is meant for a Kubernetes Deployment with a ConfigMap, a Secret and one PersistentVolumeClaim mounted at /data. Manifests are not part of this repository yet. Things the deployment has to get right:

  • One replica. Live state (which Macs are connected, their latest status, the admin WebSocket clients, pending re-plans) is held in memory and the database is SQLite, so the server must run as a single replica (replicas: 1, strategy: Recreate).
  • The ingress must pass WebSockets, for /api/device/v1/ws and /api/admin/v1/ws, and must not time out idle connections after less than about a minute. The server pings device sockets every 10 s and admin sockets every 20 s.
  • The Macs need HTTPS for everything that is not localhost. The Mac app has no App Transport Security exception, so it can use plain http only to localhost. In production that means two things must be https: the server URL the Macs are given (their WebSocket then uses wss), and S3_PUBLIC_ENDPOINT, which is the host inside every presigned download URL. S3_ENDPOINT, used only by the server inside the cluster, can stay http.
  • Presigned URLs are signed for the S3_PUBLIC_ENDPOINT host. Whatever fronts S3 for the Macs must pass the Host header through unchanged and must not rewrite the path or query, or SeaweedFS will reject the signature. It must also pass Range requests.
  • The container runs as UID/GID 10001 and writes only to /data. Give the pod securityContext.fsGroup: 10001 (or otherwise make the volume writable by that user).
  • Probes: GET /healthz for liveness, GET /readyz for readiness (checks the database).
  • Shutdown: on SIGTERM the server closes its WebSockets, stops listening and closes the database; this takes well under a second. The Macs reconnect by themselves.

Behaviour worth knowing

  • Scans are manual. The library is scanned only on POST /api/admin/v1/library/scan (the scan button in the admin page): not at startup and not on a timer. A new server has an empty library until the first scan.
  • New folders inherit. A folder discovered by a scan gets its parent's enabled or disabled setting for each Mac; new top-level folders start disabled.
  • Changes re-plan playback, gently. When what a Mac may play changes (its folders are edited, or a scan adds, changes, removes or brings back media in folders it has enabled), the server waits for 3 s without further changes and sends a fresh shuffle that starts after the video on screen ends. A Mac that was offline gets it when it reconnects.
  • Stop is a setting, not a command. POST /api/admin/v1/devices/:id/stop (the stop button) marks the Mac as stopped in the database and tells it at once if it is connected: playback ends and the screen goes black. Because it is stored, it also holds for a Mac that is offline at the time, and across restarts of the server and of the Mac. A stopped Mac is sent nothing to play: no re-plans (none are owed afterwards either), no resent playlists, and an empty playlist if it asks. POST …/resume lifts it, and so does sending the Mac a playlist by hand; the Mac then asks for a fresh shuffle itself.
  • A playlist that never arrived is sent again. If a Mac's connection had silently died when a playlist was sent, the Mac reconnects still playing its old one. Its first status after reconnecting shows that, and the server sends the latest playlist again.
  • Names are matched byte for byte. Two spellings of a name that differ only in Unicode normalisation (a precomposed "ā" versus "a" plus a combining macron, which macOS tools tend to produce) are different objects and different folders, as they are to S3. Re-uploading a folder under the other spelling therefore shows up as a new folder (disabled, if it is top-level) next to the old one marked missing.
  • The database holds devices and their settings (name, cache reserve, stopped), the library (folders and media, by S3 key) and the latest 20 playlists per device. It does not hold a Mac's status, which is gone after a restart until the Mac reports again (within 5 s of reconnecting).

Running locally

swift build
dev/run-server.sh

dev/run-server.sh sources dev/env.sh (the dev stack's endpoint, bucket and credentials), serves web/dist if it has been built, and runs swift run App. The database goes to data/simpleplayer.sqlite; delete that file to start from scratch. Override anything through the environment, for example:

PORT=9090 LOG_LEVEL=debug dev/run-server.sh
S3_PUBLIC_ENDPOINT=http://192.168.1.20:8333 dev/run-server.sh

The second form is for a Mac elsewhere on the network: it must download from this machine's address rather than localhost. (A Mac app built without an ATS exception will refuse that plain-http address; see Deploying.)

While developing the SPA, run its Vite dev server (see web/README.md) with /api proxied to :8080; the server sends no CORS headers.

Dev stack

dev/compose.yaml runs one SeaweedFS container whose S3 gateway listens on localhost:8333, with fixed development credentials (dev/s3.json, repeated in dev/env.sh): access key simpleplayer-dev, secret simpleplayer-dev-secret, bucket simpleplayer-media. Its data is in a named Docker volume.

docker compose -f dev/compose.yaml up -d      # start
docker compose -f dev/compose.yaml logs -f    # watch
docker compose -f dev/compose.yaml down       # stop, keeping the data
docker compose -f dev/compose.yaml down -v    # stop and delete the bucket

dev/seed.sh fills the bucket. It needs only ffmpeg, swift and curl; clips are generated once into dev/.clips (about 100 MB) and reused.

dev/seed.sh                             # the standard library, below
dev/seed.sh add                         # later: a new top-level folder "Lakeside" with 2 clips
dev/seed.sh add Harbour/Night 3         # a new nested folder with 3 clips (shows inheritance)
dev/seed.sh remove "Harbour/Morning.mp4"   # make one clip go missing
dev/seed.sh list                        # what is in the bucket

Scan after each change to see it in the library. The standard library is eight 1080p H.264 clips of 20 to 60 s and 3 to 35 MB, each with its folder and filename burned in and a running clock in the corner:

Welcome.mp4                      a file at the bucket root
Harbour/Morning.mp4
Harbour/Low tide at dusk.mp4     spaces in the filename
Harbour/notes.txt                not a video; the scan ignores it
Forest/Canopy.m4v
Forest/Stream/Day1/Pool.mp4      two levels down; Forest/Stream itself holds no media
Forest/Stream/Day1/Rapids.m4v
Mountain/Ridge.mp4               the largest, 35 MB
Mountain/Snowline.mov

dev/fake-device.mjs (Node 22 or later, no packages) plays the part of a Mac: it registers, connects, reports status, asks for playlists and download URLs, checks each URL with a Range request, obeys stop and resume, and prints every frame in both directions.

node dev/fake-device.mjs [--server http://localhost:8080] [--hostname FakeMac] [--device-id <id>] [--once]

Tests

swift test
swift test --filter ReplanTests       # one suite
TEST_LOG=debug swift test --filter ScanTests   # with the server's log, SQL included

The tests run the real application on an in-memory SQLite database with S3 replaced by an in-memory fake (Tests/AppTests/Support/FakeStorage.swift). Each test starts its own server on a free port, so the device and admin WebSockets are exercised over real connections. Nothing in the tests touches the dev stack or the network.

Docker image

docker build -t simpleplayer-svc .

Three stages: Node builds web/ into web/dist, Swift builds the server, and a slim Ubuntu runtime gets the binary and the SPA in /app/Public. It runs as a non-root user (10001), declares /data as a volume and exposes 8080.

Every push to main builds the image in Forgejo Actions (.forgejo/workflows/build-docker.yml) and publishes it as code.m.ac.nz/containers/simpleplayer-svc:main, with a sha-<short commit> tag beside it.

To try it against the dev stack (the container reaches SeaweedFS through the host, while download URLs are signed for localhost, where a Mac app on this machine can use them):

docker run --rm -p 8080:8080 -v simpleplayer-data:/data \
  -e S3_ENDPOINT=http://host.docker.internal:8333 \
  -e S3_PUBLIC_ENDPOINT=http://localhost:8333 \
  -e S3_BUCKET=simpleplayer-media \
  -e S3_ACCESS_KEY_ID=simpleplayer-dev \
  -e S3_SECRET_ACCESS_KEY=simpleplayer-dev-secret \
  simpleplayer-svc
curl http://localhost:8080/healthz

Layout

Sources/App/        the server (see CLAUDE.md for a tour)
Tests/AppTests/     tests and their support code
dev/                compose file, seed script, fake device, run script
docs/               the two contracts
web/                the admin SPA (React, Vite, TypeScript, Mantine)
.forgejo/           the workflow that builds and publishes the image
Dockerfile