diff --git a/src/music_service.ts b/src/music_service.ts index 6b72d3b..ee6c5df 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -146,4 +146,5 @@ export interface MusicLibrary { range: string | undefined; }): 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 02f161f..38434c5 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -118,12 +118,12 @@ export type artistInfo = { smallImageUrl: string | undefined; mediumImageUrl: string | undefined; largeImageUrl: string | undefined; - similarArtist: artistSummary[] + similarArtist: artistSummary[]; }; export type ArtistInfo = { image: Images; - similarArtist: {id:string, name:string}[] + similarArtist: { id: string; name: string }[]; }; export type GetArtistInfoResponse = SubsonicResponse & { @@ -244,6 +244,32 @@ export class Navidrome implements MusicService { else return response; }); + post = async ( + { username, password }: Credentials, + path: string, + q: {} = {}, + config: AxiosRequestConfig | undefined = {} + ) => + axios + .post(`${this.url}${path}`, { + params: { + ...q, + u: username, + ...t_and_s(password), + 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, @@ -258,7 +284,7 @@ export class Navidrome implements MusicService { "subsonic-response.albumList.album", "subsonic-response.album.song", "subsonic-response.genres.genre", - "subsonic-response.artistInfo.similarArtist" + "subsonic-response.artistInfo.similarArtist", ], }).xml2js(response.data) as SubconicEnvelope ) @@ -305,7 +331,10 @@ export class Navidrome implements MusicService { medium: validate(it.artistInfo.mediumImageUrl), large: validate(it.artistInfo.largeImageUrl), }, - similarArtist: (it.artistInfo.similarArtist || []).map(artist => ({id: artist._id, name: artist._name})) + similarArtist: (it.artistInfo.similarArtist || []).map((artist) => ({ + id: artist._id, + name: artist._name, + })), })); getAlbum = (credentials: Credentials, id: string): Promise => @@ -346,7 +375,7 @@ export class Navidrome implements MusicService { name: artist.name, image: artistInfo.image, albums: artist.albums, - similarArtists: artistInfo.similarArtist + similarArtists: artistInfo.similarArtist, })); getCoverArt = (credentials: Credentials, id: string, size?: number) => @@ -517,6 +546,11 @@ export class Navidrome implements MusicService { }); } }, + scrobble: async (id: string) => + navidrome + .post(credentials, `/rest/scrobble`, { id }) + .then((_) => true) + .catch(() => false), }; return Promise.resolve(musicLibrary); diff --git a/src/server.ts b/src/server.ts index bf5e20c..6783867 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; import bindSmapiSoapServiceToExpress from "./smapi"; import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; +import logger from "./logger"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; @@ -136,6 +137,14 @@ function server( } else { return musicService .login(authToken) + .then((it) => + it.scrobble(id).then((scrobbleSuccess) => { + if(!scrobbleSuccess) { + logger.warn("Failed to scrobble....") + } + return it; + }) + ) .then((it) => it.stream({ trackId: id, range: req.headers["range"] || undefined }) ) diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 825ba2d..9d224ca 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -42,7 +42,9 @@ export class InMemoryMusicService implements MusicService { this.users[username] == password ) { return Promise.resolve({ - authToken: Buffer.from(JSON.stringify({ username, password })).toString('base64'), + authToken: Buffer.from(JSON.stringify({ username, password })).toString( + "base64" + ), userId: username, nickname: username, }); @@ -52,7 +54,9 @@ export class InMemoryMusicService implements MusicService { } login(token: string): Promise { - const credentials = JSON.parse(Buffer.from(token, "base64").toString("ascii")) 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,18 +107,24 @@ export class InMemoryMusicService implements MusicService { A.sort(ordString) ) ), - 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"), - coverArt: (id: string, _: "album" | "artist", size?: number) => Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`) + 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"), + coverArt: (id: string, _: "album" | "artist", size?: number) => + Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`), + scrobble: async (_: string) => { + return Promise.resolve(true); + }, }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index b5d41ac..0c232f0 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -68,7 +68,7 @@ const ok = (data: string) => ({ }); const artistInfoXml = ( - artist: Artist, + artist: Artist ) => ` @@ -77,7 +77,10 @@ const artistInfoXml = ( ${artist.image.small || ""} ${artist.image.medium || ""} ${artist.image.large || ""} - ${artist.similarArtists.map(it => ``)} + ${artist.similarArtists.map( + (it) => + `` + )} `; @@ -173,6 +176,8 @@ const getSongXml = ( )} `; +const EMPTY = ``; + const PING_OK = ``; describe("Navidrome", () => { @@ -185,12 +190,14 @@ describe("Navidrome", () => { const mockedRandomString = (randomString as unknown) as jest.Mock; const mockGET = jest.fn(); + const mockPOST = jest.fn(); beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); axios.get = mockGET; + axios.post = mockPOST; mockedRandomString.mockReturnValue(salt); }); @@ -310,7 +317,10 @@ describe("Navidrome", () => { medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, large: `http://localhost:80/${DODGY_IMAGE_NAME}`, }, - similarArtists: [{ id: "similar1.id", name: "similar1" }, { id: "similar2.id", name: "similar2" }], + similarArtists: [ + { id: "similar1.id", name: "similar1" }, + { id: "similar2.id", name: "similar2" }, + ], }); beforeEach(() => { @@ -340,7 +350,7 @@ describe("Navidrome", () => { large: undefined, }, albums: artist.albums, - similarArtists: artist.similarArtists + similarArtists: artist.similarArtists, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -403,7 +413,7 @@ describe("Navidrome", () => { large: undefined, }, albums: artist.albums, - similarArtists: artist.similarArtists + similarArtists: artist.similarArtists, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -422,7 +432,7 @@ describe("Navidrome", () => { headers, }); }); - }); + }); describe("and has no similar artists", () => { const album1: Album = anAlbum(); @@ -466,7 +476,7 @@ describe("Navidrome", () => { large: undefined, }, albums: artist.albums, - similarArtists: artist.similarArtists + similarArtists: artist.similarArtists, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -485,7 +495,7 @@ describe("Navidrome", () => { headers, }); }); - }); + }); describe("and has dodgy looking artist image uris", () => { const album1: Album = anAlbum(); @@ -529,7 +539,7 @@ describe("Navidrome", () => { large: undefined, }, albums: artist.albums, - similarArtists: [] + similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -557,7 +567,7 @@ describe("Navidrome", () => { const artist: Artist = anArtist({ albums: [album1, album2], - similarArtists: [] + similarArtists: [], }); beforeEach(() => { @@ -583,7 +593,7 @@ describe("Navidrome", () => { name: artist.name, image: artist.image, albums: artist.albums, - similarArtists: [] + similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -609,7 +619,7 @@ describe("Navidrome", () => { const artist: Artist = anArtist({ albums: [album], - similarArtists: [] + similarArtists: [], }); beforeEach(() => { @@ -635,7 +645,7 @@ describe("Navidrome", () => { name: artist.name, image: artist.image, albums: artist.albums, - similarArtists: [] + similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -659,7 +669,7 @@ describe("Navidrome", () => { describe("and has no albums", () => { const artist: Artist = anArtist({ albums: [], - similarArtists: [] + similarArtists: [], }); beforeEach(() => { @@ -685,7 +695,7 @@ describe("Navidrome", () => { name: artist.name, image: artist.image, albums: [], - similarArtists: [] + similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { @@ -1662,7 +1672,7 @@ describe("Navidrome", () => { const artist = anArtist({ id: artistId, albums: [album1, album2], - image: images + image: images, }); mockGET @@ -1737,7 +1747,11 @@ describe("Navidrome", () => { data: Buffer.from("the image", "ascii"), }; - const artist = anArtist({ id: artistId, albums: [], image: images }); + const artist = anArtist({ + id: artistId, + albums: [], + image: images, + }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) @@ -1879,7 +1893,7 @@ describe("Navidrome", () => { const artist = anArtist({ id: artistId, albums: [album1, album2], - image: images + image: images, }); mockGET @@ -1955,7 +1969,11 @@ describe("Navidrome", () => { data: Buffer.from("the image", "ascii"), }; - const artist = anArtist({ id: artistId, albums: [], image: images }); + const artist = anArtist({ + id: artistId, + albums: [], + image: images, + }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) @@ -2022,7 +2040,7 @@ describe("Navidrome", () => { const artist = anArtist({ id: artistId, albums: [album1, album2], - image: images + image: images, }); mockGET @@ -2098,7 +2116,11 @@ describe("Navidrome", () => { data: Buffer.from("the image", "ascii"), }; - const artist = anArtist({ id: artistId, albums: [], image: images }); + const artist = anArtist({ + id: artistId, + albums: [], + image: images, + }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) @@ -2142,4 +2164,63 @@ describe("Navidrome", () => { }); }); }); + + describe("scrobble", () => { + describe("when scrobbling succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); + + mockPOST.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.scrobble(id)); + + expect(result).toEqual(true); + + expect(mockPOST).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: { + id, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when scrobbling fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); + + mockPOST.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.scrobble(id)); + + expect(result).toEqual(false); + + expect(mockPOST).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: { + id, + ...authParams, + }, + headers, + }); + }); + }); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 5885c3d..39934d2 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -198,6 +198,7 @@ describe("server", () => { }; const musicLibrary = { stream: jest.fn(), + scrobble: jest.fn() }; let now = dayjs(); const accessTokens = new ExpiringAccessTokens({ now: () => now }); @@ -239,6 +240,54 @@ describe("server", () => { }); }); + describe("scrobbling", () => { + describe("when scrobbling succeeds", () => { + it("should scrobble the track", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + }, + data: Buffer.from("some track", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + 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(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); + }); + }); + + describe("when scrobbling succeeds", () => { + it("should still return the track", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + }, + data: Buffer.from("some track", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + 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(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); + }); + }); + }); + 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 () => { @@ -252,6 +301,7 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.scrobble.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) @@ -280,6 +330,7 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.scrobble.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) @@ -308,6 +359,7 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.scrobble.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) @@ -344,6 +396,7 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.scrobble.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) @@ -382,6 +435,7 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.scrobble.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) @@ -422,6 +476,7 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.scrobble.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`)