Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
"scripts": {
"clean": "rm -Rf build node_modules",
"build": "tsc",
"dev": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
"devr": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
"dev": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
"devr": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
"test": "jest",
"gitinfo": "git describe --tags > .gitinfo"
Expand Down
4 changes: 2 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import logger from "./logger";

import {
appendMimeTypeToClientFor,
axiosImageFetcher,
cachingImageFetcher,
DEFAULT,
Subsonic,
} from "./subsonic";
Expand All @@ -17,6 +15,7 @@ import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service";
import { SystemClock } from "./clock";
import { JWTSmapiLoginTokens } from "./smapi_auth";
import { axiosImageFetcher, cachingImageFetcher } from "./images";

const config = readConfig();
const clock = SystemClock;
Expand All @@ -32,6 +31,7 @@ const bonob = bonobService(

const sonosSystem = sonos(config.sonos.discovery);

// todo: just pass in the customClientsForStringArray into subsonic and make it sort it out.
const streamUserAgent = config.subsonic.customClientsFor
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
: DEFAULT;
Expand Down
67 changes: 67 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
AxiosPromise,
AxiosRequestConfig,
Method,
ResponseType,
} from "axios";

// todo: do i need this anymore?
export interface Http {
(config: AxiosRequestConfig): AxiosPromise<any>;
}
export interface Http2 extends Http {
with: (params: Partial<RequestParams>) => Http2;
}

export type RequestParams = {
baseURL: string;
url: string;
params: any;
headers: any;
responseType: ResponseType;
method: Method;
};

const wrap = (http2: Http2, params: Partial<RequestParams>): Http2 => {
const f = ((config: AxiosRequestConfig) => http2(merge(params, config))) as Http2;
f.with = (params: Partial<RequestParams>) => wrap(f, params);
return f;
};

export const http2From = (http: Http): Http2 => {
const f = ((config: AxiosRequestConfig) => http(config)) as Http2;
f.with = (defaults: Partial<RequestParams>) => wrap(f, defaults);
return f;
}

const merge = (
defaults: Partial<RequestParams>,
config: AxiosRequestConfig
) => {
let toApply = {
...defaults,
...config,
};
if (defaults.params) {
toApply = {
...toApply,
params: {
...defaults.params,
...config.params,
},
};
}
if (defaults.headers) {
toApply = {
...toApply,
headers: {
...defaults.headers,
...config.headers,
},
};
}
return toApply;
};

export const http =
(base: Http, defaults: Partial<RequestParams>): Http => (config: AxiosRequestConfig) => base(merge(defaults, config));
48 changes: 48 additions & 0 deletions src/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

import sharp from "sharp";
import fse from "fs-extra";
import path from "path";
import { Md5 } from "ts-md5/dist/md5";
import axios from "axios";

import { CoverArt } from "./music_service";
import { BROWSER_HEADERS } from "./utils";

export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;

export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) =>
async (url: string): Promise<CoverArt | undefined> => {
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
return fse
.readFile(filename)
.then((data) => ({ contentType: "image/png", data }))
.catch(() =>
delegate(url).then((image) => {
if (image) {
return sharp(image.data)
.png()
.toBuffer()
.then((png) => {
return fse
.writeFile(filename, png)
.then(() => ({ contentType: "image/png", data: png }));
});
} else {
return undefined;
}
})
);
};

export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
axios
.get(url, {
headers: BROWSER_HEADERS,
responseType: "arraybuffer",
})
.then((res) => ({
contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"),
}))
.catch(() => undefined);
21 changes: 16 additions & 5 deletions src/music_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ export class AuthFailure extends Error {
}
};

export type IdName = {
id: string;
name: string;
};

export type ArtistSummary = {
// todo: why can this be undefined?
id: string | undefined;
name: string;
image: BUrn | undefined;
Expand Down Expand Up @@ -65,18 +71,19 @@ export type Track = {
};

export type Paging = {
_index: number;
_count: number;
_index: number | undefined;
_count: number | undefined;
};

export type Result<T> = {
results: T[];
total: number;
};

export function slice2<T>({ _index, _count }: Paging) {
export function slice2<T>({ _index, _count }: Partial<Paging> = {}) {
const i = _index || 0;
return (things: T[]): [T[], number] => [
things.slice(_index, _index + _count),
_count ? things.slice(i, i + _count) : things.slice(i),
things.length,
];
}
Expand Down Expand Up @@ -138,6 +145,10 @@ export type Playlist = PlaylistSummary & {
entries: Track[]
}

export type Sortable = {
sortName: string
}

export const range = (size: number) => [...Array(size).keys()];

export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
Expand All @@ -152,7 +163,7 @@ export interface MusicService {
}

export interface MusicLibrary {
artists(q: ArtistQuery): Promise<Result<ArtistSummary>>;
artists(q: ArtistQuery): Promise<Result<ArtistSummary & Sortable>>;
artist(id: string): Promise<Artist>;
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
album(id: string): Promise<Album>;
Expand Down
28 changes: 15 additions & 13 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,10 @@ import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
import { Icon, ICONS, festivals, features } from "./icon";
import _, { shuffle } from "underscore";
import morgan from "morgan";
import { takeWithRepeats } from "./utils";
import { mask, takeWithRepeats } from "./utils";
import { parse } from "./burn";
import { axiosImageFetcher, ImageFetcher } from "./subsonic";
import {
JWTSmapiLoginTokens,
SmapiAuthTokens,
} from "./smapi_auth";
import { axiosImageFetcher, ImageFetcher } from "./images";
import { JWTSmapiLoginTokens, SmapiAuthTokens } from "./smapi_auth";

export const BONOB_ACCESS_TOKEN_HEADER = "bat";

Expand Down Expand Up @@ -377,23 +374,28 @@ function server(
logger.info(
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
req.query
)}, headers=${JSON.stringify({ ...req.headers, "bnbt": "*****", "bnbk": "*****" })}`
)}, headers=${JSON.stringify(mask(req.headers, ["bnbt", "bnbk"]))}`
);

const serviceToken = pipe(
E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string),
E.chain(token => pipe(
E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string),
E.map(key => ({ token, key }))
)),
E.chain((token) =>
pipe(
E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string),
E.map((key) => ({ token, key }))
)
),
E.chain((auth) =>
pipe(
smapiAuthTokens.verify(auth),
E.mapLeft((_) => "Auth token failed to verify")
)
),
E.getOrElseW(() => undefined)
)
E.getOrElseW((e: string) => {
logger.error(`Failed to get serviceToken for stream: ${e}`);
return undefined;
})
);

if (!serviceToken) {
return res.status(401).send();
Expand Down
67 changes: 67 additions & 0 deletions src/smapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Playlist,
Rating,
slice2,
Sortable,
Track,
} from "./music_service";
import { APITokens } from "./api_tokens";
Expand Down Expand Up @@ -366,6 +367,54 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(),
});

export const scrollIndicesFrom = (things: Sortable[]) => {
const indicies: Record<string, number | undefined> = {
"A":undefined,
"B":undefined,
"C":undefined,
"D":undefined,
"E":undefined,
"F":undefined,
"G":undefined,
"H":undefined,
"I":undefined,
"J":undefined,
"K":undefined,
"L":undefined,
"M":undefined,
"N":undefined,
"O":undefined,
"P":undefined,
"Q":undefined,
"R":undefined,
"S":undefined,
"T":undefined,
"U":undefined,
"V":undefined,
"W":undefined,
"X":undefined,
"Y":undefined,
"Z":undefined,
}
const upperNames = things.map(thing => thing.sortName.toUpperCase());
for(var i = 0; i < upperNames.length; i++) {
const char = upperNames[i]![0]!;
if(Object.keys(indicies).includes(char) && indicies[char] == undefined) {
indicies[char] = i;
}
}
var lastIndex = 0;
const result: string[] = [];
Object.entries(indicies).forEach(([letter, index]) => {
result.push(letter);
if(index) {
lastIndex = index;
}
result.push(`${lastIndex}`);
})
return result.join(",")
}

function splitId<T>(id: string) {
const [type, typeId] = id.split(":");
return (t: T) => ({
Expand Down Expand Up @@ -707,6 +756,7 @@ function bindSmapiSoapServiceToExpress(
title: lang("artists"),
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
itemType: "container",
canScroll: true,
},
{
id: "albums",
Expand Down Expand Up @@ -945,6 +995,23 @@ function bindSmapiSoapServiceToExpress(
throw `Unsupported getMetadata id=${id}`;
}
}),
getScrollIndices: async (
{ id }: { id: string },
_,
soapyHeaders: SoapyHeaders
) => {
switch(id) {
case "artists": {
return login(soapyHeaders?.credentials)
.then(({ musicLibrary }) => musicLibrary.artists({ _index: 0, _count: undefined }))
.then((artists) => ({
getScrollIndicesResult: scrollIndicesFrom(artists.results)
}))
}
default:
throw `Unsupported getScrollIndices id=${id}`;
}
},
createContainer: async (
{ title, seedId }: { title: string; seedId: string | undefined },
_,
Expand Down
Loading