import { Md5 } from "ts-md5/dist/md5"; import { v4 as uuid } from "uuid"; import { isDodgyImage, Navidrome, t, BROWSER_HEADERS, DODGY_IMAGE_NAME, } from "../src/navidrome"; import encryption from "../src/encryption"; import axios from "axios"; jest.mock("axios"); import sharp from "sharp"; jest.mock("sharp"); import randomString from "../src/random_string"; import { Album, Artist, AuthSuccess, Images, albumToAlbumSummary, range, asArtistAlbumPairs, Track, AlbumSummary, artistToArtistSummary, NO_IMAGES, } from "../src/music_service"; import { anAlbum, anArtist, aTrack } from "./builders"; jest.mock("../src/random_string"); describe("t", () => { it("should be an md5 of the password and the salt", () => { const p = "password123"; const s = "saltydog"; expect(t(p, s)).toEqual(Md5.hashStr(`${p}${s}`)); }); }); describe("isDodgyImage", () => { describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { it("is dodgy", () => { expect( isDodgyImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") ).toEqual(true); }); }); describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { it("is dodgy", () => { expect(isDodgyImage("http://something/somethingelse.png")).toEqual(false); expect( isDodgyImage( "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" ) ).toEqual(false); }); }); }); const ok = (data: string) => ({ status: 200, data, }); const artistInfoXml = ( artist: Artist ) => ` ${artist.image.small || ""} ${artist.image.medium || ""} ${artist.image.large || ""} ${artist.similarArtists.map( (it) => `` )} `; const albumXml = ( artist: Artist, album: AlbumSummary, tracks: Track[] = [] ) => `${tracks.map((track) => songXml(track))}`; const songXml = (track: Track) => ``; const albumListXml = ( albums: [Artist, Album][] ) => ` ${albums.map(([artist, album]) => albumXml(artist, album) )} `; const artistXml = ( artist: Artist ) => ` ${artist.albums.map((album) => albumXml(artist, album))} `; const genresXml = ( genres: string[] ) => ` ${genres.map( (it) => `${it}` )} `; const getAlbumXml = ( artist: Artist, album: Album, tracks: Track[] ) => ` ${albumXml( artist, album, tracks )} `; const getSongXml = ( track: Track ) => ` ${songXml( track )} `; const EMPTY = ``; const PING_OK = ``; describe("Navidrome", () => { const url = "http://127.0.0.22:4567"; const username = "user1"; const password = "pass1"; const salt = "saltysalty"; const navidrome = new Navidrome(url, encryption("secret")); 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); }); const authParams = { u: username, t: t(password, salt), s: salt, v: "1.16.1", c: "bonob", }; const headers = { "User-Agent": "bonob", }; describe("generateToken", () => { describe("when the credentials are valid", () => { it("should be able to generate a token and then login using it", async () => { (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); const token = (await navidrome.generateToken({ username, password, })) as AuthSuccess; expect(token.authToken).toBeDefined(); expect(token.nickname).toEqual(username); expect(token.userId).toEqual(username); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { params: authParams, headers, }); }); }); describe("when the credentials are not valid", () => { it("should be able to generate a token and then login using it", async () => { (axios.get as jest.Mock).mockResolvedValue({ status: 200, data: ` `, }); const token = await navidrome.generateToken({ username, password }); expect(token).toEqual({ message: "Wrong username or password" }); }); }); }); describe("getting genres", () => { describe("when there is only 1", () => { const genres = ["HipHop"]; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres)))); }); it("should return them alphabetically sorted", async () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.genres()); expect(result).toEqual(genres.sort()); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { params: { ...authParams, }, headers, }); }); }); describe("when there are many", () => { const genres = ["HipHop", "Rap", "TripHop", "Pop", "Rock"]; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres)))); }); it("should return them alphabetically sorted", async () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.genres()); expect(result).toEqual(genres.sort()); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { params: { ...authParams, }, headers, }); }); }); }); describe("getting an artist", () => { describe("when the artist exists", () => { describe("and has many similar artists", () => { const album1: Album = anAlbum(); const album2: Album = anAlbum(); const artist: Artist = anArtist({ albums: [album1, album2], image: { small: `http://localhost:80/${DODGY_IMAGE_NAME}`, 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" }, ], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return the similar artists", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: { small: undefined, medium: undefined, large: undefined, }, albums: artist.albums, similarArtists: artist.similarArtists, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); describe("and has one similar artists", () => { const album1: Album = anAlbum(); const album2: Album = anAlbum(); const artist: Artist = anArtist({ albums: [album1, album2], image: { small: `http://localhost:80/${DODGY_IMAGE_NAME}`, medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, large: `http://localhost:80/${DODGY_IMAGE_NAME}`, }, similarArtists: [{ id: "similar1.id", name: "similar1" }], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return the similar artists", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: { small: undefined, medium: undefined, large: undefined, }, albums: artist.albums, similarArtists: artist.similarArtists, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); describe("and has no similar artists", () => { const album1: Album = anAlbum(); const album2: Album = anAlbum(); const artist: Artist = anArtist({ albums: [album1, album2], image: { small: `http://localhost:80/${DODGY_IMAGE_NAME}`, medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, large: `http://localhost:80/${DODGY_IMAGE_NAME}`, }, similarArtists: [], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return the similar artists", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: { small: undefined, medium: undefined, large: undefined, }, albums: artist.albums, similarArtists: artist.similarArtists, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); describe("and has dodgy looking artist image uris", () => { const album1: Album = anAlbum(); const album2: Album = anAlbum(); const artist: Artist = anArtist({ albums: [album1, album2], image: { small: `http://localhost:80/${DODGY_IMAGE_NAME}`, medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, large: `http://localhost:80/${DODGY_IMAGE_NAME}`, }, similarArtists: [], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return remove the dodgy looking image uris and return undefined", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: { small: undefined, medium: undefined, large: undefined, }, albums: artist.albums, similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); describe("and has multiple albums", () => { const album1: Album = anAlbum(); const album2: Album = anAlbum(); const artist: Artist = anArtist({ albums: [album1, album2], similarArtists: [], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return it", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: artist.image, albums: artist.albums, similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); describe("and has only 1 album", () => { const album: Album = anAlbum(); const artist: Artist = anArtist({ albums: [album], similarArtists: [], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return it", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: artist.image, albums: artist.albums, similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); describe("and has no albums", () => { const artist: Artist = anArtist({ albums: [], similarArtists: [], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ); }); it("should return it", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artist(artist.id)); expect(result).toEqual({ id: artist.id, name: artist.name, image: artist.image, albums: [], similarArtists: [], }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artist.id, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { id: artist.id, ...authParams, }, headers, }); }); }); }); }); describe("getting artists", () => { describe("when there are no results", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve( ok(` `) ) ); }); it("should return empty", async () => { const artists = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artists({ _index: 0, _count: 100 })); expect(artists).toEqual({ results: [], total: 0, }); }); }); describe("when there are artists", () => { const artist1 = anArtist(); const artist2 = anArtist(); const artist3 = anArtist(); const artist4 = anArtist(); const getArtistsXml = ` `; describe("when no paging is in effect", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))); }); it("should return all the artists", async () => { const artists = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artists({ _index: 0, _count: 100 })); const expectedResults = [artist1, artist2, artist3, artist4].map( (it) => ({ id: it.id, name: it.name, }) ); expect(artists).toEqual({ results: expectedResults, total: 4, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { params: authParams, headers, }); }); }); describe("when paging specified", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))); }); it("should return only the correct page of artists", async () => { const artists = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artists({ _index: 1, _count: 2 })); const expectedResults = [artist2, artist3].map((it) => ({ id: it.id, name: it.name, })); expect(artists).toEqual({ results: expectedResults, total: 4 }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { params: authParams, headers, }); }); }); }); }); describe("getting albums", () => { describe("filtering", () => { const album1 = anAlbum({ genre: "Pop" }); const album2 = anAlbum({ genre: "Rock" }); const album3 = anAlbum({ genre: "Pop" }); const artist = anArtist({ albums: [album1, album2, album3] }); describe("by genre", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve( ok( albumListXml([ [artist, album1], [artist, album3], ]) ) ) ); }); it("should pass the filter to navidrome", async () => { const q = { _index: 0, _count: 500, genre: "Pop" }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.albums(q)); expect(result).toEqual({ results: [album1, album3].map(albumToAlbumSummary), total: 2, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { params: { type: "byGenre", genre: "Pop", size: 500, offset: 0, ...authParams, }, headers, }); }); }); }); describe("when the artist has only 1 album", () => { const artist1 = anArtist({ name: "one hit wonder", albums: [anAlbum()], }); const artists = [artist1]; const albums = artists.flatMap((artist) => artist.albums); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) ); }); it("should return the album", async () => { const paging = { _index: 0, _count: 500 }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.albums(paging)); expect(result).toEqual({ results: albums, total: 1, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { params: { type: "alphabeticalByArtist", size: 500, offset: 0, ...authParams, }, headers, }); }); }); describe("when the artist has only no albums", () => { const artist1 = anArtist({ name: "one hit wonder", albums: [], }); const artists = [artist1]; const albums = artists.flatMap((artist) => artist.albums); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) ); }); it("should return the album", async () => { const paging = { _index: 0, _count: 500 }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.albums(paging)); expect(result).toEqual({ results: albums, total: 0, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { params: { type: "alphabeticalByArtist", size: 500, offset: 0, ...authParams, }, headers, }); }); }); describe("when there are less than 500 albums", () => { const artist1 = anArtist({ name: "abba", albums: [anAlbum(), anAlbum(), anAlbum()], }); const artist2 = anArtist({ name: "babba", albums: [anAlbum(), anAlbum(), anAlbum()], }); const artists = [artist1, artist2]; const albums = artists.flatMap((artist) => artist.albums); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) ); }); describe("querying for all of them", () => { it("should return all of them with corrent paging information", async () => { const paging = { _index: 0, _count: 500 }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.albums(paging)); expect(result).toEqual({ results: albums, total: 6, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { params: { type: "alphabeticalByArtist", size: 500, offset: 0, ...authParams, }, headers, }); }); }); describe("querying for a page of them", () => { it("should return the page with the corrent paging information", async () => { const paging = { _index: 2, _count: 2 }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.albums(paging)); expect(result).toEqual({ results: [albums[2], albums[3]], total: 6, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { params: { type: "alphabeticalByArtist", size: 500, offset: 0, ...authParams, }, headers, }); }); }); }); describe("when there are more than 500 albums", () => { const first500Albums = range(500).map((i) => anAlbum({ name: `album ${i}` }) ); const artist = anArtist({ name: "> 500 albums", albums: [...first500Albums, anAlbum(), anAlbum(), anAlbum()], }); beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve( ok( albumListXml( first500Albums.map( (album) => [artist, album] as [Artist, Album] ) ) ) ) ); }); describe("querying for all of them", () => { it("will return only the first 500 with the correct paging information", async () => { const paging = { _index: 0, _count: 1000 }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.albums(paging)); expect(result).toEqual({ results: first500Albums.map(albumToAlbumSummary), total: 500, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { params: { type: "alphabeticalByArtist", size: 500, offset: 0, ...authParams, }, headers, }); }); }); }); }); describe("getting an album", () => { describe("when it exists", () => { const album = anAlbum(); const artist = anArtist({ albums: [album] }); 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))) ); }); 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.album(album.id)); expect(result).toEqual(album); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { params: { id: album.id, ...authParams, }, headers, }); }); }); }); describe("getting tracks", () => { describe("for an album", () => { describe("when the album has multiple tracks", () => { 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 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))) ); }); 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("when the album has only 1 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 tracks = [aTrack({ artist: artistSummary, album: albumSummary })]; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => 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("when the album has only no tracks", () => { const album = anAlbum({ id: "album1", name: "Burnin" }); const artist = anArtist({ id: "artist1", name: "Bob Marley", albums: [album], }); const tracks: Track[] = []; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => 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("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, }); }); }); 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("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", }); }); }); }); describe("fetching cover art", () => { describe("fetching album art", () => { describe("when no size is specified", () => { it("should fetch the image", async () => { const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const coverArtId = "someCoverArt"; 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.coverArt(coverArtId, "album")); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: streamResponse.data, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { params: { id: coverArtId, ...authParams, }, headers, responseType: "arraybuffer", }); }); }); describe("when size is specified", () => { it("should fetch the image", async () => { const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const coverArtId = "someCoverArt"; const size = 1879; 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.coverArt(coverArtId, "album", size)); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: streamResponse.data, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { params: { id: coverArtId, size, ...authParams, }, headers, responseType: "arraybuffer", }); }); }); }); describe("fetching artist art", () => { describe("when no size is specified", () => { describe("when the artist has a valid artist uri", () => { it("should fetch the image from the artist uri", async () => { const artistId = "someArtist123"; const images: Images = { small: "http://example.com/images/small", medium: "http://example.com/images/medium", large: "http://example.com/images/large", }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const artist = anArtist({ id: artistId, image: images }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist")); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: streamResponse.data, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); expect(axios.get).toHaveBeenCalledWith(images.large, { headers: BROWSER_HEADERS, responseType: "arraybuffer", }); }); }); describe("when the artist doest not have a valid artist uri", () => { describe("however has some albums", () => { it("should fetch the artists first album image", async () => { const artistId = "someArtist123"; const images: Images = { small: undefined, medium: undefined, large: undefined, }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const album1 = anAlbum(); const album2 = anAlbum(); const artist = anArtist({ id: artistId, albums: [album1, album2], image: images, }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist")); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: streamResponse.data, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artistId, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getCoverArt`, { params: { id: album1.id, ...authParams, }, headers, responseType: "arraybuffer", } ); }); }); describe("and has no albums", () => { it("should return undefined", async () => { const artistId = "someArtist123"; const images: Images = { small: undefined, medium: undefined, large: undefined, }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const artist = anArtist({ id: artistId, albums: [], image: images, }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist")); expect(result).toBeUndefined(); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artistId, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); }); }); }); }); describe("when size is specified", () => { const size = 189; describe("when the artist has a valid artist uri", () => { it("should fetch the image from the artist uri and resize it", async () => { const artistId = "someArtist123"; const images: Images = { small: "http://example.com/images/small", medium: "http://example.com/images/medium", large: "http://example.com/images/large", }; const originalImage = Buffer.from("original image", "ascii"); const resizedImage = Buffer.from("resized image", "ascii"); const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: originalImage, }; const artist = anArtist({ id: artistId, image: images }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); const resize = jest.fn(); ((sharp as unknown) as jest.Mock).mockReturnValue({ resize }); resize.mockReturnValue({ toBuffer: () => Promise.resolve(resizedImage), }); const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.coverArt(artistId, "artist", size)); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: resizedImage, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); expect(axios.get).toHaveBeenCalledWith(images.large, { headers: BROWSER_HEADERS, responseType: "arraybuffer", }); expect(sharp).toHaveBeenCalledWith(streamResponse.data); expect(resize).toHaveBeenCalledWith(size); }); }); describe("when the artist does not have a valid artist uri", () => { describe("however has some albums", () => { it("should fetch the artists first album image", async () => { const artistId = "someArtist123"; const images: Images = { small: undefined, medium: undefined, large: undefined, }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const album1 = anAlbum({ id: "album1Id" }); const album2 = anAlbum({ id: "album2Id" }); const artist = anArtist({ id: artistId, albums: [album1, album2], image: images, }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist", size)); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: streamResponse.data, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artistId, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getCoverArt`, { params: { id: album1.id, size, ...authParams, }, headers, responseType: "arraybuffer", } ); }); }); describe("and has no albums", () => { it("should return undefined", async () => { const artistId = "someArtist123"; const images: Images = { small: undefined, medium: undefined, large: undefined, }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const artist = anArtist({ id: artistId, albums: [], image: images, }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist")); expect(result).toBeUndefined(); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artistId, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); }); }); }); describe("when the artist has a dodgy looking artist uri", () => { describe("however has some albums", () => { it("should fetch the artists first album image", async () => { const artistId = "someArtist123"; const images: Images = { small: `http://localhost:111/${DODGY_IMAGE_NAME}`, medium: `http://localhost:111/${DODGY_IMAGE_NAME}`, large: `http://localhost:111/${DODGY_IMAGE_NAME}`, }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const album1 = anAlbum({ id: "album1Id" }); const album2 = anAlbum({ id: "album2Id" }); const artist = anArtist({ id: artistId, albums: [album1, album2], image: images, }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist", size)); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], data: streamResponse.data, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artistId, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getCoverArt`, { params: { id: album1.id, size, ...authParams, }, headers, responseType: "arraybuffer", } ); }); }); describe("and has no albums", () => { it("should return undefined", async () => { const artistId = "someArtist123"; const images: Images = { small: `http://localhost:111/${DODGY_IMAGE_NAME}`, medium: `http://localhost:111/${DODGY_IMAGE_NAME}`, large: `http://localhost:111/${DODGY_IMAGE_NAME}`, }; const streamResponse = { status: 200, headers: { "content-type": "image/jpeg", }, data: Buffer.from("the image", "ascii"), }; const artist = anArtist({ id: artistId, albums: [], image: images, }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(ok(artistInfoXml(artist))) ) .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.coverArt(artistId, "artist")); expect(result).toBeUndefined(); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { id: artistId, ...authParams, }, headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { params: { id: artistId, ...authParams, }, headers, } ); }); }); }); }); }); }); 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, }); }); }); }); });