Ability for Navidrome to have custom client app per mime type, so can have custom transcoders per audio file type. Change stream to stream rather than buffer response in byte array

This commit is contained in:
simojenki
2021-04-19 10:36:40 +10:00
parent 9458da74ed
commit 759592767f
8 changed files with 485 additions and 265 deletions

View File

@@ -61,6 +61,7 @@ BONOB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or omm
BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos
BONOB_SONOS_SERVICE_ID | 246 | service id for sonos BONOB_SONOS_SERVICE_ID | 246 | service id for sonos
BONOB_NAVIDROME_URL | http://localhost:4533 | URL for navidrome BONOB_NAVIDROME_URL | http://localhost:4533 | URL for navidrome
BONOB_STREAM_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
## Initialising service within sonos app ## Initialising service within sonos app

View File

@@ -1,7 +1,7 @@
import sonos, { bonobService } from "./sonos"; import sonos, { bonobService } from "./sonos";
import server from "./server"; import server from "./server";
import logger from "./logger"; import logger from "./logger";
import { Navidrome } from "./navidrome"; import { DEFAULT, Navidrome, appendMimeTypeToClientFor } from "./navidrome";
import encryption from "./encryption"; import encryption from "./encryption";
import { InMemoryAccessTokens, sha256 } from "./access_tokens"; import { InMemoryAccessTokens, sha256 } from "./access_tokens";
import { InMemoryLinkCodes } from "./link_codes"; import { InMemoryLinkCodes } from "./link_codes";
@@ -23,21 +23,28 @@ const bonob = bonobService(
const secret = process.env["BONOB_SECRET"] || "bonob"; const secret = process.env["BONOB_SECRET"] || "bonob";
const sonosSystem = sonos(SONOS_DEVICE_DISCOVERY, SONOS_SEED_HOST); const sonosSystem = sonos(SONOS_DEVICE_DISCOVERY, SONOS_SEED_HOST);
if(process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") { if (process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
sonosSystem.register(bonob).then(success => { sonosSystem.register(bonob).then((success) => {
if(success) { if (success) {
logger.info(`Successfully registered ${bonob.name}(SID:${bonob.sid}) with sonos`) logger.info(
`Successfully registered ${bonob.name}(SID:${bonob.sid}) with sonos`
);
} }
}) });
} }
const customClientsFor = process.env["BONOB_STREAM_CUSTOM_CLIENTS"] || "none";
const streamUserAgent =
customClientsFor == "none" ? DEFAULT : appendMimeTypeToClientFor(customClientsFor.split(","));
const app = server( const app = server(
sonosSystem, sonosSystem,
bonob, bonob,
WEB_ADDRESS, WEB_ADDRESS,
new Navidrome( new Navidrome(
process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533", process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533",
encryption(secret) encryption(secret),
streamUserAgent
), ),
new InMemoryLinkCodes(), new InMemoryLinkCodes(),
new InMemoryAccessTokens(sha256(secret)) new InMemoryAccessTokens(sha256(secret))

View File

@@ -115,10 +115,10 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges"; export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
export type Stream = { export type TrackStream = {
status: number; status: number;
headers: Record<StreamingHeader, string>; headers: Record<StreamingHeader, string>;
data: Buffer; stream: any;
}; };
export type CoverArt = { export type CoverArt = {
@@ -152,7 +152,7 @@ export interface MusicLibrary {
}: { }: {
trackId: string; trackId: string;
range: string | undefined; range: string | undefined;
}): Promise<Stream>; }): Promise<TrackStream>;
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>; coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
scrobble(id: string): Promise<boolean> scrobble(id: string): Promise<boolean>
} }

View File

@@ -16,8 +16,8 @@ import {
MusicLibrary, MusicLibrary,
Images, Images,
AlbumSummary, AlbumSummary,
NO_IMAGES,
Genre, Genre,
Track,
} from "./music_service"; } from "./music_service";
import X2JS from "x2js"; import X2JS from "x2js";
import sharp from "sharp"; import sharp from "sharp";
@@ -199,7 +199,6 @@ const asTrack = (album: Album, song: song) => ({
artist: { artist: {
id: song._artistId, id: song._artistId,
name: song._artist, name: song._artist,
image: NO_IMAGES,
}, },
}); });
@@ -210,7 +209,10 @@ const asAlbum = (album: album) => ({
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
}); });
export const asGenre = (genreName: string) => ({ id: genreName, name: genreName }); export const asGenre = (genreName: string) => ({
id: genreName,
name: genreName,
});
const maybeAsGenre = (genreName: string | undefined): Genre | undefined => const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
pipe( pipe(
@@ -219,13 +221,32 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
O.getOrElseW(() => undefined) O.getOrElseW(() => undefined)
); );
export type StreamClientApplication = (track: Track) => string;
export const DEFAULT_CLIENT_APPLICATION = "bonob";
export const USER_AGENT = "bonob";
export const DEFAULT: StreamClientApplication = (_: Track) =>
DEFAULT_CLIENT_APPLICATION;
export function appendMimeTypeToClientFor(mimeTypes: string[]) {
return (track: Track) =>
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
}
export class Navidrome implements MusicService { export class Navidrome implements MusicService {
url: string; url: string;
encryption: Encryption; encryption: Encryption;
streamClientApplication: StreamClientApplication;
constructor(url: string, encryption: Encryption) { constructor(
url: string,
encryption: Encryption,
streamClientApplication: StreamClientApplication = DEFAULT
) {
this.url = url; this.url = url;
this.encryption = encryption; this.encryption = encryption;
this.streamClientApplication = streamClientApplication;
} }
get = async ( get = async (
@@ -237,14 +258,14 @@ export class Navidrome implements MusicService {
axios axios
.get(`${this.url}${path}`, { .get(`${this.url}${path}`, {
params: { params: {
...q,
u: username, u: username,
...t_and_s(password),
v: "1.16.1", v: "1.16.1",
c: "bonob", c: DEFAULT_CLIENT_APPLICATION,
...t_and_s(password),
...q,
}, },
headers: { headers: {
"User-Agent": "bonob", "User-Agent": USER_AGENT,
}, },
...config, ...config,
}) })
@@ -373,6 +394,17 @@ export class Navidrome implements MusicService {
} }
); );
getTrack = (credentials: Credentials, id: string) =>
this.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
id,
})
.then((it) => it.song)
.then((song) =>
this.getAlbum(credentials, song._albumId).then((album) =>
asTrack(album, song)
)
);
async login(token: string) { async login(token: string) {
const navidrome = this; const navidrome = this;
const credentials: Credentials = this.parseToken(token); const credentials: Credentials = this.parseToken(token);
@@ -391,7 +423,7 @@ export class Navidrome implements MusicService {
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> => albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
navidrome navidrome
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", { .getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
...pick(q, 'type', 'genre'), ...pick(q, "type", "genre"),
size: Math.min(MAX_ALBUM_LIST, q._count), size: Math.min(MAX_ALBUM_LIST, q._count),
offset: q._index, offset: q._index,
}) })
@@ -431,17 +463,7 @@ export class Navidrome implements MusicService {
.then((album) => .then((album) =>
(album.song || []).map((song) => asTrack(asAlbum(album), song)) (album.song || []).map((song) => asTrack(asAlbum(album), song))
), ),
track: (trackId: string) => track: (trackId: string) => navidrome.getTrack(credentials, trackId),
navidrome
.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
id: trackId,
})
.then((it) => it.song)
.then((song) =>
navidrome
.getAlbum(credentials, song._albumId)
.then((album) => asTrack(album, song))
),
stream: async ({ stream: async ({
trackId, trackId,
range, range,
@@ -449,36 +471,38 @@ export class Navidrome implements MusicService {
trackId: string; trackId: string;
range: string | undefined; range: string | undefined;
}) => }) =>
navidrome navidrome.getTrack(credentials, trackId).then((track) =>
.get( navidrome
credentials, .get(
`/rest/stream`, credentials,
{ id: trackId }, `/rest/stream`,
{ { id: trackId, c: this.streamClientApplication(track) },
headers: pipe( {
range, headers: pipe(
O.fromNullable, range,
O.map((range) => ({ O.fromNullable,
"User-Agent": "bonob", O.map((range) => ({
Range: range, "User-Agent": USER_AGENT,
})), Range: range,
O.getOrElse(() => ({ })),
"User-Agent": "bonob", O.getOrElse(() => ({
})) "User-Agent": USER_AGENT,
), }))
responseType: "arraybuffer", ),
} responseType: "stream",
) }
.then((res) => ({ )
status: res.status, .then((res) => ({
headers: { status: res.status,
"content-type": res.headers["content-type"], headers: {
"content-length": res.headers["content-length"], "content-type": res.headers["content-type"],
"content-range": res.headers["content-range"], "content-length": res.headers["content-length"],
"accept-ranges": res.headers["accept-ranges"], "content-range": res.headers["content-range"],
}, "accept-ranges": res.headers["accept-ranges"],
data: Buffer.from(res.data, "binary"), },
})), stream: res.data,
}))
),
coverArt: async (id: string, type: "album" | "artist", size?: number) => { coverArt: async (id: string, type: "album" | "artist", size?: number) => {
if (type == "album") { if (type == "album") {
return navidrome.getCoverArt(credentials, id, size).then((res) => ({ return navidrome.getCoverArt(credentials, id, size).then((res) => ({

View File

@@ -141,19 +141,19 @@ function server(
it.scrobble(id).then((scrobbleSuccess) => { it.scrobble(id).then((scrobbleSuccess) => {
if (scrobbleSuccess) logger.info(`Scrobbled ${id}`); if (scrobbleSuccess) logger.info(`Scrobbled ${id}`);
else logger.warn(`Failed to scrobble ${id}....`); else logger.warn(`Failed to scrobble ${id}....`);
return it; return it;
}) })
) )
.then((it) => .then((it) =>
it.stream({ trackId: id, range: req.headers["range"] || undefined }) it.stream({ trackId: id, range: req.headers["range"] || undefined })
) )
.then((stream) => { .then((trackStream) => {
res.status(stream.status); res.status(trackStream.status);
Object.entries(stream.headers) Object.entries(trackStream.headers)
.filter(([_, v]) => v !== undefined) .filter(([_, v]) => v !== undefined)
.forEach(([header, value]) => res.setHeader(header, value)); .forEach(([header, value]) => res.setHeader(header, value));
res.send(stream.data);
trackStream.stream.pipe(res);
}); });
} }
}); });

View File

@@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
import { Credentials } from "../src/smapi"; import { Credentials } from "../src/smapi";
import { Service, Device } from "../src/sonos"; import { Service, Device } from "../src/sonos";
import { Album, Artist, Track } from "../src/music_service"; import { Album, Artist, Track, albumToAlbumSummary, artistToArtistSummary } from "../src/music_service";
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
@@ -110,8 +110,8 @@ export function aTrack(fields: Partial<Track> = {}): Track {
duration: randomInt(500), duration: randomInt(500),
number: randomInt(100), number: randomInt(100),
genre: randomGenre(), genre: randomGenre(),
artist: anArtist(), artist: artistToArtistSummary(anArtist()),
album: anAlbum(), album: albumToAlbumSummary(anAlbum()),
...fields, ...fields,
}; };
} }

View File

@@ -8,6 +8,7 @@ import {
BROWSER_HEADERS, BROWSER_HEADERS,
DODGY_IMAGE_NAME, DODGY_IMAGE_NAME,
asGenre, asGenre,
appendMimeTypeToClientFor
} from "../src/navidrome"; } from "../src/navidrome";
import encryption from "../src/encryption"; import encryption from "../src/encryption";
@@ -29,7 +30,6 @@ import {
Track, Track,
AlbumSummary, AlbumSummary,
artistToArtistSummary, artistToArtistSummary,
NO_IMAGES,
AlbumQuery, AlbumQuery,
} from "../src/music_service"; } from "../src/music_service";
import { anAlbum, anArtist, aTrack } from "./builders"; import { anAlbum, anArtist, aTrack } from "./builders";
@@ -64,6 +64,31 @@ describe("isDodgyImage", () => {
}); });
}); });
describe("appendMimeTypeToUserAgentFor", () => {
describe("when empty array", () => {
it("should return bonob", () => {
expect(appendMimeTypeToClientFor([])(aTrack())).toEqual("bonob");
});
});
describe("when contains some mimeTypes", () => {
const streamUserAgent = appendMimeTypeToClientFor(["audio/flac", "audio/ogg"])
describe("and the track mimeType is in the array", () => {
it("should return bonob+mimeType", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/flac"}))).toEqual("bonob+audio/flac")
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg"}))).toEqual("bonob+audio/ogg")
});
});
describe("and the track mimeType is not in the array", () => {
it("should return bonob", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3"}))).toEqual("bonob")
});
});
});
});
const ok = (data: string) => ({ const ok = (data: string) => ({
status: 200, status: 200,
data, data,
@@ -188,7 +213,8 @@ describe("Navidrome", () => {
const password = "pass1"; const password = "pass1";
const salt = "saltysalty"; const salt = "saltysalty";
const navidrome = new Navidrome(url, encryption("secret")); const streamClientApplication = jest.fn();
const navidrome = new Navidrome(url, encryption("secret"), streamClientApplication);
const mockedRandomString = (randomString as unknown) as jest.Mock; const mockedRandomString = (randomString as unknown) as jest.Mock;
const mockGET = jest.fn(); const mockGET = jest.fn();
@@ -1312,10 +1338,7 @@ describe("Navidrome", () => {
name: "Bob Marley", name: "Bob Marley",
albums: [album], albums: [album],
}); });
const artistSummary = { const artistSummary = artistToArtistSummary(artist);
...artistToArtistSummary(artist),
image: NO_IMAGES,
};
const tracks = [ const tracks = [
aTrack({ artist: artistSummary, album: albumSummary, genre: hipHop }), aTrack({ artist: artistSummary, album: albumSummary, genre: hipHop }),
@@ -1374,10 +1397,7 @@ describe("Navidrome", () => {
name: "Bob Marley", name: "Bob Marley",
albums: [album], albums: [album],
}); });
const artistSummary = { const artistSummary = artistToArtistSummary(artist);
...artistToArtistSummary(artist),
image: NO_IMAGES,
};
const tracks = [ const tracks = [
aTrack({ aTrack({
@@ -1464,10 +1484,7 @@ describe("Navidrome", () => {
name: "Bob Marley", name: "Bob Marley",
albums: [album], albums: [album],
}); });
const artistSummary = { const artistSummary = artistToArtistSummary(artist);
...artistToArtistSummary(artist),
image: NO_IMAGES,
};
const track = aTrack({ const track = aTrack({
artist: artistSummary, artist: artistSummary,
@@ -1514,85 +1531,47 @@ describe("Navidrome", () => {
describe("streaming a track", () => { describe("streaming a track", () => {
const trackId = uuid(); const trackId = uuid();
const genre = { id: "foo", name: "foo" };
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { const album = anAlbum({ genre });
it("should return undefined values", async () => { const artist = anArtist({
const streamResponse = { albums: [album],
status: 200, image: { large: "foo", medium: undefined, small: undefined },
headers: { });
"content-type": "audio/mpeg", const track = aTrack({
}, id: trackId,
data: Buffer.from("the track", "ascii"), album: albumToAlbumSummary(album),
}; artist: artistToArtistSummary(artist),
genre,
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({
"content-type": "audio/mpeg",
"content-length": undefined,
"content-range": undefined,
"accept-ranges": undefined,
});
});
}); });
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { describe("content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => { beforeEach(() => {
const streamResponse = { streamClientApplication.mockReturnValue("bonob");
status: 200,
headers: {
"content-type": "audio/mpeg",
"content-length": undefined,
"content-range": undefined,
"accept-ranges": undefined,
},
data: Buffer.from("the track", "ascii"),
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({
"content-type": "audio/mpeg",
"content-length": undefined,
"content-range": undefined,
"accept-ranges": undefined,
});
}); });
});
describe("with no range specified", () => { describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
describe("navidrome returns a 200", () => { it("should return undefined values", async () => {
it("should return the content", async () => { const stream = {
pipe: jest.fn()
};
const streamResponse = { const streamResponse = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mpeg", "content-type": "audio/mpeg",
"content-length": "1667",
"content-range": "-200",
"accept-ranges": "bytes",
"some-other-header": "some-value",
}, },
data: Buffer.from("the track", "ascii"), data: stream,
}; };
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome const result = await navidrome
@@ -1603,11 +1582,183 @@ describe("Navidrome", () => {
expect(result.headers).toEqual({ expect(result.headers).toEqual({
"content-type": "audio/mpeg", "content-type": "audio/mpeg",
"content-length": "1667", "content-length": undefined,
"content-range": "-200", "content-range": undefined,
"accept-ranges": "bytes", "accept-ranges": undefined,
}); });
expect(result.data.toString()).toEqual("the track"); });
});
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => {
const stream = {
pipe: jest.fn()
};
const streamResponse = {
status: 200,
headers: {
"content-type": "audio/mpeg",
"content-length": undefined,
"content-range": undefined,
"accept-ranges": undefined,
},
data: stream,
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({
"content-type": "audio/mpeg",
"content-length": undefined,
"content-range": undefined,
"accept-ranges": undefined,
});
});
});
describe("with no range specified", () => {
describe("navidrome returns a 200", () => {
it("should return the content", async () => {
const stream = {
pipe: jest.fn()
};
const streamResponse = {
status: 200,
headers: {
"content-type": "audio/mpeg",
"content-length": "1667",
"content-range": "-200",
"accept-ranges": "bytes",
"some-other-header": "some-value",
},
data: stream,
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({
"content-type": "audio/mpeg",
"content-length": "1667",
"content-range": "-200",
"accept-ranges": "bytes",
});
expect(result.stream).toEqual(stream);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: {
id: trackId,
...authParams,
},
headers: {
"User-Agent": "bonob",
},
responseType: "stream",
});
});
});
describe("navidrome returns something other than a 200", () => {
it("should return the content", async () => {
const trackId = "track123";
const streamResponse = {
status: 400,
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const musicLibrary = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken));
return expect(
musicLibrary.stream({ trackId, range: undefined })
).rejects.toEqual(`Navidrome failed with a 400`);
});
});
});
describe("with range specified", () => {
it("should send the range to navidrome", async () => {
const stream = {
pipe: jest.fn()
};
const range = "1000-2000";
const streamResponse = {
status: 200,
headers: {
"content-type": "audio/flac",
"content-length": "66",
"content-range": "100-200",
"accept-ranges": "none",
"some-other-header": "some-value",
},
data: stream,
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range }));
expect(result.headers).toEqual({
"content-type": "audio/flac",
"content-length": "66",
"content-range": "100-200",
"accept-ranges": "none",
});
expect(result.stream).toEqual(stream);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: { params: {
@@ -1616,79 +1767,98 @@ describe("Navidrome", () => {
}, },
headers: { headers: {
"User-Agent": "bonob", "User-Agent": "bonob",
Range: range,
}, },
responseType: "arraybuffer", responseType: "stream",
}); });
}); });
}); });
describe("navidrome returns something other than a 200", () => {
it("should return the content", async () => {
const trackId = "track123";
const streamResponse = {
status: 400,
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const musicLibrary = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken));
return expect(
musicLibrary.stream({ trackId, range: undefined })
).rejects.toEqual(`Navidrome failed with a 400`);
});
});
}); });
describe("with range specified", () => { describe("when navidrome has a custom StreamClientApplication registered", () => {
it("should send the range to navidrome", async () => { describe("when no range specified", () => {
const range = "1000-2000"; it("should user the custom StreamUserAgent when calling navidrome", async () => {
const streamResponse = { const clientApplication = `bonob-${uuid()}`;
status: 200, streamClientApplication.mockReturnValue(clientApplication);
headers: {
"content-type": "audio/flac", const streamResponse = {
"content-length": "66", status: 200,
"content-range": "100-200", headers: {
"accept-ranges": "none", "content-type": "audio/mpeg",
"some-other-header": "some-value", },
}, data: Buffer.from("the track", "ascii"),
data: Buffer.from("the track", "ascii"), };
};
mockGET
mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
const result = await navidrome )
.generateToken({ username, password }) .mockImplementationOnce(() => Promise.resolve(streamResponse));
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) await navidrome
.then((it) => it.stream({ trackId, range })); .generateToken({ username, password })
.then((it) => it as AuthSuccess)
expect(result.headers).toEqual({ .then((it) => navidrome.login(it.authToken))
"content-type": "audio/flac", .then((it) => it.stream({ trackId, range: undefined }));
"content-length": "66",
"content-range": "100-200", expect(streamClientApplication).toHaveBeenCalledWith(track);
"accept-ranges": "none", expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: {
id: trackId,
...authParams,
c: clientApplication
},
headers: {
"User-Agent": "bonob",
},
responseType: "stream",
});
}); });
expect(result.data.toString()).toEqual("the track"); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { describe("when range specified", () => {
params: { it("should user the custom StreamUserAgent when calling navidrome", async () => {
id: trackId, const range = "1000-2000";
...authParams, const clientApplication = `bonob-${uuid()}`;
}, streamClientApplication.mockReturnValue(clientApplication);
headers: {
"User-Agent": "bonob", const streamResponse = {
Range: range, status: 200,
}, headers: {
responseType: "arraybuffer", "content-type": "audio/mpeg",
},
data: Buffer.from("the track", "ascii"),
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range }));
expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: {
id: trackId,
...authParams,
c: clientApplication
},
headers: {
"User-Agent": "bonob",
Range: range,
},
responseType: "stream",
});
}); });
}); });
}); });

View File

@@ -9,6 +9,7 @@ import { aDevice, aService } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import { ExpiringAccessTokens } from "../src/access_tokens"; import { ExpiringAccessTokens } from "../src/access_tokens";
import { InMemoryLinkCodes } from "../src/link_codes"; import { InMemoryLinkCodes } from "../src/link_codes";
import { Response } from "express";
describe("server", () => { describe("server", () => {
beforeEach(() => { beforeEach(() => {
@@ -243,46 +244,50 @@ describe("server", () => {
describe("scrobbling", () => { describe("scrobbling", () => {
describe("when scrobbling succeeds", () => { describe("when scrobbling succeeds", () => {
it("should scrobble the track", async () => { it("should scrobble the track", async () => {
const stream = { const trackStream = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mp3", "content-type": "audio/mp3",
}, },
data: Buffer.from("some track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
}); });
}); });
describe("when scrobbling succeeds", () => { describe("when scrobbling succeeds", () => {
it("should still return the track", async () => { it("should still return the track", async () => {
const stream = { const trackStream = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mp3", "content-type": "audio/mp3",
}, },
data: Buffer.from("some track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(false); musicLibrary.scrobble.mockResolvedValue(false);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
}); });
}); });
@@ -291,25 +296,26 @@ describe("server", () => {
describe("when sonos does not ask for a range", () => { describe("when sonos does not ask for a range", () => {
describe("when the music service does not return a content-range, content-length or accept-ranges", () => { describe("when the music service does not return a content-range, content-length or accept-ranges", () => {
it("should return a 200 with the data, without adding the undefined headers", async () => { it("should return a 200 with the data, without adding the undefined headers", async () => {
const stream = { const trackStream = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mp3", "content-type": "audio/mp3",
}, },
data: Buffer.from("some track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual("audio/mp3") expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
expect(Object.keys(res.headers)).not.toContain("content-range") expect(Object.keys(res.headers)).not.toContain("content-range")
expect(Object.keys(res.headers)).not.toContain("accept-ranges") expect(Object.keys(res.headers)).not.toContain("accept-ranges")
}); });
@@ -317,7 +323,7 @@ describe("server", () => {
describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => { describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => {
it("should return a 200 with the data, without adding the undefined headers", async () => { it("should return a 200 with the data, without adding the undefined headers", async () => {
const stream = { const trackStream = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mp3", "content-type": "audio/mp3",
@@ -325,20 +331,21 @@ describe("server", () => {
"accept-ranges": undefined, "accept-ranges": undefined,
"content-range": undefined, "content-range": undefined,
}, },
data: Buffer.from("some track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual("audio/mp3") expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
expect(Object.keys(res.headers)).not.toContain("content-range") expect(Object.keys(res.headers)).not.toContain("content-range")
expect(Object.keys(res.headers)).not.toContain("accept-ranges") expect(Object.keys(res.headers)).not.toContain("accept-ranges")
}); });
@@ -346,7 +353,7 @@ describe("server", () => {
describe("when the music service returns a 200", () => { describe("when the music service returns a 200", () => {
it("should return a 200 with the data", async () => { it("should return a 200 with the data", async () => {
const stream = { const trackStream = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mp3", "content-type": "audio/mp3",
@@ -354,26 +361,31 @@ describe("server", () => {
"accept-ranges": "bytes", "accept-ranges": "bytes",
"content-range": "-100", "content-range": "-100",
}, },
data: Buffer.from("some track", "ascii"), stream: {
pipe: (res: Response) => {
console.log("calling send on response")
res.send("")
}
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
stream.headers["content-type"] `${trackStream.headers["content-type"]}; charset=utf-8`
); );
expect(res.header["accept-ranges"]).toEqual( expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"] trackStream.headers["accept-ranges"]
); );
expect(res.header["content-range"]).toEqual( expect(res.header["content-range"]).toEqual(
stream.headers["content-range"] trackStream.headers["content-range"]
); );
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
@@ -383,7 +395,7 @@ describe("server", () => {
describe("when the music service returns a 206", () => { describe("when the music service returns a 206", () => {
it("should return a 206 with the data", async () => { it("should return a 206 with the data", async () => {
const stream = { const trackStream = {
status: 206, status: 206,
headers: { headers: {
"content-type": "audio/ogg", "content-type": "audio/ogg",
@@ -391,26 +403,28 @@ describe("server", () => {
"accept-ranges": "bytez", "accept-ranges": "bytez",
"content-range": "100-200", "content-range": "100-200",
}, },
data: Buffer.from("some other track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
stream.headers["content-type"] `${trackStream.headers["content-type"]}; charset=utf-8`
); );
expect(res.header["accept-ranges"]).toEqual( expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"] trackStream.headers["accept-ranges"]
); );
expect(res.header["content-range"]).toEqual( expect(res.header["content-range"]).toEqual(
stream.headers["content-range"] trackStream.headers["content-range"]
); );
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
@@ -422,7 +436,7 @@ describe("server", () => {
describe("when sonos does ask for a range", () => { describe("when sonos does ask for a range", () => {
describe("when the music service returns a 200", () => { describe("when the music service returns a 200", () => {
it("should return a 200 with the data", async () => { it("should return a 200 with the data", async () => {
const stream = { const trackStream = {
status: 200, status: 200,
headers: { headers: {
"content-type": "audio/mp3", "content-type": "audio/mp3",
@@ -430,11 +444,13 @@ describe("server", () => {
"accept-ranges": "bytes", "accept-ranges": "bytes",
"content-range": "-100", "content-range": "-100",
}, },
data: Buffer.from("some track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
@@ -442,15 +458,15 @@ describe("server", () => {
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "3000-4000"); .set("Range", "3000-4000");
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
stream.headers["content-type"] `${trackStream.headers["content-type"]}; charset=utf-8`
); );
expect(res.header["accept-ranges"]).toEqual( expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"] trackStream.headers["accept-ranges"]
); );
expect(res.header["content-range"]).toEqual( expect(res.header["content-range"]).toEqual(
stream.headers["content-range"] trackStream.headers["content-range"]
); );
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
@@ -463,7 +479,7 @@ describe("server", () => {
describe("when the music service returns a 206", () => { describe("when the music service returns a 206", () => {
it("should return a 206 with the data", async () => { it("should return a 206 with the data", async () => {
const stream = { const trackStream = {
status: 206, status: 206,
headers: { headers: {
"content-type": "audio/ogg", "content-type": "audio/ogg",
@@ -471,11 +487,13 @@ describe("server", () => {
"accept-ranges": "bytez", "accept-ranges": "bytez",
"content-range": "100-200", "content-range": "100-200",
}, },
data: Buffer.from("some other track", "ascii"), stream: {
pipe: (res: Response) => res.send("")
}
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true); musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
@@ -483,15 +501,15 @@ describe("server", () => {
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "4000-5000"); .set("Range", "4000-5000");
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
stream.headers["content-type"] `${trackStream.headers["content-type"]}; charset=utf-8`
); );
expect(res.header["accept-ranges"]).toEqual( expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"] trackStream.headers["accept-ranges"]
); );
expect(res.header["content-range"]).toEqual( expect(res.header["content-range"]).toEqual(
stream.headers["content-range"] trackStream.headers["content-range"]
); );
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);