Ability to browser related artists

This commit is contained in:
simojenki
2021-03-14 13:55:36 +11:00
parent 439c2eae87
commit cd979c2265
4 changed files with 484 additions and 74 deletions

View File

@@ -7,6 +7,7 @@ import logger from "./logger";
import { LinkCodes } from "./link_codes";
import {
Album,
AlbumSummary,
ArtistSummary,
MusicLibrary,
@@ -77,52 +78,30 @@ export type MediaCollection = {
title: string;
};
export type GetMetadataResponse = {
getMetadataResult: {
export type getMetadataResult = {
count: number;
index: number;
total: number;
mediaCollection: any[] | undefined;
mediaMetadata: any[] | undefined;
};
mediaCollection?: any[];
mediaMetadata?: any[];
};
export function getMetadataResult({
mediaCollection,
index,
total,
}: {
mediaCollection: any[] | undefined;
index: number;
total: number;
}): GetMetadataResponse {
return {
getMetadataResult: {
count: mediaCollection?.length || 0,
index,
total,
mediaCollection: mediaCollection || [],
mediaMetadata: undefined,
},
export type GetMetadataResponse = {
getMetadataResult: getMetadataResult;
};
}
export function getMetadataResult2({
mediaMetadata,
index,
total,
}: {
mediaMetadata: any[] | undefined;
index: number;
total: number;
}): GetMetadataResponse {
export function getMetadataResult(
result: Partial<getMetadataResult>
): GetMetadataResponse {
const count =
(result?.mediaCollection?.length || 0) +
(result?.mediaMetadata?.length || 0);
return {
getMetadataResult: {
count: mediaMetadata?.length || 0,
index,
total,
mediaCollection: undefined,
mediaMetadata: mediaMetadata || [],
count,
index: 0,
total: count,
...result,
},
};
}
@@ -225,7 +204,7 @@ export const defaultArtistArtURI = (
) =>
`${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`;
const album = (
export const album = (
webAddress: string,
accessToken: string,
album: AlbumSummary
@@ -234,7 +213,7 @@ const album = (
id: `album:${album.id}`,
title: album.name,
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, album),
canPlay: true
canPlay: true,
});
export const track = (
@@ -262,6 +241,18 @@ export const track = (
},
});
export const artist = (
webAddress: string,
accessToken: string,
artist: ArtistSummary
) => ({
itemType: "artist",
id: `artist:${artist.id}`,
artistId: artist.id,
title: artist.name,
albumArtURI: defaultArtistArtURI(webAddress, accessToken, artist),
});
type SoapyHeaders = {
credentials?: Credentials;
};
@@ -355,6 +346,68 @@ function bindSmapiSoapServiceToExpress(
};
});
},
getExtendedMetadata: async (
{
id,
index,
count,
}: // recursive,
{ id: string; index: number; count: number; recursive: boolean },
_,
headers?: SoapyHeaders
) => {
if (!headers?.credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
};
}
const authToken = headers.credentials.loginToken.token;
const login = await musicService.login(authToken).catch((_) => {
throw {
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
},
};
});
const musicLibrary = login as MusicLibrary;
const [type, typeId] = id.split(":");
const paging = { _index: index, _count: count };
switch (type) {
case "artist":
return await musicLibrary.artist(typeId!).then((artist) => {
const [page, total] = slice2<Album>(paging)(artist.albums);
const accessToken = accessTokens.mint(authToken);
return {
getExtendedMetadataResult: {
count: page.length,
index: paging._index,
total,
mediaCollection: page.map((it) =>
album(webAddress, accessToken, it)
),
relatedBrowse:
artist.similarArtists.length > 0
? [
{
id: `relatedArtists:${artist.id}`,
type: "RELATED_ARTISTS",
},
]
: [],
},
};
});
default:
throw `Unsupported id:${id}`;
}
},
getMetadata: async (
{
id,
@@ -387,7 +440,7 @@ function bindSmapiSoapServiceToExpress(
const [type, typeId] = id.split(":");
const paging = { _index: index, _count: count };
logger.debug(`Fetching type=${type}, typeId=${typeId}`);
logger.debug(`Fetching metadata type=${type}, typeId=${typeId}`);
switch (type) {
case "root":
return getMetadataResult({
@@ -403,13 +456,9 @@ function bindSmapiSoapServiceToExpress(
return await musicLibrary.artists(paging).then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(webAddress, accessToken, it),
})),
mediaCollection: result.results.map((it) =>
artist(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
@@ -451,13 +500,28 @@ function bindSmapiSoapServiceToExpress(
total,
});
});
case "relatedArtists":
return await musicLibrary
.artist(typeId!)
.then((artist) => artist.similarArtists)
.then(slice2(paging))
.then(([page, total]) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: page.map((it) =>
artist(webAddress, accessToken, it)
),
index: paging._index,
total,
});
});
case "album":
return await musicLibrary
.tracks(typeId!)
.then(slice2(paging))
.then(([page, total]) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult2({
return getMetadataResult({
mediaMetadata: page.map((it) =>
track(webAddress, accessToken, it)
),

View File

@@ -5,9 +5,26 @@ import { MusicService } from "@svrooij/sonos/lib/services";
import { head } from "underscore";
import logger from "./logger";
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
import qs from "querystring"
export const STRINGS_VERSION = "2";
export const PRESENTATION_MAP_VERSION = "7";
export type Capability =
| "search"
| "trFavorites"
| "alFavorites"
| "ucPlaylists"
| "extendedMD"
| "contextHeaders"
| "authorizationHeader";
export const BONOB_CAPABILITIES: Capability[] = [
// "search",
// "trFavorites",
// "alFavorites",
// "ucPlaylists",
"extendedMD",
];
export type Device = {
name: string;
@@ -103,6 +120,7 @@ export const asCustomdForm = (csrfToken: string, service: Service) => ({
manifestVersion: "0",
manifestUri: "",
containerType: "MService",
caps: BONOB_CAPABILITIES,
});
const setupDiscovery = (
@@ -156,7 +174,9 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
return false;
}
logger.info(`Registering ${service.name}(SID:${service.sid}) with sonos device ${anyDevice.Name} @ ${anyDevice.Host}`)
logger.info(
`Registering ${service.name}(SID:${service.sid}) with sonos device ${anyDevice.Name} @ ${anyDevice.Host}`
);
const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`;
@@ -175,7 +195,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
}
return axios
.post(customd, new URLSearchParams(asCustomdForm(csrfToken, service)), {
.post(customd, new URLSearchParams(qs.stringify(asCustomdForm(csrfToken, service))), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},

View File

@@ -14,10 +14,10 @@ import {
STRINGS_ROUTE,
LOGIN_ROUTE,
getMetadataResult,
getMetadataResult2,
PRESENTATION_MAP_ROUTE,
SONOS_RECOMMENDED_IMAGE_SIZES,
track,
album,
defaultAlbumArtURI,
defaultArtistArtURI,
} from "../src/smapi";
@@ -96,18 +96,20 @@ describe("service config", () => {
});
describe("getMetadataResult", () => {
describe("when there are a zero mediaCollections", () => {
describe("when there are a no mediaCollections & no mediaMetadata", () => {
it("should have zero count", () => {
const result = getMetadataResult({
mediaCollection: [],
index: 33,
total: 99,
});
expect(result.getMetadataResult.count).toEqual(0);
expect(result.getMetadataResult.index).toEqual(33);
expect(result.getMetadataResult.total).toEqual(99);
expect(result.getMetadataResult.mediaCollection).toEqual([]);
expect(result).toEqual({
getMetadataResult: {
count: 0,
index: 33,
total: 99,
},
});
});
});
@@ -120,10 +122,57 @@ describe("getMetadataResult", () => {
total: 3,
});
expect(result.getMetadataResult.count).toEqual(2);
expect(result.getMetadataResult.index).toEqual(22);
expect(result.getMetadataResult.total).toEqual(3);
expect(result.getMetadataResult.mediaCollection).toEqual(mediaCollection);
expect(result).toEqual({
getMetadataResult: {
count: 2,
index: 22,
total: 3,
mediaCollection,
},
});
});
});
describe("when there are a number of mediaMetadata", () => {
it("should add correct counts", () => {
const mediaMetadata = [{}, {}];
const result = getMetadataResult({
mediaMetadata,
index: 22,
total: 3,
});
expect(result).toEqual({
getMetadataResult: {
count: 2,
index: 22,
total: 3,
mediaMetadata,
},
});
});
});
describe("when there are both a number of mediaMetadata & mediaCollections", () => {
it("should sum the counts", () => {
const mediaCollection = [{}, {}, {}];
const mediaMetadata = [{}, {}];
const result = getMetadataResult({
mediaCollection,
mediaMetadata,
index: 22,
total: 3,
});
expect(result).toEqual({
getMetadataResult: {
count: 5,
index: 22,
total: 3,
mediaCollection,
mediaMetadata,
},
});
});
});
});
@@ -165,6 +214,22 @@ describe("track", () => {
});
});
describe("album", () => {
it("should map to a sonos album", () => {
const webAddress = "http://localhost:9988";
const accessToken = uuid();
const someAlbum = anAlbum({ id: "id123", name: "What a great album" });
expect(album(webAddress, accessToken, someAlbum)).toEqual({
itemType: "album",
id: `album:${someAlbum.id}`,
title: someAlbum.name,
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum),
canPlay: true,
});
});
});
describe("defaultAlbumArtURI", () => {
it("should create the correct URI", () => {
const webAddress = "http://localhost:1234";
@@ -576,7 +641,7 @@ describe("api", () => {
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true
canPlay: true,
})),
index: 0,
total: artistWithManyAlbums.albums.length,
@@ -602,7 +667,7 @@ describe("api", () => {
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true
canPlay: true,
})),
index: 2,
total: artistWithManyAlbums.albums.length,
@@ -663,7 +728,11 @@ describe("api", () => {
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(rootUrl, accessToken, it),
albumArtURI: defaultArtistArtURI(
rootUrl,
accessToken,
it
),
})
),
index: 1,
@@ -674,6 +743,117 @@ describe("api", () => {
});
});
describe("asking for relatedArtists", () => {
describe("when the artist has many", () => {
const relatedArtist1 = anArtist();
const relatedArtist2 = anArtist();
const relatedArtist3 = anArtist();
const relatedArtist4 = anArtist();
const artist = anArtist({
similarArtists: [
relatedArtist1,
relatedArtist2,
relatedArtist3,
relatedArtist4,
],
});
beforeEach(() => {
musicService.hasArtists(
artist,
relatedArtist1,
relatedArtist2,
relatedArtist3,
relatedArtist4
);
});
describe("when they fit on one page", () => {
it("should return them", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
relatedArtist1,
relatedArtist2,
relatedArtist3,
relatedArtist4,
].map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
rootUrl,
accessToken,
it
),
})),
index: 0,
total: 4,
})
);
});
});
describe("when they dont fit on one page", () => {
it("should return them", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 1,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [relatedArtist2, relatedArtist3].map(
(it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
rootUrl,
accessToken,
it
),
})
),
index: 1,
total: 4,
})
);
});
});
});
describe("when the artist has none", () => {
const artist = anArtist({ similarArtists: [] });
beforeEach(() => {
musicService.hasArtists(artist);
});
it("should return an empty list", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
index: 0,
total: 0,
})
);
});
});
});
describe("asking for albums", () => {
const artist1 = anArtist({
albums: [anAlbum(), anAlbum(), anAlbum()],
@@ -708,7 +888,7 @@ describe("api", () => {
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true
canPlay: true,
})),
index: 0,
total: 6,
@@ -735,7 +915,7 @@ describe("api", () => {
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true
canPlay: true,
})),
index: 2,
total: 6,
@@ -771,7 +951,7 @@ describe("api", () => {
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult2({
getMetadataResult({
mediaMetadata: [
track1,
track2,
@@ -796,7 +976,7 @@ describe("api", () => {
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult2({
getMetadataResult({
mediaMetadata: [track3, track4].map((it) =>
track(rootUrl, accessTokens.mint(token.authToken), it)
),
@@ -811,6 +991,149 @@ describe("api", () => {
});
});
describe("getExtendedMetadata", () => {
const server = makeServer(
SONOS_DISABLED,
service,
rootUrl,
musicService,
linkCodes,
accessTokens
);
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
const username = "userThatGetsDeleted";
const password = "password1";
musicService.hasUser({ username, password });
const token = (await musicService.generateToken({
username,
password,
})) as AuthSuccess;
musicService.hasNoUsers();
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(token.authToken) });
await ws
.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
const username = "validUser";
const password = "validPassword";
let token: AuthSuccess;
let ws: Client;
beforeEach(async () => {
musicService.hasUser({ username, password });
token = (await musicService.generateToken({
username,
password,
})) as AuthSuccess;
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(token.authToken) });
});
describe("asking for an artist", () => {
describe("when it has similar artists", () => {
const similar1 = anArtist();
const similar2 = anArtist();
const artist = anArtist({
similarArtists: [similar1, similar2],
albums: [],
});
beforeEach(() => {
musicService.hasArtists(artist);
});
it("should return a RELATED_ARTISTS browse option", async () => {
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
index: 0,
count: 100,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
count: "0",
index: "0",
total: "0",
relatedBrowse: [
{
id: `relatedArtists:${artist.id}`,
type: "RELATED_ARTISTS",
},
],
},
});
});
});
describe("when it has no similar artists", () => {
const artist = anArtist({
similarArtists: [],
albums: [],
});
beforeEach(() => {
musicService.hasArtists(artist);
});
it("should not return a RELATED_ARTISTS browse option", async () => {
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
index: 0,
count: 100,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
count: "0",
index: "0",
total: "0",
},
});
});
});
});
});
});
describe("getMediaURI", () => {
const accessTokenMint = jest.fn();

View File

@@ -1,3 +1,4 @@
import qs from "querystring"
import { SonosManager, SonosDevice } from "@svrooij/sonos";
import {
MusicServicesService,
@@ -21,6 +22,7 @@ import sonos, {
Service,
STRINGS_VERSION,
PRESENTATION_MAP_VERSION,
BONOB_CAPABILITIES,
} from "../src/sonos";
import { aSonosDevice, aService } from "./builders";
@@ -209,6 +211,7 @@ describe("sonos", () => {
manifestVersion: "0",
manifestUri: "",
containerType: "MService",
caps: BONOB_CAPABILITIES
});
});
});
@@ -553,7 +556,7 @@ describe("sonos", () => {
expect(mockPost).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`,
new URLSearchParams(asCustomdForm(csrfToken, serviceToAdd)),
new URLSearchParams(qs.stringify(asCustomdForm(csrfToken, serviceToAdd))),
POST_CONFIG
);