No description
  • TypeScript 76.6%
  • JavaScript 12%
  • Nix 9.7%
  • CSS 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
haze 2b983b29c9 Write releases to the server when asked to
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.
2026-10-30 13:36:45 -04:00
app Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
components Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
config Native Country Switching | Minor UI Fixes 2025-09-27 19:03:28 +01:00
hooks Changed Indent Size | Fixed Minor CSS Issues 2025-09-27 15:06:25 +01:00
lib Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
nix Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
public Fix MD5 Hash for FLAC files 2025-03-24 00:34:17 +00:00
tools Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
.env.example Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
.gitignore Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
.prettierignore Use prettier for consistent linting 2025-09-01 09:03:47 -06:00
.prettierrc Native Country Switching | Minor UI Fixes 2025-09-27 19:03:28 +01:00
changelog.json Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
components.json Changed Indent Size | Fixed Minor CSS Issues 2025-09-27 15:06:25 +01:00
docker-compose.yml Changed Indent Size | Fixed Minor CSS Issues 2025-09-27 15:06:25 +01:00
Dockerfile Create Dockerfile 2025-06-18 21:24:06 +03:00
eslint.config.mjs Native Country Switching | Minor UI Fixes 2025-09-27 19:03:28 +01:00
flake.lock Add nix package derivation and NixOS module 2026-10-30 10:32:10 -04:00
flake.nix Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
LICENSE Create LICENSE 2025-01-02 00:00:40 +00:00
next.config.ts Changed Indent Size | Fixed Minor CSS Issues 2025-09-27 15:06:25 +01:00
package-lock.json Native Country Switching | Minor UI Fixes 2025-09-27 19:03:28 +01:00
package.json Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
postcss.config.mjs Changed Indent Size | Fixed Minor CSS Issues 2025-09-27 15:06:25 +01:00
README.md Write releases to the server when asked to 2026-10-30 13:36:45 -04:00
tailwind.config.ts Native Country Switching | Minor UI Fixes 2025-09-27 19:03:28 +01:00
tsconfig.json Changed Indent Size | Fixed Minor CSS Issues 2025-09-27 15:06:25 +01:00

Qobuz-DL

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 environmentFile fails — the app cannot work without them
  • putting QOBUZ_APP_ID / QOBUZ_SECRET / QOBUZ_AUTH_TOKENS in services.qobuz-dl.environment fails — that list is rendered into the world-readable Nix store
  • openFirewall = true while host = "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 musicDirectory that is relative, ~-prefixed, or inside the Nix store fails; one under /home or /root warns, because ProtectHome = true would 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

  1. Fork the repository.
  2. Create a new branch: git checkout -b feature-name.
  3. Make your changes.
  4. Push your branch: git push origin feature-name.
  5. Create a pull request.

License

This project is licensed under the MIT License.