Aulos is a native Rust download server: you hand it a URL, it works out what is behind it, queues
it, downloads it with yt-dlp (or StreamingCommunity, or a plugin you wrote), and tells every
connected client what is happening while it happens. It serves the Aulos v2 REST and native
WebSocket APIs for the iOS app and web UI, supervises the BgUtils POT sidecar, and runs
post-completion hooks (Jellyfin refresh, NFO writing, audio sync) plus community plugins.
Existing Aulos SQLite state and deployment paths continue to work. MeTube clients using the v1
API are no longer supported. Migrating from MeTube JSON state requires the explicit
import command before starting the server.
# docker-compose.yml β see docker/compose.example.yml for the full annotated version.
services:
aulos:
image: ghcr.io/tatoalo/aulos:latest
container_name: aulos
restart: unless-stopped
ports: ["8081:8081"]
# Must exceed the serial shutdown chain -- AULOS_SHUTDOWN_GRACE_SECS (20) + engine shutdown (5s)
# + WS close (2s) + engine drain (2s) + consumer drain (2s) + tracker (10s) = 41s -- which runs
# *before* the WAL checkpoint. Docker's 10 s default would SIGKILL mid-shutdown.
stop_grace_period: 60s
environment:
PUID: "1000"
PGID: "1000"
UMASK: "022"
DOWNLOAD_DIR: /downloads
AUDIO_DOWNLOAD_DIR: /downloads/audio
STATE_DIR: /downloads/.metube # stable location for the SQLite database
TEMP_DIR: /downloads/.aulos-tmp
MAX_CONCURRENT_DOWNLOADS: "3"
YTDL_OPTIONS_FILE: /config/ytdl-options.json
# Optional integrations, all off unless enabled:
# JELLYFIN_SYNC_ENABLED / JELLYFIN_URL / JELLYFIN_API_KEY / JELLYFIN_PATH_MAP
# TELEGRAM_BOT_ENABLED / TELEGRAM_BOT_TOKEN / TELEGRAM_ALLOWED_CHAT_IDS
# APNS_ENABLED / APNS_KEY_FILE / APNS_KEY_ID / APNS_TEAM_ID (iOS push, docs/DESIGN.md Β§25)
# A download reports on the channel that added it: the bot announces its own adds (and every
# subscription), the phone announces its own. Two knobs widen that, both off by default:
# AULOS_TELEGRAM_WATCH_ALL: "true" -- the bot also reports web/curl/iOS adds
# APNS_PUSH_ALL: "true" -- the phone is also pushed for non-iOS adds
volumes:
- /srv/media:/downloads
- /srv/aulos/config:/configdocker compose up -d
curl -s localhost:8081/healthz | python3 -m json.tool
curl -s -X POST localhost:8081/api/v2/downloads \
-H 'content-type: application/json' \
-d '{"url":"https://www.youtube.com/watch?v=aqz-KE-bpKQ","download_type":"video"}'POST api/v2/downloads answers 202 {"id": β¦} immediately β extraction happens on the queue β
and the WebSocket at /ws then carries added β delta β completed for that id.
Before the first real run, two subcommands are worth a minute:
docker compose run --rm aulos check-config # the effective config, secrets redacted; exit 1 if invalid
docker compose run --rm aulos doctor # ffmpeg, ffprobe, N_m3u8DL-RE, deno, python3, yt-dlp, bgutil-potOptional tool version checks allow 30 seconds. At startup, timeouts are reported as SLOW
and other transient failures as FAILED; both leave the component degraded and retry in the
background after 5, 15, 30 and 60 seconds, then every 5 minutes. Recovery updates health and
connected web clients automatically. MISSING means the executable was not found or could not
be executed due to permissions; those failures remain degraded until restart. doctor runs
one check per tool without background retries.
Every setting is an environment variable, and every legacy MeTube-POT variable keeps its name,
meaning and default. The complete table β roughly ninety variables, each marked as legacy
(L), legacy-with-a-behaviour-note (L*) or new (AULOS_*) β is
docs/DESIGN.md Β§17.3. yt-dlp options themselves come from
YTDL_OPTIONS / YTDL_OPTIONS_FILE (hot-reloaded on change), not from env vars.
Two keys in that file outrank the per-download format picker, so Aulos logs one WARN naming them each time the options are loaded or reloaded:
formatreplaces the selector for every job. The format and quality chosen in the web UI, the Telegram bot and the API are then ignored β a leftoverbv[vcodec^=avc1]+bamakes every download 1080p H.264 no matter what was asked for. The single exception is video / mp4 /best_remux, which pops the key back out.merge_output_formatworks the other way: it names the container for every job except video/mp4 downloads, where Aulos sets"mp4"over it so a video asked for as mp4 is always remuxed into one (never re-encoded).
JELLYFIN_SYNC_ENABLED=true with a JELLYFIN_URL and a JELLYFIN_API_KEY asks Jellyfin to scan
its libraries when a download finishes. Completions inside a 30 s window
(AULOS_JELLYFIN_DEBOUNCE_SECS) are coalesced into one request, capped at 5 minutes
(AULOS_JELLYFIN_MAX_WAIT_SECS), so a 50-item playlist costs one scan rather than fifty.
By default that request is POST /Library/Refresh β a global scan. It is the only endpoint on
any Jellyfin version that discovers a file the server has never seen; a new file has no item yet,
so nothing scoped to an item or a library can find it. docs/reference/jellyfin-refresh-experiment.md
has the measurements.
JELLYFIN_LIBRARY_IDdoes nothing. It is still accepted so no deployment fails to boot over it, but Jellyfin exposes no per-library scan, so an id cannot narrow the work. Setting it logs one WARN at boot and showslibrary_id_ignored: truein/healthz.JELLYFIN_PATH_MAPis how you get a targeted scan. Set it to comma-separatedaulos_prefix=jellyfin_prefixpairs β e.g.JELLYFIN_PATH_MAP=/downloads=/data/videoswhen the same media is mounted at/downloadsin this container and/data/videosin Jellyfin's. Aulos then sendsPOST /Library/Media/Updatednaming the finished files in Jellyfin's own spelling, and only the containing folder is rescanned. The longest matching source prefix wins. Anything the map does not cover, and any request Jellyfin does not accept, falls back to the global scan, so a wrong map costs latency and never silence. Note the trade-off: the targeted call is handled by Jellyfin's library monitor, which waits out its ownLibraryMonitorDelay(60 s by default) before it scans, whereas a global scan starts within a second or two. Leave the map unset unless full scans are actually costing you something.
GET /healthz reports components.jellyfin with mode (global_scan or media_updated),
last_request_at, last_status (the HTTP code Jellyfin returned) and the usual
runs_total / failures_total. mode and last_status together are how you tell a working
sync from one that is being politely accepted and ignored.
The Aulos iOS app can be told about a download it is not looking at: one alert when a top-level item or a playlist finishes or fails, and a Live Activity that runs the progress ring on the lock screen from the moment bytes start moving. Both go through Apple's push service, so the server needs a signing key of your own β there is no shared one.
-
Create an APNs auth key. In the Apple Developer portal β Certificates, Identifiers & Profiles β Keys β +, tick Apple Push Notifications service (APNs) and register it. You get a one-time
AuthKey_XXXXXXXXXX.p8download and a Key ID; your Team ID is in the top-right of the same page. One key covers both the sandbox and the production gateway, and every app in the team. -
Put the key where the container can read it, and nowhere else. It is the only secret of the four settings β the key id and the team id are identifiers. Keep it out of the image and out of git; mount it read-only:
environment: APNS_ENABLED: "true" APNS_KEY_FILE: /config/AuthKey_ABCD1234EF.p8 APNS_KEY_ID: ABCD1234EF APNS_TEAM_ID: TEAM123456 volumes: - /srv/aulos/apns/AuthKey_ABCD1234EF.p8:/config/AuthKey_ABCD1234EF.p8:ro
APNS_TOPICdefaults tocom.tatoalo.aulosand only matters for a device that registers without reporting its own bundle id, which the shipped app always does. -
Register the phone. Nothing else to do on the server: the app
PUTs its device token to<prefix>api/v2/devices/{token}on launch and the server starts pushing. The routes are behind the same auth as the rest of v2 (docs/PROTOCOL.mdΒ§4.8).
Which downloads actually push. Only the ones you added from the app β the share sheet
included. A download you started from Telegram is announced by the bot, one you started from the
web UI or curl is announced by nothing, and a subscription's auto-downloads go to Telegram, which
is the only push channel background work has. The server tells them apart by the
X-Aulos-Client: ios/<version> header the app sends on every add, recorded once on the item and
never rewritten β so a retried or resumed download still reaches the phone that asked for it.
Set APNS_PUSH_ALL=true if you would rather the phone hear about everything; its mirror image is
AULOS_TELEGRAM_WATCH_ALL=true, which puts every download on the bot's board. Both default to
false, which is what keeps one download from ringing twice. Neither affects a Live Activity the
app is already showing: it keeps its progress and is always closed at the end, whatever the knobs
say, and the download it tracks does ring the phone when it finishes.
GET /healthz reports components.apns:
status |
What it means |
|---|---|
disabled |
APNS_ENABLED is false. Nothing is wired, and this is not a failure. |
ok |
The key parsed and the last push (if any) was accepted. |
degraded |
Either the key file is missing/unreadable/not an ES256 .p8 β check last_error and the ERROR line at boot β or Apple rejected the provider token, which means APNS_KEY_ID/APNS_TEAM_ID do not match the key. |
The detail also carries devices, live_activities, sent_total, failed_total,
pruned_tokens_total, last_error and last_sent_at. A misconfigured key never stops the
server: it logs one ERROR, reports degraded and serves everything else normally. Tokens Apple
tells us are dead (410 Unregistered, 400 BadDeviceToken) are forgotten automatically, which is
what pruned_tokens_total counts β an app deleted from a phone cleans itself up.
Opening the server in a browser gives you the built-in UI: the queue, the add form, per-item
progress and the completed history. It is compiled into the binary β no static root to mount, no
Node, no CDN β and it is served at the same address as the API, under URL_PREFIX.
GET <prefix> is content-negotiated, so nothing that already talks to the server changes: a
request whose Accept list contains text/html (every browser, on a document navigation) gets
the page, and everything else β Accept: application/json, the bare */* that curl sends, no
Accept header at all β gets the same JSON identity document it always did.
AULOS_WEB_UI=falseturns the UI off entirely:GET <prefix>is then the identity document for everyAccept, and the assets and the manifest answer404.DEFAULT_THEME(auto|light|dark) is the theme the page starts in; a viewer's own choice is remembered in their browser, never in a cookie the server sets.- The page, its assets and its manifest are served without authentication even when
AULOS_API_TOKENorAULOS_TRUSTED_PROXY_AUTH_HEADERis configured β they contain nothing secret, and a browser cannot attach a bearer token to a document navigation. Every API route keeps its auth: the page asks for the token when the API answers401, and stores it locally. Put the whole thing behind your reverse proxy's auth if you want the UI itself gated. - It installs as a PWA (
<prefix>manifest.webmanifest), which is what makes it usable full-screen from an iPhone home screen.
Under 640 px it becomes a phone layout β a bottom add sheet, 44 px targets, safe-area padding β
from the same stylesheet, so there is no second URL and no app to install. The design source is
docs/design/web-ui/ (see its README); the full specification,
including the routes, the CSP and the front-end architecture, is
docs/DESIGN.md Β§24.
Screenshots are not checked in β they are generated, not authored. Every run of the browser
smoke below regenerates five of them into tools/web/screenshots/ (desktop light and dark, phone
light and dark, the phone add sheet), and CI's web job uploads that directory as the
web-screenshots artifact on every run, so the current look is always one download away from a
build page.
docs/PROTOCOL.mdis the wire contract: the v2 REST surface (Β§4), the WebSocket snapshot/delta/resume model (Β§5), the item and group shapes, the error envelope, and the compatibility rules a client can rely on across versions.- Only Aulos v2 is supported. Legacy routes,
/version, and/socket.io/return404. Read versions fromGET api/v2/capabilities; useAuthorization: Bearer <token>when token authentication is configured (a raw token in the Authorization header is no longer accepted). GET api/v2/catalog?url=β¦tells you which provider would take a URL and what it can do with it, before you queue anything.
A plugin is one directory with one plugin.toml inside AULOS_PLUGINS_DIR (default
/config/plugins, i.e. /srv/aulos/config/plugins in the compose above). It is picked up at boot,
on SIGHUP, on POST api/v2/plugins/reload, and about a second after you save the file β no
recompile, no restart, and the executables can be in any language.
A directory can declare a provider ([match] + [download] β support for a new site) or one
or more hooks ([[hook]] tables β something that happens after a download), or both.
manifest_version = 1
name = "Bandcamp"
version = "0.3.1"
[match]
hosts = ["bandcamp.com"]
path_regex = '^/(album|track)/'
[capabilities]
resolve = true
[download]
command = ["python3", "download.py",
"--url", "{url}", "--quality", "{quality}",
"--out", "{out_path}", "--tmp", "{tmp_dir}"]
expect_output = "result_frame"Start from the two worked examples in plugins/examples/ β
bandcamp/ (a provider, with a real resolve.py and download.py) and media-server-hooks/
(hook-only). The reference is docs/DESIGN.md Β§6.5 for the provider manifest
(the complete key table, the placeholder vocabulary, the progress-parsing spec) and
Β§13.4 for the [[hook]] manifest. A manifest that fails to parse degrades that one plugin with
a reason surfaced in GET api/v2/providers and healthz.plugin_warnings; it never fails the
startup and never fails silently.
MeTube called POST /Library/Refresh unconditionally and never read JELLYFIN_LIBRARY_ID, so
carrying your env file across changes nothing about which endpoint gets called: aulos calls the
same one. Two notes for anyone who read the earlier cutover note or the earlier docs:
- Unsetting
JELLYFIN_LIBRARY_IDis no longer necessary. Between the cutover and 2026-09-06 a set id put aulos onPOST /Items/{id}/Refresh, which cannot discover a new file and answers204while doing nothing β downloads landed on disk and never appeared. That path is gone; the id is inert and merely warns. If you unset it as a workaround, you can leave it unset or put it back, it makes no difference now. - Set
JELLYFIN_PATH_MAPif you want a targeted scan, per the Jellyfin section above. That is the only supported way to narrow the work, and it needs the paths as Jellyfin sees them β which, in the usual compose, are not the paths aulos writes to.
Stop the old server and back up its state before running the commands below. Normal startup opens and migrates Aulos SQLite only; it never imports legacy JSON automatically. If it finds legacy JSON beside an empty database, it stops with the explicit import command instead of silently presenting an empty queue.
The importer reads queue.json, pending.json, completed.json, subscriptions.json, and
telegram_bot_config.json. It preserves history and subscription data, records the report in
SQLite, and writes .aulos-imported. Existing Aulos databases need no manual import.
# Rehearse in a temporary database; leave the destination and legacy files unchanged.
docker compose run --rm aulos import \
--state-dir /downloads/.metube --db /downloads/.metube/aulos.db --dry-run
# For real.
docker compose run --rm aulos import \
--state-dir /downloads/.metube --db /downloads/.metube/aulos.db--force imports even when the destination already holds items; --skip-corrupt skips
unparseable records instead of aborting. Only schema_version: 2 JSON is supported β the legacy
pickle/shelve state is out of scope. The report lists every error and warning; a clean run reports
errors: [].
Aulos defaults TEMP_DIR to <DOWNLOAD_DIR>/.aulos-tmp, for both yt-dlp partials and
StreamingCommunity segments. Explicit TEMP_DIR settings still win; remove an old override
that points at the library root to use the hidden default. Each job owns its entire ULID
subdirectory, including files a media scanner may have created inside it, and removes it before
completion hooks run. Failed and canceled jobs are cleaned too; paused yt-dlp jobs keep their
partials so they can resume.
AULOS_CLEAN_ORPHAN_TEMP=true also removes scratch directories belonging to terminal or
unknown items on startup. It remains opt-in and scans only the configured TEMP_DIR; switching
to the new default does not sweep leftovers in an old temp root.
Aulos downloads into a per-job scratch directory (/downloads/.aulos-tmp/<ULID>/) and moves the finished
file to its destination at the end of the job β MeTube downloaded straight into DOWNLOAD_DIR.
That matters for a YTDL_OPTIONS entry many MeTube setups carry:
{ "key": "Exec", "exec_cmd": "python3 /config/jellyfin_nfo_generator.py %(filepath)q" }A postprocessor's default when is post_process, which yt-dlp runs before the move, so
%(filepath)q is a path inside the scratch directory. Aulos keeps every sidecar it writes β the
.info.json, the .description, thumbnails, subtitles β next to the media in that directory,
and moves the whole set together with anything a postprocessor of yours wrote there. So a hook
that reads a sidecar relative to %(filepath)q, or writes its output next to it, works: it sees
the file set it expects, and what it produces reaches DOWNLOAD_DIR.
Four things to know:
- The path an
Exechook is handed is the pre-move one. If your script needs the final path (to hand it to another service, say), add"when": "after_move"to the postprocessor entry β yt-dlp then runs it with%(filepath)q, and Aulos with%(infojson_filename)q, already pointing atDOWNLOAD_DIR. - Remove the
jellyfin_nfo_generator.pyentry rather than carrying it over.AULOS_NFO_ENABLED=truewrites the same.nfoin-process for every provider β not just yt-dlp β with no grandchild process per download, and deletes the.info.jsonit rendered from once the.nfois on disk, exactly as the script did. SetAULOS_NFO_PROVIDERS(a comma-separated list of provider ids, empty by default, meaning all) to narrow it. Running both is worse than running either: the script deletes the.info.jsonas soon as it has read it, so the built-in hook β which runs after the download β finds no metadata at all and, exactly as the script itself does in that situation, writes nothing. Whichever.nfoyou end up with is then the script's, wherever itswhenleft it. If you would rather keep the script, leaveAULOS_NFO_ENABLEDoff. - The built-in hook renders from the
.info.json, so yt-dlp has to be writing one: keep"writeinfojson": truein yourYTDL_OPTIONS(a MeTube setup that ran the NFO script already has it). Without a sidecar β and without one of the providers that store their metadata in the queue row, such as StreamingCommunity β there is nothing to render from, and the hook writes no file rather than a title-and-empty-plot stub that Jellyfin would adopt as that video's metadata.GET /healthzcounts those runs ascomponents.nfo.wrote_nothing_total. - A cancelled download takes its sidecars with it. The scratch directory is removed on cancel
(it is removed on delete either way), and since the
.info.jsonnow lives there until the move, a cancelled job no longer leaves one orphaned inDOWNLOAD_DIRwith no video beside it.
Requires Rust 1.95 (edition 2024). The four gates, which are what CI runs:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test -p aulos-workspace-tests # DESIGN Β§3 dependency rules + packaging gatesThe web UI has its own suite, in Node rather than Rust, and it is fully offline β it drives a mock server that speaks the same protocol, so it needs neither a built binary nor the network:
cd tools/web
npm ci
npx playwright install chromium
npx playwright test # the smoke: ~38 tests, chromium, a few seconds
npm run serve # a mock on http://127.0.0.1:8099/ to hand-drive the pageThis is what CI's web job runs, along with the 70 KB budget check on app.js + app.css. To
point the serving half of the same suite at a real binary instead β the manual check that the two
sides of the contract meet β start a server and set AULOS_WEB_BASE to its base URL including
URL_PREFIX; everything that needs the mock's scripted queue skips itself:
cargo build -p aulos-server
DOWNLOAD_DIR=/tmp/dl STATE_DIR=/tmp/state PORT=8091 HOST=127.0.0.1 ./target/debug/aulos-server &
cd tools/web && AULOS_WEB_BASE=http://127.0.0.1:8091/ npx playwright testtools/web/README.md has the mock's flags and what each mode covers.
Docker CI builds and smoke-tests PRs that change docker/, .dockerignore, .cargo/, Cargo
manifests/lockfiles, Rust toolchains/build scripts, the packaged Python shim or plugin examples,
or the Docker workflow itself. Other PRs keep the regular CI checks and skip the image build.
Add the docker-build label to request an image build on any PR; subsequent pushes also build
while that label is present. main and v* tags still build and publish, and manual dispatch
can build and smoke-test without publishing (push=false).
The end-to-end suite runs the real image against a real Creative-Commons video, so it is gated
behind an env var and never runs under cargo test:
docker build -f docker/Dockerfile -t aulos-server:dev .
AULOS_E2E=1 tests/e2e/run.shThe release architecture is linux/amd64. To run the suite against that image from an arm64 Mac
β OrbStack runs amd64 under Rosetta β set the platform:
AULOS_E2E=1 AULOS_E2E_PLATFORM=linux/amd64 AULOS_E2E_BUILD=1 tests/e2e/run.shThis suite is a developer-machine gate and is deliberately not run in CI: it downloads a real
video, and YouTube answers GitHub's datacenter ranges with "Sign in to confirm you're not a bot".
CI builds the image and smokes it offline instead; the one network check there is the three-daily
mode=extract in update-yt-dlp.yml.
The suite asserts the things only a container can be asked: the healthz roll-up with the POT sidecar
supervised, 202-before-extraction, the WebSocket sequence, PUID/PGID/UMASK on the produced
file, Range requests on the file routes, retired-route rejection, a restart mid-download resuming rather than
stranding the item, a clean docker logs ERROR sweep, and a second profile that imports a real
legacy STATE_DIR. Knobs: AULOS_IMAGE, AULOS_E2E_BUILD, AULOS_E2E_URL, AULOS_E2E_PORT, AULOS_E2E_PLATFORM,
AULOS_E2E_KEEP.
| Path | What lives there |
|---|---|
crates/aulos-core |
Domain types, status vocabulary, ids, events, config, health registry |
crates/aulos-store |
SQLite (WAL) store actor + legacy JSON importer |
crates/aulos-provider |
Provider trait, registry, progress sink, command plugins |
crates/aulos-provider-ytdlp |
yt-dlp provider and its Python shim |
crates/aulos-provider-sc |
StreamingCommunity provider |
crates/aulos-queue |
Scheduler, slots, resolution pool, cancellation, realtime aggregator |
crates/aulos-api |
axum: v2 REST + WebSocket, health, file serving, the embedded web UI |
crates/aulos-api/web |
The shipped page itself β HTML, CSS, one ES module, two icons, the manifest |
crates/aulos-telegram |
teloxide bot |
crates/aulos-subscriptions |
Subscription manager and scheduler |
crates/aulos-hooks |
Jellyfin, NFO, audio-sync and community hooks |
crates/aulos-apns |
Apple push: alerts and Live Activities for the iOS app |
crates/aulos-server |
The binary: wiring, POT sidecar supervisor, config watcher, signals |
docker/ |
Dockerfile, entrypoint.sh, compose.example.yml |
plugins/examples/ |
Worked example plugins |
tools/web/ |
Dev-only: the protocol mock and the Playwright smoke for the page (nothing here ships) |
tests/e2e/ |
The container end-to-end suite |
aulos-server [serve | check-config | import | doctor | healthcheck]
The subcommand is optional and defaults to serve, because the entrypoint ends in
exec β¦ aulos-server "$@". healthcheck is what the image's HEALTHCHECK runs; it loads the same
config as serve, so URL_PREFIX normalisation applies, and it exits non-zero against a server
that is not running β which is the point.
Add the release label before merging a PR into main to publish a release. The merge is
tagged vYYYY.MM.DD using its UTC date, with .1, .2, etc. for additional releases that day.
The tag triggers the existing GitHub release (binary, checksums and generated notes) and GHCR
image workflows. Rerunning the tag job reuses the existing tag for that merge.
This requires the repository's AULOS_REPO_PAT secret with permission to create tags: tags
created with GITHUB_TOKEN do not trigger the downstream workflows. Ordinary merges keep
publishing the usual dated and latest container images without a GitHub release.
docs/DESIGN.mdβ the architecture, and the env var table (Β§17.3).docs/PROTOCOL.mdβ the v2 wire contract for clients.docs/BRIEF.mdβ binding decisions and the v1.0 scope trims.docs/PLAN.md/docs/STATUS.mdβ work packages and state.docs/design/web-ui/β the web UI's design artboards, and how their tokens map onto the shipped CSS.docs/INTEGRATION-NOTES.mdβ where the implementation deviates from DESIGN, and why.