Ability to heart and star tracks whilst playing

Ability to heart and star tracks whilst playing
This commit is contained in:
Simon J
2021-10-07 15:57:09 +11:00
committed by GitHub
parent a02b8c1ecd
commit 8f3d2bddf7
28 changed files with 1739 additions and 861 deletions

View File

@@ -24,8 +24,11 @@ import {
iconArtURI,
playlistAlbumArtURL,
sonosifyMimeType,
ratingAsInt,
ratingFromInt,
} from "../src/smapi";
import { keys as i8nKeys } from '../src/i8n';
import {
aService,
getAppLinkMessage,
@@ -54,6 +57,32 @@ import { iconForGenre } from "../src/icon";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
describe("rating to and from ints", () => {
describe("ratingAsInt", () => {
[
{ rating: { love: false, stars: 0 }, expectedValue: 100 },
{ rating: { love: true, stars: 0 }, expectedValue: 101 },
{ rating: { love: false, stars: 1 }, expectedValue: 110 },
{ rating: { love: true, stars: 1 }, expectedValue: 111 },
{ rating: { love: false, stars: 2 }, expectedValue: 120 },
{ rating: { love: true, stars: 2 }, expectedValue: 121 },
{ rating: { love: false, stars: 3 }, expectedValue: 130 },
{ rating: { love: true, stars: 3 }, expectedValue: 131 },
{ rating: { love: false, stars: 4 }, expectedValue: 140 },
{ rating: { love: true, stars: 4 }, expectedValue: 141 },
{ rating: { love: false, stars: 5 }, expectedValue: 150 },
{ rating: { love: true, stars: 5 }, expectedValue: 151 },
].forEach(({ rating, expectedValue }) => {
it(`should map ${JSON.stringify(rating)} to a ${expectedValue} and back`, () => {
const actualValue = ratingAsInt(rating);
expect(actualValue).toEqual(expectedValue);
expect(ratingFromInt(actualValue)).toEqual(rating);
});
});
});
});
describe("service config", () => {
const bonobWithNoContextPath = url("http://localhost:1234");
const bonobWithContextPath = url("http://localhost:5678/some-context-path");
@@ -72,18 +101,18 @@ describe("service config", () => {
pathname: PRESENTATION_MAP_ROUTE,
});
async function fetchStringsXml() {
const res = await request(server).get(stringsUrl.path()).send();
expect(res.status).toEqual(200);
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
return parseXML(
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
);
}
describe(STRINGS_ROUTE, () => {
async function fetchStringsXml() {
const res = await request(server).get(stringsUrl.path()).send();
expect(res.status).toEqual(200);
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
return parseXML(
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
);
}
it("should return xml for the strings", async () => {
const xml = await fetchStringsXml();
@@ -120,15 +149,17 @@ describe("service config", () => {
});
describe(PRESENTATION_MAP_ROUTE, () => {
it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => {
async function presentationMapXml() {
const res = await request(server).get(presentationUrl.path()).send();
expect(res.status).toEqual(200);
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
const xml = parseXML(
return parseXML(
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
);
}
it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => {
const xml = await presentationMapXml();
const imageSizeMap = (size: string) =>
xpath.select(
@@ -142,14 +173,7 @@ describe("service config", () => {
});
it("should have an BrowseIconSizeMap for all sizes recommended by sonos", async () => {
const res = await request(server).get(presentationUrl.path()).send();
expect(res.status).toEqual(200);
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
const xml = parseXML(
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
);
const xml = await presentationMapXml();
const imageSizeMap = (size: string) =>
xpath.select(
@@ -161,6 +185,64 @@ describe("service config", () => {
expect(imageSizeMap(size)).toEqual(`/size/${size}`);
});
});
describe("NowPlayingRatings", () => {
it("should have Matches with propname = rating", async () => {
const xml = await presentationMapXml();
const matchElements = xpath.select(
`/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match`,
xml
) as Element[];
expect(matchElements.length).toBe(12);
matchElements.forEach((match) => {
expect(match.getAttributeNode("propname")?.value).toEqual(
"rating"
);
});
});
it("should have Rating stringIds that are in strings.xml", async () => {
const xml = await presentationMapXml();
const ratingElements = xpath.select(
`/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match/Ratings/Rating`,
xml
) as Element[];
expect(ratingElements.length).toBeGreaterThan(1);
ratingElements.forEach((rating) => {
const OnSuccessStringId =
rating.getAttributeNode("OnSuccessStringId")!.value;
const StringId = rating.getAttributeNode("StringId")!.value;
expect(i8nKeys()).toContain(OnSuccessStringId);
expect(i8nKeys()).toContain(StringId);
});
});
it("should have Rating Ids that are valid ratings as ints", async () => {
const xml = await presentationMapXml();
const ratingElements = xpath.select(
`/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match/Ratings/Rating`,
xml
) as Element[];
expect(ratingElements.length).toBeGreaterThan(1);
ratingElements.forEach((ratingElement) => {
const rating = ratingFromInt(Math.abs(Number.parseInt(ratingElement.getAttributeNode("Id")!.value)))
expect(rating.love).toBeDefined();
expect(rating.stars).toBeGreaterThanOrEqual(0);
expect(rating.stars).toBeLessThanOrEqual(5);
});
});
});
});
});
});
@@ -264,13 +346,17 @@ describe("track", () => {
genre: { id: "genre101", name: "some genre" },
}),
artist: anArtist({ name: "great artist", id: uuid() }),
coverArt:"coverArt:887766"
coverArt: "coverArt:887766",
rating: {
love: true,
stars: 5
}
});
expect(track(bonobUrl, someTrack)).toEqual({
itemType: "track",
id: `track:${someTrack.id}`,
mimeType: 'audio/flac',
mimeType: "audio/flac",
title: someTrack.name,
trackMetadata: {
@@ -286,6 +372,14 @@ describe("track", () => {
genreId: someTrack.album.genre?.id,
trackNumber: someTrack.number,
},
dynamic: {
property: [
{
name: "rating",
value: `${ratingAsInt(someTrack.rating)}`,
},
],
},
});
});
});
@@ -328,7 +422,10 @@ describe("playlistAlbumArtURL", () => {
it("should return question mark icon", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({
entries: [aTrack({ coverArt: undefined }), aTrack({ coverArt: undefined })],
entries: [
aTrack({ coverArt: undefined }),
aTrack({ coverArt: undefined }),
],
});
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
@@ -403,7 +500,9 @@ describe("playlistAlbumArtURL", () => {
});
describe("defaultAlbumArtURI", () => {
const bonobUrl = new URLBuilder("http://bonob.example.com:8080/context?search=yes");
const bonobUrl = new URLBuilder(
"http://bonob.example.com:8080/context?search=yes"
);
describe("when there is an album coverArt", () => {
it("should use it in the image url", () => {
@@ -421,10 +520,7 @@ describe("defaultAlbumArtURI", () => {
describe("when there is no album coverArt", () => {
it("should return a vinly icon image", () => {
expect(
defaultAlbumArtURI(
bonobUrl,
anAlbum({ coverArt: undefined })
).href()
defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt: undefined })).href()
).toEqual(
"http://bonob.example.com:8080/context/icon/vinyl/size/legacy?search=yes"
);
@@ -473,6 +569,7 @@ describe("api", () => {
removeFromPlaylist: jest.fn(),
scrobble: jest.fn(),
nowPlaying: jest.fn(),
rate: jest.fn(),
};
const accessTokens = {
mint: jest.fn(),
@@ -868,6 +965,18 @@ describe("api", () => {
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
itemType: "albumList",
},
{
id: "randomAlbums",
title: "Random",
albumArtURI: iconArtURI(bonobUrl, "random").href(),
itemType: "albumList",
},
{
id: "favouriteAlbums",
title: "Favourites",
albumArtURI: iconArtURI(bonobUrl, "heart").href(),
itemType: "albumList",
},
{
id: "playlists",
title: "Playlists",
@@ -885,18 +994,6 @@ describe("api", () => {
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
itemType: "container",
},
{
id: "randomAlbums",
title: "Random",
albumArtURI: iconArtURI(bonobUrl, "random").href(),
itemType: "albumList",
},
{
id: "starredAlbums",
title: "Starred",
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
itemType: "albumList",
},
{
id: "recentlyAdded",
title: "Recently added",
@@ -955,6 +1052,18 @@ describe("api", () => {
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
itemType: "albumList",
},
{
id: "randomAlbums",
title: "Willekeurig",
albumArtURI: iconArtURI(bonobUrl, "random").href(),
itemType: "albumList",
},
{
id: "favouriteAlbums",
title: "Favorieten",
albumArtURI: iconArtURI(bonobUrl, "heart").href(),
itemType: "albumList",
},
{
id: "playlists",
title: "Afspeellijsten",
@@ -972,18 +1081,6 @@ describe("api", () => {
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
itemType: "container",
},
{
id: "randomAlbums",
title: "Willekeurig",
albumArtURI: iconArtURI(bonobUrl, "random").href(),
itemType: "albumList",
},
{
id: "starredAlbums",
title: "Favorieten",
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
itemType: "albumList",
},
{
id: "recentlyAdded",
title: "Onlangs toegevoegd",
@@ -1568,7 +1665,7 @@ describe("api", () => {
});
});
describe("asking for starred albums", () => {
describe("asking for favourite albums", () => {
const albums = [rock2, rock1, pop2];
beforeEach(() => {
@@ -1585,7 +1682,7 @@ describe("api", () => {
};
const result = await ws.getMetadataAsync({
id: "starredAlbums",
id: "favouriteAlbums",
...paging,
});
@@ -2325,42 +2422,90 @@ describe("api", () => {
});
describe("asking for a track", () => {
it("should return the track", async () => {
const track = aTrack();
describe("that has a love", () => {
it("should return the track", async () => {
const track = aTrack();
musicLibrary.track.mockResolvedValue(track);
musicLibrary.track.mockResolvedValue(track);
const root = await ws.getExtendedMetadataAsync({
id: `track:${track.id}`,
});
const root = await ws.getExtendedMetadataAsync({
id: `track:${track.id}`,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
mediaMetadata: {
id: `track:${track.id}`,
itemType: "track",
title: track.name,
mimeType: track.mimeType,
trackMetadata: {
artistId: `artist:${track.artist.id}`,
artist: track.artist.name,
albumId: `album:${track.album.id}`,
albumArtist: track.artist.name,
albumArtistId: `artist:${track.artist.id}`,
album: track.album.name,
genre: track.genre?.name,
genreId: track.genre?.id,
duration: track.duration,
albumArtURI: defaultAlbumArtURI(
bonobUrlWithAccessToken,
track
).href(),
trackNumber: track.number,
expect(root[0]).toEqual({
getExtendedMetadataResult: {
mediaMetadata: {
id: `track:${track.id}`,
itemType: "track",
title: track.name,
mimeType: track.mimeType,
trackMetadata: {
artistId: `artist:${track.artist.id}`,
artist: track.artist.name,
albumId: `album:${track.album.id}`,
albumArtist: track.artist.name,
albumArtistId: `artist:${track.artist.id}`,
album: track.album.name,
genre: track.genre?.name,
genreId: track.genre?.id,
duration: track.duration,
albumArtURI: defaultAlbumArtURI(
bonobUrlWithAccessToken,
track
).href(),
trackNumber: track.number,
},
dynamic: {
property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }],
},
},
},
},
});
expect(musicLibrary.track).toHaveBeenCalledWith(track.id);
});
});
describe("that does not have a love", () => {
it("should return the track", async () => {
const track = aTrack();
musicLibrary.track.mockResolvedValue(track);
const root = await ws.getExtendedMetadataAsync({
id: `track:${track.id}`,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
mediaMetadata: {
id: `track:${track.id}`,
itemType: "track",
title: track.name,
mimeType: track.mimeType,
trackMetadata: {
artistId: `artist:${track.artist.id}`,
artist: track.artist.name,
albumId: `album:${track.album.id}`,
albumArtist: track.artist.name,
albumArtistId: `artist:${track.artist.id}`,
album: track.album.name,
genre: track.genre?.name,
genreId: track.genre?.id,
duration: track.duration,
albumArtURI: defaultAlbumArtURI(
bonobUrlWithAccessToken,
track
).href(),
trackNumber: track.number,
},
dynamic: {
property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }],
},
},
},
});
expect(musicLibrary.track).toHaveBeenCalledWith(track.id);
});
expect(musicLibrary.track).toHaveBeenCalledWith(track.id);
});
});
@@ -2471,7 +2616,7 @@ describe("api", () => {
getMediaURIResult: bonobUrl
.append({
pathname: `/stream/track/${trackId}`,
searchParams: { "bat": accessToken }
searchParams: { bat: accessToken },
})
.href(),
});
@@ -2788,6 +2933,64 @@ describe("api", () => {
});
});
describe("rateItem", () => {
let ws: Client;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("rating a track with a positive rating value", () => {
const trackId = "123";
const ratingIntValue = 31;
it("should give the track a love", async () => {
musicLibrary.rate.mockResolvedValue(true);
const result = await ws.rateItemAsync({
id: `track:${trackId}`,
rating: ratingIntValue,
});
expect(result[0]).toEqual({
rateItemResult: { shouldSkip: false },
});
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
expect(musicLibrary.rate).toHaveBeenCalledWith(trackId, ratingFromInt(ratingIntValue));
});
});
describe("rating a track with a negative rating value", () => {
const trackId = "123";
const ratingIntValue = -20;
it("should give the track a love", async () => {
musicLibrary.rate.mockResolvedValue(true);
const result = await ws.rateItemAsync({
id: `track:${trackId}`,
rating: ratingIntValue,
});
expect(result[0]).toEqual({
rateItemResult: { shouldSkip: false },
});
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
expect(musicLibrary.rate).toHaveBeenCalledWith(trackId, ratingFromInt(Math.abs(ratingIntValue)));
});
});
});
describe("setPlayedSeconds", () => {
let ws: Client;
@@ -2812,7 +3015,7 @@ describe("api", () => {
}: {
trackId: string;
secondsPlayed: number;
shouldMarkNowPlaying: boolean,
shouldMarkNowPlaying: boolean;
}) {
it("should scrobble", async () => {
musicLibrary.scrobble.mockResolvedValue(true);
@@ -2827,7 +3030,7 @@ describe("api", () => {
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
expect(musicLibrary.track).toHaveBeenCalledWith(trackId);
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
if(shouldMarkNowPlaying) {
if (shouldMarkNowPlaying) {
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
} else {
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
@@ -2842,7 +3045,7 @@ describe("api", () => {
}: {
trackId: string;
secondsPlayed: number;
shouldMarkNowPlaying: boolean,
shouldMarkNowPlaying: boolean;
}) {
it("should scrobble", async () => {
const result = await ws.setPlayedSecondsAsync({
@@ -2855,7 +3058,7 @@ describe("api", () => {
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
expect(musicLibrary.track).toHaveBeenCalledWith(trackId);
expect(musicLibrary.scrobble).not.toHaveBeenCalled();
if(shouldMarkNowPlaying) {
if (shouldMarkNowPlaying) {
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
} else {
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
@@ -2871,23 +3074,43 @@ describe("api", () => {
});
describe("when the seconds played is 30 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 30,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is > 30 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 90, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 90,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is < 30 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 29, shouldMarkNowPlaying: true });
itShouldNotScroble({
trackId,
secondsPlayed: 29,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 1 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 1, shouldMarkNowPlaying: true });
itShouldNotScroble({
trackId,
secondsPlayed: 1,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 0 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false });
itShouldNotScroble({
trackId,
secondsPlayed: 0,
shouldMarkNowPlaying: false,
});
});
});
@@ -2899,23 +3122,43 @@ describe("api", () => {
});
describe("when the seconds played is 30 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 30,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is > 30 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 90, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 90,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is < 30 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 29, shouldMarkNowPlaying: true });
itShouldNotScroble({
trackId,
secondsPlayed: 29,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 1 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 1, shouldMarkNowPlaying: true });
itShouldNotScroble({
trackId,
secondsPlayed: 1,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 0 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false });
itShouldNotScroble({
trackId,
secondsPlayed: 0,
shouldMarkNowPlaying: false,
});
});
});
@@ -2927,27 +3170,51 @@ describe("api", () => {
});
describe("when the seconds played is 29 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 30,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is > 29 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 30,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 10 seconds", () => {
itShouldScroble({ trackId, secondsPlayed: 10, shouldMarkNowPlaying: true });
itShouldScroble({
trackId,
secondsPlayed: 10,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is < 10 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 9, shouldMarkNowPlaying: true });
itShouldNotScroble({
trackId,
secondsPlayed: 9,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 1 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 1, shouldMarkNowPlaying: true });
itShouldNotScroble({
trackId,
secondsPlayed: 1,
shouldMarkNowPlaying: true,
});
});
describe("when the seconds played is 0 seconds", () => {
itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false });
itShouldNotScroble({
trackId,
secondsPlayed: 0,
shouldMarkNowPlaying: false,
});
});
});
});