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

@@ -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,
};
}

View File

@@ -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}`),
});
}

View File

@@ -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) =>
albumXml(artist, 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) =>
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("")}
</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),
@@ -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,
});
});
});
});
});
});
});

View File

@@ -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);
});
});
});
});
});