├── .gitignore ├── magic.json.default ├── dub.json ├── source ├── qobuz │ └── api.d └── app.d └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .dub 2 | docs.json 3 | __dummy.html 4 | *.o 5 | *.obj 6 | __test__*__ 7 | qobuz-get 8 | *.swp 9 | magic.json 10 | qobuz-get.exe 11 | -------------------------------------------------------------------------------- /magic.json.default: -------------------------------------------------------------------------------- 1 | { 2 | "ffmpeg" : "/usr/bin/ffmpeg", 3 | "sox" : "/usr/bin/sox", 4 | "mktorrent" : "/usr/bin/mktorrent", 5 | 6 | "app_secret" : "your_secret", 7 | "app_id" : "your_id", 8 | "user_auth_token" : "your_token" 9 | } 10 | -------------------------------------------------------------------------------- /dub.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qobuz-get", 3 | "authors": [ 4 | "Al Beano" 5 | ], 6 | "libs": [ 7 | "curl" 8 | ], 9 | "description": "Tool to download albums from Qobuz.", 10 | "copyright": "Copyright (C) 2017, Al Beano", 11 | "license": "GPLv2 or later" 12 | } 13 | -------------------------------------------------------------------------------- /source/qobuz/api.d: -------------------------------------------------------------------------------- 1 | module qobuz.api; 2 | import core.stdc.stdlib, std.digest.md, std.conv, std.uni, std.json, std.net.curl, std.stdio, std.datetime; 3 | import std.algorithm : sort; 4 | 5 | string createSignature(string obj, string method, string[string] params, string tstamp, string secret) { 6 | string str = obj; 7 | str ~= method; 8 | foreach (k; sort(params.keys)) { 9 | str ~= k ~ params[k]; 10 | } 11 | str ~= tstamp; 12 | str ~= secret; 13 | 14 | auto md5 = new MD5Digest(); 15 | return md5.digest(str).toHexString.toLower; 16 | } 17 | 18 | string apiRequest(JSONValue magic, string request) { 19 | auto curl = HTTP(); 20 | curl.addRequestHeader("x-app-id", magic["app_id"].str); 21 | curl.addRequestHeader("x-user-auth-token", magic["user_auth_token"].str); 22 | // curl.proxy = "localhost:8080"; 23 | 24 | string jsonResponse; 25 | try { 26 | jsonResponse = get("http://qobuz.com/api.json/0.2/"~request, curl).text(); 27 | } catch (Exception e) { 28 | writeln("Request to qobuz failed!"); 29 | exit(-2); 30 | } 31 | 32 | return jsonResponse; 33 | } 34 | 35 | JSONValue getAlbum(JSONValue magic, string id) { 36 | try { 37 | return apiRequest(magic, "album/get?offset=0&limit=500&album_id="~id).parseJSON; 38 | } catch (Exception e) { 39 | writeln("Invalid JSON data!"); 40 | exit(-3); 41 | } 42 | 43 | assert(0); 44 | } 45 | 46 | string buildGETRequest(string[string] params) { 47 | string req; 48 | foreach (i, k; params.keys) { 49 | if (i == 0) 50 | req ~= "?"; 51 | else 52 | req ~= "&"; 53 | req ~= k~"="~params[k]; 54 | } 55 | return req; 56 | } 57 | 58 | string getDownloadUrl(JSONValue magic, string id) { 59 | string[string] params; 60 | params["track_id"] = id; 61 | params["format_id"] = "6"; // TODO: support for multiple formats 62 | params["intent"] = "stream"; 63 | 64 | auto tstamp = Clock.currTime.toUnixTime.text; 65 | auto sig = createSignature("track", "getFileUrl", params, tstamp, magic["app_secret"].str); 66 | 67 | JSONValue response; 68 | try { 69 | response = apiRequest(magic, "track/getFileUrl"~buildGETRequest(params)~"&request_ts="~tstamp~"&request_sig="~sig).parseJSON; 70 | } catch (Exception e) { 71 | writeln("Invalid JSON data!"); 72 | exit(-5); 73 | } 74 | try { 75 | return response["url"].str; 76 | } catch (Exception e) { 77 | writeln("No download URI given!"); 78 | exit(-6); 79 | } 80 | 81 | assert(0); 82 | } 83 | 84 | string getArtUrl(string id) { 85 | if (id.length != 13) { 86 | writeln("Album ID of invalid length given!"); 87 | exit(-10); 88 | } 89 | 90 | string a = id[11..13]; 91 | string b = id[9..11]; 92 | return "http://static.qobuz.com/images/covers/"~a~"/"~b~"/"~id~"_max.jpg"; 93 | } 94 | 95 | // ex: set tabstop=2 expandtab: 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qobuz-get 2 | 3 | Tool to download FLACs from qobuz.com. 4 | 5 | ## Setup 6 | 7 | **If git.fuwafuwa.moe is down, click [here](https://github.com/whiteisthenewblack/qobuz-get/files/1599102/qobuz-get-win32-1.3.zip) for the latest Windows binary.** 8 | 9 | Statically linked 64-bit Linux and Windows binaries are available in the [Releases](https://git.fuwafuwa.moe/albino/qobuz-get/releases) tab. On Linux, you should install sox, ffmpeg and mktorrent with your package manager, and insert the paths to the binaries (found using `which sox`, `which ffmpeg`, etc...) into magic.json. 10 | 11 | There are three other values which must be inserted into magic.json. `app_id` and `app_secret` are listed on [this page](http://shell.cyberia.is/~albino/qobuz-creds.html). `user_auth_token` is specific to your qobuz account. See the bottom of this README for instructions on finding it. These values could change from time to time, so if qobuz-get stops working suddenly, you probably need to get new ones. 12 | 13 | On Windows, run the `InitEnvironment.bat` script to set things up. On Linux, just call the binary from your shell. 14 | 15 | ### But what about FreeBSD, macOS, ARM....? 16 | 17 | It should be easy to build qobuz-get on any platform supported by a D compiler. Just install libcurl, libphobos, dub and a D compiler (such as DMD), then run `dub build -b release`. 18 | 19 | ## Troubleshooting 20 | 21 | ### `No such file or directory` when trying to run qobuz-get 22 | 23 | Some Linux distros might not have `/lib/ld-linux-x86-64.so.2`. If this is the case with yours, try `ln -s /lib64/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2`, it's a bit of a hack but it works. 24 | 25 | ### FFmpeg fails! 26 | 27 | On Linux, try using the statically linked ffmpeg binary provided [here](https://shell.cyberia.is/~albino/ffmpeg). 28 | 29 | ### Sox fails! 30 | 31 | Sox might not be compiled with the right features. Try compiling it yourself, using `./configure --with-flac`. Also, if you try and link sox statically, it might not work. Try with dynamic linking. 32 | 33 | ### It's still not working! 34 | 35 | Check that the values in magic.json are correct, then ask for help on IRC. Connect to `irc.rizon.net` and then join the channel `#qobuz-get`. If you want my attention, just highlight me (say "albino"). I'll try and respond quickly but please be patient. 36 | 37 | ## Finding `user_auth_token` 38 | 39 | * Open http://play.qobuz.com in your browser and log in with your credentials. 40 | * Open the 'Network' tab of your browser's developer tools. (In Firefox, right click on page -> inspect element -> select the 'Network' tab) 41 | * Open the page for any album. 42 | * In the Network window, you should see a `GET` request beginning with `get?album_id`. Select it. 43 | * You should see a list of headers on the right hand side (in Chrome, you need to click the "Headers" tab). Scroll down to the one which says `x-user-auth-token`. Select the content, and copy and paste it into magic.json. Done! 44 | -------------------------------------------------------------------------------- /source/app.d: -------------------------------------------------------------------------------- 1 | import std.stdio, std.regex, std.json, std.file, std.datetime, std.conv, std.process, std.net.curl, std.string; 2 | import qobuz.api; 3 | import etc.c.curl; 4 | 5 | int main(string[] args) 6 | { 7 | string VERSION = "1.4"; 8 | 9 | if (args.length != 2) { 10 | writefln("Usage: %s ", args[0]); 11 | return -1; 12 | } 13 | 14 | auto path = thisExePath(); 15 | path = path.replaceFirst(regex("qobuz-get(\\.exe)?$"), "magic.json"); // HACK 16 | string json; 17 | try { 18 | json = readText(path); 19 | } catch (Exception e) { 20 | writeln("Could not open magic.json!"); 21 | } 22 | auto magic = parseJSON(json); 23 | 24 | // strip url part if we have it 25 | string id; 26 | auto urlPart = regex("^https?://play.qobuz.com/album/"); 27 | if (args[1].matchFirst(urlPart)) { 28 | id = args[1].replaceFirst(urlPart, ""); 29 | } else { 30 | id = args[1]; 31 | } 32 | 33 | writeln("Looking up album..."); 34 | auto album = getAlbum(magic, id); 35 | 36 | string title, artist, genre, year; 37 | JSONValue[] tracks; 38 | 39 | try { 40 | title = album["title"].str; 41 | artist = album["artist"]["name"].str; 42 | genre = album["genre"]["name"].str; 43 | auto releaseTime = SysTime.fromUnixTime(album["released_at"].integer, UTC()); 44 | year = releaseTime.year.text; 45 | 46 | writefln("[ %s - %s (%s, %s) ]", artist, title, genre, year); 47 | 48 | tracks = album["tracks"]["items"].array(); 49 | } catch (Exception e) { 50 | writeln("Could not parse album data!"); 51 | return -4; 52 | } 53 | 54 | string dirName = artist~" - "~title~" ("~year~") [WEB FLAC]"; 55 | dirName = dirName.replaceAll(regex("[\\?<>:\"/\\\\|\\*]"), ""); 56 | 57 | try { 58 | mkdir(dirName); 59 | } catch (Exception e) { 60 | writeln("Could not create directory: `"~dirName~"`. Does it exist already?"); 61 | return -9; 62 | } 63 | 64 | auto discs = tracks[tracks.length - 1]["media_number"].integer; 65 | 66 | foreach (track; tracks) { 67 | string url, num, discNum, trackName, trackArtist; 68 | try { 69 | num = track["track_number"].integer.text; 70 | discNum = track["media_number"].integer.text; 71 | trackName = track["title"].str; 72 | try { 73 | trackArtist = track["performer"]["name"].str; 74 | } catch (Exception e) { 75 | // Qobuz doesn't return a "performer" for all albums, and I'm not sure about 76 | // the best way to deal with this. Leaving blank for now.A 77 | trackArtist = ""; 78 | } 79 | if (num.length < 2) 80 | num = "0"~num; 81 | writef(" [%s/%s] %s... ", discNum, num, trackName); 82 | stdout.flush; 83 | url = getDownloadUrl(magic, track["id"].integer.text); 84 | } catch (Exception e) { 85 | writeln("Failed to parse track data!"); 86 | return -7; 87 | } 88 | 89 | string discDir; 90 | if (discs > 1) 91 | discDir = dirName~"/Disc "~discNum; 92 | else 93 | discDir = dirName; 94 | 95 | if (!discDir.exists || !discDir.isDir) { 96 | try { 97 | mkdir(discDir); 98 | } catch (Exception e) { 99 | writeln("Failed to create directory `"~discDir~"`."); 100 | return -11; 101 | } 102 | } 103 | 104 | try { 105 | auto fileName = trackName; 106 | fileName = fileName.replaceAll(regex("[\\?<>:\"/\\\\|\\*]"), ""); 107 | auto relPath = discDir~"/"~num~" - "~fileName~".flac"; 108 | 109 | version (Windows) { 110 | // making up for NTFS/Windows inadequacy 111 | // can't really do much better than truncating, sorry. 112 | auto totalPath = getcwd() ~ relPath; 113 | if (totalPath.length > 255) { 114 | totalPath = totalPath[0..(totalPath.length - 4)]; 115 | relPath = relPath[0..(relPath.length - 4)]; 116 | while (totalPath.length > 250) { 117 | totalPath = totalPath[0..(totalPath.length - 1)]; 118 | relPath = relPath[0..(relPath.length - 1)]; 119 | } 120 | totalPath ~= ".flac"; 121 | relPath ~= ".flac"; 122 | } 123 | } 124 | 125 | auto pipes = pipeProcess([magic["ffmpeg"].str, "-i", "-", "-metadata", "title="~trackName, "-metadata", "artist="~trackArtist, 126 | "-metadata", "album="~title, "-metadata", "date="~year, "-metadata", "track="~num, "-metadata", "genre="~genre, 127 | "-metadata", "albumartist="~artist, "-metadata", "discnumber="~discNum, "-metadata", "tracktotal="~tracks.length.text, 128 | "-metadata", "disctotal="~discs.text, relPath], 129 | Redirect.stdin | Redirect.stderr | Redirect.stdout); 130 | 131 | extern(C) static size_t writefunc(const ubyte* data, size_t size, size_t nmemb, void* p) { 132 | auto pp = *(cast(ProcessPipes*) p); 133 | pp.stdin.rawWrite(data[0..size*nmemb]); 134 | pp.stdin.flush(); 135 | return size*nmemb; 136 | } 137 | 138 | CURL* curl; 139 | CURLcode res; 140 | curl = curl_easy_init(); 141 | curl_easy_setopt(curl, CurlOption.url, toStringz(url)); 142 | curl_easy_setopt(curl, CurlOption.followlocation, 1L); 143 | curl_easy_setopt(curl, CurlOption.writefunction, cast(void*) &writefunc); 144 | curl_easy_setopt(curl, CurlOption.writedata, cast(void*) &pipes); 145 | res = curl_easy_perform(curl); 146 | assert(res == CurlError.ok); 147 | curl_easy_cleanup(curl); 148 | 149 | pipes.stdin.close; 150 | wait(pipes.pid); 151 | } catch (Exception e) { 152 | writeln("Failed to download track! Check that ffmpeg is properly configured."); 153 | writeln(e.msg); 154 | return -8; 155 | } 156 | writeln("Done!"); 157 | } 158 | 159 | string firstDisc; 160 | if (discs > 1) 161 | firstDisc = dirName~"/Disc 1"; 162 | else 163 | firstDisc = dirName; 164 | 165 | // Get album art 166 | write("Getting album art... "); 167 | stdout.flush; 168 | download(id.getArtUrl, firstDisc~"/cover.jpg"); 169 | for (int i = 2; i <= discs; i++) { 170 | copy(firstDisc~"/cover.jpg", dirName~"/Disc "~i.text~"/cover.jpg"); 171 | } 172 | writeln("Done!"); 173 | 174 | string choice; 175 | while (choice != "n" && choice != "y") { 176 | write("Generate spectrals? [y/n] "); 177 | stdout.flush; 178 | choice = readln().chomp; 179 | } 180 | if (choice == "y") { 181 | try { 182 | auto trackName = tracks[0]["title"].str; 183 | trackName = trackName.replaceAll(regex("[\\?<>:\"/\\\\|\\*]"), ""); 184 | 185 | auto full = execute([magic["sox"].str, firstDisc~"/01 - "~trackName~".flac", "-n", "remix", "1", "spectrogram", 186 | "-x", "3000", "-y", "513", "-z", "120", "-w", "Kaiser", "-o", "SpecFull.png"]); 187 | auto zoom = execute([magic["sox"].str, firstDisc~"/01 - "~trackName~".flac", "-n", "remix", "1", "spectrogram", 188 | "-X", "500", "-y", "1025", "-z", "120", "-w", "Kaiser", "-S", "0:30", "-d", "0:04", "-o", "SpecZoom.png"]); 189 | if (full.status != 0 || zoom.status != 0) 190 | throw new Exception("sox failed"); 191 | writeln("SpecFull.png and SpecZoom.png written."); 192 | } catch (Exception e) { 193 | writeln("Generating spectrals failed! Is sox configured properly?"); 194 | } 195 | } 196 | 197 | choice = null; 198 | while (choice != "n" && choice != "y") { 199 | write("Create .torrent file? [y/n] "); 200 | stdout.flush; 201 | choice = readln().chomp; 202 | } 203 | if (choice == "y") { 204 | write("Announce URL: "); 205 | stdout.flush; 206 | string announce = readln().chomp; 207 | 208 | try { 209 | auto t = execute([magic["mktorrent"].str, "-l", "20", "-a", announce, dirName]); 210 | if (t.status != 0) 211 | throw new Exception("mktorrent failed"); 212 | writeln("'"~dirName~".torrent' created."); 213 | } catch (Exception e) { 214 | writeln("Creating .torrent file failed! Is mktorrent configured properly?"); 215 | } 216 | } 217 | 218 | writeln("All done, exiting."); 219 | 220 | return 0; 221 | } 222 | 223 | // ex: set tabstop=2 expandtab: 224 | --------------------------------------------------------------------------------