- Swift 58.5%
- TypeScript 37.2%
- JavaScript 2%
- Shell 1.5%
- Dockerfile 0.6%
- Other 0.2%
|
Some checks failed
Build Docker Image / build (push) Failing after 16m33s
Reviewed-on: #1 |
||
|---|---|---|
| .forgejo/workflows | ||
| dev | ||
| docs | ||
| Sources/App | ||
| Tests/AppTests | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| Package.resolved | ||
| Package.swift | ||
| README.md | ||
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/:
docs/DEVICE_PROTOCOL.md: Mac ⇄ server (HTTP registration and a WebSocket).docs/ADMIN_API.md: admin SPA ⇄ server (REST and a WebSocket for live updates).
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/wsand/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
httponly tolocalhost. In production that means two things must behttps: the server URL the Macs are given (their WebSocket then useswss), andS3_PUBLIC_ENDPOINT, which is the host inside every presigned download URL.S3_ENDPOINT, used only by the server inside the cluster, can stayhttp. - Presigned URLs are signed for the
S3_PUBLIC_ENDPOINThost. Whatever fronts S3 for the Macs must pass theHostheader through unchanged and must not rewrite the path or query, or SeaweedFS will reject the signature. It must also passRangerequests. - The container runs as UID/GID 10001 and writes only to
/data. Give the podsecurityContext.fsGroup: 10001(or otherwise make the volume writable by that user). - Probes:
GET /healthzfor liveness,GET /readyzfor 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 …/resumelifts 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