Skip to content

About

Self-hosted YouTube-to-library music service: drop in a link, get a tagged track with cover art in a Navidrome-served personal library.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

🎵 local-Spotify

Русская версия

CI License: MIT Python 3.12+

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.


Contents


✨ Features

  • 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 clean Artist / 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 (not on an iPhone, where a page cannot set volume).
  • DJ transitions (laptop only) — with «Как диджей» chosen, the service mixes the end of one track into the start of the next, like a DJ: matched tempo where the tempos are close, the outgoing track fading over the incoming one's intro, or a quick cut when the singing starts at once. The mix is made ahead of time on the server (scripts/render_transition.py, needs the analysis packages) and the player switches to it and back sample for sample. Album neighbours, tracks from outside the library and short tracks keep the plain fade; the iPhone has neither.
  • 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, and replaces a cover that is not a real JPEG or PNG (or re-encodes it when nothing is found).
  • 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 — SIGTERM stops workers cleanly, including child yt-dlp/ffmpeg processes, 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 .m3u in 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 .m3u in ten seconds.
  • Reordering that survives duplicates — a line is identified by its index, not its path. Monday.m3u holds 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 409 instead 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.py measures 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. A recent skip, and an artist skipped more than most, make a track rarer; one heard through, likelier (weights fitted on the journal by scripts/backtest_shuffle.py). «Какое перемешивание лучше» in «Сервис» compares it blind with a plain shuffle. See scripts/README.md.
  • Radio — «Радио» in a track's or an artist's «⋯» starts an endless queue: the artist, the artists Deezer calls similar that are in the library, and about one track in three from outside it. It comes in portions of 20, the next one asked for when fewer than five are left; the last 50 tracks and anything recently skipped are left out. Downloads from outside are capped at 20 an hour (and «В фонотеку» at 10 of its own) and wait while the add queue is busy.
  • New releases — every night, for the 150 biggest library artists Deezer knows by the same name, albums, EPs and singles of the last 60 days that are not in the library yet go to a «Новые релизы» shelf on the home page, with a desktop notification for the ones seen for the first time. A card opens the artist's discography with ticks. Downloading them by itself is a setting, NEW_RELEASES_AUTO, off by default; what was found while it was off is not downloaded later.
  • Weekly selection — once a week (the night to Monday) 25 tracks not in the library, picked from the artists listened to over the last four weeks and the ones Deezer calls similar, at most two per artist and nothing offered in the last six months while there are fresh ones (when the circle runs out, the tracks offered longest ago come back first). They play like the smart shuffle's new tracks: downloaded on request to a cache, «В фонотеку» in the player keeps one. «Слушать» starts from a track already downloaded.
  • Library audit tooling — offline scripts for finding duplicates, checking metadata, and bulk-migrating a playlist from CSV.

🏗️ Architecture

                    ┌──────────────┐
                    │   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.


🚀 Quick start

Tested on Debian/Ubuntu. Every step ends with a command that proves it worked — do not move on until it does.

1. System packages

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

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

2. Clone

git clone https://github.com/Whyslab/local-Spotify.git
cd local-Spotify

All commands below are run from this directory.

3. Virtualenv and dependencies

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

Optional: 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.txt

The venv must be at .venv in the repository root: deploy/install.sh and the systemd unit look for it there.

4. Configure

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.

5. Run and check

.venv/bin/python -m adder.server

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

The 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). What to check by hand on an iPhone after a change to the offline player: docs/iphone-checklist.md (in Russian).

Stop the service with Ctrl+C before step 7.

6. Navidrome

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

7. Run permanently (systemd)

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


⚙️ Configuration

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


🔌 API

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
POST /api/covers A screen's thumbnails in one answer ({"items": [{"path", "size"}]}, up to 64); one still to be made is answered "later"
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, the night's new releases among them
GET /api/discover-external Tracks by similar artists that are not in the library; a new set on every call, with Deezer covers
GET /api/web-cover A Deezer picture (?url= on *.dzcdn.net only), fetched once and kept in adder/web-covers/
GET /api/artists/search /api/artists/find Deezer artists by name / the library artist on Deezer (exact name first)
GET /api/artists/{id}/discography Albums, EPs and singles with tracks; versions and repeats hidden, what the library has is marked
GET POST DELETE /api/artists/import Chosen tracks wait on disk and are searched on YouTube one by one in the background
GET POST DELETE /api/artist-photo Artist header: your own picture if uploaded, otherwise the Deezer photo; /info says which
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
POST /api/radio Next portion of a radio from a track or an artist, without the recent tracks sent
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.


🖥 Production: systemd

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

The 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/.env

Updating:

git pull
.venv/bin/pip install -r adder/requirements.txt
./deploy/install.sh                      # the unit changes between versions

The 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

🧪 Tests

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

The 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, the Artist/Singles/Title.m4a layout, 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, the DJ transition's handover (tests/test_ui_mix.py), retry, library health, album choice, duplicates, and titles rendered as text;
  • playlists, Navidrome synchronisation, streaming signatures, file import, lyrics, shuffles, the radio 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.


🛠 Troubleshooting

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

🔒 Security

  • 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, never innerHTML.
  • Only YouTube URLs with an exact host match are accepted, which blocks bypasses of the youtube.com.evil.example variety.
  • The systemd unit sets ProtectSystem=strict, ProtectHome=read-only, NoNewPrivileges and PrivateTmp, and permits writes only to adder/, trash/ and the library path. ProtectHome is what actually confines the process: ProtectSystem=strict does not cover /home, so without it the service could write to its own source tree.
  • The token and .env are never committed (.gitignore). Do not paste a real API_TOKEN into a README or an issue.

Found a vulnerability? Please open a private security advisory on the repository rather than a public issue.


📁 Project layout

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

⚠️ Limitations

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.


🗺️ Roadmap

  • 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

📜 Licence

MIT. Make sure you have the right to download and store any third-party content you add to the library.


Whyslab · local-Spotify

About

Self-hosted YouTube-to-library music service: drop in a link, get a tagged track with cover art in a Navidrome-served personal library.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages