Self-hosted Docker service that turns the music ZIPs you get from @deezload2bot on Telegram — playlists, albums, compilations — into clean, correctly named and properly tagged albums, ready for Navidrome or any other music server.
Published on Docker Hub as
jorgejiro/zymphony
(linux/amd64 + linux/arm64).
Everything happens locally, on your own machine or NAS: Zymphony watches a folder, processes the ZIPs it finds there and writes the result into your music library. Your files are never uploaded anywhere. The only outbound traffic is a read-only lookup against the Spotify Web API to fetch the playlist's real name and cover art.
What it does for each download:
- Detects when all ZIP parts have finished downloading (size + stability heuristic).
- Fetches the playlist name and cover art from the Spotify API (with fallbacks for editorial/regional playlists — see Playlist metadata).
- Extracts all ZIPs, tags every MP3 (
album,albumartist,tracknumber,compilation) and writescover.jpgto the output folder. - Renames the folder after the real playlist title (sanitized for the filesystem), moves the result to your music library and deletes the source ZIPs.
- Optionally sends a Discord notification on success or failure.
Zymphony is the post-processing half of a two-step workflow. It does not download anything itself — the download is done by @deezload2bot, a Telegram bot that takes a Spotify (or Deezer) link and sends back the music as ZIP files.
-
Send the link to the bot. In Telegram, open @deezload2bot and send it the share link of the Spotify playlist or album you want, e.g.
https://open.spotify.com/playlist/7EAqBCOVkDZcbccjxZmgjp. -
The bot replies with one or more ZIP files. The archive is named after the Spotify ID of what you asked for. Large playlists are split into parts of roughly 1 GB each, numbered with a
NN.prefix:7EAqBCOVkDZcbccjxZmgjp.zip ← fits in a single archive 00. 7EAqBCOVkDZcbccjxZmgjp.zip ← first part of a split download 01. 7EAqBCOVkDZcbccjxZmgjp.zip ← …middle parts, ~1 GB each 02. 7EAqBCOVkDZcbccjxZmgjp.zip ← last part, smaller than the rest -
Save the ZIPs into the watched folder. Drop them into whatever directory you mapped to
/input(e.g./volume1/downloadson a Synology NAS). Point your Telegram client's download folder there, or copy the files over — either way works, Zymphony just looks at the directory. -
Zymphony takes it from here. On its next scan it groups the parts, waits until they are complete and stable, and produces a finished album in your music library.
The ZIPs the bot produces are raw dumps: the folder is named after an opaque 22-character Spotify ID, there is no cover art on disk, and each MP3 carries whatever album/track metadata came from its original release. Import that straight into Navidrome and a 60-track playlist explodes into 60 one-track albums with no artwork and meaningless names.
Zymphony fixes exactly that, without you touching a single file:
| Problem in the raw ZIP | What Zymphony does |
|---|---|
Folder named 7EAqBCOVkDZcbccjxZmgjp |
Renames it to the real playlist title from Spotify, sanitized for the filesystem |
| Playlist split across several ZIPs | Groups the parts by ID, waits for all of them and merges them into one album |
| No cover art | Downloads the highest-resolution playlist cover, saves it as cover.jpg and embeds it in every MP3 (APIC) |
Every track has its own album tag |
Rewrites album to the playlist name so the tracks group as one release |
| Tracks scatter as separate albums | Sets compilation (TCMP=1) and albumartist (Various Artists, or the shared artist if all tracks have the same one) |
| Track numbers from the original releases | Renumbers tracknumber sequentially 1..N, following part order (NN) and then filename order |
| ZIPs pile up in the downloads folder | Deletes the source ZIPs once the album has been written successfully |
The result is a single, coherent album entry in Navidrome, with artwork and the tracks in the right order.
Note on IDs: Zymphony identifies a download by the 22-character Spotify playlist ID in the filename (bare, with an
NN.prefix, or as a fullopen.spotify.com/playlist/…URL orspotify:playlist:…URI). Files whose name does not contain such an ID are ignored and left untouched.
- Docker (Engine 24+ / Desktop 4.25+) with buildx if you build locally.
- A Spotify Developer app — see Create a Spotify app.
- A one-time bootstrap to obtain the OAuth refresh token.
- Go to https://developer.spotify.com/dashboard and log in.
- Click Create app. Name and description can be anything.
- Under Redirect URIs, add
http://localhost:8888/callbackand save. - Open the app settings and note the Client ID and Client Secret.
# 1. Copy the example compose file and fill in your values
cp docker-compose.yml docker-compose.local.yml
# Edit: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, volume paths, PUID/PGID
# 2. Run the one-time authorization (on a machine with a browser — see below)
docker compose -f docker-compose.local.yml run --rm \
-p 8888:8888 zymphony zymphony auth
# 3. Start the service
docker compose -f docker-compose.local.yml up -dThe service needs a refresh token stored in /config/spotify_token.json.
This token is permanent — you only do this step once.
Run the auth command locally (not in Docker). The browser opens automatically and the token is written to the path you specify:
CONFIG_DIR=/path/to/config \
SPOTIFY_CLIENT_ID=your_id \
SPOTIFY_CLIENT_SECRET=your_secret \
SPOTIFY_REDIRECT_URI=http://localhost:8888/callback \
pip install zymphony && zymphony authThen copy spotify_token.json to the NAS config volume:
scp /path/to/config/spotify_token.json \
user@NAS_IP:/volume1/docker/zymphony/config/Expose port 8888 temporarily, set SPOTIFY_REDIRECT_URI to the NAS IP, and
register that URI in your Spotify app settings first:
# On the NAS via SSH:
docker run --rm -it \
-p 8888:8888 \
-v /volume1/docker/zymphony/config:/config \
-e SPOTIFY_CLIENT_ID=your_id \
-e SPOTIFY_CLIENT_SECRET=your_secret \
-e SPOTIFY_REDIRECT_URI=http://NAS_IP:8888/callback \
-e CONFIG_DIR=/config \
jorgejiro/zymphony:latest zymphony authOpen the printed URL in your browser, authorize, and the token is saved.
| Variable | Default | Description |
|---|---|---|
SPOTIFY_CLIENT_ID |
(required) | Spotify app client ID |
SPOTIFY_CLIENT_SECRET |
(required) | Spotify app client secret |
SPOTIFY_REDIRECT_URI |
http://localhost:8888/callback |
Must match your Spotify app settings |
PUID |
1000 |
UID that will own output files |
PGID |
1000 |
GID that will own output files |
TZ |
UTC |
Container timezone |
SCAN_INTERVAL_SECONDS |
60 |
Seconds between directory scans |
STABLE_MINUTES |
5 |
Minutes a file must be unchanged before processing |
PART_SIZE_THRESHOLD_MB |
1000 |
Size threshold (MB) separating full parts from the final part |
MIN_TOTAL_SIZE_MB |
100 |
Minimum total group size before considering it |
ALBUM_ARTIST |
Various Artists |
Value written to the albumartist tag, unless every track shares the same artist (then that artist is kept) |
DELETE_ZIPS_AFTER |
true |
Delete source ZIPs after successful processing |
LOG_LEVEL |
INFO |
Python logging level (DEBUG, INFO, WARNING, ERROR) |
INPUT_DIR |
/input |
Directory to watch for ZIP files |
OUTPUT_DIR |
/output |
Navidrome music library root |
CONFIG_DIR |
/config |
Persistent config directory (Spotify token) |
DISCORD_WEBHOOK_URL |
(empty) | Discord webhook for success/failure notifications; leave empty to disable |
Get a notification in Discord every time a compilation is added or a group fails to process.
- In your Discord server, open Server Settings → Integrations → Webhooks.
- Click New Webhook, pick the target channel, and Copy Webhook URL.
- Set
DISCORD_WEBHOOK_URLto that URL in yourdocker-compose.yml(or-eflag) and restart the container.
Leave the variable empty to disable notifications entirely. Delivery is best-effort: if Discord is unreachable the failure is logged and processing continues normally.
Zymphony fetches the playlist name and cover separately so that
Spotify's editorial and regional playlists (e.g. "Los 90 España") work even
though their authenticated Web API endpoint returns 404:
- Name — the authenticated Web API is tried first (this is what makes
private playlists work). If it returns
404, Zymphony falls back to Spotify's public oEmbed endpoint. If both fail, the playlist ID is used as the album name so processing never blocks. - Cover — always fetched from the dedicated
/imagesendpoint, which works for editorial playlists even when the main endpoint rejects the request. If no cover is available, the tracks are still tagged and moved (nocover.jpg).
If you ask @deezload2bot for the same playlist
again and drop the new ZIPs into /input, Zymphony replaces the existing
output folder with the freshly processed content instead of skipping it. This
lets you refresh a compilation after adding tracks to the Spotify playlist —
just request it from the bot again and drop the new ZIPs in.
SSH into your NAS and run:
id your_username
# uid=1026(your_username) gid=100(users) ...Use uid as PUID and gid as PGID. These must match the user that owns
your Navidrome music library, so that Zymphony can write files and Navidrome
can read them.
# Folder where you save the ZIPs sent by @deezload2bot
mkdir -p /volume1/downloads
# Navidrome library root (skip if it already exists)
mkdir -p /volume1/music
# Persistent config: Spotify token + processing state
mkdir -p /volume1/docker/zymphony/configSuggested folder structure:
/volume1/
├── downloads/ → /input (ZIPs from @deezload2bot)
├── music/ → /output (Navidrome library root)
└── docker/
└── zymphony/
└── config/ → /config (Spotify token + state)
Before starting the service, generate the refresh token once. The easiest
approach is to do this from your Mac (Option A above) and then copy the
resulting spotify_token.json to the NAS:
scp spotify_token.json user@NAS_IP:/volume1/docker/zymphony/config/-
Open Container Manager → Project → Create.
-
Set a project name (e.g.
zymphony). -
Under Source, choose Create docker-compose.yml and paste the contents of the
docker-compose.ymlfrom this repo — or import the file directly. -
Edit the three volume paths under
volumes:to match step 2:volumes: - /volume1/downloads:/input - /volume1/music:/output - /volume1/docker/zymphony/config:/config
-
Fill in
SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRET,PUID, andPGID. -
Click Next → Done. Container Manager starts the service automatically.
# SSH into the NAS
ssh user@NAS_IP
mkdir -p /volume1/docker/zymphony
cd /volume1/docker/zymphony
# Upload docker-compose.yml and edit it, then:
docker compose up -ddocker compose logs -f zymphonyA healthy start-up looks like:
zymphony | [INFO] Starting Zymphony service
zymphony | [INFO] Watching /input every 60s
docker compose pull
docker compose up -dThe image is published to Docker Hub as
jorgejiro/zymphony as a
multi-arch manifest (linux/amd64 + linux/arm64):
jorgejiro/zymphony:latest— always the newest release.jorgejiro/zymphony:<version>— pin a specific release (e.g.0.2.1).
Pinning a version is recommended for a NAS deployment so an update is a
deliberate change. To upgrade, bump the tag (or docker compose pull on
latest) and recreate the container.
docker build -t zymphony:latest .# One-time setup of a multi-arch builder
docker buildx create --use --name multiarch --driver docker-container
# Build and push both architectures, tagging the version and `latest` together.
# Keep the version in sync with `version` in pyproject.toml.
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t jorgejiro/zymphony:0.2.1 \
-t jorgejiro/zymphony:latest \
--push \
.The service processes no files after dropping ZIPs into /input.
Check LOG_LEVEL=DEBUG for detail. Common causes:
- The ZIPs were not yet stable for
STABLE_MINUTES— wait or lower the value. - The total size is below
MIN_TOTAL_SIZE_MB— lower the threshold or verify the ZIPs are correct. - The filename does not contain a 22-character Spotify playlist ID. Keep the
names exactly as @deezload2bot sends them (
00. PLAYLISTID.zip); renaming them, or letting Telegram append a(1)suffix to a duplicate download, makes the file unrecognizable.
SpotifyException: No token / auth errors.
The refresh token is missing or expired. Re-run zymphony auth and copy the
new spotify_token.json to /config.
Output files are owned by root instead of my user.
PUID/PGID are probably not set. Check the environment section of your
compose file and verify the values with id your_username on the NAS.
MP3s appear in Navidrome as individual albums instead of one compilation.
The compilation (TCMP=1) and albumartist tags were not written correctly.
Enable LOG_LEVEL=DEBUG, check for tagging errors, and verify the file
is a valid ID3v2.3/2.4 MP3.
A playlist fails repeatedly and I want to retry from scratch. The ZIPs are not deleted on failure — just restart the service or wait for the next scan cycle. No manual cleanup is needed.
Discord notifications don't arrive.
Check that DISCORD_WEBHOOK_URL is set and correct. Delivery is best-effort, so
a bad URL or an unreachable Discord only produces a WARNING in the logs and
never blocks processing — enable LOG_LEVEL=DEBUG and look for
Failed to send Discord notification.
# Install in editable mode with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Lint and format
ruff check src tests
black src testsexport SPOTIFY_CLIENT_ID=your_id
export SPOTIFY_CLIENT_SECRET=your_secret
export SPOTIFY_REDIRECT_URI=http://localhost:8888/callback
export CONFIG_DIR=./local-config
mkdir -p ./local-config
zymphony authReleased under the MIT License.