A personal "*arr-stack for music" — acquires music and podcasts from streaming sources you already subscribe to, organizes and tags it, and syncs it onto a real click-wheel iPod. No streaming/serving component: this is a pipeline that ends at a physical device, not a Navidrome/Jellyfin alternative.
Built almost entirely through AI-assisted ("vibe-coded") pair programming with Claude Code — hence the name.
- Acquires music from streaming sources (Apple Music, Spotify, YouTube Music) based on config-defined playlists.
- Organizes and tags the acquired audio, deduplicates across sources,
and writes
.m3u8playlists. - Syncs podcasts, using Pocket Casts as the source of truth for subscriptions and played/unplayed state.
- Syncs everything onto a real iPod — music, playlists, and podcasts — using iOpenPod as a headless library, no GUI required.
Everything is driven by plain YAML config files and supports multiple
user/iPod profiles. See music-stack-planning.md
for the full architecture and milestone plan, and notes.md for
a running log of real bugs found (and fixed) in both this project and the
upstream tools it depends on.
It can run entirely hands-off once set up: a scheduler keeps library/
fresh on each playlist's/show's own cron schedule, and plugging in the
iPod triggers a real device sync automatically (udev → a systemd
service) — see "Running it" below.
| Milestone | What | Status |
|---|---|---|
| M1 | Repo scaffold, config loader/validator | Done |
| M2 | Apple Music fetcher (gamdl wrapper) |
Done |
| M3 | Spotify + YouTube Music fetchers | YouTube Music: done, downloads work end to end (needed a companion PO-token service for yt-dlp — see services/fetcher-ytmusic/README.md). Spotify: built and auth-working, but downloads are blocked on a Spotify Premium requirement for API access outside this project's control (see services/fetcher-spotify/README.md) |
| M4 | Library manager: cross-source dedup, playlist writer | Done |
| M5 | Podcast manager: Pocket Casts client, episode downloader | Done |
| M6 | iOpenPod headless spike: full real sync (music + playlists + podcasts) | Done — see docs/m6-ipod-headless-recommendation.md |
| M7 | Sync orchestrator core (services/sync-orchestrator) |
Done — real device discovery + profile-driven sync plan, live-verified |
| M8 | Play-status round trip | Device-read-back path (playstate.py) fixed and live-verified 2026-08-02 — matched on the wrong path shape (full path vs. iopenpod's bare-filename mapping hint) and had never actually resolved a device play back to an episode; now matches by filename (see notes.md). Pocket-Casts-remote path (episodes marked played in the Pocket Casts app) was already working independently. Resume-position sync doesn't work via the simple write endpoint used (see notes.md); Pocket Casts' real app likely needs its protobuf-based sync protocol for that, not yet built |
| M9 | Automation: scheduled fetch, udev-triggered device sync, multi-profile matching | Done, live-verified — services/fetch-scheduler/README.md (fetch scheduling, plus automatic library dedup/cleanup and device backup retention) and services/sync-orchestrator/README.md (auto-sync + udev/systemd) |
| M10 | Hardening: secrets review, auth-expiry/API-failure alerting, docs | Secrets handling reviewed (consistent gitignore convention across every credential type); alerting not built yet (fetch-scheduler's per-tick errors are logged, nothing pages anyone); docs — this pass |
| M11–M14 | Web GUI (backend, profiles/playlists, podcasts/sources, sync visibility) | Not started |
| M15 | Audiobooks via Libby/OverDrive | Acquisition is manual (Libby's automated auth paths are confirmed dead upstream, see notes.md) — but the merge/tag/sync pipeline from manually-downloaded MP3 parts to a real device is done, see services/audiobook-manager/README.md |
Requires uv — all Python tooling runs
through it so nothing touches your system Python.
uv sync
uv run pytest # runs the root workspace's tests, should all passservices/fetcher-spotify and services/sync-orchestrator are separate,
standalone uv projects (see "Running it" below) — their tests
run with their own uv run pytest, inside their own directories, not
picked up by the command above.
config/
├── global.yaml # shared source enable flags, credential paths
├── profiles/
│ ├── alice.yaml, bob.yaml # example profiles — copy one to get started
│ └── <you>.yaml # your real profile — gitignored, never commit this
└── secrets/ # real credentials — gitignored entirely
Copy an example profile (config/profiles/alice.yaml or bob.yaml) to
config/profiles/<your-name>.yaml and fill in your real device match info,
playlists, and Pocket Casts credentials path. Real per-user profiles and
everything under config/secrets/ are gitignored — only the example
profiles are meant to be committed.
Three ways to run this, roughly in order of "how hands-off do you want
it to be." All examples assume you're at the repo root with a real
profile at config/profiles/<you>.yaml (see Configuration above).
1. One-shot manual sync — fetches every playlist across every source, plus podcasts, for a profile in one call:
uv run music-stack sync --profile config/profiles/<you>.yamlFull flag reference: services/music-stack-cli/README.md.
2. Scheduled, unattended fetching — runs continuously (or via
cron/systemd timer with --once), fetching whatever's due per each
playlist's/show's own fetch_schedule (a cron expression in profile
config), independent of whether any device is connected. Also runs
library dedup/cleanup and device backup retention automatically, if
enabled:
uv run --project services/fetch-scheduler fetch-scheduler --config-root configFull details, config schema, and the maintenance-task flags:
services/fetch-scheduler/README.md.
3. Fully automatic device sync on connect — plugging the iPod in
triggers a real sync with no manual command at all, via a udev rule →
systemd service. Requires a one-time manual install (sudo, touches
system udev/systemd config — deliberately not automated):
services/sync-orchestrator/README.md.
Audiobooks — manual, one-off per book (no automated acquisition
exists, see M15 above): merge a folder of MP3 parts into one tagged,
chaptered .m4b, then sync it like anything else via --pc-folder:
uv run audiobook-manager import-audiobook \
--parts-dir "path/to/Author - Title" \
--library-root library/audiobooks --state-root stateFull details: services/audiobook-manager/README.md.
Each of the above composes smaller, independently-usable services — see their own READMEs for manual/advanced usage (single-playlist fetches, listing an account's playlists, pushing podcast play-state back to Pocket Casts, running dedup on demand, plan-only device syncs, etc.):
| Service | What |
|---|---|
common |
Shared config schema, state db, scheduling/backup-retention logic every other service builds on |
fetcher-apple |
Apple Music playlist downloader (gamdl) |
fetcher-ytmusic |
YouTube Music playlist downloader (ytmusicapi + yt-dlp) — needs a companion PO-token service |
fetcher-spotify |
Spotify playlist downloader (zotify) — shelved, blocked on a Premium requirement |
library-manager |
Cross-source dedup + quarantine cleanup |
podcast-manager |
Pocket Casts client, episode downloader, play-state push-back |
music-stack-cli |
The unified music-stack sync command |
fetch-scheduler |
Cron-scheduled fetching + automatic library/backup maintenance |
sync-orchestrator |
Device sync engine (bare metal) + auto-sync/udev automation + full-sync (interactive fetch+device in one command) |
audiobook-manager |
Merges manually-acquired MP3 parts into a tagged, chaptered .m4b (ffmpeg + beets-audible) |
Device sync (sync-orchestrator) needs the iPod connected/mounted and
must run on bare metal, not through Docker (see Architecture below) —
plan-only first, review the plan (especially to_remove), then
--execute:
cd services/sync-orchestrator
uv run sync-orchestrator sync \
--profile ../../config/profiles/<you>.yaml \
--library-root ../../library \
--state-root ../../state
# review the plan, then:
uv run sync-orchestrator sync \
--profile ../../config/profiles/<you>.yaml \
--library-root ../../library \
--state-root ../../state \
--executeFor fetch + device sync in one interactive command instead (bare
profile name, no other paths needed) see full-sync in
services/sync-orchestrator/README.md:
uv run sync-orchestrator full-sync --profile <you> --config-root ../../configfetch-scheduler is the one long-running Compose service — restart: unless-stopped, no profile gate, always included:
docker compose up -d fetch-schedulerThat's the containerized equivalent of "Running it" option 2 above
(scheduled fetching + automatic maintenance) — it reads the same
config/global.yaml/config/profiles/*.yaml and bind-mounts the same
library//state/ directories a bare-metal run would use.
Individual fetcher containers are one-shot and gated behind Compose
profiles instead, one per music source (apple, spotify, ytmusic),
matching global.yaml's sources.*.enabled flags — useful for a manual
run without invoking uv directly. library-manager and
podcast-manager have no profile and always run when invoked.
docker compose --profile apple up
docker compose --profile apple --profile spotify up # multiple sourcesytmusic needs one more thing Compose doesn't manage: the
bgutil-ytdlp-pot-provider companion service must be running and
reachable before fetcher-ytmusic can actually download anything (not
just an optional nicety — every download fails without it). See
services/fetcher-ytmusic/README.md for setup.
audiobook-manager is also gated behind its own profile, but for a
different reason than the fetchers — it's a manual, one-book-at-a-time
CLI tool (not tied to any global.yaml flag), and it pulls in beets'
real dependency weight (numpy/scipy/numba/llvmlite), so it isn't
built by default:
docker compose --profile audiobooks run --rm audiobook-manager \
import-audiobook --parts-dir /data/library/... \
--library-root /data/library/audiobooks --state-root /data/stateOr set COMPOSE_PROFILES in .env once instead of passing --profile
every time (see .env.example). Compose doesn't read global.yaml
itself, so keep the two in sync by hand — enabling a source there
without also enabling its profile here just means that fetcher's
container never runs.
- Docker vs. bare metal split: acquisition/processing services
(fetchers, library-manager, podcast-manager, fetch-scheduler) only read
config and write to shared volumes, so they containerize cleanly. The
iPod sync step (
sync-orchestrator) needs real USB device access and runs on bare metal — udev + a systemd service trigger it automatically on connect (seeservices/sync-orchestrator/README.md), rather than trying to replicate udev hotplug handling inside a container (would need--privileged+ host/devsharing, mostly erasing the point of containerizing it — seenotes.md). - Config is the only source of truth — no database of settings, no
hidden state beyond what's in
config/and the per-profilestate/*.sqlite(source-ID-to-local-file maps and sync history, not configuration). - iOpenPod as a library, not a GUI dependency — the sync step drives
iOpenPod's real sync engine (
SyncEngine,BackupManager,itunesdb_parser/itunesdb_writer) directly, headlessly. See the M6 recommendation doc for the full investigation.
gamdl (Apple Music), the Spotify fetcher, and the YouTube Music fetcher
(yt-dlp + a PO-token companion service to get past YouTube's bot-check)
all operate in a legal/ToS gray area — they're personal-use tools for
downloading music you already have access to via your own paid
subscription, not intended for redistribution or exposure as a public
service. Use accordingly.
- No streaming/serving of music — this is not a Navidrome/Jellyfin alternative.
- No iPod Touch / iOS device support — click-wheel iPods only.
- No in-browser playback or user-account system in the (future) web GUI.
This project exists because of the real, hard reverse-engineering and protocol work done by others. In particular:
- gamdl by
glomatico — the Apple Music
downloader
fetcher-applewraps. - iOpenPod by
John Gibbons — the click-wheel iPod
sync engine
sync-orchestratordrives headlessly. Seedocs/m6-ipod-headless-recommendation.mdfor how deep this project actually goes. - zotify and its actively
maintained fork, Googolplexed0/zotify —
the Spotify fetcher this project migrated to (currently shelved on a
Spotify Premium API requirement, not a code issue — see
notes.md).
None of these projects are affiliated with or endorse this one.
MIT — see LICENSE.