A self-hosted music library: drop in a YouTube link, get back a tagged track with cover art in a personal library you can stream to your phone through Navidrome.
local-Spotify is a small home-network service. It pulls audio from YouTube, normalises it into a consistent shape — clean filenames, ID3/MP4 tags, HD cover art — and files it into a library served by Navidrome over the Subsonic API. On a phone it behaves like your own Spotify: Amperfy, play:Sub, DSub and any other Subsonic client connect to it as they would to a commercial streaming service.
It is not built to be a public SaaS or to work around YouTube's restrictions — it is a tool for one person or household on a trusted network.
- Features
- Architecture
- Quick start
- Configuration
- API
- Production: systemd
- Tests
- Troubleshooting
- Security
- Project layout
- Limitations
- Roadmap
- Licence
- Add music by link — POST a list of YouTube URLs; the service handles the rest.
- Background queue with multiple workers — downloads run in parallel (
MAX_WORKERS) and never block the API. - Automatic metadata cleanup —
Song (Official Video) [4K]becomes a cleanArtist / Song, while genuine variants like(Live)or(Remix)are preserved in the tags. - Sleep timer and fades — the player stops after 15, 30 or 60 minutes or at the end of the track, lowering the volume over the last ten seconds; track changes can fade out and in over 3 or 6 seconds (a fade, not an overlapping crossfade; not on an iPhone, where a page cannot set volume).
- Even loudness (ReplayGain) — every track is measured with ffmpeg (EBU R 128) and tagged; Navidrome, Subsonic clients that honour ReplayGain and the built-in desktop player play everything at the same level. On an iPhone the browser does not let a page change volume, so there it is up to the Subsonic client.
- Real album data — album, track number, release date and every credited artist come from Deezer, and from MusicBrainz (with the Cover Art Archive) when Deezer does not know the track; only when both miss is a track filed as its own single.
- HD cover art — iTunes Search API with a fallback to the YouTube thumbnail; a separate script (
fix_covers.py) backfills missing artwork afterwards via iTunes → Deezer. - Content-based deduplication — each track is hashed (SHA-256) and compared against what is already in the library, rather than matched on filename.
- Retry with exponential backoff — transient network and download failures are retried automatically; permanent ones are not.
- Graceful shutdown and recovery —
SIGTERMstops workers cleanly, including childyt-dlp/ffmpegprocesses, and unfinished tasks survive a service restart. - File integrity checks — every M4A is validated before and after tags are written, so corrupt files never reach the library.
- Resource limits — caps on queue size, links per request, and free disk space required before a download starts.
- Bearer-token auth on the API; without a token the health check answers only whether the service is up.
- Web interface — a minimal single-page UI for adding links and watching the queue (
web/). - Playlists as files — every playlist is an
.m3uin the library root, edited by rewriting it; Navidrome re-reads it within about six seconds. - An edit made on the phone is not lost — Navidrome accepts a reorder sent by a client but never writes the
.m3u, and its own watcher discards that edit the next time the file changes. Every thirty seconds the service looks for a playlist whose database was edited past its file and writes the order into the file. Measured: the edit reaches the.m3uin ten seconds. - Reordering that survives duplicates — a line is identified by its index, not its path.
Monday.m3uholds nineteen tracks that appear twice. - Concurrent edits are refused, not merged — every read hands out a hash of the file and every write must present it; a stale edit gets
409instead of quietly overwriting one made from the phone. - Playlist covers — stored locally and replicated to Navidrome, so a rename is a re-upload rather than a loss. The type is decided by the bytes, not by the file name.
- A player — the same page in two widths, with playback served through short-lived signed URLs, so an
<audio>element can seek without the API token ever entering a URL. - Play journal — Navidrome keeps a play count and a last-played date, not a log. This one records track, time, how far it got and whether it was skipped.
- Shuffling that has a shape — the library has no genre or BPM tags at all, so
scripts/analyze_audio.pymeasures tempo, energy, brightness and key from the audio itself, and queues are walked rather than sampled: neighbours are close in tempo, the same artist does not follow itself, and a track heard recently is unlikely to come back. Seescripts/README.md. - Library audit tooling — offline scripts for finding duplicates, checking metadata, and bulk-migrating a playlist from CSV.
┌──────────────┐
│ iPhone / │
│ Android │
│ (Amperfy) │
└──────┬───────┘
│ Subsonic API
▼
┌──────────────┐ reads files
│ Navidrome │───────────────────────┐
└──────────────┘ │
▼
┌──────────┐ POST /api/add ┌──────────────┐ ┌──────────────────┐
│ client │ ─────────────────▶│ adder API │ │ Normalized Library│
│ (curl/UI)│ │ (FastAPI) │ │ Artist/Singles/ │
└──────────┘ └──────┬───────┘ └────────▲──────────┘
│ task queue │
▼ │
┌──────────────────┐ │
│ N worker threads │───────────────────┘
│ yt-dlp → ffmpeg │ validate, then write
│ → mutagen (tags) │ only once checks pass
└──────────────────┘
The governing principle: a file never lands in the library directly. Downloading and processing happen in a temporary directory (adder/tmp/), and only after metadata, integrity and duplicate checks all pass is the file moved atomically into Normalized Library.
Tested on Debian/Ubuntu. Every step ends with a command that proves it worked — do not move on until it does.
| Needed | Why | Check |
|---|---|---|
| Linux with systemd | the production unit (step 7) | systemctl --user status |
Python 3.12+ with venv |
the service | python3 --version |
FFmpeg (with ffprobe) |
yt-dlp extracts the audio with it; without it every download fails | ffmpeg -version && ffprobe -version |
| Deno | yt-dlp needs a JavaScript runtime to solve YouTube's player challenges; Deno is the one it uses by default | deno --version |
| git | cloning | git --version |
sudo apt update
sudo apt install -y python3 python3-venv ffmpeg git curl unzip libnotify-bin
# Deno into /usr/local/bin, so the systemd service finds it on its default PATH.
# (The official installer's default, ~/.deno/bin, is on your shell's PATH but not the service's.)
curl -fsSLo /tmp/deno.zip \
"https://github.com/denoland/deno/releases/latest/download/deno-$(uname -m)-unknown-linux-gnu.zip"
sudo unzip -o /tmp/deno.zip -d /usr/local/bin
deno --versionOn Ubuntu 22.04 and older python3 is 3.10/3.11: install 3.12 (for example from the deadsnakes PPA as python3.12 + python3.12-venv) and use python3.12 instead of python3 below.
git clone https://github.com/Whyslab/local-Spotify.git
cd local-SpotifyAll commands below are run from this directory.
python3 -m venv .venv
.venv/bin/pip install -r adder/requirements.txt
.venv/bin/python -m yt_dlp --version # prints a version, e.g. 2026.08.19Optional: the smart shuffle measures tempo, key and energy of each track with librosa, which is kept out of the main requirements because it pulls in a compiler toolchain. Without it everything else works:
.venv/bin/pip install -r scripts/requirements-analysis.txtThe venv must be at .venv in the repository root: deploy/install.sh and the systemd unit look for it there.
cp .env.example adder/.env
TOKEN=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
sed -i "s|^API_TOKEN=.*|API_TOKEN=$TOKEN|" adder/.env
grep '^API_TOKEN=' adder/.env # must NOT be CHANGE_ME_...That is the only required setting. The service refuses to start with an empty token or with the placeholder from .env.example.
Optional, in adder/.env:
LIBRARY_PATH— where the music goes. Default:~/Music/Normalized Library. To change it, uncomment the line and give an absolute path without quotes. Navidrome must read the same folder (step 6).COOKIES_FROM_BROWSER— only if YouTube starts answering "Sign in to confirm you're not a bot" (see Troubleshooting).
The full list is under Configuration.
.venv/bin/python -m adder.serverThe service listens on http://0.0.0.0:8787. In a second terminal:
TOKEN=$(grep '^API_TOKEN=' adder/.env | cut -d= -f2-)
curl -s http://127.0.0.1:8787/health -H "Authorization: Bearer $TOKEN"Without the token /health answers only {"status": ...}. Expect "status":"healthy", "ffmpeg":"ok" and "js_runtime":"ok". A 503 with "ffmpeg":"missing" means step 1 is incomplete; the startup log says the same.
Add a track:
curl -X POST http://127.0.0.1:8787/api/add \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"links": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ"]}'
# {"added":[1]}
curl -s http://127.0.0.1:8787/api/tasks -H "Authorization: Bearer $TOKEN"
# status goes queued -> downloading -> tagging -> doneThe file appears as <LIBRARY_PATH>/<Artist>/Singles/<Title>.m4a. The log line Task finished: stored marks it; on failure the log has an ERROR line with the cause and /api/tasks has it in error / error_type.
The web interface is at http://<host>:8787/: paste the token once, then add links and watch the queue from a phone. On an iPhone, a Shortcuts action puts Share → local-Spotify straight into the YouTube app: see docs/iphone-shortcut.md (in Russian).
Stop the service with Ctrl+C before step 7.
local-Spotify only fills a folder; Navidrome streams it. Install it by following the official Linux guide (a .deb/.rpm package or the release tarball; the service must be called navidrome).
Then point Navidrome's MusicFolder at your LIBRARY_PATH. Step 7 does this for you if Navidrome has no config yet: it writes /etc/navidrome/navidrome.toml from deploy/navidrome.toml.example and adds deploy/navidrome-override.conf, which lets the Navidrome service read a folder under /home. An existing config is never overwritten — the script prints the MusicFolder line to check instead.
For playlist covers, and to remove a playlist from Navidrome when its .m3u is deleted, also set NAVIDROME_USER and NAVIDROME_PASSWORD (a Navidrome user) in adder/.env. Without them the service works and queues that part until they appear.
Check: open http://<host>:4533, create the admin user, and the track from step 5 shows up after the scan (Navidrome watches the folder; a full rescan is under Settings → Scan).
./deploy/install.sh
systemctl --user status music-adder # active (running)
curl -s http://127.0.0.1:8787/health # {"status":"healthy"}See Production: systemd for what the script does. A desktop window with media keys around the same page is described in desktop/README.md.
Everything is read from adder/.env (see .env.example); real environment variables take precedence. The service refuses to start without a valid API_TOKEN — a deliberate choice, since the API is reachable from the whole local network.
| Variable | Default | Purpose |
|---|---|---|
API_TOKEN |
(required) | Bearer token for API access. Empty or the .env.example placeholder is rejected |
LIBRARY_PATH |
~/Music/Normalized Library |
Where the music library lives; must match Navidrome's MusicFolder. Absolute path, no quotes |
PORT / HOST |
8787 / 0.0.0.0 |
Listen address |
MAX_WORKERS |
2 |
Parallel download workers |
MAX_LINKS_PER_REQUEST |
100 |
Link cap for one /api/add call |
MAX_QUEUE_SIZE |
5000 |
Maximum queued tasks |
PRESERVE_FEAT_ARTISTS |
true |
Keep feat./ft. in the artist directory name |
MAX_RETRIES |
3 |
Attempts per task on transient errors (network, HTTP 429) |
RETRY_BACKOFF_BASE |
2.0 |
Exponential backoff base, in seconds |
RATE_LIMIT_BACKOFF |
60 |
After YouTube rate-limits (HTTP 429), seconds to wait per attempt; no yt-dlp call starts meanwhile |
MAX_DURATION_MINUTES |
30 |
Longest video accepted, in minutes; 0 for no limit. Live streams are always refused |
DESKTOP_NOTIFICATIONS |
true |
Pop-up notifications on this machine: tracks added (batched), downloads failed, a yt-dlp update rolled back. Needs notify-send |
LISTENBRAINZ_TOKEN |
(empty) | User token from listenbrainz.org/settings: listens in the web player (half a track or 4 minutes) are sent to ListenBrainz, queued so none are lost offline |
SHUTDOWN_TIMEOUT |
30 |
Graceful shutdown timeout, in seconds |
MIN_FREE_SPACE_MB |
2048 |
Free disk space required before downloading |
TMP_TTL_HOURS |
24 |
Age at which stranded temp files are cleaned up |
COOKIES_FROM_BROWSER |
(empty) | yt-dlp --cookies-from-browser value: firefox, chrome, or firefox:/path/to/profile |
NAVIDROME_URL |
http://127.0.0.1:4533 |
Navidrome, for playlist covers and deleting playlists there |
NAVIDROME_USER / NAVIDROME_PASSWORD |
(empty) | A Navidrome user; optional, that work is queued until they are set |
MAX_COVER_BYTES |
8388608 |
Largest accepted playlist cover upload |
PLAY_HISTORY_DAYS |
400 |
How long the play journal is kept |
DELAY_BETWEEN_TRACKS |
1.1 |
Pause between tracks in the offline scripts, not the service |
Changes take effect after a restart (systemctl --user restart music-adder).
Authorise with an Authorization: Bearer <API_TOKEN> header. /health without it returns only {"status": ...}; the full answer below needs the token.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Status of the service, database, library, ffmpeg, JS runtime and queue |
POST |
/api/add |
Add one or more YouTube links |
GET |
/api/tasks |
The 50 most recent tasks and their status |
POST |
/api/tasks/retry-failed |
Queue every failed task again (also a button in the panel) |
GET |
/api/duplicates |
Tracks stored with a "looks like one you have" warning, each beside its twin |
POST |
/api/duplicates/resolve |
{"task", "keep": "new"|"existing"|"both"}: the other copy goes to trash/, playlists follow |
GET |
/api/library/health |
Tracks without cover, album or ReplayGain, unreadable files, and the state of a running fix |
POST |
/api/library/health/fix |
{"what": "covers"|"loudness"}: find missing covers or measure missing loudness in the background |
GET |
/ |
Web interface |
GET |
/api/playlists |
List playlists |
POST |
/api/playlists |
Create a playlist |
PATCH |
/api/playlists/{name} |
Rename a playlist |
DELETE |
/api/playlists/{name} |
Delete a playlist (file to trash/, and removed from Navidrome) |
GET |
/api/playlists/{name}/tracks |
A playlist's tracks, with the revision needed to edit it |
PUT |
/api/playlists/{name}/tracks |
Replace order and contents; 409 if the revision is stale |
POST GET DELETE |
/api/playlists/{name}/cover |
Upload, fetch or remove a playlist cover |
GET |
/api/stream-url |
Mint a short-lived signed URL for one track |
GET |
/api/stream |
Serve a track to <audio>; authorised by that signature, not the token |
POST |
/api/plays |
Record a finished or abandoned track |
GET |
/api/plays/stats |
Skip rate overall and per queue kind |
POST |
/api/import |
Take audio files from disk through the same pipeline |
GET |
/api/search |
Look for a track on YouTube without downloading |
POST |
/api/import-playlist |
Queue a whole playlist from one link: YouTube (including YouTube Music albums), Spotify, or a Deezer album |
GET |
/api/albums/search?q= |
Albums on Deezer matching free text, to choose one |
POST |
/api/import-album |
{"id"}: queue every track of that Deezer album, tagged as that album |
GET |
/api/shuffle |
Build a queue (mode=smart or plain) |
POST |
/api/shuffle/blind |
Two queues, one of each kind, unlabelled |
POST |
/api/shuffle/blind/{id} |
Record which one was preferred |
GET |
/api/shuffle/blind/results |
How the comparison stands |
GET |
/api/sync |
What the last synchronisation pass found |
POST |
/api/sync |
Run a pass now; ?apply=false reports only |
GET |
/api/track |
A track's tags plus its tempo, key and energy |
PATCH |
/api/track |
Correct title, artists and album; refetch_cover looks the cover up again |
GET |
/api/cover |
The artwork inside the file; ?size=96|300|600 for a cached thumbnail |
GET DELETE |
/api/library |
The library (search, sort, paging) / move a track to trash/ |
POST |
/api/replace /api/replace-file |
Replace a track by a better version, keeping its place in every playlist |
GET |
/api/home |
Home page shelves |
GET |
/api/discover-external |
Tracks by similar artists that are not in the library |
POST |
/api/shuffle/smart |
Smart shuffle of any set of tracks (album, current queue); about a third are new tracks |
GET POST |
/api/outside/status /api/outside/prefetch |
State of new (non-library) tracks; download ahead |
POST |
/api/outside/{key}/keep |
Put a new track into the library through the normal import |
GET |
/api/lyrics |
Lyrics, with timings where available |
GET POST |
/api/lyrics/candidates /api/lyrics/choose /api/lyrics/custom |
Pick lyrics by hand or paste your own |
POST /api/add — example
curl -X POST http://127.0.0.1:8787/api/add \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"links": [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/anotherVideoId"
]
}'{ "added": [12, 13] }Only links to youtube.com, m.youtube.com, music.youtube.com and youtu.be are accepted. Shorts, live and embed links (/shorts/ID, /live/ID, /embed/ID) are accepted too. Different URL forms pointing at the same video are canonicalised, so they do not create duplicate tasks. Re-submitting a link whose task has already completed or is still running is ignored; a link whose task ended in error can be re-submitted to queue it again.
GET /api/tasks — task statuses and error types
status is one of queued, downloading, tagging, done, error. For error, error holds yt-dlp's own error line and error_type says what to do:
error_type |
Retried automatically | Meaning |
|---|---|---|
network_error, download_error, artwork_error |
yes | Transient failure |
rate_limited |
yes, and again by itself an hour later (up to 3 times) | YouTube is throttling (HTTP 429 / "try again later") |
youtube_not_found |
no | Video removed, private or blocked in your region |
unsupported_video |
no | A live stream, or longer than MAX_DURATION_MINUTES |
youtube_auth_required |
no | Bot check, age gate, members-only: set COOKIES_FROM_BROWSER |
dependency_error |
no | ffmpeg/ffprobe missing on this machine |
invalid_url, filesystem_error, database_error, metadata_error, internal_error, unknown_error |
no | See the service log for the task id |
A failed link can be re-submitted once the cause is fixed.
GET /health — example response
{
"status": "healthy",
"database": "ok",
"library": "ok",
"library_path": "/home/user/Music/Normalized Library",
"ffmpeg": "ok",
"js_runtime": "ok",
"workers": 2,
"queue_size": 0,
"max_queue_size": 5000
}If the database is unreachable, the library directory is missing or ffmpeg/ffprobe are not installed, the status becomes unhealthy and the response code 503. A missing Deno shows as "js_runtime": "missing" but does not make the service unhealthy.
For continuous background operation the service runs as a systemd user unit. The install script checks that .venv exists and API_TOKEN is filled in, generates the unit, and optionally configures Navidrome and ufw rules for the LAN:
./deploy/install.shThe script refuses to continue without .venv or with an unset/placeholder API_TOKEN; writes the unit for LIBRARY_PATH and enables lingering; installs three timers — nightly ReplayGain tagging and audio analysis, nightly shelf export, and a weekly yt-dlp update that rolls itself back if the new version cannot read YouTube; if navidrome is installed and has no config yet, writes /etc/navidrome/navidrome.toml with the same MusicFolder (an existing config is left alone); and, if ufw is active, opens Navidrome and the service to the LAN subnet (LAN_SUBNET=192.168.1.0/24 to choose it).
systemctl --user status music-adder
journalctl --user -u music-adder -f # one line per task: Processing / Task finished / ERROR
systemctl --user restart music-adder # after editing adder/.envUpdating:
git pull
.venv/bin/pip install -r adder/requirements.txt
./deploy/install.sh # the unit changes between versionsThe unit runs with WorkingDirectory at the repository root and permits writes only to adder/ (database and temp files), trash/ (deleted tracks) and the library path. ProtectSystem=strict alone is not enough for that: it mounts / read-only but leaves /home writable, since /home is a separate mount point. ProtectHome=read-only closes it, and the paths above are carved back out with ReadWritePaths.
Back up state (SQLite plus .env):
./deploy/backup.sh # to ~/local-spotify-backups, keeps the last 10.venv/bin/pip install -r requirements-dev.txt # pytest, ruff, playwright
.venv/bin/python -m playwright install chromium # once, for the browser tests
.venv/bin/pytest -q
.venv/bin/ruff check . && .venv/bin/ruff format --check adder scripts tests desktop
.venv/bin/mypy # type check, settings in mypy.iniThe suite runs fully offline and needs no real .env; the import and streaming tests need ffmpeg, which CI installs. tests/conftest.py fails any test that opens a network connection, so YouTube, Deezer, MusicBrainz and iTunes are always mocked. It covers:
- downloading — the yt-dlp commands, reading its JSON and its
ERROR:line, the subprocess runner (timeouts, shutdown, large output), and which failures are retried; - metadata — splitting YouTube titles into artist and title, and Deezer and MusicBrainz enrichment against canned API responses;
- writing into the library —
process()end to end on a real one-second AAC file (tests/fixtures/tone.m4a): tags read back with mutagen, theArtist/Singles/Title.m4alayout, cover fallback, corrupt downloads, content deduplication; - the API: authorisation, link validation and canonicalisation, deletion and path traversal,
/health, task recovery after a restart, graceful shutdown, and an XSS regression in the frontend; - the panel and player in a real browser (
tests/test_ui.py, Playwright + Chromium): tag editing, ReplayGain volume, fades, the sleep timer, retry, library health, album choice, duplicates, and titles rendered as text; - playlists, Navidrome synchronisation, streaming signatures, file import, lyrics, shuffles and the smart-shuffle downloads.
CI (.github/workflows/ci.yml) runs ruff check, ruff format --check, mypy, compileall and the full suite on a clean environment for every push and pull request.
Start with curl -s http://127.0.0.1:8787/health -H "Authorization: Bearer $TOKEN" and journalctl --user -u music-adder -n 50.
| Symptom | Cause and fix |
|---|---|
RuntimeError: API_TOKEN is required at startup |
Token empty or still the placeholder. Redo step 4. |
PermissionError creating the library at startup |
LIBRARY_PATH points somewhere you cannot write. Fix it in adder/.env or comment it out for the default. |
/health → "ffmpeg": "missing", tasks fail with dependency_error |
sudo apt install ffmpeg, then restart the service. |
/health → "js_runtime": "missing", log warns about Deno |
Install Deno into /usr/local/bin as in step 1. A Deno under ~/.deno/bin works in your shell but not in the systemd service. |
youtube_auth_required: "Sign in to confirm you're not a bot" |
YouTube distrusts this IP. Log in to YouTube in a browser on the same machine and set COOKIES_FROM_BROWSER=firefox (or chrome, or firefox:/path/to/profile) in adder/.env, restart, re-submit the link. |
Many rate_limited errors |
YouTube is throttling; the service already pauses all downloads for RATE_LIMIT_BACKOFF seconds per attempt. If it persists, lower MAX_WORKERS to 1, wait an hour and re-submit the failed links. |
| Downloads that used to work start failing | YouTube changed something. yt-dlp updates itself weekly; to update now run .venv/bin/python scripts/update_ytdlp.py (no restart needed). journalctl --user -u music-ytdlp-update shows past runs. |
Task is done but Navidrome doesn't show the track |
Navidrome reads another folder: its MusicFolder (/etc/navidrome/navidrome.toml) must equal library_path from /health. Check that the Navidrome user can read it: sudo -u navidrome ls "<library_path>". Then Settings → Scan in Navidrome. |
| Deleting a track from the web interface fails | Deleted files go to trash/ in the repository root. After git pull, re-run ./deploy/install.sh so the unit has ReadWritePaths for it. |
| Web interface says the token is wrong | Paste the value of API_TOKEN from adder/.env, without API_TOKEN=. |
- The API is protected by a Bearer token compared with
secrets.compare_digest(timing-attack resistant); the service will not start without one. - Data from YouTube (video title, uploader) is treated as untrusted: the frontend renders it only through
textContent/replaceChildren, neverinnerHTML. - Only YouTube URLs with an exact host match are accepted, which blocks bypasses of the
youtube.com.evil.examplevariety. - The systemd unit sets
ProtectSystem=strict,ProtectHome=read-only,NoNewPrivilegesandPrivateTmp, and permits writes only toadder/,trash/and the library path.ProtectHomeis what actually confines the process:ProtectSystem=strictdoes not cover/home, so without it the service could write to its own source tree. - The token and
.envare never committed (.gitignore). Do not paste a realAPI_TOKENinto a README or an issue.
Found a vulnerability? Please open a private security advisory on the repository rather than a public issue.
local-Spotify/
├── adder/ # Ingest and processing service
│ ├── app.py # FastAPI app, workers, business logic
│ ├── config.py # Loads and validates configuration from .env
│ ├── server.py # Entry point (uvicorn)
│ ├── fix_covers.py # Offline backfill for missing cover art
│ └── requirements.txt
├── web/ # Static web interface (vanilla JS)
├── scripts/ # Offline tools: library audit, duplicate finder, playlist migration
├── tests/ # pytest
├── deploy/ # systemd unit, install/backup scripts, Navidrome config
└── .env.example
This is a self-hosted home project. It is not intended for:
- a public SaaS or high-load production deployment;
- bulk or commercial use;
- circumventing YouTube's regional or other restrictions.
Before downloading third-party content, make sure you have the right to do so.
One thing about the phone. Amperfy 2.1 does not show a playlist's own cover: its response parser does not read the coverArt field at all and draws a collage from the first tracks' album art instead. Verified against its source (SsPlaylistParserDelegate, Playlist.updateArtworkItems). A cover set here shows up in Navidrome's own web UI and in this player; it will not show up in Amperfy. Track order, by contrast, it re-reads every time a playlist is opened.
- Deleting and reorganising tracks through the API
- Importing whole albums and playlists, not just individual links
- A Docker image, for deployment without systemd
- Prometheus metrics on top of the current
/health
MIT. Make sure you have the right to download and store any third-party content you add to the library.