initial commit
This commit is contained in:
commit
c6362020c1
|
@ -0,0 +1,9 @@
|
||||||
|
.dub
|
||||||
|
docs.json
|
||||||
|
__dummy.html
|
||||||
|
*.o
|
||||||
|
*.obj
|
||||||
|
__test__*__
|
||||||
|
qobuz-get
|
||||||
|
*.swp
|
||||||
|
magic.json
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "qobuz-get",
|
||||||
|
"authors": [
|
||||||
|
"Al Beano"
|
||||||
|
],
|
||||||
|
"libs": [
|
||||||
|
"curl"
|
||||||
|
],
|
||||||
|
"description": "Tool to download albums from Qobuz.",
|
||||||
|
"copyright": "Copyright (C) 2017, Al Beano",
|
||||||
|
"license": "GPLv2"
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"ffmpeg" : "/usr/bin/ffmpeg",
|
||||||
|
|
||||||
|
"app_secret" : "your_secret",
|
||||||
|
"app_id" : "your_id",
|
||||||
|
"user_auth_token" : "your_token"
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
import std.stdio, std.regex, std.json, std.file, std.datetime, std.conv, std.process, std.net.curl;
|
||||||
|
import qobuz.api;
|
||||||
|
|
||||||
|
int main(string[] args)
|
||||||
|
{
|
||||||
|
if (args.length != 2) {
|
||||||
|
writefln("Usage: %s <album id or url>", args[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto path = thisExePath();
|
||||||
|
path = path.replaceFirst(regex("qobuz-get$"), "magic.json"); // HACK
|
||||||
|
string json;
|
||||||
|
try {
|
||||||
|
json = readText(path);
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Could not open magic.json!");
|
||||||
|
}
|
||||||
|
auto magic = parseJSON(json);
|
||||||
|
|
||||||
|
// strip url part if we have it
|
||||||
|
string id;
|
||||||
|
auto urlPart = regex("^https?://play.qobuz.com/album/");
|
||||||
|
if (args[1].matchFirst(urlPart)) {
|
||||||
|
id = args[1].replaceFirst(urlPart, "");
|
||||||
|
} else {
|
||||||
|
id = args[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln("Looking up album...");
|
||||||
|
auto album = getAlbum(magic, id);
|
||||||
|
|
||||||
|
string title, artist, genre, year;
|
||||||
|
JSONValue[] tracks;
|
||||||
|
|
||||||
|
try {
|
||||||
|
title = album["title"].str;
|
||||||
|
artist = album["artist"]["name"].str;
|
||||||
|
genre = album["genres_list"][0].str;
|
||||||
|
auto releaseTime = SysTime.fromUnixTime(album["released_at"].integer, UTC());
|
||||||
|
year = releaseTime.year.text;
|
||||||
|
|
||||||
|
writefln("[ %s - %s (%s, %s) ]", artist, title, genre, year);
|
||||||
|
|
||||||
|
tracks = album["tracks"]["items"].array();
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Could not parse album data!");
|
||||||
|
return -4;
|
||||||
|
}
|
||||||
|
|
||||||
|
string dirName = artist~" - "~title~" ("~year~") [WEB FLAC]";
|
||||||
|
mkdir(dirName);
|
||||||
|
|
||||||
|
foreach (i, track; tracks) {
|
||||||
|
auto num = (i+1).text;
|
||||||
|
string url, trackName;
|
||||||
|
try {
|
||||||
|
trackName = track["title"].str;
|
||||||
|
if (num.length < 2)
|
||||||
|
num = "0"~num;
|
||||||
|
writef(" [%s] %s... ", num, trackName);
|
||||||
|
stdout.flush;
|
||||||
|
url = getDownloadUrl(magic, track["id"].integer.text);
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Failed to parse track data!");
|
||||||
|
return -7;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
auto pipes = pipeProcess([magic["ffmpeg"].str, "-i", "-", "-metadata", "title="~trackName, "-metadata", "author="~artist,
|
||||||
|
"-metadata", "album="~title, "-metadata", "year="~year, "-metadata", "track="~num, "-metadata", "genre="~genre,
|
||||||
|
dirName~"/"~num~" "~trackName~".flac"], Redirect.stdin | Redirect.stderr | Redirect.stdout);
|
||||||
|
foreach (chunk; byChunkAsync(url, 1024)) {
|
||||||
|
pipes.stdin.rawWrite(chunk);
|
||||||
|
pipes.stdin.flush;
|
||||||
|
}
|
||||||
|
pipes.stdin.close;
|
||||||
|
wait(pipes.pid);
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Failed to download track! Check that ffmpeg is properly configured.");
|
||||||
|
return -8;
|
||||||
|
}
|
||||||
|
writeln("Done!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
module qobuz.api;
|
||||||
|
import core.stdc.stdlib, std.digest.md, std.conv, std.uni, std.json, std.net.curl, std.stdio, std.datetime;
|
||||||
|
import std.algorithm : sort;
|
||||||
|
|
||||||
|
string createSignature(string obj, string method, string[string] params, string tstamp, string secret) {
|
||||||
|
string str = obj;
|
||||||
|
str ~= method;
|
||||||
|
foreach (k; sort(params.keys)) {
|
||||||
|
str ~= k ~ params[k];
|
||||||
|
}
|
||||||
|
str ~= tstamp;
|
||||||
|
str ~= secret;
|
||||||
|
|
||||||
|
auto md5 = new MD5Digest();
|
||||||
|
return md5.digest(str).toHexString.toLower;
|
||||||
|
}
|
||||||
|
|
||||||
|
string apiRequest(JSONValue magic, string request) {
|
||||||
|
auto curl = HTTP();
|
||||||
|
curl.addRequestHeader("x-app-id", magic["app_id"].str);
|
||||||
|
curl.addRequestHeader("x-user-auth-token", magic["user_auth_token"].str);
|
||||||
|
// curl.proxy = "localhost:8080";
|
||||||
|
|
||||||
|
string jsonResponse;
|
||||||
|
try {
|
||||||
|
jsonResponse = get("http://qobuz.com/api.json/0.2/"~request, curl).text();
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Request to qobuz failed!");
|
||||||
|
exit(-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONValue getAlbum(JSONValue magic, string id) {
|
||||||
|
try {
|
||||||
|
return apiRequest(magic, "album/get?offset=0&limit=500&album_id="~id).parseJSON;
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Invalid JSON data!");
|
||||||
|
exit(-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
string buildGETRequest(string[string] params) {
|
||||||
|
string req;
|
||||||
|
foreach (i, k; params.keys) {
|
||||||
|
if (i == 0)
|
||||||
|
req ~= "?";
|
||||||
|
else
|
||||||
|
req ~= "&";
|
||||||
|
req ~= k~"="~params[k];
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
string getDownloadUrl(JSONValue magic, string id) {
|
||||||
|
string[string] params;
|
||||||
|
params["track_id"] = id;
|
||||||
|
params["format_id"] = "6"; // TODO: support for multiple formats
|
||||||
|
params["intent"] = "stream";
|
||||||
|
|
||||||
|
auto tstamp = Clock.currTime.toUnixTime.text;
|
||||||
|
auto sig = createSignature("track", "getFileUrl", params, tstamp, magic["app_secret"].str);
|
||||||
|
|
||||||
|
JSONValue response;
|
||||||
|
try {
|
||||||
|
response = apiRequest(magic, "track/getFileUrl"~buildGETRequest(params)~"&request_ts="~tstamp~"&request_sig="~sig).parseJSON;
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("Invalid JSON data!");
|
||||||
|
exit(-5);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return response["url"].str;
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeln("No download URI given!");
|
||||||
|
exit(-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(0);
|
||||||
|
}
|
Loading…
Reference in New Issue