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_ID | 246 | service id for sonos
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

View File

@@ -1,7 +1,7 @@
import sonos, { bonobService } from "./sonos";
import server from "./server";
import logger from "./logger";
import { Navidrome } from "./navidrome";
import { DEFAULT, Navidrome, appendMimeTypeToClientFor } from "./navidrome";
import encryption from "./encryption";
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
import { InMemoryLinkCodes } from "./link_codes";
@@ -23,21 +23,28 @@ const bonob = bonobService(
const secret = process.env["BONOB_SECRET"] || "bonob";
const sonosSystem = sonos(SONOS_DEVICE_DISCOVERY, SONOS_SEED_HOST);
if(process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
sonosSystem.register(bonob).then(success => {
if(success) {
logger.info(`Successfully registered ${bonob.name}(SID:${bonob.sid}) with sonos`)
if (process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
sonosSystem.register(bonob).then((success) => {
if (success) {
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(
sonosSystem,
bonob,
WEB_ADDRESS,
new Navidrome(
process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533",
encryption(secret)
encryption(secret),
streamUserAgent
),
new InMemoryLinkCodes(),
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 Stream = {
export type TrackStream = {
status: number;
headers: Record<StreamingHeader, string>;
data: Buffer;
stream: any;
};
export type CoverArt = {
@@ -152,7 +152,7 @@ export interface MusicLibrary {
}: {
trackId: string;
range: string | undefined;
}): Promise<Stream>;
}): Promise<TrackStream>;
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
scrobble(id: string): Promise<boolean>
}

View File

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

View File

@@ -141,19 +141,19 @@ function server(
it.scrobble(id).then((scrobbleSuccess) => {
if (scrobbleSuccess) logger.info(`Scrobbled ${id}`);
else logger.warn(`Failed to scrobble ${id}....`);
return it;
})
)
.then((it) =>
it.stream({ trackId: id, range: req.headers["range"] || undefined })
)
.then((stream) => {
res.status(stream.status);
Object.entries(stream.headers)
.then((trackStream) => {
res.status(trackStream.status);
Object.entries(trackStream.headers)
.filter(([_, v]) => v !== undefined)
.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 { 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 randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
@@ -110,8 +110,8 @@ export function aTrack(fields: Partial<Track> = {}): Track {
duration: randomInt(500),
number: randomInt(100),
genre: randomGenre(),
artist: anArtist(),
album: anAlbum(),
artist: artistToArtistSummary(anArtist()),
album: albumToAlbumSummary(anAlbum()),
...fields,
};
}

View File

@@ -8,6 +8,7 @@ import {
BROWSER_HEADERS,
DODGY_IMAGE_NAME,
asGenre,
appendMimeTypeToClientFor
} from "../src/navidrome";
import encryption from "../src/encryption";
@@ -29,7 +30,6 @@ import {
Track,
AlbumSummary,
artistToArtistSummary,
NO_IMAGES,
AlbumQuery,
} from "../src/music_service";
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) => ({
status: 200,
data,
@@ -188,7 +213,8 @@ describe("Navidrome", () => {
const password = "pass1";
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 mockGET = jest.fn();
@@ -1312,10 +1338,7 @@ describe("Navidrome", () => {
name: "Bob Marley",
albums: [album],
});
const artistSummary = {
...artistToArtistSummary(artist),
image: NO_IMAGES,
};
const artistSummary = artistToArtistSummary(artist);
const tracks = [
aTrack({ artist: artistSummary, album: albumSummary, genre: hipHop }),
@@ -1374,10 +1397,7 @@ describe("Navidrome", () => {
name: "Bob Marley",
albums: [album],
});
const artistSummary = {
...artistToArtistSummary(artist),
image: NO_IMAGES,
};
const artistSummary = artistToArtistSummary(artist);
const tracks = [
aTrack({
@@ -1464,10 +1484,7 @@ describe("Navidrome", () => {
name: "Bob Marley",
albums: [album],
});
const artistSummary = {
...artistToArtistSummary(artist),
image: NO_IMAGES,
};
const artistSummary = artistToArtistSummary(artist);
const track = aTrack({
artist: artistSummary,
@@ -1514,85 +1531,47 @@ describe("Navidrome", () => {
describe("streaming a track", () => {
const trackId = uuid();
const genre = { id: "foo", name: "foo" };
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => {
const streamResponse = {
status: 200,
headers: {
"content-type": "audio/mpeg",
},
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,
});
});
const album = anAlbum({ genre });
const artist = anArtist({
albums: [album],
image: { large: "foo", medium: undefined, small: undefined },
});
const track = aTrack({
id: trackId,
album: albumToAlbumSummary(album),
artist: artistToArtistSummary(artist),
genre,
});
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => {
const streamResponse = {
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("content-range, accept-ranges or content-length", () => {
beforeEach(() => {
streamClientApplication.mockReturnValue("bonob");
});
});
describe("with no range specified", () => {
describe("navidrome returns a 200", () => {
it("should return the content", async () => {
describe("when navidrome doesnt return a 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": "1667",
"content-range": "-200",
"accept-ranges": "bytes",
"some-other-header": "some-value",
},
data: Buffer.from("the track", "ascii"),
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
@@ -1603,11 +1582,183 @@ describe("Navidrome", () => {
expect(result.headers).toEqual({
"content-type": "audio/mpeg",
"content-length": "1667",
"content-range": "-200",
"accept-ranges": "bytes",
"content-length": undefined,
"content-range": undefined,
"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`, {
params: {
@@ -1616,79 +1767,98 @@ describe("Navidrome", () => {
},
headers: {
"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", () => {
it("should send the range to navidrome", async () => {
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: Buffer.from("the track", "ascii"),
};
describe("when navidrome has a custom StreamClientApplication registered", () => {
describe("when no range specified", () => {
it("should user the custom StreamUserAgent when calling navidrome", async () => {
const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication);
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const streamResponse = {
status: 200,
headers: {
"content-type": "audio/mpeg",
},
data: Buffer.from("the track", "ascii"),
};
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range }));
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));
expect(result.headers).toEqual({
"content-type": "audio/flac",
"content-length": "66",
"content-range": "100-200",
"accept-ranges": "none",
await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range: undefined }));
expect(streamClientApplication).toHaveBeenCalledWith(track);
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`, {
params: {
id: trackId,
...authParams,
},
headers: {
"User-Agent": "bonob",
Range: range,
},
responseType: "arraybuffer",
describe("when range specified", () => {
it("should user the custom StreamUserAgent when calling navidrome", async () => {
const range = "1000-2000";
const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = {
status: 200,
headers: {
"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 { ExpiringAccessTokens } from "../src/access_tokens";
import { InMemoryLinkCodes } from "../src/link_codes";
import { Response } from "express";
describe("server", () => {
beforeEach(() => {
@@ -243,46 +244,50 @@ describe("server", () => {
describe("scrobbling", () => {
describe("when scrobbling succeeds", () => {
it("should scrobble the track", async () => {
const stream = {
const trackStream = {
status: 200,
headers: {
"content-type": "audio/mp3",
},
data: Buffer.from("some track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.status).toEqual(trackStream.status);
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
});
});
describe("when scrobbling succeeds", () => {
it("should still return the track", async () => {
const stream = {
const trackStream = {
status: 200,
headers: {
"content-type": "audio/mp3",
},
data: Buffer.from("some track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(false);
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.status).toEqual(trackStream.status);
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
});
});
@@ -291,25 +296,26 @@ describe("server", () => {
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", () => {
it("should return a 200 with the data, without adding the undefined headers", async () => {
const stream = {
const trackStream = {
status: 200,
headers: {
"content-type": "audio/mp3",
},
data: Buffer.from("some track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual("audio/mp3")
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
expect(Object.keys(res.headers)).not.toContain("content-range")
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", () => {
it("should return a 200 with the data, without adding the undefined headers", async () => {
const stream = {
const trackStream = {
status: 200,
headers: {
"content-type": "audio/mp3",
@@ -325,20 +331,21 @@ describe("server", () => {
"accept-ranges": undefined,
"content-range": undefined,
},
data: Buffer.from("some track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual("audio/mp3")
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
expect(Object.keys(res.headers)).not.toContain("content-range")
expect(Object.keys(res.headers)).not.toContain("accept-ranges")
});
@@ -346,7 +353,7 @@ describe("server", () => {
describe("when the music service returns a 200", () => {
it("should return a 200 with the data", async () => {
const stream = {
const trackStream = {
status: 200,
headers: {
"content-type": "audio/mp3",
@@ -354,26 +361,31 @@ describe("server", () => {
"accept-ranges": "bytes",
"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);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual(
stream.headers["content-type"]
`${trackStream.headers["content-type"]}; charset=utf-8`
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
trackStream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toEqual(
stream.headers["content-range"]
trackStream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(authToken);
@@ -383,7 +395,7 @@ describe("server", () => {
describe("when the music service returns a 206", () => {
it("should return a 206 with the data", async () => {
const stream = {
const trackStream = {
status: 206,
headers: {
"content-type": "audio/ogg",
@@ -391,26 +403,28 @@ describe("server", () => {
"accept-ranges": "bytez",
"content-range": "100-200",
},
data: Buffer.from("some other track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual(
stream.headers["content-type"]
`${trackStream.headers["content-type"]}; charset=utf-8`
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
trackStream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toEqual(
stream.headers["content-range"]
trackStream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(authToken);
@@ -422,7 +436,7 @@ describe("server", () => {
describe("when sonos does ask for a range", () => {
describe("when the music service returns a 200", () => {
it("should return a 200 with the data", async () => {
const stream = {
const trackStream = {
status: 200,
headers: {
"content-type": "audio/mp3",
@@ -430,11 +444,13 @@ describe("server", () => {
"accept-ranges": "bytes",
"content-range": "-100",
},
data: Buffer.from("some track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
@@ -442,15 +458,15 @@ describe("server", () => {
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "3000-4000");
expect(res.status).toEqual(stream.status);
expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual(
stream.headers["content-type"]
`${trackStream.headers["content-type"]}; charset=utf-8`
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
trackStream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toEqual(
stream.headers["content-range"]
trackStream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(authToken);
@@ -463,7 +479,7 @@ describe("server", () => {
describe("when the music service returns a 206", () => {
it("should return a 206 with the data", async () => {
const stream = {
const trackStream = {
status: 206,
headers: {
"content-type": "audio/ogg",
@@ -471,11 +487,13 @@ describe("server", () => {
"accept-ranges": "bytez",
"content-range": "100-200",
},
data: Buffer.from("some other track", "ascii"),
stream: {
pipe: (res: Response) => res.send("")
}
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(trackStream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server)
@@ -483,15 +501,15 @@ describe("server", () => {
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "4000-5000");
expect(res.status).toEqual(stream.status);
expect(res.status).toEqual(trackStream.status);
expect(res.header["content-type"]).toEqual(
stream.headers["content-type"]
`${trackStream.headers["content-type"]}; charset=utf-8`
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
trackStream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toEqual(
stream.headers["content-range"]
trackStream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(authToken);