Quick start · Configuration · Troubleshooting · فارسی
Paste a Spotify / Deezer / Apple Music / YouTube / SoundCloud track, album or playlist URL — or search every catalog at once — and get tagged audio files at the quality you pick (128 / 192 / 320 kbps mp3, or the original stream untouched).
No accounts. No API keys. Nothing paid. You run it, so the files are yours and nobody is standing in between.
The interface is Farsi only — that was a deliberate product decision, not an oversight (why). Everything else here is in English.
You need Docker. Nothing else — no Python, no Node, no ffmpeg on your machine.
git clone https://github.com/amiralibg/unstream.git
cd unstream
docker compose up -dOpen http://localhost:8080. Search for something, or paste a link, and hit download.
Finished tracks land in ./downloads/<job-id>/ — real files in a real folder, yours to move wherever your music lives. Nothing expires and nothing is cleaned up unless you ask for it.
That's the whole setup. If you want to change something, cp .env.example .env and read the comments — every setting is optional and documented there.
![]() |
![]() |
A Raspberry Pi, a NAS, an old laptop — all fine, and you won't build anything: docker compose up pulls prebuilt linux/amd64 and linux/arm64 images from GHCR and only falls back to building from source if it can't.
On Linux, set the ownership of the files it writes so they belong to you rather than to the container's user:
echo "PUID=$(id -u)" >> .env
echo "PGID=$(id -g)" >> .env
docker compose up -dmacOS and Windows can skip that — Docker Desktop handles it.
Streaming services are DRM-protected, so nothing is downloaded from them directly. Instead:
-
Metadata comes from free, keyless providers:
- Spotify embed pages (
open.spotify.com/embed/…) — the public iframe widget ships full track lists as JSON. Used for pasted Spotify URLs. - Deezer public API — keyless; powers search (songs, albums, artists, playlists), full artist discographies, and Deezer URLs.
- iTunes Search API — keyless; adds Apple Music coverage to search and resolves
music.apple.comURLs. - SoundCloud web API — the site's own
api-v2endpoints, using a client id scraped from its public JS bundles (the same trick yt-dlp uses). Full search parity: tracks, people, albums and playlists. Go-only (DRM) tracks are filtered out. - yt-dlp — reads public YouTube and SoundCloud pages; resolves their URLs and contributes YouTube search results.
Search fans out to four of these in parallel and merges the results, deduped by name + artist. There is deliberately no Spotify Web API integration — since 2025 it requires the app owner to hold an active Premium subscription, and this project stays free and keyless.
- Spotify embed pages (
-
yt-dlp finds the audio: tracks already pointing at a YouTube/SoundCloud page download directly; everything else is searched (
ytsearch8:on "artists - title") picking the result whose duration is closest to the catalog's, which rejects live versions and hour-long mixes. Retries exclude broken uploads, and the last attempt searches SoundCloud instead of YouTube. -
The best audio stream is downloaded at the quality you picked — ffmpeg encodes mp3 at 128, 192 (default) or 320 kbps. Original skips the encode and keeps the upload's own stream (m4a, or opus remuxed out of webm so it can carry tags) — best fidelity, since re-encoding an already-lossy source can only lose more.
-
mutagen embeds tags and cover art, as ID3, MP4 atoms or Vorbis comments depending on what came out.
frontend (Vite + React + Tailwind) ──proxy /api──▶ backend (FastAPI)
├─ embed.py Spotify URL → metadata
├─ deezer.py search, artists, Deezer URLs
├─ itunes.py search, Apple Music URLs
├─ ytdlp.py YouTube/SoundCloud URLs + search
├─ downloader.py find audio → encode → tags
└─ jobs.py thread pool + progress + sweeper
Everything is optional. cp .env.example .env and uncomment what you want — that file documents each setting and is the authority; this is the summary.
| Variable | Default (self-hosted) | Does |
|---|---|---|
UNSTREAM_PORT |
8080 |
Host port for the web UI |
DOWNLOADS_DIR |
./downloads |
Where finished tracks land. Point it at an external disk or a NAS mount |
PUID / PGID |
1001 |
Who owns those files. Use id -u / id -g on Linux |
DOWNLOADS_TTL_HOURS |
0 (never) |
Hours before a finished download is deleted |
MAX_DOWNLOADS_GB |
0 (no cap) |
Disk ceiling; over it, finished jobs go oldest-first |
DOWNLOAD_WORKERS |
3 |
Tracks downloaded in parallel |
RATE_LIMITS_ENABLED |
false |
Per-caller rate limits. Turn on if strangers can reach it |
MAX_TRACKS_PER_JOB |
0 (no limit) |
Tracks in one job |
ADMIN_TOKEN |
unset | Enables the /admin dashboard. Unset = it doesn't exist |
The code's defaults differ from the compose file's: they assume a server shared with strangers (downloads expire after 24h, disk capped at 20 GB, limits tight). docker-compose.yml overrides them toward "this is my machine". See the design notes.
Not on an ordinary VPS, and this is the one thing that works locally and fails on a rented server.
YouTube treats a datacenter address differently from a home one. From a VPS it answers LOGIN_REQUIRED at the playability check — before a proof-of-origin token is asked for and before a JS challenge exists to solve — so the defences the image ships cannot reach the point where they'd help. From a home connection none of that happens.
Making a public instance work needs egress from a non-datacenter address: a residential or ISP proxy, which costs money. There is no free workaround; if there were, it would be in this repo. SoundCloud is unaffected throughout.
If you're deploying it anyway — for yourself, behind a tunnel, or with proxied egress — use compose.dokploy.yml, which is the live deployment's file, and set RATE_LIMITS_ENABLED=true. Without accounts, per-IP limits are the only thing between your server and everyone.
Check GET /api/admin/extraction first (needs ADMIN_TOKEN). It reports what actually landed in the container and tells "never configured" apart from "configured and still blocked". js_runtime: null means nothing else is worth debugging first.
Then, roughly in order of how often it's the answer:
- Keep yt-dlp current. These bypasses ship inside yt-dlp releases, so a stale image is a stale workaround. The published images rebuild weekly for exactly this; if you built your own,
docker compose build --pull --no-cache api. - The JS challenge solver. yt-dlp needs both a runtime (deno, in the image) and a script it downloads on first use, which
YTDLP_REMOTE_COMPONENTSallows. With a runtime and no script it reportsSignature solving failedand hands back a video with no audio streams at all. - The PO token provider. The
pot-providersidecar mints proof-of-origin tokens some clients are asked for. On by default, free, keyless. - Cookies, only if the above aren't enough — and on a home connection they almost never are needed. Use a throwaway Google account: every download is attributed to it and YouTube bans accounts for exactly that. Note that authenticating changes which client yt-dlp picks, moving it onto the web clients — so cookies without 2 and 3 in place make things worse, not better. See
.env.examplefor the mount.
Unstream can count its own usage, with no third-party service, no cookie and no consent banner (how it works). It is off unless you set ADMIN_TOKEN, and local either way — events go to a SQLite file on the analytics volume and are read back only by /admin on your own instance. Nothing is ever sent anywhere.
openssl rand -hex 24 # a token worth usingA caller is identified by a daily-rotating hash of IP + user agent — no address is stored, which also makes "returning visitors" deliberately unmeasurable. The page is Disallowed in robots.txt and sets noindex on itself.
The analytics volume is the only copy of that history. It's in WAL mode and written to while you read it, so cp can hand you a torn database — use SQLite's own backup:
docker compose exec -T api python -c \
"import sqlite3; src = sqlite3.connect('/app/data/analytics.db'); \
dst = sqlite3.connect('/app/data/backup.db'); src.backup(dst); \
dst.close(); src.close()" \
&& docker compose cp api:/app/data/backup.db "./analytics-$(date +%F).db"| Endpoint | Purpose |
|---|---|
GET /api/search?q= |
Multi-source search → tracks, albums, artists, playlists |
GET /api/artist/{id} |
Artist page: top tracks + complete discography (Deezer) |
POST /api/resolve {url} |
Spotify/Deezer/Apple Music/YouTube/SoundCloud URL → track list |
POST /api/download {url, track_ids?, quality?} |
Start a job, returns job_id. quality is 128 | 192 | 320 | original |
GET /api/jobs/{id} |
Per-track status/progress |
GET /api/jobs?ids=a,b,c |
The same for several jobs — what the UI polls. Unknown ids are omitted rather than 404ing |
GET /api/jobs/{id}/tracks/{tid}/file |
Download one finished track |
GET /api/jobs/{id}/zip |
ZIP of all finished tracks, streamed as it's built |
GET /api/admin/stats?days= · /api/admin/extraction |
Dashboard and diagnostics, Authorization: Bearer $ADMIN_TOKEN |
Prereqs: Python 3.12+ with uv, Node 20+, ffmpeg (brew install ffmpeg).
cd backend && uv sync && uv run uvicorn app.main:app --reload --port 8000
cd frontend && npm install && npm run devThen http://localhost:5173. Tests: cd backend && uv run pytest.
The decisions worth knowing before you change anything are in docs/DESIGN.md.
- Playlists must be public. The embed-page provider even reads Spotify's editorial playlists, which the official API blocks for new apps.
- The embed pages are an undocumented structure — Spotify can change them any time. If resolving suddenly breaks, look at
backend/app/embed.pyfirst. - Rate limits are in-memory and per process. Fine for the single-container stack here; a scaled-out API would need a shared store.
MIT. The bundled Vazirmatn typeface is under the SIL Open Font License 1.1 — see frontend/public/fonts/OFL.txt.
This project is a technical exercise in APIs, media pipelines and background jobs, and a tool for getting at music you already have the right to.
Only download what you have the rights to. Copyright law is yours to comply with, and running your own instance means the responsibility is yours too.
Unstream is not affiliated with, endorsed by, or connected to Spotify, Deezer, Apple, YouTube or SoundCloud. Those names are used only to describe what the software reads. No DRM is circumvented: nothing is downloaded from any streaming service, and audio comes from public YouTube and SoundCloud pages via yt-dlp.
Built by amiralibgi and yazdanctx.


