diff --git a/README.md b/README.md index b0529f1..8a0ae79 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.ts b/src/app.ts index d9dde3d..ea72fae 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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)) diff --git a/src/music_service.ts b/src/music_service.ts index cd0cc09..d076bb9 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -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; - data: Buffer; + stream: any; }; export type CoverArt = { @@ -152,7 +152,7 @@ export interface MusicLibrary { }: { trackId: string; range: string | undefined; - }): Promise; + }): Promise; coverArt(id: string, type: "album" | "artist", size?: number): Promise; scrobble(id: string): Promise } diff --git a/src/navidrome.ts b/src/navidrome.ts index 9b26f82..34ea70d 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -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(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> => navidrome .getJSON(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(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) => ({ diff --git a/src/server.ts b/src/server.ts index 0b1a7bf..283a37e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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); }); } }); diff --git a/tests/builders.ts b/tests/builders.ts index 98496f5..68323b5 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -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 { duration: randomInt(500), number: randomInt(100), genre: randomGenre(), - artist: anArtist(), - album: anAlbum(), + artist: artistToArtistSummary(anArtist()), + album: albumToAlbumSummary(anAlbum()), ...fields, }; } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 777d115..aa0f270 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -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"), - }; - - 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 })); - - expect(result.headers).toEqual({ - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", + 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); + + 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: 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", + }); }); }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 67a1a6b..fd4e521 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -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);