mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability to play a playlist
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ArtistSummary[]>;
|
||||
searchAlbums(query: string): Promise<AlbumSummary[]>;
|
||||
searchTracks(query: string): Promise<Track[]>;
|
||||
playlists(): Promise<PlaylistSummary[]>;
|
||||
playlist(id: string): Promise<Playlist>;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
42
src/smapi.ts
42
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!)
|
||||
|
||||
@@ -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> = {}): Service => ({
|
||||
...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 {
|
||||
return {
|
||||
name: `device-${uuid()}`,
|
||||
@@ -109,15 +126,17 @@ export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
|
||||
|
||||
export function aTrack(fields: Partial<Track> = {}): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
`<similarArtist id="${artistSummary.id}" name="${artistSummary.name}" albumCount="3"></similarArtist>`;
|
||||
|
||||
const getArtistInfoXml = (
|
||||
artist: Artist
|
||||
) => `<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>
|
||||
<mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl>
|
||||
<largeImageUrl>${artist.image.large || ""}</largeImageUrl>
|
||||
${artist.similarArtists.map(
|
||||
(it) =>
|
||||
`<similarArtist id="${it.id}" name="${it.name}" albumCount="3"></similarArtist>`
|
||||
)}
|
||||
${artist.similarArtists.map(similarArtistXml).join("")}
|
||||
</artistInfo>
|
||||
</subsonic-response>`;
|
||||
|
||||
@@ -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))}</album>`;
|
||||
isVideo="false">${tracks.map(songXml).join("")}</album>`;
|
||||
|
||||
const songXml = (track: Track) => `<song
|
||||
id="${track.id}"
|
||||
@@ -165,18 +174,20 @@ const albumListXml = (
|
||||
albums: [Artist, Album][]
|
||||
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
|
||||
<albumList>
|
||||
${albums.map(([artist, album]) =>
|
||||
albumXml(artist, album)
|
||||
)}
|
||||
${albums
|
||||
.map(([artist, album]) => albumXml(artist, album))
|
||||
.join("")}
|
||||
</albumList>
|
||||
</subsonic-response>`;
|
||||
|
||||
const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${
|
||||
artist.name
|
||||
}" albumCount="${artist.albums.length}" artistImageUrl="....">
|
||||
${artist.albums.map((album) =>
|
||||
${artist.albums
|
||||
.map((album) =>
|
||||
albumXml(artist, album)
|
||||
)}
|
||||
)
|
||||
.join("")}
|
||||
</artist>`;
|
||||
|
||||
const getArtistXml = (
|
||||
@@ -185,14 +196,14 @@ const getArtistXml = (
|
||||
${artistXml(artist)}
|
||||
</subsonic-response>`;
|
||||
|
||||
const genreXml = (genre: string) =>
|
||||
`<genre songCount="1475" albumCount="86">${genre}</genre>`;
|
||||
|
||||
const genresXml = (
|
||||
genres: string[]
|
||||
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
|
||||
<genres>
|
||||
${genres.map(
|
||||
(it) =>
|
||||
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
||||
)}
|
||||
${genres.map(genreXml).join("")}
|
||||
</genres>
|
||||
</subsonic-response>`;
|
||||
|
||||
@@ -221,6 +232,56 @@ export type ArtistWithAlbum = {
|
||||
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 = ({
|
||||
artists,
|
||||
albums,
|
||||
@@ -231,14 +292,16 @@ const searchResult3 = ({
|
||||
tracks: Track[];
|
||||
}>) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.41.1 (43bb0758)">
|
||||
<searchResult3>
|
||||
${(artists || []).map((it) =>
|
||||
${(artists || [])
|
||||
.map((it) =>
|
||||
artistXml({
|
||||
...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>
|
||||
</subsonic-response>`;
|
||||
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +1581,7 @@ describe("api", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for tracks", () => {
|
||||
describe("for an album", () => {
|
||||
describe("asking for an album", () => {
|
||||
const album = anAlbum();
|
||||
const artist = anArtist({
|
||||
albums: [album],
|
||||
@@ -1583,6 +1651,75 @@ describe("api", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user