Ability to play a playlist

This commit is contained in:
simojenki
2021-05-08 10:33:59 +10:00
parent 5c692f6eb2
commit 4229ad1836
8 changed files with 634 additions and 115 deletions

View File

@@ -9,7 +9,7 @@ Currently only a single integration allowing Navidrome to be registered with son
## Features ## Features
- Integrates with Navidrome - 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 - Artist Art
- Album Art - Album Art
- View Related Artists via Artist -> '...' -> Menu -> Related Arists - 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. - Multiple registrations within a single household.
- Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType - Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType
- Ability to search by Album, Artist, Track - Ability to search by Album, Artist, Track
- Ability to play a playlist
## Running ## Running
@@ -83,4 +84,4 @@ BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for cust
## TODO ## TODO
- Artist Radio - Artist Radio
- Playlist support - Add tracks to playlists

View File

@@ -131,6 +131,15 @@ export type CoverArt = {
data: Buffer; 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 range = (size: number) => [...Array(size).keys()];
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
@@ -163,4 +172,6 @@ export interface MusicLibrary {
searchArtists(query: string): Promise<ArtistSummary[]>; searchArtists(query: string): Promise<ArtistSummary[]>;
searchAlbums(query: string): Promise<AlbumSummary[]>; searchAlbums(query: string): Promise<AlbumSummary[]>;
searchTracks(query: string): Promise<Track[]>; searchTracks(query: string): Promise<Track[]>;
playlists(): Promise<PlaylistSummary[]>;
playlist(id: string): Promise<Playlist>;
} }

View File

@@ -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 = { export type GetSongResponse = {
song: song; song: song;
}; };
@@ -218,7 +250,7 @@ const asAlbum = (album: album) => ({
year: album._year, year: album._year,
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist artistName: album._artist,
}); });
export const asGenre = (genreName: string) => ({ export const asGenre = (genreName: string) => ({
@@ -297,13 +329,15 @@ export class Navidrome implements MusicService {
(response) => (response) =>
new X2JS({ new X2JS({
arrayAccessFormPaths: [ arrayAccessFormPaths: [
"subsonic-response.artist.album",
"subsonic-response.albumList.album",
"subsonic-response.album.song", "subsonic-response.album.song",
"subsonic-response.genres.genre", "subsonic-response.albumList.album",
"subsonic-response.artist.album",
"subsonic-response.artistInfo.similarArtist", "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.album",
"subsonic-response.searchResult3.artist",
"subsonic-response.searchResult3.song", "subsonic-response.searchResult3.song",
], ],
}).xml2js(response.data) as SubconicEnvelope }).xml2js(response.data) as SubconicEnvelope
@@ -366,7 +400,7 @@ export class Navidrome implements MusicService {
year: album._year, year: album._year,
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist artistName: album._artist,
})); }));
getArtist = ( getArtist = (
@@ -431,7 +465,7 @@ export class Navidrome implements MusicService {
year: album._year, year: album._year,
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist artistName: album._artist,
})); }));
search3 = (credentials: Credentials, q: any) => search3 = (credentials: Credentials, q: any) =>
@@ -610,6 +644,44 @@ export class Navidrome implements MusicService {
songs.map((it) => navidrome.getTrack(credentials, it._id)) songs.map((it) => navidrome.getTrack(credentials, it._id))
) )
), ),
playlists: async () =>
navidrome
.getJSON<GetPlaylistsResponse>(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<GetPlaylistResponse>(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); return Promise.resolve(musicLibrary);

View File

@@ -13,6 +13,7 @@ import {
ArtistSummary, ArtistSummary,
Genre, Genre,
MusicService, MusicService,
PlaylistSummary,
slice2, slice2,
Track, Track,
} from "./music_service"; } from "./music_service";
@@ -194,7 +195,7 @@ export type Container = {
itemType: ContainerType; itemType: ContainerType;
id: string; id: string;
title: string; title: string;
displayType: string | undefined displayType: string | undefined;
}; };
const genre = (genre: Genre) => ({ const genre = (genre: Genre) => ({
@@ -203,6 +204,13 @@ const genre = (genre: Genre) => ({
title: genre.name, title: genre.name,
}); });
const playlist = (playlist: PlaylistSummary) => ({
itemType: "album",
id: `playlist:${playlist.id}`,
title: playlist.name,
canPlay: true,
});
export const defaultAlbumArtURI = ( export const defaultAlbumArtURI = (
webAddress: string, webAddress: string,
accessToken: string, accessToken: string,
@@ -487,6 +495,11 @@ function bindSmapiSoapServiceToExpress(
id: "albums", id: "albums",
title: "Albums", title: "Albums",
}, },
{
itemType: "container",
id: "playlists",
title: "Playlists",
},
{ {
itemType: "container", itemType: "container",
id: "genres", id: "genres",
@@ -519,7 +532,7 @@ function bindSmapiSoapServiceToExpress(
}, },
], ],
index: 0, index: 0,
total: 8, total: 9,
}); });
case "search": case "search":
return getMetadataResult({ return getMetadataResult({
@@ -589,6 +602,31 @@ function bindSmapiSoapServiceToExpress(
total, 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": case "artist":
return musicLibrary return musicLibrary
.artist(typeId!) .artist(typeId!)

View File

@@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
import { Credentials } from "../src/smapi"; import { Credentials } from "../src/smapi";
import { Service, Device } from "../src/sonos"; 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"; import randomString from "../src/random_string";
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
@@ -28,6 +28,23 @@ export const aService = (fields: Partial<Service> = {}): Service => ({
...fields, ...fields,
}); });
export function aPlaylistSummary(fields: Partial<PlaylistSummary> = {}): PlaylistSummary {
return {
id: `playlist-${uuid()}`,
name: `playlistname-${randomString()}`,
...fields
}
}
export function aPlaylist(fields: Partial<Playlist> = {}): Playlist {
return {
id: `playlist-${uuid()}`,
name: `playlist-${randomString()}`,
entries: [aTrack(), aTrack()],
...fields
}
}
export function aDevice(fields: Partial<Device> = {}): Device { export function aDevice(fields: Partial<Device> = {}): Device {
return { return {
name: `device-${uuid()}`, name: `device-${uuid()}`,
@@ -109,15 +126,17 @@ export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
export function aTrack(fields: Partial<Track> = {}): Track { export function aTrack(fields: Partial<Track> = {}): Track {
const id = uuid(); const id = uuid();
const artist = anArtist();
const genre = fields.genre || randomGenre();
return { return {
id, id,
name: `Track ${id}`, name: `Track ${id}`,
mimeType: `audio/mp3-${id}`, mimeType: `audio/mp3-${id}`,
duration: randomInt(500), duration: randomInt(500),
number: randomInt(100), number: randomInt(100),
genre: randomGenre(), genre,
artist: artistToArtistSummary(anArtist()), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(anAlbum()), album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })),
...fields, ...fields,
}; };
} }

View File

@@ -133,6 +133,9 @@ export class InMemoryMusicService implements MusicService {
searchArtists: async (_: string) => Promise.resolve([]), searchArtists: async (_: string) => Promise.resolve([]),
searchAlbums: async (_: string) => Promise.resolve([]), searchAlbums: async (_: string) => Promise.resolve([]),
searchTracks: async (_: string) => Promise.resolve([]), searchTracks: async (_: string) => Promise.resolve([]),
playlists: async () => Promise.resolve([]),
playlist: async (id: string) =>
Promise.reject(`No playlist with id ${id}`),
}); });
} }

View File

@@ -31,8 +31,17 @@ import {
AlbumSummary, AlbumSummary,
artistToArtistSummary, artistToArtistSummary,
AlbumQuery, AlbumQuery,
PlaylistSummary,
Playlist,
ArtistSummary,
} from "../src/music_service"; } from "../src/music_service";
import { anAlbum, anArtist, aTrack } from "./builders"; import {
anAlbum,
anArtist,
aPlaylist,
aPlaylistSummary,
aTrack,
} from "./builders";
jest.mock("../src/random_string"); jest.mock("../src/random_string");
@@ -103,6 +112,9 @@ const ok = (data: string) => ({
data, data,
}); });
const similarArtistXml = (artistSummary: ArtistSummary) =>
`<similarArtist id="${artistSummary.id}" name="${artistSummary.name}" albumCount="3"></similarArtist>`;
const getArtistInfoXml = ( const getArtistInfoXml = (
artist: Artist artist: Artist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
@@ -113,10 +125,7 @@ const getArtistInfoXml = (
<smallImageUrl>${artist.image.small || ""}</smallImageUrl> <smallImageUrl>${artist.image.small || ""}</smallImageUrl>
<mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl> <mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl>
<largeImageUrl>${artist.image.large || ""}</largeImageUrl> <largeImageUrl>${artist.image.large || ""}</largeImageUrl>
${artist.similarArtists.map( ${artist.similarArtists.map(similarArtistXml).join("")}
(it) =>
`<similarArtist id="${it.id}" name="${it.name}" albumCount="3"></similarArtist>`
)}
</artistInfo> </artistInfo>
</subsonic-response>`; </subsonic-response>`;
@@ -137,7 +146,7 @@ const albumXml = (
created="2021-01-07T08:19:55.834207205Z" created="2021-01-07T08:19:55.834207205Z"
artistId="${artist.id}" artistId="${artist.id}"
songCount="19" songCount="19"
isVideo="false">${tracks.map((track) => songXml(track))}</album>`; isVideo="false">${tracks.map(songXml).join("")}</album>`;
const songXml = (track: Track) => `<song const songXml = (track: Track) => `<song
id="${track.id}" id="${track.id}"
@@ -165,18 +174,20 @@ const albumListXml = (
albums: [Artist, Album][] albums: [Artist, Album][]
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<albumList> <albumList>
${albums.map(([artist, album]) => ${albums
albumXml(artist, album) .map(([artist, album]) => albumXml(artist, album))
)} .join("")}
</albumList> </albumList>
</subsonic-response>`; </subsonic-response>`;
const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${ const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${
artist.name artist.name
}" albumCount="${artist.albums.length}" artistImageUrl="...."> }" albumCount="${artist.albums.length}" artistImageUrl="....">
${artist.albums.map((album) => ${artist.albums
albumXml(artist, album) .map((album) =>
)} albumXml(artist, album)
)
.join("")}
</artist>`; </artist>`;
const getArtistXml = ( const getArtistXml = (
@@ -185,14 +196,14 @@ const getArtistXml = (
${artistXml(artist)} ${artistXml(artist)}
</subsonic-response>`; </subsonic-response>`;
const genreXml = (genre: string) =>
`<genre songCount="1475" albumCount="86">${genre}</genre>`;
const genresXml = ( const genresXml = (
genres: string[] genres: string[]
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<genres> <genres>
${genres.map( ${genres.map(genreXml).join("")}
(it) =>
`<genre songCount="1475" albumCount="86">${it}</genre>`
)}
</genres> </genres>
</subsonic-response>`; </subsonic-response>`;
@@ -221,6 +232,56 @@ export type ArtistWithAlbum = {
album: Album; album: Album;
}; };
const playlistXml = (playlist: PlaylistSummary) =>
`<playlist id="${playlist.id}" name="${playlist.name}" songCount="1" duration="190" public="true" owner="bob" created="2021-05-06T02:07:24.308007023Z" changed="2021-05-06T02:08:06Z"></playlist>`;
const getPlayLists = (
playlists: PlaylistSummary[]
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)">
<playlists>
${playlists.map(playlistXml).join("")}
</playlists>
</subsonic-response>`;
const error = (code: string, message: string) =>
`<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)"><error code="${code}" message="${message}"></error></subsonic-response>`;
const getPlayList = (
playlist: Playlist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)">
<playlist id="${playlist.id}" name="${playlist.name}" songCount="${
playlist.entries.length
}" duration="627" public="true" owner="bob" created="2021-05-06T02:07:30.460465988Z" changed="2021-05-06T02:40:04Z">
${playlist.entries
.map(
(it) => `<entry
id="${it.id}"
parent="..."
isDir="false"
title="${it.name}"
album="${it.album.name}"
artist="${it.artist.name}"
track="${it.number}"
year="${it.album.year}"
genre="${it.album.genre?.name}"
coverArt="..."
size="123"
contentType="${it.mimeType}"
suffix="mp3"
duration="${it.duration}"
bitRate="128"
path="..."
discNumber="1"
created="2019-09-04T04:07:00.138169924Z"
albumId="${it.album.id}"
artistId="${it.artist.id}"
type="music"
isVideo="false"></entry>`
)
.join("")}
</playlist>
</subsonic-response>`;
const searchResult3 = ({ const searchResult3 = ({
artists, artists,
albums, albums,
@@ -231,14 +292,16 @@ const searchResult3 = ({
tracks: Track[]; tracks: Track[];
}>) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.41.1 (43bb0758)"> }>) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.41.1 (43bb0758)">
<searchResult3> <searchResult3>
${(artists || []).map((it) => ${(artists || [])
artistXml({ .map((it) =>
...it, artistXml({
albums: [], ...it,
}) albums: [],
)} })
${(albums || []).map((it) => albumXml(it.artist, it.album, []))} )
${(tracks || []).map((it) => songXml(it))} .join("")}
${(albums || []).map((it) => albumXml(it.artist, it.album, [])).join("")}
${(tracks || []).map((it) => songXml(it)).join("")}
</searchResult3> </searchResult3>
</subsonic-response>`; </subsonic-response>`;
@@ -1382,8 +1445,16 @@ describe("Navidrome", () => {
}); });
const tracks = [ const tracks = [
aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), genre: hipHop }), aTrack({
aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), genre: hipHop }), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
genre: hipHop,
}),
aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
genre: hipHop,
}),
aTrack({ aTrack({
artist: artistToArtistSummary(artist), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album), album: albumToAlbumSummary(album),
@@ -2728,7 +2799,7 @@ describe("Navidrome", () => {
name: "foo woo", name: "foo woo",
genre: { id: "pop", name: "pop" }, genre: { id: "pop", name: "pop" },
}); });
const artist = anArtist({ name: "#1", albums:[album] }); const artist = anArtist({ name: "#1", albums: [album] });
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .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,
});
});
});
});
});
});
}); });

View File

@@ -242,7 +242,7 @@ describe("album", () => {
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum), albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum),
canPlay: true, canPlay: true,
artist: someAlbum.artistName, artist: someAlbum.artistName,
artistId: someAlbum.artistId artistId: someAlbum.artistId,
}); });
}); });
}); });
@@ -288,6 +288,8 @@ describe("api", () => {
artists: jest.fn(), artists: jest.fn(),
artist: jest.fn(), artist: jest.fn(),
genres: jest.fn(), genres: jest.fn(),
playlists: jest.fn(),
playlist: jest.fn(),
albums: jest.fn(), albums: jest.fn(),
tracks: jest.fn(), tracks: jest.fn(),
track: jest.fn(), track: jest.fn(),
@@ -644,7 +646,9 @@ describe("api", () => {
}); });
expect(result[0]).toEqual( expect(result[0]).toEqual(
searchResult({ searchResult({
mediaCollection: tracks.map((it) => album(rootUrl, accessToken, it.album)), mediaCollection: tracks.map((it) =>
album(rootUrl, accessToken, it.album)
),
index: 0, index: 0,
total: 2, total: 2,
}) })
@@ -725,6 +729,11 @@ describe("api", () => {
mediaCollection: [ mediaCollection: [
{ itemType: "container", id: "artists", title: "Artists" }, { itemType: "container", id: "artists", title: "Artists" },
{ itemType: "albumList", id: "albums", title: "Albums" }, { itemType: "albumList", id: "albums", title: "Albums" },
{
itemType: "container",
id: "playlists",
title: "Playlists",
},
{ itemType: "container", id: "genres", title: "Genres" }, { itemType: "container", id: "genres", title: "Genres" },
{ {
itemType: "albumList", itemType: "albumList",
@@ -753,7 +762,7 @@ describe("api", () => {
}, },
], ],
index: 0, 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", () => { describe("asking for a single artist", () => {
const artistWithManyAlbums = anArtist({ const artistWithManyAlbums = anArtist({
albums: [anAlbum(), anAlbum(), anAlbum(), anAlbum(), anAlbum()], albums: [anAlbum(), anAlbum(), anAlbum(), anAlbum(), anAlbum()],
@@ -856,7 +925,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: artistWithManyAlbums.albums.length, total: artistWithManyAlbums.albums.length,
@@ -889,7 +958,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 2, index: 2,
total: artistWithManyAlbums.albums.length, total: artistWithManyAlbums.albums.length,
@@ -1144,7 +1213,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 6, total: 6,
@@ -1189,7 +1258,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 6, total: 6,
@@ -1234,7 +1303,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 6, total: 6,
@@ -1279,7 +1348,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 6, total: 6,
@@ -1324,7 +1393,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 6, total: 6,
@@ -1367,7 +1436,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 6, total: 6,
@@ -1410,7 +1479,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 2, index: 2,
total: 6, total: 6,
@@ -1451,7 +1520,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 4, total: 4,
@@ -1495,7 +1564,7 @@ describe("api", () => {
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true, canPlay: true,
artistId: it.artistId, artistId: it.artistId,
artist: it.artistName artist: it.artistName,
})), })),
index: 0, index: 0,
total: 4, total: 4,
@@ -1512,78 +1581,146 @@ describe("api", () => {
}); });
}); });
describe("asking for tracks", () => { describe("asking for an album", () => {
describe("for an album", () => { const album = anAlbum();
const album = anAlbum(); const artist = anArtist({
const artist = anArtist({ albums: [album],
albums: [album], });
});
const track1 = aTrack({ artist, album, number: 1 }); const track1 = aTrack({ artist, album, number: 1 });
const track2 = aTrack({ artist, album, number: 2 }); const track2 = aTrack({ artist, album, number: 2 });
const track3 = aTrack({ artist, album, number: 3 }); const track3 = aTrack({ artist, album, number: 3 });
const track4 = aTrack({ artist, album, number: 4 }); const track4 = aTrack({ artist, album, number: 4 });
const track5 = aTrack({ artist, album, number: 5 }); const track5 = aTrack({ artist, album, number: 5 });
const tracks = [track1, track2, track3, track4, track5]; const tracks = [track1, track2, track3, track4, track5];
beforeEach(() => { beforeEach(() => {
musicLibrary.tracks.mockResolvedValue(tracks); musicLibrary.tracks.mockResolvedValue(tracks);
}); });
describe("asking for all for an album", () => { describe("asking for all for an album", () => {
it("should return them all", async () => { it("should return them all", async () => {
const paging = { 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, index: 0,
count: 100, total: tracks.length,
}; })
);
const result = await ws.getMetadataAsync({ expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id);
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);
});
}); });
});
describe("asking for a single page of tracks", () => { describe("asking for a single page of tracks", () => {
const pageOfTracks = [track3, track4]; const pageOfTracks = [track3, track4];
it("should return only that page", async () => { it("should return only that page", async () => {
const paging = { const paging = {
index: 2, index: 2,
count: 2, count: 2,
}; };
const result = await ws.getMetadataAsync({ const result = await ws.getMetadataAsync({
id: `album:${album.id}`, id: `album:${album.id}`,
...paging, ...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);
}); });
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);
});
});
});
}); });
}); });