A fully automatic scalable command line interface to download music from the internet with proper mp3 tagging and directory structuring.
Web2mp3 runs as a Docker container (see "Running with Docker" below for setup). Once it's up, the everyday commands are (there's also an optional one-page web GUI if you'd rather not use a shell at all — see "Web GUI" further down):
docker compose exec web2mp3 dl --headless <url>
Calling dl with no URL and not --headless starts an interactive
wizard that prompts for URLs one at a time:
>>> URL or [Abort]? https://www.youtube.com/watch?v=NgE5mEQiizQ
2023-01-01 09:00
Searching Spotify for: "Dirty South Hip Hop - Royalty Free Music - Topic"
1) Dirty South Hip Hop - Royalty Free Music 100%
Clear Spotify match: Dirty South Hip Hop - Royalty Free Music Instrumenta...
Success: Download added "youtube:NgE5mEQiizQ"
>>> URL or [Abort]? https://open.spotify.com/track/4mn2kNTqiGLwaUR8JdhJ1l
2023-01-01 09:00
New spotify URL: open.spotify.com/track/4mn2kNTqiGLwaUR8JdhJ1l
Searching Youtube for: "House of the Rising Sun The Animals"
1) The House of the Rising Sun 99%
2) The Animals - House Of The Rising Sun (Music Vi 97%
Clear youtube match: The Animals - House Of The Rising Sun (Music Video) [4K HD]
Success: Download added "youtube:N4bFqW_eu2I"
>>> URL or [Abort]? Each call should take about a second, since downloading is performed in the background using a daemon, and mp3 tags are applied.
The program does not require URL sanitation (although your shell might).
Command Line Arguments
Use docker compose exec web2mp3 dl --help for parameter options. You
can also, after starting the wizard -instead of providing a URL- pass
params to print a list of the current state of all parameters for
inspection.
For example, pass the quality flag to set download quality to 123 kB/s, the
reponse flag to set the default response for an unclear match to the first :
(closest match) option, and set the -d flag to allow for duplicate songs (e.g,
songs with the same name, for the same author, but on a different album):
docker compose exec web2mp3 dl --quality 123 --response 1 -d
>>> URL or [Abort]? params
quality 123
response 1
avoid_duplicates False
urls ()
max_daemons 4
headless False
init_daemons During
verbose False
verbose_continuous False
tolerance 0.1
market US
search_limit 5
do_overwrite False
print_space 24
max_time_outs 10
Web2mp3 aims to be a scalable tool. To this end, three features are paramount:
- A large range of input sources
- Reliable matching between audio and meta data
- Minimal user input
- Concise managing of downloads
1. Input Sources
Both YouTube URLs and Spotify URLs are accepted. SoundCloud support is in development, to cover audio that is not available through YouTube.
Both single tracks and collections are accepted: YouTube Music playlists, Spotify tracks, and Spotify albums.
Note (early 2026): Spotify playlist support has been removed. In early 2026 Spotify substantially restricted their Web API. The
GET /v1/playlists/{id}/itemsendpoint now requires user-level OAuth and is only accessible for playlists owned by or collaborated on by the authenticated user. Development Mode apps additionally require every user to be on the app allowlist. As a result, Spotify playlist support has been dropped from Web2MP3. Use YouTube Music playlist URLs instead (https://music.youtube.com/playlist?list=...). Spotify track and album URLs continue to work normally.
2. Reliable matching
Web2mp3 uses two ways to match audio and meta data. First it looks both if the
artist and track name overlap. Second it checks if the durations of the items
are roughly equal. The acceptable range (as percentage difference) is the
duration tolerance (see tolerance in the CLI argument list). Duration is
of especial benefit to avoid downloading audio with video clip intro chatter.
3. Minimal user input
In matching the audio the metadata, web2mp3 automatically compares several items and selects the most appropriate. When no appropriate match could be found the user is requested for input. The options are:
1-5Select any item from the listRetryGive the user an option to type a query to search forManualManually input all meta data informationAbortCancel download
When running large batches of songs (e.g., a long playlist), it is best to set
the --response flag to either Abort or 1. This way, downloads will be
added without interruption. If, after this run has completed, you do care about
manually selecting matches that were not matched automatically, you can now
rerun the previous command and will only be prompted with tracks that could not
be added during the first run.
4. Concise managing of downloads
To speed up the downloading process, Web2mp3 stores a download history in the
index (/src/index/index.sqlite3, a single SQLite database — earlier
versions stored one file per tracked URI; if you're upgrading from one of
those, run python src/migrate_index_to_sqlite.py once to convert your
existing history over before running anything else). Run
docker compose exec web2mp3 inspect any time to see how many
tracks are processed vs. still pending. To avoid these checks, the
--do_overwrite flag can be passed.
As a final check before downloading , Web2mp3 checks if the song to be
downloaded does not already exist in the music directory. It does this by
checking if the artist already has a song downloaded containing this song name.
It usually works fine, but in case you want to turn it off you can pass the
--avoid_duplicates flag
-
Clone the repo:
git clone https://github.com/MGPoirot/web2mp3.git cd web2mp3 -
Create a Spotify app for API credentials (used to look up track metadata): go to https://developer.spotify.com/dashboard and create an app, then copy its Client ID and Client Secret. In that app's settings, add
https://maartenpoirot.com/contactas a Redirect URI — Spotify requires this exact URI (web2mp3's default login callback) to be explicitly allow-listed on your app before login will work. If you'd rather use your own redirect page, setSPOTIPY_REDIRECT_URIin.envto it instead and register that one. -
Configure:
cp .env.example .envEdit
.envand setHOST_MUSIC_DIR(where your music library should live on this machine) andSPOTIPY_CLIENT_ID/SPOTIPY_CLIENT_SECRETfrom step 2. Everything else already has a working default (see "Running with Docker" below for what each value does). -
Build and start the container:
docker compose up -d --build -
Download something. The first real download is also when Spotify's one-time browser login happens, so run this one interactively (
-it, no--headless):docker compose exec -it web2mp3 dl https://www.youtube.com/watch?v=N4bFqW_eu2IThis prints a Spotify login URL — open it in your own browser, log in and authorize, then copy the full URL of the page you land on afterward and paste it back into the terminal when prompted. That login is cached in
.config/.spotify_cache, so it only happens once. Once the match succeeds you'll land in the>>> URL or [Abort]?prompt — typeAbortto exit. -
For every download after that, this is all you need:
docker compose exec web2mp3 dl --headless <url>
See "Running with Docker" below for the cookie/inspect commands, what
each .env value does, migrating an existing non-Docker install, and
deploying via a GUI stack manager (Portainer, OpenMediaVault, etc.).
Web2mp3 runs as a Docker container via docker compose, keeping the Python
environment self-contained and confining filesystem access to a handful of
explicit mounts (music library, config/auth, logs, download index) instead
of the whole host — plus an in-memory tmpfs mount for daemon coordination
locks, which is intentionally not persisted (see below). A pre-built image
is also published to ghcr.io/mgpoirot/web2mp3 on every tagged release
(build: . in docker-compose.yml is what makes --build above work from
a source checkout; omitting --build instead pulls the published image).
.env (from step 3 above) is the only config file — there's no separate
app-level config and no interactive setup wizard.
SPOTIPY_CLIENT_ID/SPOTIPY_CLIENT_SECRET are the only values you must
set (there's no sensible default for someone else's Spotify credentials);
everything else falls back to a sensible default with a printed console
notice if you leave it out (e.g. LOCATION falls back to US).
The container process runs as a non-root user, remapped at startup to the
PUID/PGID you set in .env (default 1000/1000) — no rebuild needed
to change them, they just need to match whatever user/group owns
HOST_MUSIC_DIR and the config/log/index directories below, so downloaded
files come out writable/readable as expected. Check with id -u/id -g, or
stat -c '%u %g' /path/to/your/Music.
The container stays running as a background service — this matters because
downloads happen in detached DAEMON processes that must keep running after a
given command returns, and Docker tears down a container's whole process
tree once its main command exits. /app/.daemons (the PID lock files that
track which DAEMON slot is doing what) is mounted as tmpfs rather than
bind-mounted to disk: it's purely in-container coordination state, wiped
clean on every container start, so there's nothing stale left behind by a
hard crash or docker compose kill to clean up. Actual commands are run
against the already-running container with docker compose exec, through
three small commands installed in the image (plain docker compose exec
defaults to root, since the image has no static user baked in — each of
these drops to the same PUID/PGID-mapped user as the main process
before running anything):
# download a URL, non-interactively
docker compose exec web2mp3 dl --headless <url>
# interactive wizard (also how you'd redo the Spotify login, if ever needed)
docker compose exec -it web2mp3 dl
# check cookie file setup (guides you through adding one if missing)
docker compose exec web2mp3 cookie
# check index status (how many tracks processed vs. still pending)
docker compose exec web2mp3 inspect
inspect (also works via plain docker exec <container-name> inspect if
you're not using docker compose exec) prints something like:
A cookie file was found: "/app/.config/cookies.txt"
INDEX INFORMATION:
- number of processed records 94250
- number of unprocessed records 14
- location /app/src/index/index.sqlite3
To enable age-restricted downloads, drop a *_cookies.txt file (see
"Downloading Age restricted content" below) into ./.config/ on the host —
it's bind-mounted into the container and auto-detected there. Run
docker compose exec web2mp3 cookie any time to check whether the one
currently in place looks valid.
Migrating an existing (non-Docker) install: point HOST_MUSIC_DIR at
your existing music library and keep using your existing .config/,
.logs/, and src/index/ directories as-is (leave any existing
.daemons/ behind — it's not used by the container, see above) — they
bind-mount straight into the container at the same relative paths the app
already uses. Copy your SPOTIPY_CLIENT_ID/SPOTIPY_CLIENT_SECRET/
LOCATION values from wherever your old install kept them into the new
root .env — that's the only file read now. MUSIC_DIR, COOKIE_FILE and
DENO_BIN don't need to be set at all: MUSIC_DIR always defaults to
/music in the container, and the cookie file/Deno binary are auto-detected
fresh on every start. If those directories were previously created by a
different user/UID (e.g. you ran web2mp3 as root before Dockerizing it),
the container's non-root user won't be able to write to the existing files
in them; fix this once with:
sudo chown -R <PUID>:<PGID> .config .logs src/index
using the same PUID/PGID values as in your .env.
Troubleshooting DNS: docker-compose.yml sets explicit dns: servers
(1.1.1.1, 8.8.8.8) because Docker's embedded resolver can fail to pick
up a working upstream nameserver from some hosts, which otherwise shows up
as Failed to resolve '...' (Temporary failure in name resolution). If
your network blocks public DNS or you'd rather use your own resolver,
change or remove those entries.
The published image means the stack is fully self-contained — no source
checkout, no local build. That makes it a good fit for any tool that just
wants a compose body pasted in (Portainer's "Stacks", OpenMediaVault's
Compose plugin, Unraid's Compose Manager, and so on):
Compose body (note the absolute bind-mount paths — GUI stack managers
typically store the compose file under their own internal location, so
relative ./ paths won't resolve to anywhere useful; point them at
wherever you want this state to actually live on disk):
services:
web2mp3:
image: ghcr.io/mgpoirot/web2mp3:latest
init: true
restart: unless-stopped
dns:
- 1.1.1.1
- 8.8.8.8
ports:
- "${GUI_PORT:-4546}:${GUI_PORT:-4546}"
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
SPOTIPY_CLIENT_ID: ${SPOTIPY_CLIENT_ID}
SPOTIPY_CLIENT_SECRET: ${SPOTIPY_CLIENT_SECRET}
LOCATION: ${LOCATION:-US}
GUI_ENABLED: ${GUI_ENABLED:-true}
GUI_PORT: ${GUI_PORT:-4546}
GUI_PASSWORD: ${GUI_PASSWORD:-}
volumes:
- ${HOST_MUSIC_DIR}:/music
- ${WEB2MP3_STATE_DIR}/.config:/app/.config
- ${WEB2MP3_STATE_DIR}/.logs:/app/.logs
- ${WEB2MP3_STATE_DIR}/src/index:/app/src/index
- ${WEB2MP3_STATE_DIR}/.gui:/app/.gui
tmpfs:
- /app/.daemonsEnv body:
HOST_MUSIC_DIR=/path/to/your/Music
WEB2MP3_STATE_DIR=/path/to/wherever/web2mp3/state/should/live
PUID=1000
PGID=1000
SPOTIPY_CLIENT_ID=
SPOTIPY_CLIENT_SECRET=
LOCATION=US
GUI_ENABLED=true
GUI_PORT=4546
GUI_PASSWORD=
After creating the stack, pull and start it from the GUI, then run the first-time Spotify OAuth login and subsequent downloads the same way as above, from a shell:
docker compose -p web2mp3 exec -it web2mp3 dl
(-p web2mp3 — or whatever project name the GUI assigned the stack —
targets the right compose project when running commands outside the GUI.)
Web2mp3 can also run its own small web page — not to be confused with the GUI stack managers (Portainer/OpenMediaVault) described above. It's one page: paste a URL into a textbox, watch the exact same matching → download → tagging pipeline stream live, and browse previous submissions from a sidebar. It's on by default; disable it if you don't want it.
# in .env
GUI_ENABLED=true # set to false to disable it entirely
GUI_PORT=4546 # host and container port (kept in sync)
GUI_PASSWORD= # optional; unset = no auth (fine for LAN-only use)
With docker-compose.yml's port mapping in place, visit
http://<host>:4546. If GUI_PASSWORD is set, your browser will prompt for
credentials (any username, that password) the first time — this applies to
every request including the live connection, so a wrong password simply
won't stream anything back.
If a submission's match is ambiguous, the exact same prompt the CLI would
show appears as an input box right in the page — answer it the same way
you would at a terminal (a number to pick a candidate, Manual, Abort,
etc.) and the stream continues. Each submission runs as its own isolated
process, so multiple people can use the page at once without one
submission's output leaking into another's.
Setting GUI_ENABLED=false returns the container to exactly its previous
behavior — it just idles, and dl/cookie/inspect via docker exec are
completely unaffected either way; the GUI is an additive alternative to
those, not a replacement.
Directory structure follows the recommendation by Plex Media Server:1
Music
└───Album Artist
└───Album Name
├───1 - Track Name.mp3
└───folder.jpg
Web2MP3 was tested on Windows and Linux. It requires minimal core dependencies. Starts with ytmusicapi to identify the video with the given URL. Then uses spotipy to get metadata. After which it uses yt-dlp to download audio, and finally eye3d for handling mp3 tags. pytube is optional to get a list of URLS from a playlist. See requirements.txt. Tested on Linux and Windows. In short:
- Python
v3.10: not compatible with lower versions because I like the clarity of type union type hinting (str | Path) eyed3: Reading and writing MP3 tagsrequests: Unwrapping shortened Spotify URLsspotipy: Reading metadata through the Spotify APIyt-dlp: Downloading YouTube resourcesytmusicapi: Searching for YouTube resources
An important part of this tool is to match the audio to Spotify meta-data, or inversely, find the right audio to download to a Spotify track.
-
toleranceAccepted duration difference as float. The percentage difference that the Spotify meta-data and the audio can have to still be a match as float, default is0.10(10% difference) A higher percentage decreases the number of false negatives (missed correct matches) but increases the chance on false positives (incorrect matches). -
default_marketThe Spotify API market as string. Not all tracks are available on every market. This way, the search result during matching might not return the expected results. Default is set upon initialization is advised to set this setting to your nationality. See the Spotify API guide for accepted market strings: https://engineering.atspotify.com/2015/03/understanding-spotify-web-api/ -
search_limitThe Number of tracks to check for match as integer. When matching this is the number of tracks that is compared before considering the matching attempt as failed. The default is5. Since for each matching attempt a single call is made, this increases the API call for all matching attempts. A higher number increases the chance of finding a acceptable match. However, since results are returned in order of relevance, the effectiveness of these comparisons in general decreases, increasing compute, computation time, network traffic and chance on time out errors. -
avoid_duplicatesWhether to skip a track if it exists as Boolean. Options:True(default)
By default, we will look in our MUSIC_DIR to see if a track exists already usingutils.track_exists. This avoids downloading the same track twice if -for example- the same track has been released as single on an album, so general checking if the file exists is not enough.False
The drawback of avoiding duplicates is that -for example- Live versions might be skipped if a studio recording of the same track is already in the MUSIC_DIR.
-
print_spaceThe number of whitespaces used when logging as integer This is purely cosmetic to the matching process. Default is24. A higher number might render the matching process as more clear, but only if your screen width can handle it. -
max_time_outsThe number of attempts when TimeOut as integer The Spotify API might return HTTPSTimeOutErrors, not frequently, but it can happen. In these cases, we do not want to give up and call the entire matching process quits right away. Instead, we wait for a second and try again. This number defines how many times we will reattempt before we give up. Default is10. See alsoutils.timeout_handler. -
preferred_qualityAudio downloading quality as integer in kB/s The benefit of a higher number is higher audio quality, but at the cost if increasing file size. Default is320kB/s. Common values are 64 (very low quality), 128 (low quality), 192 (medium quality), 256 (high quality) and 320 (very high quality).
In general DAEMONS are headless background processes. For this application, DAEMONs are used to perform the downloading of audio and cover images, and mp3 meta-data tags.
By performing these tasks in the background, the semi-supervised process of matching audio with meta-data is not interrupted.
After each match, songs are stored in the song database (SDB). DAEMONs will
attempt to process any unprocessed items from the index and finish when there is
nothing left. Since DAEMONs are headless by default, they store logbooks to the
.log directory.
init_daemonsWhen to initiate DAEMONs as string, not case sensitive. Options:'during'or'd'(default)
DAEMONs will start downloading as soon as possible, which is the fasted option.'after'or'a'
DAEMONS will start downloading after completing the matching process of the track URL or playlist URL provided. This can be chosen if due to limited computing power, multiprocessing might destabilize your machine.'not'or'n'Do not start DAEMONs automatically. This is useful when you are hitting Spotify Web API throttling (HTTP 429): it lets you complete the Spotify-heavy matching phase first, and only start downloads later (manually) to avoid overlapping Spotify calls with other network activity.
Spotify rate limiting is calculated over a rolling 30-second window. If the app receives an HTTP 429 from Spotify, Web2MP3 will:
-
Log the throttling event including the
Retry-Aftervalue when provided. -
Wait for
Retry-Afterseconds (or fall back to a capped exponential backoff if the header is missing), retrying up to--max-time-outs. -
After the first 429 is observed, enable pacing for subsequent Spotify Web API calls: enforce a minimum of 0.4 seconds between Spotify API calls (≈150 calls/minute) to stay under the commonly observed ~180 calls/minute ceiling.
References:
- Repeated HTTP 429 discussion (Spotipy): https://stackoverflow.com/questions/78411698/spotify-api-repeated-http-429-error-using-spotipy
- 180 calls/minute statement (example call limiting writeup): https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e
DAEMONs can then be initiated manually by running python download_daemon.py
(inside the container: docker compose exec web2mp3 setpriv --reuid "$PUID" --regid "$PGID" --clear-groups python src/download_daemon.py --verbose
— the same privilege-drop dl/cookie/inspect do internally, spelled
out manually since this isn't one of them).
You might want to choose this for the same reason as 2) but you also have multiple URLs, or if you want to manually want to run download_daemon.py in verbose mode and do not want all tasks in the SDB
to be processed straight away.
max_daemons: number of DAEMONS to spawn when download_daemon.py is called. Default is4. A higher number is faster but requires more computational power.
Verbose Mode Since DAEMONS are run in the background by default, you might not immediately notice errors until checking the logs, and even then see how fast single items are being processed. Therefor, there is the option to run in verbose mode:
-
verboseWhether to run in verbose mode. Options are:False(default)
Initiate DAEMONS in the background and store logs to .logs directoryTrue
Initiate a single process and print the logging data to the console.
-
verbose_singleWhether to only perform a single item when in verbose mode as Boolean 1.True(default) Only process a single item, then return. If your sole intent is to check if the downloading process succeeds or fails, this is your best option. Afterwards you can continue debugging or running downloads without verbose mode. 2.FalseKeep processing items. If you like looking at every one of your downloads being processed this is your option. This might be useful when downloads only break every so often and you do no not want to find out later in the logs.
Downloading age restricted content from YouTube requires a www.youtube.com_cookies.txt cookies file containing the __Secure-1PSID cookie.
Run docker compose exec web2mp3 cookie at any time to check
whether a cookie file is currently configured and looks valid — if not, it
prints the same setup steps below directly to the console.
Instructions on how to get this file can be found here. 1. Install extension "Get cookies.txt LOCALLY", 2. Go to YouTube, 3. Open the extension, 4. Export your cookies,
Place the cookie file into ./.config/ on the host, with a name ending in '*_cookies.txt' — it's bind-mounted into the container and auto-detected there. Since these files are private, the .gitignore is set up to ignore these files. This is an example of what the cookies file will require to contain:
# Netscape HTTP Cookie File
.youtube.com TRUE / TRUE 2715301110 __Secure-1PSID
HiIB398G9unpNIO8IBU9ihkb8y7jhv7YIVOB_867vYIVGhuv78_vyuio68_n8og8oV8Log.
Audio you download using this script can not contain third-party intellectual property (such as copyrighted material) unless you have permission from that party or are otherwise legally entitled to do so (including by way of any available exceptions or limitations to copyright or related rights provided for in European Union law). You are legally responsible for the Content you submit to the Service.
python 3.15or abovespotipyyt-dlpytmusicapirequestsclickeyed3
ffmpeg