Automated DJ mix upload pipeline for SoundCloud & YouTube.
Drop a recorded mix into a watch folder. fade-out analyzes the audio, generates tracklists, creates branded thumbnails and cover art, uploads to SoundCloud and YouTube, verifies each upload, and notifies you on Discord. Zero manual steps after the initial recording.
Important
fade-out has no authentication. Do not expose it directly to the internet.
Anyone who can reach the port can read your configured credentials and publish to your accounts. It is built as a single-user tool for a trusted home network — run it on your LAN, behind an authenticating reverse proxy, or on a private overlay network like Tailscale. See SECURITY.md.
| Feature | Description |
|---|---|
| File Watcher | Monitors NAS directories for new audio recordings, video captures, and cue sheets |
| Audio Analysis | Extracts duration, BPM, key, waveform data via ffmpeg |
| Tracklist Extraction | Parses cue sheets or uses AI to identify tracks from mix recordings |
| Title & Description Generation | AI-powered metadata: mix titles, descriptions, tags, and timestamps |
| Thumbnail Generation | Branded YouTube thumbnails with AI-generated backgrounds via fal.ai |
| Cover Art Generation | SoundCloud cover art with consistent DJ branding |
| SoundCloud Upload | Automated upload with metadata, cover art, and tracklist via browser automation |
| YouTube Upload | Automated upload via YouTube Data API with thumbnails and descriptions |
| Upload Verification | Post-upload checks to confirm successful publishing on both platforms |
| Discord Notifications | Webhook notifications on upload success/failure with direct links |
| Web Dashboard | React-based UI for monitoring pipeline status, managing mixes, and brand settings |
| Brand Customization | Configurable colors, fonts, logos, and templates for generated assets |
fade-out pipeline
┌──────────────────────────────────────────────────────────────────┐
│ │
│ /watch/audio ──┐ │
│ /watch/video ──┼──▶ File Watcher ──▶ Analyzer ──┐ │
│ /watch/djctl ──┘ │ │ │ │
│ │ BPM, key, │ │
│ new file duration tracklist │
│ detected extracted parsed │
│ │ │
│ ┌─────────────────────────┘ │
│ ▼ │
│ Generator │
│ ┌──────┬──────┐ │
│ │ │ │ │
│ title thumbnail cover │
│ desc (fal.ai) art │
│ │ │ │ │
│ └──────┴──────┘ │
│ │ │
│ ▼ │
│ Uploader │
│ ┌───────┴────────┐ │
│ │ │ │
│ SoundCloud YouTube │
│ (Playwright) (Data API) │
│ │ │ │
│ └───────┬────────┘ │
│ ▼ │
│ Verifier ──▶ Discord Notification │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Web Dashboard (React + FastAPI) :8000 │ │
│ │ Pipeline status │ Mix manager │ Brand settings │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
- Docker and Docker Compose (the recommended path — everything else is baked in), or
- Python 3.12, Node 20, and
ffmpegfor a local install.
Python 3.13+ is not supported. It removed the stdlib
audioopmodule thatpydub— pulled in byshazamio— imports at start-up. The Docker image pins 3.12 for this reason.
You will also need API credentials for the services you intend to use; see Configuration.
# Clone the repository
git clone https://github.com/rancur/fade-out.git
cd fade-out
# Configure environment
cp .env.example .env
# Edit .env with your API keys, credentials, and media paths.
# By default fade-out watches ./media/* next to the compose file; point
# WATCH_AUDIO_DIR and friends at your own folders (a NAS share, for example).
# Build and start
docker compose up -d
# Access the dashboard
open http://localhost:8500# Interactive setup: creates the media directories and fills in .env
bash scripts/setup.sh
# Or manually:
docker compose up -d --buildSet the WATCH_* and OUTPUT_* variables in .env to your shares — for
example WATCH_AUDIO_DIR=/volume1/music/mixes on a Synology. The dashboard is
then available at http://<host-ip>:8500; keep it on the LAN, or put an
authenticating proxy in front of it.
| Variable | Required | Description |
|---|---|---|
OPENAI_API_KEY |
Yes | OpenAI API key for tracklist analysis and metadata generation |
FAL_API_KEY |
Yes | fal.ai API key for AI thumbnail/cover art generation |
AUDD_API_TOKEN |
No | AudD fingerprint token. When set, used as a fallback for mix segments the Shazam identifier misses (better recall on layered/underground DJ audio). |
SOUNDCLOUD_EMAIL |
Yes | SoundCloud account email |
SOUNDCLOUD_PASSWORD |
Yes | SoundCloud account password |
YOUTUBE_CLIENT_ID |
Yes | YouTube Data API OAuth2 client ID |
YOUTUBE_CLIENT_SECRET |
Yes | YouTube Data API OAuth2 client secret |
YOUTUBE_REFRESH_TOKEN |
Yes | YouTube Data API OAuth2 refresh token |
NOTIFICATION_DISCORD_WEBHOOK_URL |
No | Discord webhook for upload notifications |
GITHUB_TOKEN |
Only for a private repo | Read-only credential for the deployment freshness check (see below) |
TZ |
No | IANA timezone (default: UTC) |
PUBLIC_URL |
For YouTube OAuth | Publicly resolvable URL used for OAuth callbacks |
CORS_ALLOW_ORIGINS |
No | Comma-separated browser origins allowed to call the API. Defaults to the Vite dev-server origins; PUBLIC_URL is added automatically. Do not set to * — see SECURITY.md |
DJCTL_WS_URL |
No | Live DJCTL WebSocket feed. Empty (default) disables it |
CATALOG_CHANNEL_NAME |
No | Your channel name, stripped when used as a title prefix during back-catalog import |
CATALOG_SERIES_NAMES |
No | Comma-separated recurring show names to strip from imported titles |
Note
CATALOG_CHANNEL_NAME and CATALOG_SERIES_NAMES default to the original
author's own Twitch-era show names, so that existing deployments keep working.
Set them to your own (or to empty) — a phrase that never matches simply
does nothing. The universal markers (Raid Train, Twitch, untitled, bare
dates) always apply and need no configuration.
docker-compose.yml reads these from .env, so the compose file works
unmodified on any machine. Each defaults to a folder under ./media/ next to
the compose file — point them at your own shares instead.
| Variable | Mount | Mode |
|---|---|---|
WATCH_AUDIO_DIR |
/watch/audio |
read-write |
WATCH_VIDEO_DIR |
/watch/video |
read-write |
WATCH_CUE_DIR |
/watch/djctl-cue |
read-only |
WATCH_SHORTS_DIR |
/watch/shorts |
read-only |
OUTPUT_THUMBNAILS_DIR |
/output/thumbnails |
read-write |
OUTPUT_COVER_ART_DIR |
/output/cover-art |
read-write |
Audio and video are mounted read-write only so the optional source-file renaming
feature can work; it is off by default. Set them to :ro in docker-compose.yml
if you never want fade-out to touch your source recordings.
GET /api/health and GET /api/upgrade/status compare the running build
against the newest GitHub release of GITHUB_REPO.
If that repo is private, the comparison needs GITHUB_TOKEN. GitHub
answers 404 — not 403 — for a private repo the caller cannot read, so
without a credential "no releases published" and "you cannot see this repo"
are literally the same response. The check refuses to guess between them:
it reports state: error, stale: null, healthy: false, and names the
missing credential. It never degrades to "up to date".
Give it the least privilege that works: a fine-grained personal access token scoped to this one repository, with Repository permissions → Contents: Read-only. Release and tag metadata is all this ever reads. A token with write scopes would hand a monitoring loop the ability to modify the repo it is watching, and a classic PAT cannot be scoped to a single repository at all.
A credential that authenticates but has no access to the repo is reported as an error, not as a clean bill of health — the check verifies the repository is visible before it accepts a 404 as "no releases". Inject the value from your secret manager at deploy time; never commit it or bake it into the image.
| Path | Purpose |
|---|---|
/watch/audio |
Recorded DJ mix audio files (WAV, FLAC, MP3) |
/watch/video |
Twitch/OBS video recordings for YouTube upload |
/watch/djctl-cue |
Cue sheets exported from DJ software (via djctl) |
fade-out invents a title for every mix, but the recordings on disk keep whatever OBS or your DJ software called them. Turning on Rename source files to match titles (Settings → Advanced) closes that gap: when a run finishes — and whenever a new title is applied from the catalog — the source audio and video are renamed in place and the stored paths follow.
before /watch/audio/Twitch DJs Vol 4 (2026-07-15).flac
/watch/video/will-see-live-2026-07-15.mkv
after /watch/audio/2026-07-15 Neon Drift - House Mix.flac
/watch/video/2026-07-15 Neon Drift - House Mix.mkv
It takes two keys to fire, and either one missing is a safe no-op:
- the setting is on, and
/watch/audioand/watch/videoare mounted:rwindocker-compose.yml.
With a read-only mount every rename is skipped and logged to the Activity page, and nothing else about the pipeline changes.
The date prefix comes from the original filename, so audio/video pairing and
catalog matching keep working; both files get the same token, which means the
pair ends up with identical stems. Characters that SMB and Windows cannot store
are replaced (| and / become -, : becomes -).
Safety rails worth knowing about:
- Only files inside
/watch/audioand/watch/videoare ever touched./watch/shorts,/watch/djctl-cue, and anything inCATALOG_EXTRA_AUDIO_PATHSare excluded. - An existing file is never overwritten — an occupied name gets a
(2)suffix, and after(9)the rename is refused. - Symlinks are skipped, and a rename can never fail a pipeline run.
- On a Synology, the share's ACLs and the container's UID govern whether the write actually succeeds; a permission failure shows up as a warning in the Activity page.
To preview or trigger a rename for one mix — including for back-catalog mixes that predate the feature:
curl -X POST "$FADEOUT/api/catalog/mixes/<mix_id>/rename-source?dry_run=true"dry_run=true reports the plan without touching anything, and works even while
the setting is off.
Turning on Write metadata into source files (Settings → Advanced,
tag_source_files, OFF by default) writes library metadata directly into a
completed mix's source FLAC, so the recording is identifiable in Plex or any
local player — not just on SoundCloud/YouTube. It writes:
| Tag | Value |
|---|---|
ARTIST |
Will See |
TITLE |
the mix title |
DATE |
the same YYYY-MM-DD token used for renaming (see above) |
GENRE |
the mix's genres, ; -separated |
ALBUM |
Will See Mixes |
DESCRIPTION |
the tracklist |
URL |
the SoundCloud or YouTube URL |
| cover picture block | the generated cover art |
This is a separate setting from Rename source files to match titles
(rename_source_files, above) and does not change it — renaming moves/renames
the file, tagging rewrites its metadata, and either can be on, off, or both.
Why this can't just be a mutagen call in place. The file watcher dedupes
on md5(first 10 MB), and FLAC metadata blocks live at the very start of the
file, so writing tags changes a file's dedupe hash. If a freshly tagged file
appeared in the watch folder before the watcher knew its new hash, it would be
re-ingested as a new recording and the mix re-uploaded a second time. Tagging
is therefore done out-of-place and promoted in a fixed order:
- copy the source into a hidden
.fadeout-tagging/folder inside the watch directory (same filesystem, and invisible to the watcher's non-recursive directory listing), - write the tags and cover art into the copy,
- re-read the copy to confirm it still parses as valid FLAC, that its audio payload (everything past the last metadata block) is at least as large as the source's (a truncated write — e.g. the disk filling mid-copy — still parses and still reports the original's full duration, since that duration comes from a header field a short write leaves untouched; only a size check catches it — and it has to be a payload-size check, not a total-file-size check, because a healthy re-tag of an already-tagged file writes its new metadata into the existing padding block and doesn't grow the file at all), and, when a duration is known for the mix, that it still matches,
- register the copy's new hash with the file watcher,
- only then move it into place with an atomic rename, followed by an fsync of the containing directory so a crash right after promotion can't lose the rename.
Registering the hash before promoting the file is the safety property, not an implementation detail — do not reorder steps 4 and 5. A tagged file registered too late is a tagged file the watcher can pick up as new.
These FLACs carry no padding (audio frames measured to begin at byte 86), so the first tag write on a given file is always a full rewrite; 64 KB of padding is added on write so later edits can happen in place. The rewrite is skipped — logged, never fatal to the pipeline run — when free space on the volume is under 2x the file's size, since the out-of-place copy needs room to exist alongside the original while it is written.
.fadeout-tagging/ staging copies from a write that never finished (a killed
process, an OS-level crash between writing the copy and promoting it) are no
longer left for an operator to find by hand: every retag backfill run sweeps
each watch root's .fadeout-tagging/ directory before it starts, removing
anything older than the sweep's age threshold. See "Orphaned staging
cleanup" below for the guardrails and the default threshold. Tagging
triggered by ordinary pipeline completion (a single mix finishing, not the
backfill) does not run that sweep, so an orphan from a single-mix failure
outside a backfill run still needs manual cleanup.
Re-tagging skips files that already carry the target tags. Before
writing anything, already_tagged() reads the FLAC's existing Vorbis
comments — a header read only, it never opens the file for writing or
copies it — and compares every target tag's value plus whether the embedded
cover art's CONTENT (size and a SHA-256 digest, not just whether some
picture is present) matches the current cover_art_path file on disk, so
regenerated artwork triggers a re-tag rather than leaving stale art in
place. Only when everything already matches does the mix come back
{"status": "skipped", "reason": "already tagged"} without touching the
file at all; a retitled mix has a different TITLE, so it is still
re-tagged, same as any other real change. A dry run
over an already-tagged library reports "already tagged -- would be skipped" for the same mixes rather than overstating the work a real run
would do. This is what makes the retag backfill (below) cheap to re-run
after an interruption instead of redoing a full multi-gigabyte rewrite of
every file it already touched.
To backfill renames and tags across already-published mixes:
curl -X POST "$FADEOUT/api/catalog/retag"Both tag_source_files and rename_source_files must be turned on for a
real (dry_run=false) backfill run to actually do anything. When a flag is
off, that half comes back as a no-op, but the two report it in different
shapes: the tagger's result carries "status": "disabled" directly; the
renamer's result has no top-level status key at all (its shape is
{mix_id, reason, dry_run, enabled, renamed, skipped, errors}) and instead
carries "enabled": false plus "skipped": [{"reason": "disabled"}].
GET /retag/status's summary (below) normalizes both into the same
disabled bucket so you don't need to know the shape difference yourself —
check it before trusting a run. Since both settings default to OFF, it's
easy to run a green dry-run pre-flight and then have the real run silently
do nothing.
dry_run defaults to true, so a bare POST is the safe, report-only form —
per mix, it reports whether the source file exists, whether there is
sufficient free space, and whether each of the two settings is actually on,
via an enabled field in each half's result, without touching anything. The
two halves differ here too: the tagger's dry run short-circuits with an
explicit "tag_source_files is off" reason when its own flag is disabled;
the renamer's dry run does not short-circuit — it always reports the
rename plan it would execute (planned) regardless of the flag, and relies
on its enabled field to tell you separately whether a real run would
actually apply that plan. Pass dry_run=false to actually rewrite files, and
poll progress while it runs:
curl -X POST "$FADEOUT/api/catalog/retag?dry_run=false"
curl "$FADEOUT/api/catalog/retag/status"The backfill is resumable and genuinely paginated. limit and offset
select a deterministically ordered slice of completed mixes (ORDER BY created_at, id, applied identically to the pre-flight candidates count
and to the run itself), so offset=0&limit=20 followed by
offset=20&limit=20 touches two disjoint batches rather than risking the
same rows twice. After every mix whose outcome — both the rename leg
and the tag leg — is terminal-success, a resume cursor is written to
AppSettings.settings_json["retag_progress"]. Terminal-success for the
rename leg is anything other than a failed write; for the tag leg it's the
tag actually being written, or the file already carrying every target tag
and matching cover art. A failed rename, a failed tag write, or a tag skip
that could resolve itself (a missing source, a source outside the allowed
roots, or insufficient free space) does not advance the cursor, so the
mix is retried on the next resume=true instead of being skipped forever —
the run itself still moves on to the next mix immediately either way.
A tag outcome of disabled does not advance the cursor on a real run
— even though nothing failed and nothing was attempted. disabled means
"genuinely nothing to do" only at the instant it's returned: the moment
tag_source_files is turned on, there IS something to do, and a cursor
already advanced past that mix would mean a later resume=true never
revisits it. (A dry run's own reporting of disabled is unaffected by any
of this — see below; a dry run's cursor is never terminal-success-gated in
the first place, since a dry run never writes anything.)
Passing resume=true (which ignores any offset you also pass) continues
from that cursor instead of restarting from the beginning, and with no
saved cursor it just starts at the beginning like a fresh run.
A dry run's cursor can never resume a real run, and vice versa. The
persisted cursor records the dry_run it was written under; resume=true
against a cursor from the other mode is treated as no cursor at all rather
than resolved into an offset — the response's resume_cursor_ignored is
true when this happens, and the same flag is recorded on the durable
retag_run_started activity event, so a refused resume is still visible
after the process that ran it is gone. Without this guard, a bare
(dry-run) POST over the whole backlog followed by a real
dry_run=false&resume=true run would resolve the dry run's cursor to
"everything already done" and the real run would touch nothing, report
{"started": true}, and leave every file untagged.
Do not pair resume=true with limit. A permanently-failing mix
freezes the cursor at its position (by design — see above), and a limit
caps how far each resumed pass can reach past it: with limit=10 and a
stuck mix at offset 3, every resumed pass re-processes offsets 3–12 and
never reaches 13, forever. Without limit the same stuck mix is benign —
the loop still walks every remaining candidate to the end in a single
pass; only the persisted cursor itself sticks at the stuck mix's position.
Do not mix offset runs with resume runs. They advance two different
notions of "where we are": a run started at offset=50 whose very first
mix succeeds persists THAT mix as the cursor, and a later resume=true
continues from there — silently skipping mixes 0–49, which this offset=50
run never touched at all. Pick one scheme (hand-sized offset batches, or
resume=true) for a given pass over the backlog and stick to it.
Combined with the already-tagged skip
above, an interrupted run can simply be re-issued — as a fresh call
(previously-touched files are skipped cheaply) or with resume=true to
pick up exactly where the cursor left off — rather than watched through
hand-sized batches:
curl -X POST "$FADEOUT/api/catalog/retag?dry_run=false&limit=20"
curl -X POST "$FADEOUT/api/catalog/retag?dry_run=false&resume=true"The candidates count returned by the POST reflects limit/offset (or
the resolved resume cursor when resume=true) — it's the number of mixes
this call is actually about to touch, not the size of the whole backlog.
Orphaned staging cleanup. After selecting the run's candidates but before
touching any of them, every retag run sweeps every .fadeout-tagging/
staging directory those candidates could actually be using
(source_tagger.sweep_staging, older_than_hours=6.0 — this is a fixed
threshold, not currently exposed as a request parameter) and removes
anything older than that threshold; a file younger than the threshold is
left alone because it may belong to a run currently in flight. Sweep
targets are the union of each watch root's own top-level .fadeout-tagging/
directory (the flat-layout case) and dirname(<candidate's source>)/ .fadeout-tagging/ for every candidate this call selected, since a source
in a subdirectory of a watch root (e.g. <watch_audio>/2023/foo.flac)
stages its own copy alongside itself, not at the watch root. This sweep
also honours dry_run — a bare POST reports what it would remove without
removing anything, matching every other part of a dry run. The sweep only
ever acts on a directory whose basename is exactly .fadeout-tagging and
that lives inside an allowed watch root — anything else is refused outright
rather than swept. What it reclaimed (or, under a dry run, would have
reclaimed) is logged as a retag_staging_swept activity event before this
run's candidates are actually touched — after they're selected (selection
is what determines the sweep targets), but before rename/tag runs on a
single one of them.
Every backfill run is auditable after the fact, independent of the
in-memory GET /retag/status state a process restart wipes. Durable events
go through the activity log: retag_staging_swept before the run starts,
one retag_run_started with the run's parameters and candidate count, one
retag_mix_processed per mix carrying both the rename and tag outcome AND
reason (logged at error when either failed, info otherwise), and a
closing retag_run_completed with the run's summary (logged at warn when
the summary contains any failures, or any tag skip whose reason wasn't
"already tagged" — the only thing that tells apart "every file was already
tagged" from "every file was skipped for want of disk", which otherwise
both show up identically as {skipped: N}; info otherwise). If the run
crashes outright, a retag_run_failed event is emitted before the
exception propagates, so a crashed run is distinguishable in the durable
log from one that is simply still in progress. Activity-log emits are
best-effort and layered on top — a logging failure can never abort or slow
the backfill itself.
GET /retag/status includes a summary — {ok, skipped, disabled, failed, dry_run, unknown} counts across every rename and tag outcome of the run.
Any outcome shape the summary doesn't recognise lands in unknown rather
than being silently dropped, so sum(summary.values()) always equals
2 * processed — a run where everything came back disabled can't be
mistaken for one that actually did the work; processed == total alone
can't tell those apart.
A retag run started while one is already in progress gets 409 rather than
starting a second concurrent pass over the same files.
| Path | Purpose |
|---|---|
/output/thumbnails |
Generated YouTube thumbnails |
/output/cover-art |
Generated SoundCloud cover art |
The web UI provides:
- Pipeline Monitor -- Real-time status of each mix through the pipeline stages
- Mix Manager -- Browse, edit metadata, retry failed uploads
- Brand Settings -- Customize colors, fonts, logo, and template layouts
- Upload History -- Links to published mixes on SoundCloud and YouTube
Customize generated thumbnails and cover art through the web dashboard or by editing brand settings in the database:
- Primary/secondary colors -- Used in text overlays and borders
- Font family -- Applied to mix titles and tracklist text
- Logo -- Overlaid on thumbnails and cover art
- Template layout -- Choose from preset arrangements or create custom layouts
- AI prompt modifiers -- Steer the fal.ai background generation style
The backend exposes a REST API at /api:
| Endpoint | Method | Description |
|---|---|---|
/api/health |
GET | Functional health — 503 when a credential is dead or the build is stale |
/api/health/live |
GET | Process liveness only (what the container healthcheck uses) |
/api/mixes |
GET | List all mixes |
/api/mixes/{id} |
GET | Get mix details |
/api/mixes/{id} |
PATCH | Update mix metadata |
/api/mixes/{id}/retry |
POST | Retry failed pipeline stages |
/api/mixes/{id}/retry-platform/{platform} |
POST | Re-run ONE platform leg (soundcloud/youtube/mixcloud) |
/api/mixes/{id}/publish-state |
GET | Per-platform publish state for a mix |
/api/mixes/{id}/upload |
POST | Manually trigger upload |
/api/system/unpublished |
GET | Mixes ingested but not published inside their window |
/api/pipeline/status |
GET | Current pipeline status |
/api/brand |
GET | Get brand settings |
/api/brand |
PUT | Update brand settings |
/api/settings |
GET | Get application settings |
/api/settings |
PUT | Update application settings |
/api/health asserts real function, not process liveness. It exercises the
same auth path an upload uses for every configured platform and returns 503
when a credential is dead, unknown, or its last probe went stale — a publish
pipeline that cannot authenticate is not healthy, whatever the process is
doing. It also reports the running build against the latest release, so a
container that never got redeployed is visible rather than silently old.
Container orchestration should watch /api/health/live instead: the
Dockerfile healthcheck does, so a dead OAuth grant pages a human rather than
cycling a process that is working fine.
Build identity comes from app/version.py plus BUILD_COMMIT / BUILD_TIME,
baked in at image build time:
BUILD_COMMIT=$(git rev-parse --short HEAD) \
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
docker compose build && docker compose up -d
curl -s localhost:8500/api/health | jq .build # must match what you builtPublish targets are independent legs. If SoundCloud's grant is dead, YouTube
is still attempted; the mix ends up partial with pipeline_error naming
which targets are live, and the failed leg's remaining steps are recorded
blocked rather than left at pending. Fix the credential and re-run just
that leg:
curl -X POST localhost:8500/api/mixes/<id>/retry-platform/soundcloud# Backend
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
# Frontend (separate terminal)
cd frontend
npm install
npm run dev# Start with live reload (mounts source code)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Backend at :8500, Frontend dev server at :5173# Python
ruff check backend/
ruff format backend/
# TypeScript
cd frontend && npm run typecheckMixcloud upload, YouTube chapter markers, Shazam/AudD track identification, and scheduled publishing have all shipped. See ROADMAP.md for what is done and what is next.
Issues and pull requests are welcome — see CONTRIBUTING.md for setup, the test commands, and the couple of things worth being careful with.
fade-out ships no authentication of its own; the deployment model is the security boundary. Please read SECURITY.md before exposing it anywhere, and report vulnerabilities privately rather than in a public issue.
MIT -- Copyright 2026 Will Curran