Ctrl K
ComunidadGuías…

README.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# fade-out

[![Docker](https://img.shields.io/badge/docker-ready-blue?logo=docker)](https://ghcr.io/rancur/fade-out)
[![CI](https://github.com/rancur/fade-out/actions/workflows/ci.yml/badge.svg)](https://github.com/rancur/fade-out/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**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](SECURITY.md).

---

## Features

| 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 |

## Architecture

```
                           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         │   │
  │  └─────────────────────────────────────────────────────────┘   │
  └──────────────────────────────────────────────────────────────────┘
```

## Requirements

- **Docker** and Docker Compose (the recommended path — everything else is baked in), or
- **Python 3.12**, Node 20, and `ffmpeg` for a local install.

> Python 3.13+ is not supported. It removed the stdlib `audioop` module that
> `pydub` — pulled in by `shazamio` — 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](#configuration).

## Quick Start

### Docker (recommended)

```bash
# 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
```

### NAS / server deployment

```bash
# Interactive setup: creates the media directories and fills in .env
bash scripts/setup.sh

# Or manually:
docker compose up -d --build
```

Set 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.

## Configuration

### Environment Variables

| 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](https://dashboard.audd.io) 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](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.

### Host paths

`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.

### Deployment freshness

`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.

### Watch Directories

| 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) |

### Source File Renaming

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:

1. the setting is on, and
2. `/watch/audio` and `/watch/video` are mounted `:rw` in `docker-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/audio` and `/watch/video` are ever touched.
  `/watch/shorts`, `/watch/djctl-cue`, and anything in
  `CATALOG_EXTRA_AUDIO_PATHS` are 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:

```bash
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.

### Source File Tagging

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:

1. 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),
2. write the tags and cover art into the copy,
3. 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,
4. register the copy's new hash with the file watcher,
5. 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:

```bash
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:

```bash
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:

```bash
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.

### Output Directories

| Path | Purpose |
|---|---|
| `/output/thumbnails` | Generated YouTube thumbnails |
| `/output/cover-art` | Generated SoundCloud cover art |

## Web Dashboard

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

<!-- Screenshots: TODO -->

## Brand Customization

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

## API Reference

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 |

### Health, and what "healthy" means here

`/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:

```bash
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 built
```

### When one platform fails

Publish 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:

```bash
curl -X POST localhost:8500/api/mixes/<id>/retry-platform/soundcloud
```

## Development

### Local Setup

```bash
# 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
```

### Docker Development

```bash
# 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
```

### Linting

```bash
# Python
ruff check backend/
ruff format backend/

# TypeScript
cd frontend && npm run typecheck
```

## Roadmap

Mixcloud upload, YouTube chapter markers, Shazam/AudD track identification, and
scheduled publishing have all shipped. See [ROADMAP.md](ROADMAP.md) for what is
done and what is next.

## Contributing

Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md)
for setup, the test commands, and the couple of things worth being careful with.

## Security

fade-out ships no authentication of its own; the deployment model is the
security boundary. Please read [SECURITY.md](SECURITY.md) before exposing it
anywhere, and report vulnerabilities privately rather than in a public issue.

## License

[MIT](LICENSE) -- Copyright 2026 Will Curran