README.md
Rama main · réplica de lectura del repositorio original.
# Podarium
A podcast server you run yourself. It subscribes to your shows, downloads the audio,
and serves it to your own players — a web app that installs to a phone's home screen,
and an HTTP API.
Most podcast apps are a directory with a player attached: your device fetches each
episode straight from the publisher, and Apple's catalogue decides what exists.
Podarium inverts that. Three rules shape everything else:
1. **The server does every fetch.** Feeds, audio, and artwork are all pulled by the
server and served from it. Publishers see one address, and no client ever makes a
request to a host you did not choose.
2. **Apple is never in the loop.** Discovery runs through
[Podcast Index](https://podcastindex.org), never iTunes.
3. **It is a media server, not a sync service.** It holds the files, serves them with
range requests, and decides what to keep.
## What it does
- Subscribe by search, by category, by what is trending, or by pasting a feed URL
- Downloads episodes ahead of time, with retention rules per show or globally
- Queue, inbox with filters, starred episodes, and a resume list
- Playback speed per show, chapter skip, and skipping of publisher-marked ad breaks
- Configurable intro and outro trimming per show, and a sleep timer
- Removes silence and levels loudness on the server, so no client repeats the work
- Fetches transcripts where publishers offer them, and searches your library by what
was said rather than only by title
- Bookmarks a timestamp inside an episode, which is the thing hardest to find again
- Listening statistics measured from audio that actually played, not from what was
marked as done
- Web push for new episodes, with a count on the home-screen icon
- Offline playback for episodes you save to the device
- OPML in and out, Prometheus metrics, and a nightly database dump
The server and the web app are complete and in daily use. A native iOS client is in
progress against the same API. The reasoning behind the design is in
[podarium-spec.md](podarium-spec.md).
## Local development
Requires `uv` and Docker.
```bash
docker compose -f docker-compose.dev.yml up -d # Postgres 18 on :5455
uv venv --python 3.12 && uv pip install -e ".[dev]"
cp .env.example .env # then edit the password
uv run alembic upgrade head
uv run uvicorn --app-dir src podarium.main:app --port 8044 --reload
```
For the UI, either build it once and let the API serve it at `/`:
```bash
cd web && npm install && npm run build
```
or run the Vite dev server for hot reload, which proxies `/api` to port 8044:
```bash
cd web && npm run dev
```
The proxy matters for more than convenience: the session cookie is httpOnly, so
`<audio src="/api/stream/...">` only carries it on a same-origin request.
The user account is created on first boot from `PODARIUM_USERNAME` / `PODARIUM_PASSWORD`,
and only while the `users` table is empty — restarting will not reset the password.
Interactive API docs: <http://localhost:8044/docs>
## Tests
```bash
uv run pytest && (cd web && npm test)
```
The Python tests run against the dev Postgres container, creating a `podarium_test`
database beside it. Coverage is deliberately narrow on both sides: the invariants that are
expensive to get wrong (refresh idempotency, `first_seen_at` stability, retention keeping
rows, byte-range correctness, Podcast Index signing, show-note sanitising) rather than
every endpoint and component.
## End-to-end check
`scripts/verify.sh` walks the whole phase-1 surface with curl — subscribe, refresh, queue,
download, range request, purge, sync — against a running server.
## Deployment
`deploy/portainer-stack.yml` is pasted into the Portainer web editor on the Docker host; no
compose file lives on the host. Read the comments at the top of it before deploying — the
port, the NFS mount options, and the `PGDATA` path all have specific gotchas carried over
from PinePods.
The image builds the web UI in its own stage and serves it from `/app/web`, so there is
one container and one port for both the API and the UI.
## Notifications
Off unless the server has a VAPID keypair. Generate one, paste the three lines it prints
into the environment, and restart:
```bash
docker exec podarium-api python -m podarium.vapid
```
The private key prints base64-encoded rather than as a PEM. Both are read, along with a PEM
whose newlines are written as `\n` — but this value has to cross a Portainer environment
panel, and a multi-line PEM full of newlines and slashes is exactly the shape that arrives
truncated. A mangled key says so on sight rather than surfacing later as a push that never
lands.
Browsers pin the public key at subscribe time, so rotating the private key silently breaks
every subscription already issued — every device then has to enable notifications again.
Generate a separate pair for local development rather than reusing the server's.
On iOS, web push only works once Podarium has been added to the Home Screen, and only over
HTTPS. On the LAN over plain HTTP it will not be offered at all.
## Notification volume
Cadence decides whether this feature survives. A show publishing hourly produces a
notification every hour around the clock, which buries the weekly show you actually wanted
telling about — and the only remaining control is switching notifications off entirely.
Each show therefore has its own toggle, on by default, under Settings on the show's page.
Turning it off silences the alert; episodes still arrive, download, and appear in the inbox
exactly as before.
## Offline
Podarium streams from the server by design, so with no network there is nothing to play.
Episodes explicitly kept on a device are the exception: the service worker stores them whole
and serves byte ranges out of that copy, so they play with the server unreachable.
This needs a secure context, which means the HTTPS hostname rather than the LAN address.
Browser storage is also granted rather than guaranteed — iOS in particular reclaims it
without warning — so it is a convenience for a journey, not a copy to rely on. Downloading
to the device properly is what the native client is for.
## Backups
`vzdump` already sweeps `pgdata`, but a filesystem snapshot of a running Postgres is
crash-consistent rather than consistent: what you would have if the power went out mid-write.
Postgres normally replays its WAL and comes up clean from that, and "normally" is doing real
work in that sentence.
The stack therefore runs a nightly `pg_dump` into `/home/YOUR_USER/docker/podarium/backups`,
inside the tree vzdump already picks up, so the backup holds both the raw directory and a
logically consistent dump. The service carries its own script inline — there is nothing to
place on the host, and `docker restart podarium-backup` forces a dump immediately.
Restore into a scratch database and compare before touching the live one; loading a dump
into a populated database conflicts on every table it recreates.
```bash
gunzip -c podarium-YYYYMMDD-HHMMSS.sql.gz | docker exec -i podarium-db psql -U podarium -d restore_test
```
What is in that database: subscriptions, every playback position, stars, the queue, and API
tokens. The audio can be downloaded again; none of that can.
## Security posture
Auth: argon2 passwords, optional TOTP, throttled login, httpOnly session cookie
(SameSite=Lax), bearer tokens stored as SHA-256. Publisher HTML is sanitised with an
allowlist, and a strict Content-Security-Policy backs the sanitiser up: even markup it
missed cannot run script or fetch from a publisher host in a browser that honours CSP.
Everything a publisher controls is bounded. Feed documents, artwork and chapter files are
read with hard size ceilings enforced while the bytes arrive; a single download may not
exceed `DOWNLOAD_MAX_BYTES` (default 2 GB), so a hostile feed cannot fill the disk. Outbound
fetches refuse literal private and loopback addresses -- once a feed is subscribed, its
enclosure and artwork URLs are the publisher's to choose, and without the guard a malicious
feed could point this server at the router, Portainer, or the NAS. The guard checks literal
IPs and localhost names only; it does not resolve hostnames, so DNS rebinding is out of its
scope. `ALLOW_PRIVATE_FETCH=true` disables it for a deployment that hosts feeds on its own
network (the dev `.env` sets it, so local verification can preview its own server).
`/metrics` is open by default for a same-LAN Prometheus. Through a public hostname, set
`METRICS_TOKEN` and give the scrape job `authorization: Bearer <token>` -- or block the
path at Cloudflare.
## Locked out
Two-factor codes are checked against a secret stored encrypted with a key derived from
`SECRET_KEY`. Change that variable and the secret can no longer be read — sign-in then says
so explicitly rather than pretending the code was wrong.
Either way, the way back in is one statement on the host. It clears the second factor and
leaves the password alone:
```bash
docker exec podarium-db psql -U podarium -d podarium \
-c "update users set totp_secret = null, totp_last_step = null"
```
The same applies if repeated failures have locked sign-in and you would rather not wait out
the window:
```bash
docker exec podarium-db psql -U podarium -d podarium -c "delete from login_attempts"
```
Both require access to the host, which is the point: they are recovery for the person who
owns the machine, not a bypass reachable from the login form.
## Invariants
Changes to this codebase should preserve these. Each has a test.
- No client response carries a publisher URL. Artwork is `/api/images/...`, audio is
`/api/stream/...`, and that holds for search results too — a show you have not subscribed
to still has its cover proxied, keyed by a server-minted hash so the endpoint cannot be
pointed anywhere else. Show notes are sanitised in the browser as well: an `<img>` left in
a publisher's description would fetch straight from their CDN and leak the viewer's IP.
- A show is one subscription however you reach it. Feeds are matched by URL, by resolved
URL after redirects, and by Podcast Index id — matching the raw string would let the same
podcast be subscribed twice, and two feed rows means two copies of every episode.
- Retention deletes files, never episode rows.
- `first_seen_at`, not `published_at`, decides whether an episode is new.
- Feed refresh is idempotent.
- Every user-scoped table carries `user_id`, even with exactly one user.
- A one-time code is accepted once. Its 30-second window makes a code seen over a shoulder
or in a log otherwise replayable.
- Nothing large or churny is written to the VM disk.