diff --git a/README.md b/README.md index 4d79c8e..b6be7af 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Currently only a single integration allowing Navidrome to be registered with son ## Features - Integrates with Navidrome -- Browse by Artist, Albums, Genres, Random Albums, Starred Albums, Recently Added Albums, Recently Played Albums, Most Played Albums +- Browse by Artist, Albums, Genres, Playlist, Random Albums, Starred Albums, Recently Added Albums, Recently Played Albums, Most Played Albums - Artist Art - Album Art - View Related Artists via Artist -> '...' -> Menu -> Related Arists @@ -20,6 +20,7 @@ Currently only a single integration allowing Navidrome to be registered with son - Multiple registrations within a single household. - Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType - Ability to search by Album, Artist, Track +- Ability to play a playlist ## Running @@ -83,4 +84,4 @@ BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for cust ## TODO - Artist Radio -- Playlist support +- Add tracks to playlists diff --git a/src/music_service.ts b/src/music_service.ts index a76fdec..9cecc13 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -131,6 +131,15 @@ export type CoverArt = { data: Buffer; } +export type PlaylistSummary = { + id: string, + name: string +} + +export type Playlist = PlaylistSummary & { + entries: Track[] +} + export const range = (size: number) => [...Array(size).keys()]; export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => @@ -163,4 +172,6 @@ export interface MusicLibrary { searchArtists(query: string): Promise; searchAlbums(query: string): Promise; searchTracks(query: string): Promise; + playlists(): Promise; + playlist(id: string): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index e95185d..794153e 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -164,6 +164,38 @@ export type GetAlbumResponse = { }; }; +export type playlist = { + _id: string; + _name: string; +}; + +export type entry = { + _id: string; + _parent: string; + _title: string; + _album: string; + _artist: string; + _track: string; + _year: string; + _genre: string; + _contentType: string; + _duration: string; + _albumId: string; + _artistId: string; +}; + +export type GetPlaylistResponse = { + playlist: { + _id: string; + _name: string; + entry: entry[]; + }; +}; + +export type GetPlaylistsResponse = { + playlists: { playlist: playlist[] }; +}; + export type GetSongResponse = { song: song; }; @@ -218,7 +250,7 @@ const asAlbum = (album: album) => ({ year: album._year, genre: maybeAsGenre(album._genre), artistId: album._artistId, - artistName: album._artist + artistName: album._artist, }); export const asGenre = (genreName: string) => ({ @@ -297,13 +329,15 @@ export class Navidrome implements MusicService { (response) => new X2JS({ arrayAccessFormPaths: [ - "subsonic-response.artist.album", - "subsonic-response.albumList.album", "subsonic-response.album.song", - "subsonic-response.genres.genre", + "subsonic-response.albumList.album", + "subsonic-response.artist.album", "subsonic-response.artistInfo.similarArtist", - "subsonic-response.searchResult3.artist", + "subsonic-response.genres.genre", + "subsonic-response.playlist.entry", + "subsonic-response.playlists.playlist", "subsonic-response.searchResult3.album", + "subsonic-response.searchResult3.artist", "subsonic-response.searchResult3.song", ], }).xml2js(response.data) as SubconicEnvelope @@ -366,7 +400,7 @@ export class Navidrome implements MusicService { year: album._year, genre: maybeAsGenre(album._genre), artistId: album._artistId, - artistName: album._artist + artistName: album._artist, })); getArtist = ( @@ -431,7 +465,7 @@ export class Navidrome implements MusicService { year: album._year, genre: maybeAsGenre(album._genre), artistId: album._artistId, - artistName: album._artist + artistName: album._artist, })); search3 = (credentials: Credentials, q: any) => @@ -610,6 +644,44 @@ export class Navidrome implements MusicService { songs.map((it) => navidrome.getTrack(credentials, it._id)) ) ), + playlists: async () => + navidrome + .getJSON(credentials, "/rest/getPlaylists") + .then((it) => it.playlists.playlist || []) + .then((playlists) => + playlists.map((it) => ({ id: it._id, name: it._name })) + ), + playlist: async (id: string) => + navidrome + .getJSON(credentials, "/rest/getPlaylist", { + id, + }) + .then((it) => it.playlist) + .then((playlist) => ({ + id: playlist._id, + name: playlist._name, + entries: (playlist.entry || []).map((entry) => ({ + id: entry._id, + name: entry._title, + mimeType: entry._contentType, + duration: parseInt(entry._duration || "0"), + number: parseInt(entry._track || "0"), + genre: maybeAsGenre(entry._genre), + album: { + id: entry._albumId, + name: entry._album, + year: entry._year, + genre: maybeAsGenre(entry._genre), + + artistName: entry._artist, + artistId: entry._artistId, + }, + artist: { + id: entry._artistId, + name: entry._artist, + }, + })), + })), }; return Promise.resolve(musicLibrary); diff --git a/src/smapi.ts b/src/smapi.ts index e4a3191..0e527e5 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -13,6 +13,7 @@ import { ArtistSummary, Genre, MusicService, + PlaylistSummary, slice2, Track, } from "./music_service"; @@ -194,7 +195,7 @@ export type Container = { itemType: ContainerType; id: string; title: string; - displayType: string | undefined + displayType: string | undefined; }; const genre = (genre: Genre) => ({ @@ -203,6 +204,13 @@ const genre = (genre: Genre) => ({ title: genre.name, }); +const playlist = (playlist: PlaylistSummary) => ({ + itemType: "album", + id: `playlist:${playlist.id}`, + title: playlist.name, + canPlay: true, +}); + export const defaultAlbumArtURI = ( webAddress: string, accessToken: string, @@ -487,6 +495,11 @@ function bindSmapiSoapServiceToExpress( id: "albums", title: "Albums", }, + { + itemType: "container", + id: "playlists", + title: "Playlists", + }, { itemType: "container", id: "genres", @@ -519,7 +532,7 @@ function bindSmapiSoapServiceToExpress( }, ], index: 0, - total: 8, + total: 9, }); case "search": return getMetadataResult({ @@ -589,6 +602,31 @@ function bindSmapiSoapServiceToExpress( total, }) ); + case "playlists": + return musicLibrary + .playlists() + .then(slice2(paging)) + .then(([page, total]) => + getMetadataResult({ + mediaCollection: page.map(playlist), + index: paging._index, + total, + }) + ); + case "playlist": + return musicLibrary + .playlist(typeId!) + .then(playlist => playlist.entries) + .then(slice2(paging)) + .then(([page, total]) => { + return getMetadataResult({ + mediaMetadata: page.map((it) => + track(webAddress, accessToken, it) + ), + index: paging._index, + total, + }); + }); case "artist": return musicLibrary .artist(typeId!) diff --git a/tests/builders.ts b/tests/builders.ts index cdd98a8..8c5cf90 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid"; import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; -import { Album, Artist, Track, albumToAlbumSummary, artistToArtistSummary } from "../src/music_service"; +import { Album, Artist, Track, albumToAlbumSummary, artistToArtistSummary, PlaylistSummary, Playlist } from "../src/music_service"; import randomString from "../src/random_string"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); @@ -28,6 +28,23 @@ export const aService = (fields: Partial = {}): Service => ({ ...fields, }); +export function aPlaylistSummary(fields: Partial = {}): PlaylistSummary { + return { + id: `playlist-${uuid()}`, + name: `playlistname-${randomString()}`, + ...fields + } +} + +export function aPlaylist(fields: Partial = {}): Playlist { + return { + id: `playlist-${uuid()}`, + name: `playlist-${randomString()}`, + entries: [aTrack(), aTrack()], + ...fields + } +} + export function aDevice(fields: Partial = {}): Device { return { name: `device-${uuid()}`, @@ -109,15 +126,17 @@ export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]; export function aTrack(fields: Partial = {}): Track { const id = uuid(); + const artist = anArtist(); + const genre = fields.genre || randomGenre(); return { id, name: `Track ${id}`, mimeType: `audio/mp3-${id}`, duration: randomInt(500), number: randomInt(100), - genre: randomGenre(), - artist: artistToArtistSummary(anArtist()), - album: albumToAlbumSummary(anAlbum()), + genre, + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })), ...fields, }; } diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 532cc8e..582d9b9 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -133,6 +133,9 @@ export class InMemoryMusicService implements MusicService { searchArtists: async (_: string) => Promise.resolve([]), searchAlbums: async (_: string) => Promise.resolve([]), searchTracks: async (_: string) => Promise.resolve([]), + playlists: async () => Promise.resolve([]), + playlist: async (id: string) => + Promise.reject(`No playlist with id ${id}`), }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 8de93ea..3109798 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -31,8 +31,17 @@ import { AlbumSummary, artistToArtistSummary, AlbumQuery, + PlaylistSummary, + Playlist, + ArtistSummary, } from "../src/music_service"; -import { anAlbum, anArtist, aTrack } from "./builders"; +import { + anAlbum, + anArtist, + aPlaylist, + aPlaylistSummary, + aTrack, +} from "./builders"; jest.mock("../src/random_string"); @@ -103,6 +112,9 @@ const ok = (data: string) => ({ data, }); +const similarArtistXml = (artistSummary: ArtistSummary) => + ``; + const getArtistInfoXml = ( artist: Artist ) => ` @@ -113,10 +125,7 @@ const getArtistInfoXml = ( ${artist.image.small || ""} ${artist.image.medium || ""} ${artist.image.large || ""} - ${artist.similarArtists.map( - (it) => - `` - )} + ${artist.similarArtists.map(similarArtistXml).join("")} `; @@ -137,7 +146,7 @@ const albumXml = ( created="2021-01-07T08:19:55.834207205Z" artistId="${artist.id}" songCount="19" - isVideo="false">${tracks.map((track) => songXml(track))}`; + isVideo="false">${tracks.map(songXml).join("")}`; const songXml = (track: Track) => ` ` - ${albums.map(([artist, album]) => - albumXml(artist, album) - )} + ${albums + .map(([artist, album]) => albumXml(artist, album)) + .join("")} `; const artistXml = (artist: Artist) => ` - ${artist.albums.map((album) => - albumXml(artist, album) - )} + ${artist.albums + .map((album) => + albumXml(artist, album) + ) + .join("")} `; const getArtistXml = ( @@ -185,14 +196,14 @@ const getArtistXml = ( ${artistXml(artist)} `; +const genreXml = (genre: string) => + `${genre}`; + const genresXml = ( genres: string[] ) => ` - ${genres.map( - (it) => - `${it}` - )} + ${genres.map(genreXml).join("")} `; @@ -221,6 +232,56 @@ export type ArtistWithAlbum = { album: Album; }; +const playlistXml = (playlist: PlaylistSummary) => + ``; + +const getPlayLists = ( + playlists: PlaylistSummary[] +) => ` + + ${playlists.map(playlistXml).join("")} + +`; + +const error = (code: string, message: string) => + ``; + +const getPlayList = ( + playlist: Playlist +) => ` + + ${playlist.entries + .map( + (it) => `` + ) + .join("")} + +`; + const searchResult3 = ({ artists, albums, @@ -231,14 +292,16 @@ const searchResult3 = ({ tracks: Track[]; }>) => ` - ${(artists || []).map((it) => - artistXml({ - ...it, - albums: [], - }) - )} - ${(albums || []).map((it) => albumXml(it.artist, it.album, []))} - ${(tracks || []).map((it) => songXml(it))} + ${(artists || []) + .map((it) => + artistXml({ + ...it, + albums: [], + }) + ) + .join("")} + ${(albums || []).map((it) => albumXml(it.artist, it.album, [])).join("")} + ${(tracks || []).map((it) => songXml(it)).join("")} `; @@ -1382,8 +1445,16 @@ describe("Navidrome", () => { }); const tracks = [ - aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), genre: hipHop }), - aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), genre: hipHop }), + aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + }), + aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + }), aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), @@ -2728,7 +2799,7 @@ describe("Navidrome", () => { name: "foo woo", genre: { id: "pop", name: "pop" }, }); - const artist = anArtist({ name: "#1", albums:[album] }); + const artist = anArtist({ name: "#1", albums: [album] }); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) @@ -2988,4 +3059,171 @@ describe("Navidrome", () => { }); }); }); + + describe("playlists", () => { + describe("getting playlists", () => { + describe("when there is 1 playlist results", () => { + it("should return it", async () => { + const playlist = aPlaylistSummary(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayLists([playlist]))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.playlists()); + + expect(result).toEqual([playlist]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: authParams, + headers, + }); + }); + }); + + describe("when there are many playlists", () => { + it("should return them", async () => { + const playlist1 = aPlaylistSummary(); + const playlist2 = aPlaylistSummary(); + const playlist3 = aPlaylistSummary(); + const playlists = [playlist1, playlist2, playlist3]; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayLists(playlists))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.playlists()); + + expect(result).toEqual(playlists); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: authParams, + headers, + }); + }); + }); + + describe("when there are no playlists", () => { + it("should return []", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayLists([]))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.playlists()); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: authParams, + headers, + }); + }); + }); + }); + + describe("getting a single playlist", () => { + describe("when there is no playlist with the id", () => { + it("should raise error", async () => { + const id = "id404"; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(error("70", "not there"))) + ); + + return expect( + navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.playlist(id)) + ).rejects.toEqual("not there"); + }); + }); + + describe("when there is a playlist with the id", () => { + describe("and it has tracks", () => { + it("should return the playlist with entries", async () => { + const playlist = aPlaylist({ + entries: [ + aTrack({ genre: { id: "pop", name: "pop" } }), + aTrack({ genre: { id: "rock", name: "rock" } }), + ], + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayList(playlist))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.playlist(playlist.id)); + + expect(result).toEqual(playlist); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { + params: { + id: playlist.id, + ...authParams, + }, + headers, + }); + }); + }); + + describe("and it has no tracks", () => { + it("should return the playlist with empty entries", async () => { + const playlist = aPlaylist({ + entries: [], + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayList(playlist))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.playlist(playlist.id)); + + expect(result).toEqual(playlist); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { + params: { + id: playlist.id, + ...authParams, + }, + headers, + }); + }); + }); + }); + }); + }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 6070761..83c4ea1 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -242,7 +242,7 @@ describe("album", () => { albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum), canPlay: true, artist: someAlbum.artistName, - artistId: someAlbum.artistId + artistId: someAlbum.artistId, }); }); }); @@ -288,6 +288,8 @@ describe("api", () => { artists: jest.fn(), artist: jest.fn(), genres: jest.fn(), + playlists: jest.fn(), + playlist: jest.fn(), albums: jest.fn(), tracks: jest.fn(), track: jest.fn(), @@ -644,7 +646,9 @@ describe("api", () => { }); expect(result[0]).toEqual( searchResult({ - mediaCollection: tracks.map((it) => album(rootUrl, accessToken, it.album)), + mediaCollection: tracks.map((it) => + album(rootUrl, accessToken, it.album) + ), index: 0, total: 2, }) @@ -725,6 +729,11 @@ describe("api", () => { mediaCollection: [ { itemType: "container", id: "artists", title: "Artists" }, { itemType: "albumList", id: "albums", title: "Albums" }, + { + itemType: "container", + id: "playlists", + title: "Playlists", + }, { itemType: "container", id: "genres", title: "Genres" }, { itemType: "albumList", @@ -753,7 +762,7 @@ describe("api", () => { }, ], index: 0, - total: 8, + total: 9, }) ); }); @@ -830,6 +839,66 @@ describe("api", () => { }); }); + describe("asking for playlists", () => { + const expectedPlayLists = [ + { id: "1", name: "pl1" }, + { id: "2", name: "pl2" }, + { id: "3", name: "pl3" }, + { id: "4", name: "pl4" }, + ]; + + beforeEach(() => { + musicLibrary.playlists.mockResolvedValue(expectedPlayLists); + }); + + describe("asking for all playlists", () => { + it("should return a collection of playlists", async () => { + const result = await ws.getMetadataAsync({ + id: `playlists`, + index: 0, + count: 100, + }); + expect(result[0]).toEqual( + getMetadataResult({ + mediaCollection: expectedPlayLists.map((playlist) => ({ + itemType: "album", + id: `playlist:${playlist.id}`, + title: playlist.name, + canPlay: true, + })), + index: 0, + total: expectedPlayLists.length, + }) + ); + }); + }); + + describe("asking for a page of playlists", () => { + it("should return just that page", async () => { + const result = await ws.getMetadataAsync({ + id: `playlists`, + index: 1, + count: 2, + }); + expect(result[0]).toEqual( + getMetadataResult({ + mediaCollection: [ + expectedPlayLists[1]!, + expectedPlayLists[2]!, + ].map((playlist) => ({ + itemType: "album", + id: `playlist:${playlist.id}`, + title: playlist.name, + canPlay: true, + })), + index: 1, + total: expectedPlayLists.length, + }) + ); + }); + }); + }); + describe("asking for a single artist", () => { const artistWithManyAlbums = anArtist({ albums: [anAlbum(), anAlbum(), anAlbum(), anAlbum(), anAlbum()], @@ -856,7 +925,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: artistWithManyAlbums.albums.length, @@ -889,7 +958,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 2, total: artistWithManyAlbums.albums.length, @@ -1144,7 +1213,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 6, @@ -1189,7 +1258,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 6, @@ -1234,7 +1303,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 6, @@ -1279,7 +1348,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 6, @@ -1324,7 +1393,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 6, @@ -1367,7 +1436,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 6, @@ -1410,7 +1479,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 2, total: 6, @@ -1451,7 +1520,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 4, @@ -1495,7 +1564,7 @@ describe("api", () => { albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), canPlay: true, artistId: it.artistId, - artist: it.artistName + artist: it.artistName, })), index: 0, total: 4, @@ -1512,78 +1581,146 @@ describe("api", () => { }); }); - describe("asking for tracks", () => { - describe("for an album", () => { - const album = anAlbum(); - const artist = anArtist({ - albums: [album], - }); + describe("asking for an album", () => { + const album = anAlbum(); + const artist = anArtist({ + 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 }); - const tracks = [track1, track2, track3, track4, track5]; + const tracks = [track1, track2, track3, track4, track5]; - beforeEach(() => { - musicLibrary.tracks.mockResolvedValue(tracks); - }); + beforeEach(() => { + musicLibrary.tracks.mockResolvedValue(tracks); + }); - describe("asking for all for an album", () => { - it("should return them all", async () => { - const paging = { + describe("asking for all for an album", () => { + it("should return them all", async () => { + const paging = { + index: 0, + count: 100, + }; + + const result = await ws.getMetadataAsync({ + id: `album:${album.id}`, + ...paging, + }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaMetadata: tracks.map((it) => + track(rootUrl, accessToken, it) + ), index: 0, - count: 100, - }; - - const result = await ws.getMetadataAsync({ - id: `album:${album.id}`, - ...paging, - }); - - expect(result[0]).toEqual( - getMetadataResult({ - mediaMetadata: tracks.map((it) => - track(rootUrl, accessToken, it) - ), - index: 0, - total: tracks.length, - }) - ); - expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); - }); + total: tracks.length, + }) + ); + expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); }); + }); - describe("asking for a single page of tracks", () => { - const pageOfTracks = [track3, track4]; + describe("asking for a single page of tracks", () => { + const pageOfTracks = [track3, track4]; - it("should return only that page", async () => { - const paging = { - index: 2, - count: 2, - }; + it("should return only that page", async () => { + const paging = { + index: 2, + count: 2, + }; - const result = await ws.getMetadataAsync({ - id: `album:${album.id}`, - ...paging, - }); - - expect(result[0]).toEqual( - getMetadataResult({ - mediaMetadata: pageOfTracks.map((it) => - track(rootUrl, accessToken, it) - ), - index: paging.index, - total: tracks.length, - }) - ); - expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); + const result = await ws.getMetadataAsync({ + id: `album:${album.id}`, + ...paging, }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaMetadata: pageOfTracks.map((it) => + track(rootUrl, accessToken, it) + ), + index: paging.index, + total: tracks.length, + }) + ); + expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); }); }); }); + + describe("asking for a playlist", () => { + const track1 = aTrack(); + const track2 = aTrack(); + const track3 = aTrack(); + const track4 = aTrack(); + const track5 = aTrack(); + + const playlist = { + id: uuid(), + name: "playlist for test", + entries: [track1, track2, track3, track4, track5] + } + + beforeEach(() => { + musicLibrary.playlist.mockResolvedValue(playlist); + }); + + describe("asking for all for a playlist", () => { + it("should return them all", async () => { + const paging = { + index: 0, + count: 100, + }; + + const result = await ws.getMetadataAsync({ + id: `playlist:${playlist.id}`, + ...paging, + }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaMetadata: playlist.entries.map((it) => + track(rootUrl, accessToken, it) + ), + index: 0, + total: playlist.entries.length, + }) + ); + expect(musicLibrary.playlist).toHaveBeenCalledWith(playlist.id); + }); + }); + + describe("asking for a single page of a playlists entries", () => { + const pageOfTracks = [track3, track4]; + + it("should return only that page", async () => { + const paging = { + index: 2, + count: 2, + }; + + const result = await ws.getMetadataAsync({ + id: `playlist:${playlist.id}`, + ...paging, + }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaMetadata: pageOfTracks.map((it) => + track(rootUrl, accessToken, it) + ), + index: paging.index, + total: playlist.entries.length, + }) + ); + expect(musicLibrary.playlist).toHaveBeenCalledWith(playlist.id); + }); + }); + }); }); });