basic navidrome implementation

This commit is contained in:
simojenki
2021-03-01 17:28:48 +11:00
parent 3b350c4402
commit 007db24713
17 changed files with 305 additions and 105 deletions

View File

@@ -1,19 +1,25 @@
import sonos, { bonobService } from "./sonos";
import server from "./server";
import logger from "./logger";
import { Navidrome } from './music_service';
import { Navidrome } from "./navidrome";
import encryption from "./encryption";
const PORT = +(process.env["BONOB_PORT"] || 4534);
const WEB_ADDRESS = process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`;
const WEB_ADDRESS =
process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`;
const bonob = bonobService(
process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
Number(process.env["BONOS_SONOS_SERVICE_ID"] || "246"),
WEB_ADDRESS,
'AppLink'
"AppLink"
);
const app = server(
sonos(process.env["BONOB_SONOS_SEED_HOST"]),
bonob,
WEB_ADDRESS,
new Navidrome(process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533", encryption())
);
const app = server(sonos(process.env["BONOB_SONOS_SEED_HOST"]), bonob, WEB_ADDRESS, new Navidrome());
app.listen(PORT, () => {
logger.info(`Listening on ${PORT} available @ ${WEB_ADDRESS}`);

33
src/encryption.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
const ALGORITHM = "aes-256-cbc"
const IV = randomBytes(16);
const KEY = randomBytes(32);
export type Hash = {
iv: string,
encryptedData: string
}
export type Encryption = {
encrypt: (value:string) => Hash
decrypt: (hash: Hash) => string
}
const encryption = (): Encryption => {
return {
encrypt: (value: string) => {
const cipher = createCipheriv(ALGORITHM, KEY, IV);
return {
iv: IV.toString("hex"),
encryptedData: Buffer.concat([cipher.update(value), cipher.final()]).toString("hex")
};
},
decrypt: (hash: Hash) => {
const decipher = createDecipheriv(ALGORITHM, KEY, Buffer.from(hash.iv, 'hex'));
return Buffer.concat([decipher.update(Buffer.from(hash.encryptedData, 'hex')), decipher.final()]).toString();
}
}
}
export default encryption;

View File

@@ -1,10 +1,3 @@
import axios from "axios";
import { Md5 } from "ts-md5/dist/md5";
const s = "foobar100";
const navidrome = process.env["BONOB_NAVIDROME_URL"];
const u = process.env["BONOB_USER"];
const t = Md5.hashStr(`${process.env["BONOB_PASSWORD"]}${s}`);
export type Credentials = { username: string; password: string };
@@ -31,8 +24,8 @@ export type AuthFailure = {
};
export interface MusicService {
generateToken(credentials: Credentials): AuthSuccess | AuthFailure;
login(authToken: string): MusicLibrary | AuthFailure;
generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>;
login(authToken: string): Promise<MusicLibrary>;
}
export type Artist = {
@@ -50,38 +43,3 @@ export interface MusicLibrary {
artist(id: string): Artist;
albums({ artistId, _index, _count }: { artistId?: string, _index?: number, _count?: number }): Album[];
}
export class Navidrome implements MusicService {
generateToken({ username }: Credentials) {
return {
authToken: `v1:${username}`,
userId: username,
nickname: username,
};
}
login(_: string) {
return {
artists: () => [],
artist: (id: string) => ({
id,
name: id,
}),
albums: ({ artistId }: { artistId?: string }) => {
console.log(artistId)
return []
},
};
}
ping = (): Promise<boolean> =>
axios
.get(
`${navidrome}/rest/ping.view?u=${u}&t=${t}&s=${s}&v=1.16.1.0&c=myapp`
)
.then((_) => true)
.catch((e) => {
console.log(e);
return false;
});
}

91
src/navidrome.ts Normal file
View File

@@ -0,0 +1,91 @@
import { Md5 } from "ts-md5/dist/md5";
import { Credentials, MusicService } from "./music_service";
import X2JS from "x2js";
import axios from "axios";
import { Encryption } from "./encryption";
import randomString from "./random_string";
export const t = (password: string, s: string) =>
Md5.hashStr(`${password}${s}`);
export type SubconicEnvelope = {
"subsonic-response": SubsonicResponse;
};
export type SubsonicResponse = {
_status: string;
};
export type SubsonicError = SubsonicResponse & {
error: {
_code: string;
_message: string;
};
};
export function isError(
subsonicResponse: SubsonicResponse
): subsonicResponse is SubsonicError {
return (subsonicResponse as SubsonicError).error !== undefined;
}
export class Navidrome implements MusicService {
url: string;
encryption: Encryption;
constructor(url: string, encryption: Encryption) {
this.url = url;
this.encryption = encryption;
}
generateToken = async ({ username, password }: Credentials) => {
const s = randomString();
return axios
.get(
`${this.url}/rest/ping.view?u=${username}&t=${t(
password,
s
)}&s=${s}&v=1.16.1.0&c=bonob`
)
.then((response) => new X2JS().xml2js(response.data) as SubconicEnvelope)
.then((json) => json["subsonic-response"])
.then((json) => {
if (isError(json)) throw json.error._message;
else return json;
})
.then((_) => {
return {
authToken: Buffer.from(
JSON.stringify(
this.encryption.encrypt(JSON.stringify({ username, password }))
)
).toString("base64"),
userId: username,
nickname: username,
};
});
};
parseToken = (token: string): Credentials =>
JSON.parse(
this.encryption.decrypt(
JSON.parse(Buffer.from(token, "base64").toString("ascii"))
)
);
async login(_: string) {
// const credentials: Credentials = this.parseToken(token);
return Promise.resolve({
artists: () => [],
artist: (id: string) => ({
id,
name: id,
}),
albums: ({ artistId }: { artistId?: string }) => {
console.log(artistId);
return [];
},
});
}
}

6
src/random_string.ts Normal file
View File

@@ -0,0 +1,6 @@
import { randomBytes } from "crypto";
const randomString = () => randomBytes(32).toString('hex')
export default randomString

View File

@@ -69,27 +69,27 @@ function server(
});
});
app.post(LOGIN_ROUTE, (req, res) => {
app.post(LOGIN_ROUTE, async (req, res) => {
const { username, password, linkCode } = req.body;
const authResult = musicService.generateToken({
username,
password,
});
if (isSuccess(authResult)) {
if (linkCodes.has(linkCode)) {
if (!linkCodes.has(linkCode)) {
res.status(400).render("failure", {
message: `Invalid linkCode!`,
});
} else {
const authResult = await musicService.generateToken({
username,
password,
});
if (isSuccess(authResult)) {
linkCodes.associate(linkCode, authResult);
res.render("success", {
message: `Login successful`,
});
} else {
res.status(400).render("failure", {
message: `Invalid linkCode!`,
} else {
res.status(403).render("failure", {
message: `Login failed, ${authResult.message}!`,
});
}
} else {
res.status(403).render("failure", {
message: `Login failed, ${authResult.message}!`,
});
}
});

View File

@@ -6,7 +6,7 @@ import path from "path";
import logger from "./logger";
import { LinkCodes } from "./link_codes";
import { isFailure, MusicLibrary, MusicService } from "./music_service";
import { MusicLibrary, MusicService } from "./music_service";
export const LOGIN_ROUTE = "/login";
export const SOAP_PATH = "/ws/sonos";
@@ -176,7 +176,7 @@ function bindSmapiSoapServiceToExpress(
getAppLink: () => sonosSoap.getAppLink(),
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
sonosSoap.getDeviceAuthToken({ linkCode }),
getMetadata: (
getMetadata: async (
{
id,
index,
@@ -194,17 +194,16 @@ function bindSmapiSoapServiceToExpress(
},
};
}
const login = musicService.login(
const login = await musicService.login(
headers.credentials.loginToken.token
);
if (isFailure(login)) {
).catch(_ => {
throw {
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
},
};
}
});
const musicLibrary = login as MusicLibrary;

View File

@@ -129,7 +129,6 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
}
})
.catch((e) => {
console.log("booom");
logger.error(`Failed looking for sonos devices ${e}`);
return [];
});