diff --git a/src/music_service.ts b/src/music_service.ts index 31066e8..53c4eec 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -37,8 +37,8 @@ export type Images = { export const NO_IMAGES: Images = { small: undefined, medium: undefined, - large: undefined -} + large: undefined, +}; export type Artist = ArtistSummary & { albums: AlbumSummary[]; @@ -51,18 +51,17 @@ export type AlbumSummary = { genre: string | undefined; }; -export type Album = AlbumSummary & { -}; +export type Album = AlbumSummary & {}; export type Track = { id: string; name: string; mimeType: string; - duration: string; - number: string | undefined; + duration: number; + number: number | undefined; genre: string | undefined; album: AlbumSummary; - artist: ArtistSummary + artist: ArtistSummary; }; export type Paging = { @@ -106,6 +105,14 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({ genre: it.genre, }); +export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges"; + +export type Stream = { + status: number; + headers: Record; + data: Buffer; +}; + export const range = (size: number) => [...Array(size).keys()]; export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => @@ -124,5 +131,13 @@ export interface MusicLibrary { albums(q: AlbumQuery): Promise>; album(id: string): Promise; tracks(albumId: string): Promise; + track(trackId: string): Promise; genres(): Promise; + stream({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index 2230b82..b388ec0 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -20,7 +20,7 @@ import { } from "./music_service"; import X2JS from "x2js"; -import axios from "axios"; +import axios, { AxiosRequestConfig } from "axios"; import { Encryption } from "./encryption"; import randomString from "./random_string"; @@ -51,7 +51,7 @@ export type album = { _name: string; _genre: string | undefined; _year: string | undefined; - _coverArt: string; + _coverArt: string | undefined; }; export type artistSummary = { @@ -124,11 +124,11 @@ export type song = { _title: string; _album: string; _artist: string; - _track: string; + _track: string | undefined; _genre: string; _coverArt: string; _created: "2004-11-08T23:36:11"; - _duration: string; + _duration: string | undefined; _bitRate: "128"; _suffix: "mp3"; _contentType: string; @@ -138,15 +138,15 @@ export type song = { }; export type GetAlbumResponse = { - album: { - _id: string; - _name: string; - _genre: string; - _year: string; + album: album & { song: song[]; }; }; +export type GetSongResponse = { + song: song; +}; + export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { @@ -169,6 +169,28 @@ export type getAlbumListParams = { const MAX_ALBUM_LIST = 500; +const asTrack = (album: Album, song: song) => ({ + id: song._id, + name: song._title, + mimeType: song._contentType, + duration: parseInt(song._duration || "0"), + number: parseInt(song._track || "0"), + genre: song._genre, + album, + artist: { + id: song._artistId, + name: song._artist, + image: NO_IMAGES, + }, +}); + +const asAlbum = (album: album) => ({ + id: album._id, + name: album._name, + year: album._year, + genre: album._genre, +}); + export class Navidrome implements MusicService { url: string; encryption: Encryption; @@ -178,11 +200,12 @@ export class Navidrome implements MusicService { this.encryption = encryption; } - get = async ( + get = async ( { username, password }: Credentials, path: string, - q: {} = {} - ): Promise => + q: {} = {}, + config: AxiosRequestConfig | undefined = {} + ) => axios .get(`${this.url}${path}`, { params: { @@ -192,7 +215,23 @@ export class Navidrome implements MusicService { v: "1.16.1", c: "bonob", }, + headers: { + "User-Agent": "bonob", + }, + ...config, }) + .then((response) => { + if (response.status != 200 && response.status != 206) + throw `Navidrome failed with a ${response.status}`; + else return response; + }); + + getJSON = async ( + { username, password }: Credentials, + path: string, + q: {} = {} + ): Promise => + this.get({ username, password }, path, q) .then((response) => new X2JS().xml2js(response.data) as SubconicEnvelope) .then((json) => json["subsonic-response"]) .then((json) => { @@ -201,7 +240,7 @@ export class Navidrome implements MusicService { }); generateToken = async (credentials: Credentials) => - this.get(credentials, "/rest/ping.view") + this.getJSON(credentials, "/rest/ping.view") .then(() => ({ authToken: Buffer.from( JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials))) @@ -219,7 +258,7 @@ export class Navidrome implements MusicService { ); getArtists = (credentials: Credentials): Promise => - this.get(credentials, "/rest/getArtists") + this.getJSON(credentials, "/rest/getArtists") .then((it) => it.artists.index.flatMap((it) => it.artist || [])) .then((artists) => artists.map((artist) => ({ @@ -229,7 +268,7 @@ export class Navidrome implements MusicService { ); getArtistInfo = (credentials: Credentials, id: string): Promise => - this.get(credentials, "/rest/getArtistInfo", { + this.getJSON(credentials, "/rest/getArtistInfo", { id, }).then((it) => ({ image: { @@ -239,11 +278,21 @@ export class Navidrome implements MusicService { }, })); + getAlbum = (credentials: Credentials, id: string): Promise => + this.getJSON(credentials, "/rest/getAlbum", { id }) + .then((it) => it.album) + .then((album) => ({ + id: album._id, + name: album._name, + year: album._year, + genre: album._genre, + })); + getArtist = ( credentials: Credentials, id: string ): Promise => - this.get(credentials, "/rest/getArtist", { + this.getJSON(credentials, "/rest/getArtist", { id, }) .then((it) => it.artist) @@ -312,7 +361,7 @@ export class Navidrome implements MusicService { ); return navidrome - .get(credentials, "/rest/getAlbumList", { + .getJSON(credentials, "/rest/getAlbumList", { ...p, size: MAX_ALBUM_LIST, offset: 0, @@ -333,24 +382,10 @@ export class Navidrome implements MusicService { })); }, album: (id: string): Promise => - navidrome - .get(credentials, "/rest/getAlbum", { id }) - .then((it) => it.album) - .then((album) => ({ - id: album._id, - name: album._name, - year: album._year, - genre: album._genre, - // tracks: album.song.map(track => ({ - // id: track._id, - // name: track._title, - // mimeType: track._contentType, - // duration: track._duration, - // })) - })), + navidrome.getAlbum(credentials, id), genres: () => navidrome - .get(credentials, "/rest/getGenres") + .getJSON(credentials, "/rest/getGenres") .then((it) => pipe( it.genres.genre, @@ -360,29 +395,61 @@ export class Navidrome implements MusicService { ), tracks: (albumId: string) => navidrome - .get(credentials, "/rest/getAlbum", { id: albumId }) + .getJSON(credentials, "/rest/getAlbum", { + id: albumId, + }) .then((it) => it.album) .then((album) => - album.song.map((song) => ({ - id: song._id, - name: song._title, - mimeType: song._contentType, - duration: song._duration, - number: song._track, - genre: song._genre, - album: { - id: album._id, - name: album._name, - year: album._year, - genre: album._genre, - }, - artist: { - id: song._artistId, - name: song._artist, - image: NO_IMAGES, - }, - })) + 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)) + ), + stream: async ({ + trackId, + range, + }: { + 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"), + })), }; return Promise.resolve(musicLibrary); diff --git a/src/server.ts b/src/server.ts index 2e193b6..bc92e87 100644 --- a/src/server.ts +++ b/src/server.ts @@ -113,6 +113,23 @@ function server( res.send(""); }); + app.get("/stream/track/:id", async (req, res) => { + const id = req.params["id"]!; + const token = req.headers["bonob-token"] as string; + return musicService + .login(token) + .then((it) => + it.stream({ trackId: id, range: req.headers["range"] || undefined }) + ) + .then((stream) => { + res.status(stream.status); + Object.entries(stream.headers).forEach(([header, value]) => + res.setHeader(header, value) + ); + res.send(stream.data); + }); + }); + // app.get("/album/:albumId/art", (req, res) => { // console.log(`Trying to load image for ${req.params["albumId"]}, token ${JSON.stringify(req.cookies)}`) // const authToken = req.headers["X-AuthToken"]! as string; diff --git a/src/smapi.ts b/src/smapi.ts index 3346ec2..af34b3b 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -64,7 +64,8 @@ export type GetMetadataResponse = { count: number; index: number; total: number; - mediaCollection: MediaCollection[]; + mediaCollection: any[] | undefined; + mediaMetadata: any[] | undefined; }; }; @@ -83,6 +84,27 @@ export function getMetadataResult({ index, total, mediaCollection: mediaCollection || [], + mediaMetadata: undefined, + }, + }; +} + +export function getMetadataResult2({ + mediaMetadata, + index, + total, +}: { + mediaMetadata: any[] | undefined; + index: number; + total: number; +}): GetMetadataResponse { + return { + getMetadataResult: { + count: mediaMetadata?.length || 0, + index, + total, + mediaCollection: undefined, + mediaMetadata: mediaMetadata || [], }, }; } @@ -225,6 +247,71 @@ function bindSmapiSoapServiceToExpress( getAppLink: () => sonosSoap.getAppLink(), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), + getMediaURI: async ( + { id }: { id: string }, + _, + headers?: SoapyHeaders + ) => { + if (!headers?.credentials) { + throw { + Fault: { + faultcode: "Client.LoginUnsupported", + faultstring: "Missing credentials...", + }, + }; + } + await musicService + .login(headers.credentials.loginToken.token) + .catch((_) => { + throw { + Fault: { + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }, + }; + }); + + const [type, typeId] = id.split(":"); + return { + getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`, + httpHeaders: [ + { + header: "bonob-token", + value: headers?.credentials?.loginToken.token, + }, + ], + }; + }, + getMediaMetadata: async ( + { id }: { id: string }, + _, + headers?: SoapyHeaders + ) => { + if (!headers?.credentials) { + throw { + Fault: { + faultcode: "Client.LoginUnsupported", + faultstring: "Missing credentials...", + }, + }; + } + const login = await musicService + .login(headers.credentials.loginToken.token) + .catch((_) => { + throw { + Fault: { + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }, + }; + }); + + const typeId = id.split(":")[1]; + const musicLibrary = login as MusicLibrary; + return musicLibrary + .track(typeId!) + .then((it) => ({ getMediaMetadataResult: track(it) })); + }, getMetadata: async ( { id, @@ -320,8 +407,8 @@ function bindSmapiSoapServiceToExpress( .tracks(typeId!) .then(slice2(paging)) .then(([page, total]) => - getMetadataResult({ - mediaCollection: page.map(track), + getMetadataResult2({ + mediaMetadata: page.map(track), index: paging._index, total, }) diff --git a/tests/builders.ts b/tests/builders.ts index eb5065f..233bbd5 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -92,8 +92,8 @@ export function aTrack(fields: Partial = {}): Track { id, name: `Track ${id}`, mimeType: `audio/mp3-${id}`, - duration: `${randomInt(500)}`, - number: `${randomInt(100)}`, + duration: randomInt(500), + number: randomInt(100), genre: randomGenre(), artist: anArtist(), album: anAlbum(), diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index 8fab0d5..c313cee 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -148,33 +148,6 @@ describe("InMemoryMusicService", () => { }); }); - describe("album", () => { - describe("when it exists", () => { - const albumToLookFor = anAlbum({ id: "albumToLookFor" }); - const artist1 = anArtist({ albums: [anAlbum(), anAlbum(), anAlbum()] }); - const artist2 = anArtist({ - albums: [anAlbum(), albumToLookFor, anAlbum()], - }); - - beforeEach(() => { - service.hasArtists(artist1, artist2); - }); - - it("should provide an artist", async () => { - expect(await musicLibrary.album(albumToLookFor.id)).toEqual( - albumToLookFor - ); - }); - }); - - describe("when it doesnt exist", () => { - it("should blow up", async () => { - return expect(musicLibrary.album("-1")).rejects.toEqual( - "No album with id '-1'" - ); - }); - }); - }); describe("tracks", () => { const artist1Album1 = anAlbum(); @@ -193,13 +166,26 @@ describe("InMemoryMusicService", () => { describe("fetching tracks for an album", () => { it("should return only tracks on that album", async () => { - expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([track1, track2]) + expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([ + track1, + track2, + ]); }); }); describe("fetching tracks for an album that doesnt exist", () => { it("should return empty array", async () => { - expect(await musicLibrary.tracks("non existant album id")).toEqual([]) + expect(await musicLibrary.tracks("non existant album id")).toEqual( + [] + ); + }); + }); + + describe("fetching a single track", () => { + describe("when it exists", () => { + it("should return the track", async () => { + expect(await musicLibrary.track(track3.id)).toEqual(track3); + }); }); }); }); @@ -235,125 +221,145 @@ describe("InMemoryMusicService", () => { service.hasArtists(artist1, artist2, artist3, artistWithNoAlbums); }); - describe("with no filtering", () => { - describe("fetching all on one page", () => { - it("should return all the albums for all the artists", async () => { - expect( - await musicLibrary.albums({ _index: 0, _count: 100 }) - ).toEqual({ - results: [ - albumToAlbumSummary(artist1_album1), - albumToAlbumSummary(artist1_album2), - albumToAlbumSummary(artist1_album3), - albumToAlbumSummary(artist1_album4), - albumToAlbumSummary(artist1_album5), - - albumToAlbumSummary(artist2_album1), - - albumToAlbumSummary(artist3_album1), - albumToAlbumSummary(artist3_album2), - ], - total: totalAlbumCount, + describe("fetching multiple albums", () => { + describe("with no filtering", () => { + describe("fetching all on one page", () => { + it("should return all the albums for all the artists", async () => { + expect( + await musicLibrary.albums({ _index: 0, _count: 100 }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist1_album1), + albumToAlbumSummary(artist1_album2), + albumToAlbumSummary(artist1_album3), + albumToAlbumSummary(artist1_album4), + albumToAlbumSummary(artist1_album5), + + albumToAlbumSummary(artist2_album1), + + albumToAlbumSummary(artist3_album1), + albumToAlbumSummary(artist3_album2), + ], + total: totalAlbumCount, + }); + }); + }); + + describe("fetching a page", () => { + it("should return only that page", async () => { + expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual( + { + results: [ + albumToAlbumSummary(artist1_album5), + albumToAlbumSummary(artist2_album1), + albumToAlbumSummary(artist3_album1), + ], + total: totalAlbumCount, + } + ); + }); + }); + + describe("fetching the last page", () => { + it("should return only that page", async () => { + expect( + await musicLibrary.albums({ _index: 6, _count: 100 }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist3_album1), + albumToAlbumSummary(artist3_album2), + ], + total: totalAlbumCount, + }); }); }); }); - - describe("fetching a page", () => { - it("should return only that page", async () => { - expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual( - { + + describe("filtering by genre", () => { + describe("fetching all on one page", () => { + it("should return all the albums of that genre for all the artists", async () => { + expect( + await musicLibrary.albums({ + genre: "Pop", + _index: 0, + _count: 100, + }) + ).toEqual({ results: [ + albumToAlbumSummary(artist1_album1), + albumToAlbumSummary(artist1_album4), albumToAlbumSummary(artist1_album5), - albumToAlbumSummary(artist2_album1), - albumToAlbumSummary(artist3_album1), + albumToAlbumSummary(artist3_album2), ], - total: totalAlbumCount, - } - ); + total: 4, + }); + }); }); - }); - - describe("fetching the last page", () => { - it("should return only that page", async () => { + + describe("when the genre has more albums than a single page", () => { + describe("can fetch a single page", () => { + it("should return only the albums for that page", async () => { + expect( + await musicLibrary.albums({ + genre: "Pop", + _index: 1, + _count: 2, + }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist1_album4), + albumToAlbumSummary(artist1_album5), + ], + total: 4, + }); + }); + }); + + describe("can fetch the last page", () => { + it("should return only the albums for the last page", async () => { + expect( + await musicLibrary.albums({ + genre: "Pop", + _index: 3, + _count: 100, + }) + ).toEqual({ + results: [albumToAlbumSummary(artist3_album2)], + total: 4, + }); + }); + }); + }); + + it("should return empty list if there are no albums for the genre", async () => { expect( - await musicLibrary.albums({ _index: 6, _count: 100 }) + await musicLibrary.albums({ + genre: "genre with no albums", + _index: 0, + _count: 100, + }) ).toEqual({ - results: [ - albumToAlbumSummary(artist3_album1), - albumToAlbumSummary(artist3_album2), - ], - total: totalAlbumCount, + results: [], + total: 0, }); }); }); }); - describe("filtering by genre", () => { - describe("fetching all on one page", () => { - it("should return all the albums of that genre for all the artists", async () => { - expect( - await musicLibrary.albums({ - genre: "Pop", - _index: 0, - _count: 100, - }) - ).toEqual({ - results: [ - albumToAlbumSummary(artist1_album1), - albumToAlbumSummary(artist1_album4), - albumToAlbumSummary(artist1_album5), - albumToAlbumSummary(artist3_album2), - ], - total: 4, - }); + describe("fetching a single album", () => { + describe("when it exists", () => { + it("should provide an album", async () => { + expect(await musicLibrary.album(artist1_album5.id)).toEqual( + artist1_album5 + ); }); }); - - describe("when the genre has more albums than a single page", () => { - describe("can fetch a single page", () => { - it("should return only the albums for that page", async () => { - expect( - await musicLibrary.albums({ - genre: "Pop", - _index: 1, - _count: 2, - }) - ).toEqual({ - results: [ - albumToAlbumSummary(artist1_album4), - albumToAlbumSummary(artist1_album5), - ], - total: 4, - }); - }); - }); - - describe("can fetch the last page", () => { - it("should return only the albums for the last page", async () => { - expect( - await musicLibrary.albums({ - genre: "Pop", - _index: 3, - _count: 100, - }) - ).toEqual({ - results: [albumToAlbumSummary(artist3_album2)], - total: 4, - }); - }); - }); - }); - - it("should return empty list if there are no albums for the genre", async () => { - expect( - await musicLibrary.albums({ - genre: "genre with no albums", - _index: 0, - _count: 100, - }) - ).toEqual({ - results: [], - total: 0, + + describe("when it doesnt exist", () => { + it("should blow up", async () => { + return expect(musicLibrary.album("-1")).rejects.toEqual( + "No album with id '-1'" + ); }); }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 156c1a1..1133d9a 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -42,7 +42,7 @@ export class InMemoryMusicService implements MusicService { this.users[username] == password ) { return Promise.resolve({ - authToken: JSON.stringify({ username, password }), + authToken: Buffer.from(JSON.stringify({ username, password })).toString('base64'), userId: username, nickname: username, }); @@ -52,7 +52,7 @@ export class InMemoryMusicService implements MusicService { } login(token: string): Promise { - const credentials = JSON.parse(token) as Credentials; + const credentials = JSON.parse(Buffer.from(token, "base64").toString("ascii")) as Credentials; if (this.users[credentials.username] != credentials.password) return Promise.reject("Invalid auth token"); @@ -103,7 +103,17 @@ export class InMemoryMusicService implements MusicService { A.sort(ordString) ) ), - tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId)) + tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId)), + track: (trackId: string) => pipe( + this.tracks.find(it => it.id === trackId), + O.fromNullable, + O.map(it => Promise.resolve(it)), + O.getOrElse(() => Promise.reject(`Failed to find track with id ${trackId}`)) + ), + stream: (_: { + trackId: string; + range: string | undefined; + }) => Promise.reject("unsupported operation") }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 2bfde46..9ab02eb 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -1,4 +1,5 @@ import { Md5 } from "ts-md5/dist/md5"; +import { v4 as uuid } from "uuid"; import { isDodgyImage, Navidrome, t } from "../src/navidrome"; import encryption from "../src/encryption"; @@ -18,7 +19,7 @@ import { Track, AlbumSummary, artistToArtistSummary, - NO_IMAGES + NO_IMAGES, } from "../src/music_service"; import { anAlbum, anArtist, aTrack } from "./builders"; @@ -70,7 +71,11 @@ const artistInfoXml = ( `; -const albumXml = (artist: Artist, album: AlbumSummary, tracks: Track[] = []) => ` ` created="2021-01-07T08:19:55.834207205Z" artistId="${artist.id}" songCount="19" - isVideo="false">${tracks.map(track => songXml(track))}`; + isVideo="false">${tracks.map((track) => songXml(track))}`; const songXml = (track: Track) => ` ` ${albums.map(([artist, album]) => - albumXml(artist, album) -)} + albumXml(artist, album) + )} `; const artistXml = ( artist: Artist ) => ` - + ${artist.albums.map((album) => albumXml(artist, album))} `; @@ -131,15 +137,31 @@ const genresXml = ( ) => ` ${genres.map( - (it) => - `${it}` -)} + (it) => + `${it}` + )} `; -const getAlbumXml = (artist: Artist, album: Album, tracks: Track[]) => ` - ${albumXml(artist, album, tracks)} - ` +const getAlbumXml = ( + artist: Artist, + album: Album, + tracks: Track[] +) => ` + ${albumXml( + artist, + album, + tracks + )} + `; + +const getSongXml = ( + track: Track +) => ` + ${songXml( + track + )} + `; const PING_OK = ``; @@ -169,6 +191,9 @@ describe("Navidrome", () => { v: "1.16.1", c: "bonob", }; + const headers = { + "User-Agent": "bonob", + }; describe("generateToken", () => { describe("when the credentials are valid", () => { @@ -186,6 +211,7 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { params: authParams, + headers, }); }); }); @@ -226,6 +252,7 @@ describe("Navidrome", () => { params: { ...authParams, }, + headers, }); }); }); @@ -268,6 +295,7 @@ describe("Navidrome", () => { id: artist.id, ...authParams, }, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { @@ -275,6 +303,7 @@ describe("Navidrome", () => { id: artist.id, ...authParams, }, + headers, }); }); }); @@ -377,30 +406,35 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { params: authParams, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist1.id, ...authParams, }, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist2.id, ...authParams, }, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist3.id, ...authParams, }, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist4.id, ...authParams, }, + headers, }); }); }); @@ -435,18 +469,21 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { params: authParams, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist2.id, ...authParams, }, + headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist3.id, ...authParams, }, + headers, }); }); }); @@ -498,6 +535,7 @@ describe("Navidrome", () => { offset: 0, ...authParams, }, + headers, }); }); }); @@ -544,6 +582,7 @@ describe("Navidrome", () => { offset: 0, ...authParams, }, + headers, }); }); }); @@ -569,6 +608,7 @@ describe("Navidrome", () => { offset: 0, ...authParams, }, + headers, }); }); }); @@ -620,6 +660,7 @@ describe("Navidrome", () => { offset: 0, ...authParams, }, + headers, }); }); }); @@ -630,24 +671,20 @@ describe("Navidrome", () => { describe("when it exists", () => { const album = anAlbum(); - const artist = anArtist({ albums: [album] }) + const artist = anArtist({ albums: [album] }); - const tracks = [ + const tracks = [ aTrack({ artist, album }), aTrack({ artist, album }), aTrack({ artist, album }), aTrack({ artist, album }), - ] + ]; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumXml(artist, album, tracks) - ) - ) + Promise.resolve(ok(getAlbumXml(artist, album, tracks))) ); }); @@ -665,6 +702,7 @@ describe("Navidrome", () => { id: album.id, ...authParams, }, + headers, }); }); }); @@ -675,49 +713,223 @@ describe("Navidrome", () => { describe("when it exists", () => { const album = anAlbum({ id: "album1", name: "Burnin" }); const albumSummary = albumToAlbumSummary(album); - - const artist = anArtist({ id: "artist1", name: "Bob Marley", albums: [album] }) + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); const artistSummary = { ...artistToArtistSummary(artist), - image: NO_IMAGES + image: NO_IMAGES, }; - - const tracks = [ + + const tracks = [ aTrack({ artist: artistSummary, album: albumSummary }), aTrack({ artist: artistSummary, album: albumSummary }), aTrack({ artist: artistSummary, album: albumSummary }), aTrack({ artist: artistSummary, album: albumSummary }), - ] - + ]; + beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumXml(artist, album, tracks) - ) - ) + Promise.resolve(ok(getAlbumXml(artist, album, tracks))) ); }); - + it("should return the album", async () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.tracks(album.id)); - + expect(result).toEqual(tracks); - + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { params: { id: album.id, ...authParams, }, + headers, }); }); }); }); + + describe("a single track", () => { + const album = anAlbum({ id: "album1", name: "Burnin" }); + const albumSummary = albumToAlbumSummary(album); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + const artistSummary = { + ...artistToArtistSummary(artist), + image: NO_IMAGES, + }; + + const track = aTrack({ artist: artistSummary, album: albumSummary }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumXml(artist, album, []))) + ); + }); + + it("should return the track", async () => { + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.track(track.id)); + + expect(result).toEqual(track); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: { + id: track.id, + ...authParams, + }, + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: { + id: album.id, + ...authParams, + }, + headers, + }); + }); + }); + }); + + describe("streaming a track", () => { + const trackId = uuid(); + + describe("with no range specified", () => { + describe("navidrome returns a 200", () => { + it("should return the content", async () => { + 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"), + }; + + 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": "1667", + "content-range": "-200", + "accept-ranges": "bytes" + }); + expect(result.data.toString()).toEqual("the track"); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: { + id: trackId, + ...authParams, + }, + headers: { + "User-Agent": "bonob", + }, + responseType: "arraybuffer", + }); + }); + }); + + 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" + }); + 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", + }); + }); + }); }); }); diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 3de68ef..8e14d03 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -41,7 +41,7 @@ class LoggedInSonosDriver { let next = path.shift(); while (next) { if (next != "root") { - const childIds = this.currentMetadata!.getMetadataResult.mediaCollection.map( + const childIds = this.currentMetadata!.getMetadataResult.mediaCollection!.map( (it) => it.id ); if (!childIds.includes(next)) { @@ -56,7 +56,7 @@ class LoggedInSonosDriver { expectTitles(titles: string[]) { expect( - this.currentMetadata!.getMetadataResult.mediaCollection.map( + this.currentMetadata!.getMetadataResult.mediaCollection!.map( (it) => it.title ) ).toEqual(titles); diff --git a/tests/server.test.ts b/tests/server.test.ts index 9294691..7dd2d99 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,4 +1,6 @@ +import { v4 as uuid } from "uuid"; import request from "supertest"; +import { MusicService } from "../src/music_service"; import makeServer from "../src/server"; import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; @@ -185,4 +187,187 @@ describe("server", () => { }); }); }); + + describe("/stream", () => { + const musicService = { + login: jest.fn(), + }; + const musicLibrary = { + stream: jest.fn(), + }; + const server = makeServer( + (jest.fn() as unknown) as Sonos, + aService(), + "http://localhost:1234", + (musicService as unknown) as MusicService + ); + + const authToken = uuid(); + const trackId = uuid(); + + describe("when sonos does not ask for a range", () => { + describe("when the music service returns a 200", () => { + it("should return a 200 with the data", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + "content-length": "222", + "accept-ranges": "bytes", + "content-range": "-100", + }, + data: Buffer.from("some track", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + + const res = await request(server) + .get(`/stream/track/${trackId}`) + .set("bonob-token", authToken); + + console.log("testing finished watiting"); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + stream.headers["content-type"] + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toEqual( + stream.headers["content-range"] + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + + describe("when the music service returns a 206", () => { + it("should return a 206 with the data", async () => { + const stream = { + status: 206, + headers: { + "content-type": "audio/ogg", + "content-length": "333", + "accept-ranges": "bytez", + "content-range": "100-200", + }, + data: Buffer.from("some other track", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + + const res = await request(server) + .get(`/stream/track/${trackId}`) + .set("bonob-token", authToken); + + console.log("testing finished watiting"); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + stream.headers["content-type"] + ); + // expect(res.header["content-length"]).toEqual(stream.headers["content-length"]); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toEqual( + stream.headers["content-range"] + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + }); + + 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 = { + status: 200, + headers: { + "content-type": "audio/mp3", + "content-length": "222", + "accept-ranges": "bytes", + "content-range": "-100", + }, + data: Buffer.from("some track", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + + const res = await request(server) + .get(`/stream/track/${trackId}`) + .set("bonob-token", authToken) + .set("Range", "3000-4000"); + + console.log("testing finished watiting"); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + stream.headers["content-type"] + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toEqual( + stream.headers["content-range"] + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.stream).toHaveBeenCalledWith({ + trackId, + range: "3000-4000", + }); + }); + }); + + describe("when the music service returns a 206", () => { + it("should return a 206 with the data", async () => { + const stream = { + status: 206, + headers: { + "content-type": "audio/ogg", + "content-length": "333", + "accept-ranges": "bytez", + "content-range": "100-200", + }, + data: Buffer.from("some other track", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + + const res = await request(server) + .get(`/stream/track/${trackId}`) + .set("bonob-token", authToken) + .set("Range", "4000-5000"); + + console.log("testing finished watiting"); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + stream.headers["content-type"] + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toEqual( + stream.headers["content-range"] + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.stream).toHaveBeenCalledWith({ + trackId, + range: "4000-5000", + }); + }); + }); + }); + }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index f143aad..ffc1bfc 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -2,11 +2,17 @@ import crypto from "crypto"; import request from "supertest"; import { Client, createClientAsync } from "soap"; import X2JS from "x2js"; +import { v4 as uuid } from "uuid"; import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import makeServer from "../src/server"; import { bonobService, SONOS_DISABLED } from "../src/sonos"; -import { STRINGS_ROUTE, LOGIN_ROUTE, getMetadataResult } from "../src/smapi"; +import { + STRINGS_ROUTE, + LOGIN_ROUTE, + getMetadataResult, + getMetadataResult2, +} from "../src/smapi"; import { aService, @@ -276,7 +282,7 @@ describe("api", () => { ); describe("when no credentials header provided", () => { - it("should return a fault of LoginUnauthorized", async () => { + it("should return a fault of LoginUnsupported", async () => { const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server, rootUrl), @@ -295,7 +301,7 @@ describe("api", () => { }); describe("when invalid credentials are provided", () => { - it("should return a fault of LoginInvalid", async () => { + it("should return a fault of LoginUnauthorized", async () => { const username = "userThatGetsDeleted"; const password = "password1"; musicService.hasUser({ username, password }); @@ -640,11 +646,11 @@ describe("api", () => { albums: [album], }); - const track1 = aTrack({ artist, album, number: "1" }); - const track2 = aTrack({ artist, album, number: "2" }); - const track3 = aTrack({ artist, album, number: "3" }); - const track4 = aTrack({ artist, album, number: "4" }); - const track5 = aTrack({ artist, album, number: "5" }); + const track1 = aTrack({ artist, album, number: 1 }); + const track2 = aTrack({ artist, album, number: 2 }); + const track3 = aTrack({ artist, album, number: 3 }); + const track4 = aTrack({ artist, album, number: 4 }); + const track5 = aTrack({ artist, album, number: 5 }); beforeEach(() => { musicService.hasArtists(artist); @@ -659,33 +665,29 @@ describe("api", () => { count: 100, }); expect(result[0]).toEqual( - getMetadataResult({ - mediaCollection: [ - track1, - track2, - track3, - track4, - track5, - ].map((track) => ({ - itemType: "track", - id: `track:${track.id}`, - mimeType: track.mimeType, - title: track.name, + getMetadataResult2({ + mediaMetadata: [track1, track2, track3, track4, track5].map( + (track) => ({ + itemType: "track", + id: `track:${track.id}`, + mimeType: track.mimeType, + title: track.name, - trackMetadata: { - album: track.album.name, - albumId: track.album.id, - albumArtist: track.artist.name, - albumArtistId: track.artist.id, - // albumArtURI - artist: track.artist.name, - artistId: track.artist.id, - duration: track.duration, - genre: track.album.genre, - // genreId - trackNumber: track.number, - }, - })), + trackMetadata: { + album: track.album.name, + albumId: track.album.id, + albumArtist: track.artist.name, + albumArtistId: track.artist.id, + // albumArtURI + artist: track.artist.name, + artistId: track.artist.id, + duration: track.duration, + genre: track.album.genre, + // genreId + trackNumber: track.number, + }, + }) + ), index: 0, total: 5, }) @@ -701,11 +703,8 @@ describe("api", () => { count: 2, }); expect(result[0]).toEqual( - getMetadataResult({ - mediaCollection: [ - track3, - track4, - ].map((track) => ({ + getMetadataResult2({ + mediaMetadata: [track3, track4].map((track) => ({ itemType: "track", id: `track:${track.id}`, mimeType: track.mimeType, @@ -735,5 +734,217 @@ describe("api", () => { }); }); }); + + describe("getMediaURI", () => { + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes + ); + + describe("when no credentials header provided", () => { + it("should return a fault of LoginUnsupported", async () => { + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + await ws + .getMediaURIAsync({ id: "track:123" }) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnsupported", + faultstring: "Missing credentials...", + }); + }); + }); + }); + + describe("when invalid credentials are provided", () => { + it("should return a fault of LoginUnauthorized", async () => { + const username = "userThatGetsDeleted"; + const password = "password1"; + musicService.hasUser({ username, password }); + const token = (await musicService.generateToken({ + username, + password, + })) as AuthSuccess; + musicService.hasNoUsers(); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + ws.addSoapHeader({ credentials: someCredentials(token.authToken) }); + await ws + .getMediaURIAsync({ id: "track:123" }) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }); + }); + }); + }); + + describe("when valid credentials are provided", () => { + const username = "validUser"; + const password = "validPassword"; + let token: AuthSuccess; + let ws: Client; + + beforeEach(async () => { + musicService.hasUser({ username, password }); + token = (await musicService.generateToken({ + username, + password, + })) as AuthSuccess; + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(token.authToken) }); + }); + + describe("asking for a URI to stream a track", () => { + it("should return it with auth header", async () => { + const trackId = uuid(); + + const root = await ws.getMediaURIAsync({ + id: `track:${trackId}`, + }); + expect(root[0]).toEqual({ + getMediaURIResult: `${rootUrl}/stream/track/${trackId}`, + httpHeaders: { + header: "bonob-token", + value: token.authToken, + }, + }); + }); + }); + }); + }); + + describe("getMediaMetadata", () => { + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes + ); + + describe("when no credentials header provided", () => { + it("should return a fault of LoginUnsupported", async () => { + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + await ws + .getMediaMetadataAsync({ id: "track:123" }) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnsupported", + faultstring: "Missing credentials...", + }); + }); + }); + }); + + describe("when invalid credentials are provided", () => { + it("should return a fault of LoginUnauthorized", async () => { + const username = "userThatGetsDeleted"; + const password = "password1"; + musicService.hasUser({ username, password }); + const token = (await musicService.generateToken({ + username, + password, + })) as AuthSuccess; + musicService.hasNoUsers(); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + ws.addSoapHeader({ credentials: someCredentials(token.authToken) }); + await ws + .getMediaMetadataAsync({ id: "track:123" }) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }); + }); + }); + }); + + describe("when valid credentials are provided", () => { + const username = "validUser"; + const password = "validPassword"; + let token: AuthSuccess; + let ws: Client; + + const album = anAlbum(); + const artist = anArtist({ + albums: [album], + }); + const track = aTrack(); + + beforeEach(async () => { + musicService.hasUser({ username, password }); + token = (await musicService.generateToken({ + username, + password, + })) as AuthSuccess; + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(token.authToken) }); + + musicService.hasArtists(artist); + musicService.hasTracks(track); + }); + + describe("asking for media metadata for a tack", () => { + it("should return it with auth header", async () => { + const root = await ws.getMediaMetadataAsync({ + id: `track:${track.id}`, + }); + expect(root[0]).toEqual({ + getMediaMetadataResult: { + itemType: "track", + id: `track:${track.id}`, + mimeType: track.mimeType, + title: track.name, + + trackMetadata: { + album: track.album.name, + albumId: track.album.id, + albumArtist: track.artist.name, + albumArtistId: track.artist.id, + // albumArtURI + artist: track.artist.name, + artistId: track.artist.id, + duration: track.duration, + genre: track.album.genre, + // genreId + trackNumber: track.number, + }, + }, + }); + }); + }); + }); + }); }); });