Sign in Sign up
1 branch
README.md

Mamma's Advent Calendar

An audio advent calendar for a serialized story. One chapter per day through December: narration, the actual text, and an illustration. See SPEC.md for the design and the reasoning behind it.

Stack: SvelteKit (adapter-node) on Bun · SQLite · system ffmpeg · Docker. No runtime dependencies — build/ is the whole application.

Getting started

bun install
cp .env.example .env      # then fill in the passwords
bun run dev

With no .env the dev defaults are site password kerst, admin password admin. These exist only in dev: in production the app refuses to serve a single request unless SITE_PASSWORD, ADMIN_PASSWORD and a 32+ character SESSION_SECRET are all set — it starts, listens, and answers everything with a 500 naming what is missing. Nothing is baked in.

Most visitors never see a password — see Sharing below.

ffmpeg and ffprobe must be on PATH. The app warns at boot if they are not.

bun run build     # production build
bun run check     # typecheck

A demo calendar

bun run demo

Fills the instance with a twelve-part story to look at: illustrations in every gallery layout, a recording per chapter, and dates set so that most doors are already open and the last few are not — which is the state the calendar is in for most of December, and the only state worth judging.

It goes in through the same admin forms mom uses, rather than being written into SQLite behind the app's back, so it exercises uploading, the two-pass loudnorm transcode, date gating, share links and the podcast feed for real. Afterwards it checks the result from a visitor's point of view and prints the share link, the feed address and the admin page.

The pictures are drawn and the audio synthesised on the spot — no binary assets in the repository, and no ffmpeg filters standing in for content. Each chapter is deliberately recorded at a different level, from −3 to −24 dB, so the normalisation has something to do. It starts a dev server if none is running, writes about 70 MB into data/, and replaces its own calendar when re-run, leaving every other calendar alone.

What exists (phase 1)

  • As many calendars as she wants, created from the admin page and each with its own dates, doors, share link, podcast feed and cover. / forwards to whichever is running today
  • Configurable date range with presets (advent, through Christmas Day, twelve days, custom). Door 1 opens on the start date, one per day after, at 00:00 Europe/Amsterdam, staying open afterwards. Shrinking the range is refused if it would discard a chapter with content.
  • Doors that behave like doors: a coloured paper flap per day that peeks open on hover and stays folded back once you have read that chapter, so the grid shows at a glance what is left. Deterministic colours, staggered entrance, today's door badged
  • A locked door wobbles and tells you how long until it opens, instead of doing nothing
  • Snowfall, off by default under prefers-reduced-motion, with a corner toggle that sticks
  • Chapter pages: real selectable text, audio player with resume position, ±15s, speed control and lock-screen/car controls via the Media Session API
  • Any number of pictures per chapter, arranged as a grid, collage, swipeable row or stacked full width — or left on automatic, which picks from how many there are. Tap to enlarge
  • A private podcast feed: one episode appears by itself each morning, listenable in Apple Podcasts, Overcast or Pocket Casts alongside the web calendar
  • Admin editor: title, teaser, story text, audio and image upload, draft/ready status
  • Upload pipeline: any audio format in, mono 44.1k MP3 at −19 LUFS out, two-pass loudnorm, running in the background with progress shown in the editor
  • Date-gated media serving with HTTP Range support
  • Time travel: admins can set ?as_of=2026-12-07 to rehearse December in November

Listening in a podcast app

The admin page carries a feed address. Added in any podcast app under "add by URL", it delivers one episode per morning — same drip as the calendar, same unlock dates, no password.

The feed authenticates with the token in its path, since podcast apps cannot hold cookies, and declares itunes:block so it never appears in a public podcast directory. Give the calendar a cover and a read by name in the admin page: without artwork, podcast apps show a blank tile.

PODCAST_LANGUAGE sets the declared language (default nl).

Not built yet

The EPUB/PDF/MP3-zip keepsake export. See SPEC.md.

Sharing

Visitors get in by opening a link, not by typing a password. ?key=<token> on any URL admits the visitor, sets a one-year cookie, and redirects to the same page without the token.

  • Copy link on the calendar page gives the link to the whole calendar.
  • Copy link at the bottom of a chapter links straight to that chapter — so "chapter 7 is up" lands people on chapter 7 even if they have never opened the calendar.
  • The shared password still works for anyone whose messaging app mangles the URL.
  • Create a new link in the admin page revokes every link already sent. People who have already opened the calendar stay signed in, so a leaked link can be revoked without evicting the family mid-story.

Chapters still only open on their own date. The link controls who gets in, not what is open.

How mom uses it

  1. Go to /admin, log in with the admin password. This lists every calendar and is where new ones are started.
  2. Open a calendar. Under Dates, pick the kind of calendar (or set your own start and end dates). The number of doors follows from the range.
  3. Pick a day. Type the title and paste the story text. Choose an audio file — any format, any size; it is converted automatically.
  4. Leave it on Draft while working. Switch to Ready when the chapter is finished.
  5. A chapter that is Ready opens by itself on its date. Nothing to do on the day.

Everything can be prepared months ahead. Recording 24 chapters is the slow part, not the publishing.

Continuous integration

.neosource/workflows/ci.yml typechecks, builds and runs the whole smoke suite on every push. Deployment does not go through it — Dokploy builds the Dockerfile on the VPS itself — so a red run never blocks a release. It exists to say, within a few minutes, that the transcode pipeline or the date gating has broken.

The job declares tools with packages: rather than choosing a container image, which is how neosource CI works: bun, ffmpeg, nodejs_24 and sqlite on top of the baseline. The smoke suite runs twice, once per runtime — under Bun, which is what the container actually runs, and under Node, which is the documented fallback the cross-runtime SQLite layer exists for, and an escape hatch nobody tests is not one. Node has to be 24 (or 22.13+) for node:sqlite to exist unflagged.

Three neosource specifics, each of which has a way of being discovered the hard way:

  • Workflows are registered from the default branch only, and only from the top level of .neosource/workflows/. A workflow living on a feature branch does not exist as far as the server is concerned. .github/workflows/ is parsed and reported, and never run.

  • Parse warnings — an ignored key, a trigger that silently never fires — are not rendered in the web UI for registered workflows. The API is the only place they show up:

    curl -s -H "Authorization: Bearer $TOKEN" \
      https://neosource.dev/api/repos/structwafel/mamma-calendar/workflows \
      | jq '.workflows[] | {path, warnings}'
  • neo run --workflow .neosource/workflows/ci.yml runs the job on this machine. Today it fails at actions/checkout, which materialises the triggering commit from https://github.com/<owner>/<repo>.git — not where this repository lives. Running a copy of the file with the checkout step removed executes the rest of the job against the working tree, which is how it was verified before ever being pushed.

Deploying

Everything the app needs is inside the image: the adapter-node build, ffmpeg, tzdata, and no runtime dependencies at all. Exactly two things come from outside — the environment variables and a persistent volume — and those are also the only two ways this deployment goes wrong.

On Dokploy

Create an Application (not a Compose service; there is one container and Dokploy's own Traefik does the TLS).

  1. ProviderGit, repository https://neosource.dev/structwafel/mamma-calendar.git, branch main, build path /. A private repo wants the git@… URL and an SSH key instead. Autodeploy needs the webhook Dokploy shows you added on the repo side; without it the Deploy button is the whole workflow, which is fine for something that changes twice a year.

  2. Build Type — Dockerfile, Dockerfile. Not Nixpacks: the transcode pipeline shells out to ffmpeg and ffprobe, and unlock times need tzdata or Europe/Amsterdam silently resolves to UTC. The Dockerfile installs both; a buildpack installs neither.

  3. Environment — without these the app deliberately serves nothing rather than running an open site on a dev secret. Note the shape of that failure: the container starts and Dokploy shows it healthy, but every request is a 500 and the log says Refusing to start: missing required environment variables: …. Read the logs, not the status dot.

    SITE_PASSWORD=...
    ADMIN_PASSWORD=...
    SESSION_SECRET=...            # 32+ chars: openssl rand -hex 32
    ORIGIN=https://advent.structwafel.dev
    CALENDAR_YEAR=2026

    DATA_DIR, TIMEZONE, PORT and BODY_SIZE_LIMIT are already baked into the image.

  4. Domainsadvent.structwafel.dev, container port 3000, HTTPS on, Let's Encrypt. ORIGIN must match that hostname exactly, scheme included. If it doesn't, pages load fine and every form submission is rejected by SvelteKit's CSRF check — which looks like "the save button does nothing" and is the single most likely support call.

  5. Advanced → Volumes — the setting that actually matters. Add a Volume Mount at mount path /var/lib/mamma-calendar. The SQLite database and every recording and picture she uploads live there. Without an explicit volume, a redeploy hands the new container a fresh anonymous volume and the whole calendar is gone — in November, silently, with no error anywhere.

  6. Volume Backups — schedule that volume to a bucket. Her recordings are the irreplaceable part of this project and a copy that only exists on the VPS is not a backup.

Uploads are large by design — a 200 MB WAV master is normal. Traefik does not cap request bodies by default, so nothing extra is needed; if a big upload ever comes back as 413, a Traefik buffering middleware is what's imposing it.

On a plain VPS

# on the VPS
echo "SITE_PASSWORD=..."    >> .env
echo "ADMIN_PASSWORD=..."   >> .env
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
echo "ORIGIN=https://kalender.example.nl"     >> .env

docker compose up -d --build

ORIGIN must match the public URL or form submissions are rejected by SvelteKit's CSRF check. Caddy in front:

kalender.example.nl {
	reverse_proxy 127.0.0.1:3000
	request_body {
		max_size 600MB
	}
}

The max_size matters: without it Caddy rejects real recordings before they reach the app.

Backups

Everything mutable is under /var/lib/mamma-calendarcalendar.sqlite plus media/. The recordings are the irreplaceable part, so sync them off the VPS:

docker compose exec calendar sh -c 'sqlite3 /var/lib/mamma-calendar/calendar.sqlite ".backup /var/lib/mamma-calendar/backup.sqlite"'
restic -r <repo> backup /var/lib/docker/volumes/mamma-calendar_calendar-data

Runtime note

The app runs on Bun, and the Dockerfile pins oven/bun:1.3.14-alpine — which is also Bun's newest release; the 1.4 line is unreleased, and every 1.4.0 build is a canary off main. The SQLite layer selects bun:sqlite or node:sqlite at runtime, so if a future Bun release misbehaves, falling back to Node is a base-image change rather than a rewrite. CI proves both runtimes on every push, and SPEC.md records what breaks on 1.4 and how to re-test it.