- TypeScript 76.6%
- JavaScript 12%
- Nix 9.7%
- CSS 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Downloading has always been a browser affair: the page pulls the audio from Qobuz's CDN, tags it with an FFmpeg WASM build, and hands you a file. That is the wrong shape for a headless host or a NAS library, where the point of leaving the box running is that it fills a music directory by itself. Setting MUSIC_DIR now lets the server stream a release, encode and tag it with a real FFmpeg, and write one folder per release under $MUSIC_DIR/Qobuz, with cover.jpg beside the tracks and CD1/, CD2/ for multi-disc releases. Only identifiers cross the wire. Folder and file names are built from Qobuz metadata and the client's own naming templates and treated as hostile from there: each becomes a single sanitised path component, and every write goes through joinUnderRoot, which refuses anything that resolves outside the root. A release is staged inside its own folder and renamed into place, so an interrupted job leaves nothing half-written for a player to index, and an empty folder is removed rather than left as a phantom album. Tracks already in the library are skipped unless "Overwrite existing files" is ticked, so re-adding an album costs nothing. The toggle has to survive a host that cannot deliver it, so a capability endpoint reports whether MUSIC_DIR resolves and is writable and whether ffmpeg runs; Settings greys the checkbox out with the reason instead of letting a download die halfway, and the album menu keeps offering ZIP while that happens because that is what the job will actually do. Server mode writes files rather than a zip, which is a browser deliverable, so the ZIP choice is replaced by a single release-folder action there. Verified against the live API: FLAC 16/44.1 and 24/96, MP3 re-encode, fixMD5 (reencode + verify pass, "Stream #0:0 OK" in the log), multi-disc placement, cover embedding, skip/overwrite, duplicate-request dedupe, cancellation, the free-space guard, and a hostile zipName of "../../../../../../tmp/pwned", which landed in the library as "_._._._._._tmp_pwned". The naming rules that keep the confinement honest are pure and unit-tested (npm test, tools/test-server-download.mjs), as are the module assertions for musicDirectory: ReadWritePaths, Environment, tmpfiles ownership, and refusals for a relative path or a library inside the store. |
||
| app | ||
| components | ||
| config | ||
| hooks | ||
| lib | ||
| nix | ||
| public | ||
| tools | ||
| .env.example | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| changelog.json | ||
| components.json | ||
| docker-compose.yml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tailwind.config.ts | ||
| tsconfig.json | ||
Qobuz-DL
Important
This repository does not contain any copyrighted material, or code to illegaly download music. Downloads are provided by the Qobuz API and should only be initiated by the API token owner. The author is not responsible for the usage of this repository nor endorses it, nor is the author responsible for any copies, forks, re-uploads made by other users, or anything else related to Qobuz-DL. Any live demo found online of this project is not associated with the authors of this repo. This is the author's only account and repository.
Qobuz-DL provides a fast and easy way to download music using Qobuz in a variety of codecs and formats entirely from the browser.
Features
- Download any song or album from Qobuz.
- Re-encode audio provided by Qobuz to a variety of different lossless and lossy codecs using FFmpeg.
- Apply metadata to downloaded songs.
Table of Contents
Installation
Before you begin, ensure you have the following installed:
-
Node.js (LTS version recommended)
Download from: https://nodejs.org/ -
npm (comes with Node.js)
To check if npm is installed, run:npm -v
Getting Started
1. Clone the repo
git clone https://github.com/QobuzDL/Qobuz-DL.git
2. Navigate to the project directory
cd Qobuz-DL
3. Install Dependencies
npm i
4. Run the development server
npm run dev
Docker Installation
1. Clone the repo
git clone https://github.com/QobuzDL/Qobuz-DL.git
2. Navigate to the project directory
cd Qobuz-DL
3. Dockerfile build
docker build -t qobuz-dl .
4. Docker compose
docker-compose up -d
Setup .env (IMPORTANT)
Before you can use Qobuz-DL, you need to change the .env file in the root directory. The default configuration will NOT work. QOBUZ_APP_ID and QOBUZ_SECRET must be set to the correct values. To find these you can use this tool. Additionally, in order to download files longer than 30 seconds, a valid Qobuz token is needed. This can be found in the localuser.token key of localstorage on the official Qobuz website for any paying members.
Downloading to the server
By default the browser does the work: it pulls the audio from Qobuz, tags it with an FFmpeg WASM build, and saves a file to your machine. Tick Download to server (in Settings, or on the download button's menu) and the host does it instead — it streams the release, encodes and tags it with a real FFmpeg, and writes it to your library. This is what you want on a headless box, and it is how you fill a NAS library from a browser tab.
One folder per release, under a fixed Qobuz subfolder of your library:
/mnt/nvmes/Music/Qobuz/
└── Boards of Canada - Music Has the Right to Children/
├── 01 Anvil Neuliah.flac
├── 02 Olsen.flac
└── cover.jpg
- The folder name comes from the Zip File Naming setting (it is labelled Release Folder Naming in server mode); track names come from Track File Naming, numbered per disc and padded to the disc's width.
- Multi-disc releases get
CD1/,CD2/subfolders. - The ZIP Archive choice is not offered in server mode — a zip is a browser deliverable. A release is written as files, so a folder of loose tracks.
- Tracks already in the folder are skipped, so re-running an album you own costs nothing. Tick Overwrite existing files to replace them instead.
- Files are written beside their final name and renamed into place, so an interrupted download never leaves a half-written track for a player to index.
Configuration
| Variable | Purpose |
|---|---|
MUSIC_DIR |
Required. Absolute path to your library. Releases go to $MUSIC_DIR/Qobuz/<release>/. Unset ⇒ the feature is off and the toggle is greyed out. |
FFMPEG_PATH |
Absolute path to ffmpeg; defaults to ffmpeg on PATH. |
DOWNLOAD_MAX_CONCURRENCY |
Releases written at once. Default 3, max 16. |
DOWNLOAD_WRITE_TOKEN |
Optional gate; see below. |
The host must have FFmpeg: nix run .#qobuz-dl wraps its own, and
ffmpeg is in the nix develop shell. If it is missing, the settings sheet says
so instead of failing mid-download.
Security
The app has no authentication, and starting a download is a request to write
files. Server downloads are confined to $MUSIC_DIR/Qobuz — folder and file
names are built from Qobuz metadata and your naming templates, sanitised to a
single path component, and every write is re-checked against the resolved root —
but anyone who can reach the port can still ask the server to spend your Qobuz
quota and fill your disk. Keep host on loopback, or authenticate at a reverse
proxy.
DOWNLOAD_WRITE_TOKEN additionally requires an X-Download-Token header on
write requests. Set NEXT_PUBLIC_DOWNLOAD_WRITE_TOKEN to the same value when
building for the browser to send it — it is inlined into the bundle, so this
raises the bar for scripts, not for a determined visitor.
Nix
A flake provides a development shell, a reproducible package, and a NixOS module.
Development shell
nix develop # or: nix flake dev
npm ci && npm run dev
Ships Node 24, git, jujutsu, gh, ripgrep, jq, and the packaged app so you
can compare against the production build. npm run lint still reports upstream's
pre-existing react/display-name error in components/country-picker.tsx.
Running the package
nix run .#qobuz-dl -- --hostname 127.0.0.1 --port 3000
Credentials are read from the environment at request time and are never baked into the Nix store:
QOBUZ_API_BASE=https://www.qobuz.com/api.json/0.2/ \
QOBUZ_APP_ID=… QOBUZ_SECRET=… QOBUZ_AUTH_TOKENS='["…"]' \
nix run .#qobuz-dl
Build-time-only settings are package arguments, because Next inlines NEXT_PUBLIC_*
into the client bundle:
nix build --impure --expr 'let f = builtins.getFlake (toString ./.);
pkgs = import f.inputs.nixpkgs { system = "x86_64-linux"; };
in f.lib.mkPackage pkgs { applicationName = "My Downloader"; }'
NixOS module
{
imports = [ inputs.qobuz-dl.nixosModules.default ];
services.qobuz-dl = {
enable = true;
port = 3000; # binds 127.0.0.1 by default
environmentFile = "/etc/qobuz-dl.env"; # mode 0600, outside the store
musicDirectory = "/mnt/nvmes/Music"; # enables downloads to the server
};
}
/etc/qobuz-dl.env holds the three secrets as KEY=value:
QOBUZ_APP_ID=…
QOBUZ_SECRET=…
QOBUZ_AUTH_TOKENS=["token1","token2"]
The module enforces three guards at evaluation time:
- enabling without
environmentFilefails — the app cannot work without them - putting
QOBUZ_APP_ID/QOBUZ_SECRET/QOBUZ_AUTH_TOKENSinservices.qobuz-dl.environmentfails — that list is rendered into the world-readable Nix store openFirewall = truewhilehost = "0.0.0.0"fails — the app has no authentication, so anyone who can reach the port can spend your Qobuz quota. Put an authenticating reverse proxy in front instead- a
musicDirectorythat is relative,~-prefixed, or inside the Nix store fails; one under/homeor/rootwarns, becauseProtectHome = truewould make it unreachable
The service runs as its own unprivileged user with the app tree mounted read-only and the usual systemd hardening.
musicDirectory is what turns on server-side downloads: it becomes MUSIC_DIR
in the unit, is added to ReadWritePaths (ProtectSystem = strict makes
everything else read-only), and is created — with its Qobuz subfolder — owned
by the service user. Leave it null and the app stays a pure browser download
client.
Fetching the app id and secret
QOBUZ_APP_ID and QOBUZ_SECRET are not credentials — they are public constants
embedded in the Qobuz web player's bundle, identical for every user. Recover them
instead of hardcoding values that Qobuz rotates:
nix run .#get-secrets # human readable
nix run .#get-secrets -- --env # QOBUZ_APP_ID=… / QOBUZ_SECRET=… , append to .env
nix run .#get-secrets -- --json # machine readable
It scrapes play.qobuz.com, reassembles the per-timezone seed fragments, and
validates each candidate against a signed track/getFileUrl probe before
reporting it. It contacts only play.qobuz.com and www.qobuz.com, needs no
credentials of its own, and has no third-party dependencies.
That leaves QOBUZ_AUTH_TOKENS, which is your actual credential and cannot be
recovered this way — see Getting Started for pulling it from
localStorage.
Tests
nix flake check
Runs the module test suite (unit generation, hardening flags, every assertion
above), and as part of the builds: an installCheckPhase that boots the
installed qobuz-dl binary and asserts it serves the index, an API route, and
the image optimizer; and an offline fixture for get-secrets that plants a
secret in a synthetic bundle and requires the tool to recover it — plus negative
cases for unparseable markup and a secret the API rejects.
Contributing
- Fork the repository.
- Create a new branch:
git checkout -b feature-name. - Make your changes.
- Push your branch:
git push origin feature-name. - Create a pull request.
License
This project is licensed under the MIT License.