mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability to search by artist, album, track
This commit is contained in:
@@ -18,7 +18,8 @@ Currently only a single integration allowing Navidrome to be registered with son
|
|||||||
- Discovery of sonos devices using seed IP address
|
- Discovery of sonos devices using seed IP address
|
||||||
- Auto register bonob service with sonos system
|
- Auto register bonob service with sonos system
|
||||||
- Multiple registrations within a single household.
|
- Multiple registrations within a single household.
|
||||||
- Transcoding performed by Navidrome with specific player for bonob/sonos
|
- Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType
|
||||||
|
- Ability to search by Album, Artist, Track
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
@@ -77,6 +78,5 @@ BONOB_STREAM_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom
|
|||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- Search
|
|
||||||
- Artist Radio
|
- Artist Radio
|
||||||
- Playlist support
|
- Playlist support
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import dayjs, { Dayjs } from "dayjs";
|
import { Dayjs } from "dayjs";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
import { Encryption } from "./encryption";
|
import { Encryption } from "./encryption";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
import { Clock, SystemClock } from "./clock";
|
||||||
export interface Clock {
|
|
||||||
now(): Dayjs;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccessToken = {
|
type AccessToken = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -24,7 +21,7 @@ export class ExpiringAccessTokens implements AccessTokens {
|
|||||||
tokens = new Map<string, AccessToken>();
|
tokens = new Map<string, AccessToken>();
|
||||||
clock: Clock;
|
clock: Clock;
|
||||||
|
|
||||||
constructor(clock: Clock = { now: () => dayjs() }) {
|
constructor(clock: Clock = SystemClock) {
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
src/clock.ts
Normal file
7
src/clock.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
|
||||||
|
export interface Clock {
|
||||||
|
now(): Dayjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SystemClock = { now: () => dayjs() };
|
||||||
@@ -155,4 +155,7 @@ export interface MusicLibrary {
|
|||||||
}): Promise<TrackStream>;
|
}): Promise<TrackStream>;
|
||||||
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
||||||
scrobble(id: string): Promise<boolean>
|
scrobble(id: string): Promise<boolean>
|
||||||
|
searchArtists(query: string): Promise<ArtistSummary[]>;
|
||||||
|
searchAlbums(query: string): Promise<AlbumSummary[]>;
|
||||||
|
searchTracks(query: string): Promise<Track[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ export type GetSongResponse = {
|
|||||||
song: song;
|
song: song;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Search3Response = SubsonicResponse & {
|
||||||
|
searchResult3: {
|
||||||
|
artist: artistSummary[];
|
||||||
|
album: album[];
|
||||||
|
song: song[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function isError(
|
export function isError(
|
||||||
subsonicResponse: SubsonicResponse
|
subsonicResponse: SubsonicResponse
|
||||||
): subsonicResponse is SubsonicError {
|
): subsonicResponse is SubsonicError {
|
||||||
@@ -270,9 +278,9 @@ export class Navidrome implements MusicService {
|
|||||||
...config,
|
...config,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status != 200 && response.status != 206)
|
if (response.status != 200 && response.status != 206) {
|
||||||
throw `Navidrome failed with a ${response.status}`;
|
throw `Navidrome failed with a ${response.status || "no!"} status`;
|
||||||
else return response;
|
} else return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
getJSON = async <T>(
|
getJSON = async <T>(
|
||||||
@@ -290,6 +298,9 @@ export class Navidrome implements MusicService {
|
|||||||
"subsonic-response.album.song",
|
"subsonic-response.album.song",
|
||||||
"subsonic-response.genres.genre",
|
"subsonic-response.genres.genre",
|
||||||
"subsonic-response.artistInfo.similarArtist",
|
"subsonic-response.artistInfo.similarArtist",
|
||||||
|
"subsonic-response.searchResult3.artist",
|
||||||
|
"subsonic-response.searchResult3.album",
|
||||||
|
"subsonic-response.searchResult3.song",
|
||||||
],
|
],
|
||||||
}).xml2js(response.data) as SubconicEnvelope
|
}).xml2js(response.data) as SubconicEnvelope
|
||||||
)
|
)
|
||||||
@@ -405,6 +416,26 @@ export class Navidrome implements MusicService {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
|
||||||
|
albumList.map((album) => ({
|
||||||
|
id: album._id,
|
||||||
|
name: album._name,
|
||||||
|
year: album._year,
|
||||||
|
genre: maybeAsGenre(album._genre),
|
||||||
|
}));
|
||||||
|
|
||||||
|
search3 = (credentials: Credentials, q: any) =>
|
||||||
|
this.getJSON<Search3Response>(credentials, "/rest/search3", {
|
||||||
|
artistCount: 0,
|
||||||
|
albumCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...q,
|
||||||
|
}).then((it) => ({
|
||||||
|
artists: it.searchResult3.artist || [],
|
||||||
|
albums: it.searchResult3.album || [],
|
||||||
|
songs: it.searchResult3.song || [],
|
||||||
|
}));
|
||||||
|
|
||||||
async login(token: string) {
|
async login(token: string) {
|
||||||
const navidrome = this;
|
const navidrome = this;
|
||||||
const credentials: Credentials = this.parseToken(token);
|
const credentials: Credentials = this.parseToken(token);
|
||||||
@@ -428,14 +459,7 @@ export class Navidrome implements MusicService {
|
|||||||
offset: q._index,
|
offset: q._index,
|
||||||
})
|
})
|
||||||
.then((response) => response.albumList.album || [])
|
.then((response) => response.albumList.album || [])
|
||||||
.then((albumList) =>
|
.then(navidrome.toAlbumSummary)
|
||||||
albumList.map((album) => ({
|
|
||||||
id: album._id,
|
|
||||||
name: album._name,
|
|
||||||
year: album._year,
|
|
||||||
genre: maybeAsGenre(album._genre),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.then(slice2(q))
|
.then(slice2(q))
|
||||||
.then(([page, total]) => ({
|
.then(([page, total]) => ({
|
||||||
results: page,
|
results: page,
|
||||||
@@ -555,6 +579,27 @@ export class Navidrome implements MusicService {
|
|||||||
})
|
})
|
||||||
.then((_) => true)
|
.then((_) => true)
|
||||||
.catch(() => false),
|
.catch(() => false),
|
||||||
|
searchArtists: async (query: string) =>
|
||||||
|
navidrome
|
||||||
|
.search3(credentials, { query, artistCount: 20 })
|
||||||
|
.then(({ artists }) =>
|
||||||
|
artists.map((artist) => ({
|
||||||
|
id: artist._id,
|
||||||
|
name: artist._name,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
searchAlbums: async (query: string) =>
|
||||||
|
navidrome
|
||||||
|
.search3(credentials, { query, albumCount: 20 })
|
||||||
|
.then(({ albums }) => navidrome.toAlbumSummary(albums)),
|
||||||
|
searchTracks: async (query: string) =>
|
||||||
|
navidrome
|
||||||
|
.search3(credentials, { query, songCount: 20 })
|
||||||
|
.then(({ songs }) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((it) => navidrome.getTrack(credentials, it._id))
|
||||||
|
)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(musicLibrary);
|
return Promise.resolve(musicLibrary);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MusicService, isSuccess } from "./music_service";
|
|||||||
import bindSmapiSoapServiceToExpress from "./smapi";
|
import bindSmapiSoapServiceToExpress from "./smapi";
|
||||||
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
|
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
import { Clock, SystemClock } from "./clock";
|
||||||
|
|
||||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ function server(
|
|||||||
webAddress: string | "http://localhost:4534",
|
webAddress: string | "http://localhost:4534",
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
||||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken()
|
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
|
||||||
|
clock: Clock = SystemClock
|
||||||
): Express {
|
): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -125,6 +127,15 @@ function server(
|
|||||||
</imageSizeMap>
|
</imageSizeMap>
|
||||||
</Match>
|
</Match>
|
||||||
</PresentationMap>
|
</PresentationMap>
|
||||||
|
<PresentationMap type="Search">
|
||||||
|
<Match>
|
||||||
|
<SearchCategories>
|
||||||
|
<Category id="artists"/>
|
||||||
|
<Category id="albums"/>
|
||||||
|
<Category id="tracks"/>
|
||||||
|
</SearchCategories>
|
||||||
|
</Match>
|
||||||
|
</PresentationMap>
|
||||||
</Presentation>`);
|
</Presentation>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +209,8 @@ function server(
|
|||||||
webAddress,
|
webAddress,
|
||||||
linkCodes,
|
linkCodes,
|
||||||
musicService,
|
musicService,
|
||||||
accessTokens
|
accessTokens,
|
||||||
|
clock
|
||||||
);
|
);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
93
src/smapi.ts
93
src/smapi.ts
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import { AccessTokens } from "./access_tokens";
|
import { AccessTokens } from "./access_tokens";
|
||||||
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
|
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
|
||||||
|
import { Clock } from "./clock";
|
||||||
|
|
||||||
export const LOGIN_ROUTE = "/login";
|
export const LOGIN_ROUTE = "/login";
|
||||||
export const SOAP_PATH = "/ws/sonos";
|
export const SOAP_PATH = "/ws/sonos";
|
||||||
@@ -107,6 +108,26 @@ export function getMetadataResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SearchResponse = {
|
||||||
|
searchResult: getMetadataResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function searchResult(
|
||||||
|
result: Partial<getMetadataResult>
|
||||||
|
): SearchResponse {
|
||||||
|
const count =
|
||||||
|
(result?.mediaCollection?.length || 0) +
|
||||||
|
(result?.mediaMetadata?.length || 0);
|
||||||
|
return {
|
||||||
|
searchResult: {
|
||||||
|
count,
|
||||||
|
index: 0,
|
||||||
|
total: count,
|
||||||
|
...result,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class SonosSoap {
|
class SonosSoap {
|
||||||
linkCodes: LinkCodes;
|
linkCodes: LinkCodes;
|
||||||
webAddress: string;
|
webAddress: string;
|
||||||
@@ -168,7 +189,7 @@ class SonosSoap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Container = {
|
export type Container = {
|
||||||
itemType: "container";
|
itemType: "container" | "search";
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
@@ -185,6 +206,12 @@ const container = ({
|
|||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const search = ({ id, title }: { id: string; title: string }): Container => ({
|
||||||
|
itemType: "search",
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
|
||||||
const genre = (genre: Genre) => ({
|
const genre = (genre: Genre) => ({
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
@@ -235,10 +262,10 @@ export const track = (
|
|||||||
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album),
|
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album),
|
||||||
artist: track.artist.name,
|
artist: track.artist.name,
|
||||||
artistId: track.artist.id,
|
artistId: track.artist.id,
|
||||||
duration: track.duration,
|
duration: `${track.duration}`,
|
||||||
genre: track.album.genre?.name,
|
genre: track.album.genre?.name,
|
||||||
genreId: track.album.genre?.id,
|
genreId: track.album.genre?.id,
|
||||||
trackNumber: track.number,
|
trackNumber: `${track.number}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +327,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
webAddress: string,
|
webAddress: string,
|
||||||
linkCodes: LinkCodes,
|
linkCodes: LinkCodes,
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
accessTokens: AccessTokens
|
accessTokens: AccessTokens,
|
||||||
|
clock: Clock
|
||||||
) {
|
) {
|
||||||
const sonosSoap = new SonosSoap(webAddress, linkCodes);
|
const sonosSoap = new SonosSoap(webAddress, linkCodes);
|
||||||
const soapyService = listen(
|
const soapyService = listen(
|
||||||
@@ -312,6 +340,13 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getAppLink: () => sonosSoap.getAppLink(),
|
getAppLink: () => sonosSoap.getAppLink(),
|
||||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||||
sonosSoap.getDeviceAuthToken({ linkCode }),
|
sonosSoap.getDeviceAuthToken({ linkCode }),
|
||||||
|
getLastUpdate: () => ({
|
||||||
|
getLastUpdateResult: {
|
||||||
|
favorites: clock.now().unix(),
|
||||||
|
catalog: clock.now().unix(),
|
||||||
|
pollInterval: 120,
|
||||||
|
},
|
||||||
|
}),
|
||||||
getMediaURI: async (
|
getMediaURI: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
@@ -339,6 +374,46 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getMediaMetadataResult: track(webAddress, accessToken, it),
|
getMediaMetadataResult: track(webAddress, accessToken, it),
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
|
search: async (
|
||||||
|
{ id, term }: { id: string; term: string },
|
||||||
|
_,
|
||||||
|
headers?: SoapyHeaders
|
||||||
|
) =>
|
||||||
|
auth(musicService, accessTokens, id, headers).then(
|
||||||
|
async ({ musicLibrary, accessToken }) => {
|
||||||
|
switch (id) {
|
||||||
|
case "albums":
|
||||||
|
return musicLibrary.searchAlbums(term).then((it) =>
|
||||||
|
searchResult({
|
||||||
|
count: it.length,
|
||||||
|
mediaCollection: it.map((albumSummary) =>
|
||||||
|
album(webAddress, accessToken, albumSummary)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
case "artists":
|
||||||
|
return musicLibrary.searchArtists(term).then((it) =>
|
||||||
|
searchResult({
|
||||||
|
count: it.length,
|
||||||
|
mediaCollection: it.map((artistSummary) =>
|
||||||
|
artist(webAddress, accessToken, artistSummary)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
case "tracks":
|
||||||
|
return musicLibrary.searchTracks(term).then((it) =>
|
||||||
|
searchResult({
|
||||||
|
count: it.length,
|
||||||
|
mediaCollection: it.map((aTrack) =>
|
||||||
|
track(webAddress, accessToken, aTrack)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw `Unsupported search by:${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
getExtendedMetadata: async (
|
getExtendedMetadata: async (
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -436,6 +511,16 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
index: 0,
|
index: 0,
|
||||||
total: 8,
|
total: 8,
|
||||||
});
|
});
|
||||||
|
case "search":
|
||||||
|
return getMetadataResult({
|
||||||
|
mediaCollection: [
|
||||||
|
search({ id: "artists", title: "Artists" }),
|
||||||
|
search({ id: "albums", title: "Albums" }),
|
||||||
|
search({ id: "tracks", title: "Tracks" }),
|
||||||
|
],
|
||||||
|
index: 0,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
case "artists":
|
case "artists":
|
||||||
return musicLibrary.artists(paging).then((result) => {
|
return musicLibrary.artists(paging).then((result) => {
|
||||||
return getMetadataResult({
|
return getMetadataResult({
|
||||||
|
|||||||
10
src/sonos.ts
10
src/sonos.ts
@@ -7,8 +7,8 @@ import logger from "./logger";
|
|||||||
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
|
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
|
||||||
import qs from "querystring"
|
import qs from "querystring"
|
||||||
|
|
||||||
export const STRINGS_VERSION = "2";
|
export const PRESENTATION_AND_STRINGS_VERSION = "12";
|
||||||
export const PRESENTATION_MAP_VERSION = "7";
|
|
||||||
export type Capability =
|
export type Capability =
|
||||||
| "search"
|
| "search"
|
||||||
| "trFavorites"
|
| "trFavorites"
|
||||||
@@ -19,7 +19,7 @@ export type Capability =
|
|||||||
| "authorizationHeader";
|
| "authorizationHeader";
|
||||||
|
|
||||||
export const BONOB_CAPABILITIES: Capability[] = [
|
export const BONOB_CAPABILITIES: Capability[] = [
|
||||||
// "search",
|
"search",
|
||||||
// "trFavorites",
|
// "trFavorites",
|
||||||
// "alFavorites",
|
// "alFavorites",
|
||||||
// "ucPlaylists",
|
// "ucPlaylists",
|
||||||
@@ -59,11 +59,11 @@ export const bonobService = (
|
|||||||
secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
|
secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
|
||||||
strings: {
|
strings: {
|
||||||
uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`,
|
uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`,
|
||||||
version: STRINGS_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
presentation: {
|
presentation: {
|
||||||
uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`,
|
uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`,
|
||||||
version: PRESENTATION_MAP_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
pollInterval: 1200,
|
pollInterval: 1200,
|
||||||
authType,
|
authType,
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
scrobble: async (_: string) => {
|
scrobble: async (_: string) => {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
|
searchArtists: async (_: string) => Promise.resolve([]),
|
||||||
|
searchAlbums: async (_: string) => Promise.resolve([]),
|
||||||
|
searchTracks: async (_: string) => Promise.resolve([]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
BROWSER_HEADERS,
|
BROWSER_HEADERS,
|
||||||
DODGY_IMAGE_NAME,
|
DODGY_IMAGE_NAME,
|
||||||
asGenre,
|
asGenre,
|
||||||
appendMimeTypeToClientFor
|
appendMimeTypeToClientFor,
|
||||||
} from "../src/navidrome";
|
} from "../src/navidrome";
|
||||||
import encryption from "../src/encryption";
|
import encryption from "../src/encryption";
|
||||||
|
|
||||||
@@ -72,18 +72,27 @@ describe("appendMimeTypeToUserAgentFor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("when contains some mimeTypes", () => {
|
describe("when contains some mimeTypes", () => {
|
||||||
const streamUserAgent = appendMimeTypeToClientFor(["audio/flac", "audio/ogg"])
|
const streamUserAgent = appendMimeTypeToClientFor([
|
||||||
|
"audio/flac",
|
||||||
|
"audio/ogg",
|
||||||
|
]);
|
||||||
|
|
||||||
describe("and the track mimeType is in the array", () => {
|
describe("and the track mimeType is in the array", () => {
|
||||||
it("should return bonob+mimeType", () => {
|
it("should return bonob+mimeType", () => {
|
||||||
expect(streamUserAgent(aTrack({ mimeType: "audio/flac"}))).toEqual("bonob+audio/flac")
|
expect(streamUserAgent(aTrack({ mimeType: "audio/flac" }))).toEqual(
|
||||||
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg"}))).toEqual("bonob+audio/ogg")
|
"bonob+audio/flac"
|
||||||
|
);
|
||||||
|
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg" }))).toEqual(
|
||||||
|
"bonob+audio/ogg"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("and the track mimeType is not in the array", () => {
|
describe("and the track mimeType is not in the array", () => {
|
||||||
it("should return bonob", () => {
|
it("should return bonob", () => {
|
||||||
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3"}))).toEqual("bonob")
|
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual(
|
||||||
|
"bonob"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -94,7 +103,7 @@ const ok = (data: string) => ({
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
const artistInfoXml = (
|
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)">
|
||||||
<artistInfo>
|
<artistInfo>
|
||||||
@@ -162,14 +171,18 @@ const albumListXml = (
|
|||||||
</albumList>
|
</albumList>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const artistXml = (
|
const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${
|
||||||
|
artist.name
|
||||||
|
}" albumCount="${artist.albums.length}" artistImageUrl="....">
|
||||||
|
${artist.albums.map((album) =>
|
||||||
|
albumXml(artist, album)
|
||||||
|
)}
|
||||||
|
</artist>`;
|
||||||
|
|
||||||
|
const getArtistXml = (
|
||||||
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)">
|
||||||
<artist id="${artist.id}" name="${artist.name}" albumCount="${
|
${artistXml(artist)}
|
||||||
artist.albums.length
|
|
||||||
}" artistImageUrl="....">
|
|
||||||
${artist.albums.map((album) => albumXml(artist, album))}
|
|
||||||
</artist>
|
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const genresXml = (
|
const genresXml = (
|
||||||
@@ -203,6 +216,32 @@ const getSongXml = (
|
|||||||
)}
|
)}
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
|
export type ArtistWithAlbum = {
|
||||||
|
artist: Artist;
|
||||||
|
album: Album;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchResult3 = ({
|
||||||
|
artists,
|
||||||
|
albums,
|
||||||
|
tracks,
|
||||||
|
}: Partial<{
|
||||||
|
artists: Artist[];
|
||||||
|
albums: ArtistWithAlbum[];
|
||||||
|
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))}
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>`;
|
||||||
|
|
||||||
const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
||||||
|
|
||||||
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
||||||
@@ -214,7 +253,11 @@ describe("Navidrome", () => {
|
|||||||
const salt = "saltysalty";
|
const salt = "saltysalty";
|
||||||
|
|
||||||
const streamClientApplication = jest.fn();
|
const streamClientApplication = jest.fn();
|
||||||
const navidrome = new Navidrome(url, encryption("secret"), streamClientApplication);
|
const navidrome = new Navidrome(
|
||||||
|
url,
|
||||||
|
encryption("secret"),
|
||||||
|
streamClientApplication
|
||||||
|
);
|
||||||
|
|
||||||
const mockedRandomString = (randomString as unknown) as jest.Mock;
|
const mockedRandomString = (randomString as unknown) as jest.Mock;
|
||||||
const mockGET = jest.fn();
|
const mockGET = jest.fn();
|
||||||
@@ -356,10 +399,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,10 +462,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -482,10 +525,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -545,10 +588,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,10 +646,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -655,10 +698,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -705,10 +748,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1553,7 +1596,7 @@ describe("Navidrome", () => {
|
|||||||
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
|
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
|
||||||
it("should return undefined values", async () => {
|
it("should return undefined values", async () => {
|
||||||
const stream = {
|
const stream = {
|
||||||
pipe: jest.fn()
|
pipe: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamResponse = {
|
const streamResponse = {
|
||||||
@@ -1592,7 +1635,7 @@ describe("Navidrome", () => {
|
|||||||
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
|
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
|
||||||
it("should return undefined values", async () => {
|
it("should return undefined values", async () => {
|
||||||
const stream = {
|
const stream = {
|
||||||
pipe: jest.fn()
|
pipe: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamResponse = {
|
const streamResponse = {
|
||||||
@@ -1635,7 +1678,7 @@ describe("Navidrome", () => {
|
|||||||
describe("navidrome returns a 200", () => {
|
describe("navidrome returns a 200", () => {
|
||||||
it("should return the content", async () => {
|
it("should return the content", async () => {
|
||||||
const stream = {
|
const stream = {
|
||||||
pipe: jest.fn()
|
pipe: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamResponse = {
|
const streamResponse = {
|
||||||
@@ -1712,7 +1755,7 @@ describe("Navidrome", () => {
|
|||||||
|
|
||||||
return expect(
|
return expect(
|
||||||
musicLibrary.stream({ trackId, range: undefined })
|
musicLibrary.stream({ trackId, range: undefined })
|
||||||
).rejects.toEqual(`Navidrome failed with a 400`);
|
).rejects.toEqual(`Navidrome failed with a 400 status`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1720,7 +1763,7 @@ describe("Navidrome", () => {
|
|||||||
describe("with range specified", () => {
|
describe("with range specified", () => {
|
||||||
it("should send the range to navidrome", async () => {
|
it("should send the range to navidrome", async () => {
|
||||||
const stream = {
|
const stream = {
|
||||||
pipe: jest.fn()
|
pipe: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const range = "1000-2000";
|
const range = "1000-2000";
|
||||||
@@ -1780,7 +1823,7 @@ describe("Navidrome", () => {
|
|||||||
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
||||||
const clientApplication = `bonob-${uuid()}`;
|
const clientApplication = `bonob-${uuid()}`;
|
||||||
streamClientApplication.mockReturnValue(clientApplication);
|
streamClientApplication.mockReturnValue(clientApplication);
|
||||||
|
|
||||||
const streamResponse = {
|
const streamResponse = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1788,27 +1831,29 @@ describe("Navidrome", () => {
|
|||||||
},
|
},
|
||||||
data: Buffer.from("the track", "ascii"),
|
data: Buffer.from("the track", "ascii"),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
|
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
await navidrome
|
await navidrome
|
||||||
.generateToken({ username, password })
|
.generateToken({ username, password })
|
||||||
.then((it) => it as AuthSuccess)
|
.then((it) => it as AuthSuccess)
|
||||||
.then((it) => navidrome.login(it.authToken))
|
.then((it) => navidrome.login(it.authToken))
|
||||||
.then((it) => it.stream({ trackId, range: undefined }));
|
.then((it) => it.stream({ trackId, range: undefined }));
|
||||||
|
|
||||||
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
params: {
|
params: {
|
||||||
id: trackId,
|
id: trackId,
|
||||||
...authParams,
|
...authParams,
|
||||||
c: clientApplication
|
c: clientApplication,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "bonob",
|
"User-Agent": "bonob",
|
||||||
@@ -1817,13 +1862,13 @@ describe("Navidrome", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when range specified", () => {
|
describe("when range specified", () => {
|
||||||
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
||||||
const range = "1000-2000";
|
const range = "1000-2000";
|
||||||
const clientApplication = `bonob-${uuid()}`;
|
const clientApplication = `bonob-${uuid()}`;
|
||||||
streamClientApplication.mockReturnValue(clientApplication);
|
streamClientApplication.mockReturnValue(clientApplication);
|
||||||
|
|
||||||
const streamResponse = {
|
const streamResponse = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1831,27 +1876,29 @@ describe("Navidrome", () => {
|
|||||||
},
|
},
|
||||||
data: Buffer.from("the track", "ascii"),
|
data: Buffer.from("the track", "ascii"),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
|
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
await navidrome
|
await navidrome
|
||||||
.generateToken({ username, password })
|
.generateToken({ username, password })
|
||||||
.then((it) => it as AuthSuccess)
|
.then((it) => it as AuthSuccess)
|
||||||
.then((it) => navidrome.login(it.authToken))
|
.then((it) => navidrome.login(it.authToken))
|
||||||
.then((it) => it.stream({ trackId, range }));
|
.then((it) => it.stream({ trackId, range }));
|
||||||
|
|
||||||
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
params: {
|
params: {
|
||||||
id: trackId,
|
id: trackId,
|
||||||
...authParams,
|
...authParams,
|
||||||
c: clientApplication
|
c: clientApplication,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "bonob",
|
"User-Agent": "bonob",
|
||||||
@@ -1968,10 +2015,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2035,10 +2082,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2113,10 +2160,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2180,10 +2227,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2256,10 +2303,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2335,10 +2382,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2403,10 +2450,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2482,10 +2529,10 @@ describe("Navidrome", () => {
|
|||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistXml(artist)))
|
Promise.resolve(ok(getArtistXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(ok(artistInfoXml(artist)))
|
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||||
)
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
@@ -2582,4 +2629,369 @@ describe("Navidrome", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("searchArtists", () => {
|
||||||
|
describe("when there is 1 search results", () => {
|
||||||
|
it("should return true", async () => {
|
||||||
|
const artist1 = anArtist({ name: "foo woo" });
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ artists: [artist1] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchArtists("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([artistToArtistSummary(artist1)]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
artistCount: 20,
|
||||||
|
albumCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are many search results", () => {
|
||||||
|
it("should return true", async () => {
|
||||||
|
const artist1 = anArtist({ name: "foo woo" });
|
||||||
|
const artist2 = anArtist({ name: "foo choo" });
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ artists: [artist1, artist2] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchArtists("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
artistToArtistSummary(artist1),
|
||||||
|
artistToArtistSummary(artist2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
artistCount: 20,
|
||||||
|
albumCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are no search results", () => {
|
||||||
|
it("should return []", async () => {
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ artists: [] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchArtists("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
artistCount: 20,
|
||||||
|
albumCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchAlbums", () => {
|
||||||
|
describe("when there is 1 search results", () => {
|
||||||
|
it("should return true", async () => {
|
||||||
|
const artist = anArtist({ name: "#1" });
|
||||||
|
const album = anAlbum({
|
||||||
|
name: "foo woo",
|
||||||
|
genre: { id: "pop", name: "pop" },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ albums: [{ artist, album }] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchAlbums("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([albumToAlbumSummary(album)]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
albumCount: 20,
|
||||||
|
artistCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are many search results", () => {
|
||||||
|
it("should return true", async () => {
|
||||||
|
const artist1 = anArtist({ name: "artist1" });
|
||||||
|
const album1 = anAlbum({
|
||||||
|
name: "album1",
|
||||||
|
genre: { id: "pop", name: "pop" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const artist2 = anArtist({ name: "artist2" });
|
||||||
|
const album2 = anAlbum({
|
||||||
|
name: "album2",
|
||||||
|
genre: { id: "pop", name: "pop" },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok(
|
||||||
|
searchResult3({
|
||||||
|
albums: [
|
||||||
|
{ artist: artist1, album: album1 },
|
||||||
|
{ artist: artist2, album: album2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchAlbums("moo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
albumToAlbumSummary(album1),
|
||||||
|
albumToAlbumSummary(album2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "moo",
|
||||||
|
albumCount: 20,
|
||||||
|
artistCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are no search results", () => {
|
||||||
|
it("should return []", async () => {
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ albums: [] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchAlbums("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
albumCount: 20,
|
||||||
|
artistCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchSongs", () => {
|
||||||
|
describe("when there is 1 search results", () => {
|
||||||
|
it("should return true", async () => {
|
||||||
|
const pop = asGenre("Pop");
|
||||||
|
|
||||||
|
const album = anAlbum({ id: "album1", name: "Burnin", genre: pop });
|
||||||
|
const artist = anArtist({
|
||||||
|
id: "artist1",
|
||||||
|
name: "Bob Marley",
|
||||||
|
albums: [album],
|
||||||
|
});
|
||||||
|
const track = aTrack({
|
||||||
|
artist: artistToArtistSummary(artist),
|
||||||
|
album: albumToAlbumSummary(album),
|
||||||
|
genre: pop,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ tracks: [track] })))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchTracks("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([track]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
songCount: 20,
|
||||||
|
artistCount: 0,
|
||||||
|
albumCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are many search results", () => {
|
||||||
|
it("should return true", async () => {
|
||||||
|
const pop = asGenre("Pop");
|
||||||
|
|
||||||
|
const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop });
|
||||||
|
const artist1 = anArtist({
|
||||||
|
id: "artist1",
|
||||||
|
name: "Bob Marley",
|
||||||
|
albums: [album1],
|
||||||
|
});
|
||||||
|
const track1 = aTrack({
|
||||||
|
id: "track1",
|
||||||
|
artist: artistToArtistSummary(artist1),
|
||||||
|
album: albumToAlbumSummary(album1),
|
||||||
|
genre: pop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop });
|
||||||
|
const artist2 = anArtist({
|
||||||
|
id: "artist2",
|
||||||
|
name: "Jane Marley",
|
||||||
|
albums: [album2],
|
||||||
|
});
|
||||||
|
const track2 = aTrack({
|
||||||
|
id: "track2",
|
||||||
|
artist: artistToArtistSummary(artist2),
|
||||||
|
album: albumToAlbumSummary(album2),
|
||||||
|
genre: pop,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok(
|
||||||
|
searchResult3({
|
||||||
|
tracks: [track1, track2],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track1))))
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track2))))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist1, album1, [])))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist2, album2, [])))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchTracks("moo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([track1, track2]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "moo",
|
||||||
|
songCount: 20,
|
||||||
|
artistCount: 0,
|
||||||
|
albumCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are no search results", () => {
|
||||||
|
it("should return []", async () => {
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(searchResult3({ tracks: [] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.searchTracks("foo"));
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
|
||||||
|
params: {
|
||||||
|
query: "foo",
|
||||||
|
songCount: 20,
|
||||||
|
artistCount: 0,
|
||||||
|
albumCount: 0,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
PRESENTATION_MAP_ROUTE,
|
PRESENTATION_MAP_ROUTE,
|
||||||
SONOS_RECOMMENDED_IMAGE_SIZES,
|
SONOS_RECOMMENDED_IMAGE_SIZES,
|
||||||
track,
|
track,
|
||||||
|
artist,
|
||||||
album,
|
album,
|
||||||
defaultAlbumArtURI,
|
defaultAlbumArtURI,
|
||||||
defaultArtistArtURI,
|
defaultArtistArtURI,
|
||||||
|
searchResult,
|
||||||
} from "../src/smapi";
|
} from "../src/smapi";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,8 +38,13 @@ import {
|
|||||||
} from "./builders";
|
} from "./builders";
|
||||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||||
import supersoap from "./supersoap";
|
import supersoap from "./supersoap";
|
||||||
import { artistToArtistSummary, MusicService } from "../src/music_service";
|
import {
|
||||||
|
albumToAlbumSummary,
|
||||||
|
artistToArtistSummary,
|
||||||
|
MusicService,
|
||||||
|
} from "../src/music_service";
|
||||||
import { AccessTokens } from "../src/access_tokens";
|
import { AccessTokens } from "../src/access_tokens";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
||||||
|
|
||||||
@@ -282,11 +289,17 @@ describe("api", () => {
|
|||||||
albums: jest.fn(),
|
albums: jest.fn(),
|
||||||
tracks: jest.fn(),
|
tracks: jest.fn(),
|
||||||
track: jest.fn(),
|
track: jest.fn(),
|
||||||
|
searchArtists: jest.fn(),
|
||||||
|
searchAlbums: jest.fn(),
|
||||||
|
searchTracks: jest.fn(),
|
||||||
};
|
};
|
||||||
const accessTokens = {
|
const accessTokens = {
|
||||||
mint: jest.fn(),
|
mint: jest.fn(),
|
||||||
authTokenFor: jest.fn(),
|
authTokenFor: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const clock = {
|
||||||
|
now: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const server = makeServer(
|
const server = makeServer(
|
||||||
SONOS_DISABLED,
|
SONOS_DISABLED,
|
||||||
@@ -294,7 +307,8 @@ describe("api", () => {
|
|||||||
rootUrl,
|
rootUrl,
|
||||||
(musicService as unknown) as MusicService,
|
(musicService as unknown) as MusicService,
|
||||||
(linkCodes as unknown) as LinkCodes,
|
(linkCodes as unknown) as LinkCodes,
|
||||||
(accessTokens as unknown) as AccessTokens
|
(accessTokens as unknown) as AccessTokens,
|
||||||
|
clock
|
||||||
);
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -466,6 +480,179 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getLastUpdate", () => {
|
||||||
|
it("should return a result with some timestamps", async () => {
|
||||||
|
const now = dayjs();
|
||||||
|
clock.now.mockReturnValue(now);
|
||||||
|
|
||||||
|
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||||
|
endpoint: service.uri,
|
||||||
|
httpClient: supersoap(server, rootUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ws.getLastUpdateAsync({});
|
||||||
|
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
getLastUpdateResult: {
|
||||||
|
favorites: `${now.unix()}`,
|
||||||
|
catalog: `${now.unix()}`,
|
||||||
|
pollInterval: 120,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
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
|
||||||
|
.getMetadataAsync({ id: "search", 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 () => {
|
||||||
|
musicService.login.mockRejectedValue("fail!");
|
||||||
|
|
||||||
|
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||||
|
endpoint: service.uri,
|
||||||
|
httpClient: supersoap(server, rootUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addSoapHeader({ credentials: someCredentials("someAuthToken") });
|
||||||
|
await ws
|
||||||
|
.getMetadataAsync({ id: "search", 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 authToken = `authToken-${uuid()}`;
|
||||||
|
const accessToken = `accessToken-${uuid()}`;
|
||||||
|
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, rootUrl),
|
||||||
|
});
|
||||||
|
ws.addSoapHeader({ credentials: someCredentials(authToken) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searching for albums", () => {
|
||||||
|
const album1 = anAlbum();
|
||||||
|
const album2 = anAlbum();
|
||||||
|
const albums = [album1, album2];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
musicLibrary.searchAlbums.mockResolvedValue([
|
||||||
|
albumToAlbumSummary(album1),
|
||||||
|
albumToAlbumSummary(album2),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the albums", async () => {
|
||||||
|
const term = "whoop";
|
||||||
|
|
||||||
|
const result = await ws.searchAsync({
|
||||||
|
id: "albums",
|
||||||
|
term,
|
||||||
|
});
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
searchResult({
|
||||||
|
mediaCollection: albums.map((it) =>
|
||||||
|
album(rootUrl, accessToken, albumToAlbumSummary(it))
|
||||||
|
),
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(musicLibrary.searchAlbums).toHaveBeenCalledWith(term);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searching for artists", () => {
|
||||||
|
const artist1 = anArtist();
|
||||||
|
const artist2 = anArtist();
|
||||||
|
const artists = [artist1, artist2];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
musicLibrary.searchArtists.mockResolvedValue([
|
||||||
|
artistToArtistSummary(artist1),
|
||||||
|
artistToArtistSummary(artist2),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the artists", async () => {
|
||||||
|
const term = "whoopie";
|
||||||
|
|
||||||
|
const result = await ws.searchAsync({
|
||||||
|
id: "artists",
|
||||||
|
term,
|
||||||
|
});
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
searchResult({
|
||||||
|
mediaCollection: artists.map((it) =>
|
||||||
|
artist(rootUrl, accessToken, artistToArtistSummary(it))
|
||||||
|
),
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(musicLibrary.searchArtists).toHaveBeenCalledWith(term);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searching for tracks", () => {
|
||||||
|
const track1 = aTrack();
|
||||||
|
const track2 = aTrack();
|
||||||
|
const tracks = [track1, track2];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
musicLibrary.searchTracks.mockResolvedValue([track1, track2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only("should return the tracks", async () => {
|
||||||
|
const term = "whoopie";
|
||||||
|
|
||||||
|
const result = await ws.searchAsync({
|
||||||
|
id: "tracks",
|
||||||
|
term,
|
||||||
|
});
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
searchResult({
|
||||||
|
mediaCollection: tracks.map((it) => track(rootUrl, accessToken, it)),
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(musicLibrary.searchTracks).toHaveBeenCalledWith(term);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getMetadata", () => {
|
describe("getMetadata", () => {
|
||||||
describe("when no credentials header provided", () => {
|
describe("when no credentials header provided", () => {
|
||||||
it("should return a fault of LoginUnsupported", async () => {
|
it("should return a fault of LoginUnsupported", async () => {
|
||||||
@@ -570,6 +757,27 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("asking for the search container", () => {
|
||||||
|
it("should return it", async () => {
|
||||||
|
const root = await ws.getMetadataAsync({
|
||||||
|
id: "search",
|
||||||
|
index: 0,
|
||||||
|
count: 100,
|
||||||
|
});
|
||||||
|
expect(root[0]).toEqual(
|
||||||
|
getMetadataResult({
|
||||||
|
mediaCollection: [
|
||||||
|
{ itemType: "search", id: "artists", title: "Artists" },
|
||||||
|
{ itemType: "search", id: "albums", title: "Albums" },
|
||||||
|
{ itemType: "search", id: "tracks", title: "Tracks" },
|
||||||
|
],
|
||||||
|
index: 0,
|
||||||
|
total: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("asking for a genres", () => {
|
describe("asking for a genres", () => {
|
||||||
const expectedGenres = [POP, PUNK, ROCK, TRIP_HOP];
|
const expectedGenres = [POP, PUNK, ROCK, TRIP_HOP];
|
||||||
|
|
||||||
@@ -984,7 +1192,7 @@ describe("api", () => {
|
|||||||
_count: paging.count,
|
_count: paging.count,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for recently played albums", () => {
|
describe("asking for recently played albums", () => {
|
||||||
const recentlyPlayed = [rock2, rock1, pop2];
|
const recentlyPlayed = [rock2, rock1, pop2];
|
||||||
@@ -1027,7 +1235,7 @@ describe("api", () => {
|
|||||||
_count: paging.count,
|
_count: paging.count,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for most played albums", () => {
|
describe("asking for most played albums", () => {
|
||||||
const mostPlayed = [rock2, rock1, pop2];
|
const mostPlayed = [rock2, rock1, pop2];
|
||||||
@@ -1070,8 +1278,8 @@ describe("api", () => {
|
|||||||
_count: paging.count,
|
_count: paging.count,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for recently added albums", () => {
|
describe("asking for recently added albums", () => {
|
||||||
const recentlyAdded = [pop4, pop3, pop2];
|
const recentlyAdded = [pop4, pop3, pop2];
|
||||||
|
|
||||||
@@ -1113,7 +1321,7 @@ describe("api", () => {
|
|||||||
_count: paging.count,
|
_count: paging.count,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for all albums", () => {
|
describe("asking for all albums", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -1377,7 +1585,7 @@ describe("api", () => {
|
|||||||
|
|
||||||
describe("when invalid credentials are provided", () => {
|
describe("when invalid credentials are provided", () => {
|
||||||
it("should return a fault of LoginUnauthorized", async () => {
|
it("should return a fault of LoginUnauthorized", async () => {
|
||||||
musicService.login.mockRejectedValue("booom!")
|
musicService.login.mockRejectedValue("booom!");
|
||||||
|
|
||||||
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||||
endpoint: service.uri,
|
endpoint: service.uri,
|
||||||
@@ -1399,7 +1607,7 @@ describe("api", () => {
|
|||||||
|
|
||||||
describe("when valid credentials are provided", () => {
|
describe("when valid credentials are provided", () => {
|
||||||
let ws: Client;
|
let ws: Client;
|
||||||
const authToken = `authToken-${uuid()}`
|
const authToken = `authToken-${uuid()}`;
|
||||||
const accessToken = `accessToken-${uuid()}`;
|
const accessToken = `accessToken-${uuid()}`;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -1425,7 +1633,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
musicLibrary.artist.mockResolvedValue(artist)
|
musicLibrary.artist.mockResolvedValue(artist);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when all albums fit on a page", () => {
|
describe("when all albums fit on a page", () => {
|
||||||
@@ -1434,18 +1642,20 @@ describe("api", () => {
|
|||||||
index: 0,
|
index: 0,
|
||||||
count: 100,
|
count: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
const root = await ws.getExtendedMetadataAsync({
|
const root = await ws.getExtendedMetadataAsync({
|
||||||
id: `artist:${artist.id}`,
|
id: `artist:${artist.id}`,
|
||||||
...paging
|
...paging,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(root[0]).toEqual({
|
expect(root[0]).toEqual({
|
||||||
getExtendedMetadataResult: {
|
getExtendedMetadataResult: {
|
||||||
count: "3",
|
count: "3",
|
||||||
index: "0",
|
index: "0",
|
||||||
total: "3",
|
total: "3",
|
||||||
mediaCollection: artist.albums.map(it => album(rootUrl, accessToken, it))
|
mediaCollection: artist.albums.map((it) =>
|
||||||
|
album(rootUrl, accessToken, it)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1457,22 +1667,24 @@ describe("api", () => {
|
|||||||
index: 1,
|
index: 1,
|
||||||
count: 2,
|
count: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const root = await ws.getExtendedMetadataAsync({
|
const root = await ws.getExtendedMetadataAsync({
|
||||||
id: `artist:${artist.id}`,
|
id: `artist:${artist.id}`,
|
||||||
...paging
|
...paging,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(root[0]).toEqual({
|
expect(root[0]).toEqual({
|
||||||
getExtendedMetadataResult: {
|
getExtendedMetadataResult: {
|
||||||
count: "2",
|
count: "2",
|
||||||
index: "1",
|
index: "1",
|
||||||
total: "3",
|
total: "3",
|
||||||
mediaCollection: [album2, album3].map(it => album(rootUrl, accessToken, it))
|
mediaCollection: [album2, album3].map((it) =>
|
||||||
|
album(rootUrl, accessToken, it)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when it has similar artists", () => {
|
describe("when it has similar artists", () => {
|
||||||
@@ -1485,7 +1697,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
musicLibrary.artist.mockResolvedValue(artist)
|
musicLibrary.artist.mockResolvedValue(artist);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return a RELATED_ARTISTS browse option", async () => {
|
it("should return a RELATED_ARTISTS browse option", async () => {
|
||||||
@@ -1496,7 +1708,7 @@ describe("api", () => {
|
|||||||
|
|
||||||
const root = await ws.getExtendedMetadataAsync({
|
const root = await ws.getExtendedMetadataAsync({
|
||||||
id: `artist:${artist.id}`,
|
id: `artist:${artist.id}`,
|
||||||
...paging
|
...paging,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(root[0]).toEqual({
|
expect(root[0]).toEqual({
|
||||||
@@ -1523,7 +1735,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
musicLibrary.artist.mockResolvedValue(artist)
|
musicLibrary.artist.mockResolvedValue(artist);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not return a RELATED_ARTISTS browse option", async () => {
|
it("should not return a RELATED_ARTISTS browse option", async () => {
|
||||||
@@ -1568,7 +1780,7 @@ describe("api", () => {
|
|||||||
|
|
||||||
describe("when invalid credentials are provided", () => {
|
describe("when invalid credentials are provided", () => {
|
||||||
it("should return a fault of LoginUnauthorized", async () => {
|
it("should return a fault of LoginUnauthorized", async () => {
|
||||||
musicService.login.mockRejectedValue("Credentials not found")
|
musicService.login.mockRejectedValue("Credentials not found");
|
||||||
|
|
||||||
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||||
endpoint: service.uri,
|
endpoint: service.uri,
|
||||||
@@ -1589,12 +1801,12 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("when valid credentials are provided", () => {
|
describe("when valid credentials are provided", () => {
|
||||||
const authToken = `authToken-${uuid()}`
|
const authToken = `authToken-${uuid()}`;
|
||||||
let ws: Client;
|
let ws: Client;
|
||||||
const accessToken = `temporaryAccessToken-${uuid()}`;
|
const accessToken = `temporaryAccessToken-${uuid()}`;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
musicService.login.mockResolvedValue(musicLibrary)
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
accessTokens.mint.mockReturnValue(accessToken);
|
accessTokens.mint.mockReturnValue(accessToken);
|
||||||
|
|
||||||
ws = await createClientAsync(`${service.uri}?wsdl`, {
|
ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||||
@@ -1656,7 +1868,9 @@ describe("api", () => {
|
|||||||
httpClient: supersoap(server, rootUrl),
|
httpClient: supersoap(server, rootUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addSoapHeader({ credentials: someCredentials("some invalid token") });
|
ws.addSoapHeader({
|
||||||
|
credentials: someCredentials("some invalid token"),
|
||||||
|
});
|
||||||
await ws
|
await ws
|
||||||
.getMediaMetadataAsync({ id: "track:123" })
|
.getMediaMetadataAsync({ id: "track:123" })
|
||||||
.then(() => fail("shouldnt get here"))
|
.then(() => fail("shouldnt get here"))
|
||||||
@@ -1695,11 +1909,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(root[0]).toEqual({
|
expect(root[0]).toEqual({
|
||||||
getMediaMetadataResult: track(
|
getMediaMetadataResult: track(rootUrl, accessToken, someTrack),
|
||||||
rootUrl,
|
|
||||||
accessToken,
|
|
||||||
someTrack
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
|
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import sonos, {
|
|||||||
asCustomdForm,
|
asCustomdForm,
|
||||||
bonobService,
|
bonobService,
|
||||||
Service,
|
Service,
|
||||||
STRINGS_VERSION,
|
PRESENTATION_AND_STRINGS_VERSION,
|
||||||
PRESENTATION_MAP_VERSION,
|
|
||||||
BONOB_CAPABILITIES,
|
BONOB_CAPABILITIES,
|
||||||
} from "../src/sonos";
|
} from "../src/sonos";
|
||||||
|
|
||||||
@@ -119,11 +118,11 @@ describe("sonos", () => {
|
|||||||
secureUri: `http://bonob.example.com/ws/sonos`,
|
secureUri: `http://bonob.example.com/ws/sonos`,
|
||||||
strings: {
|
strings: {
|
||||||
uri: `http://bonob.example.com/sonos/strings.xml`,
|
uri: `http://bonob.example.com/sonos/strings.xml`,
|
||||||
version: STRINGS_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
presentation: {
|
presentation: {
|
||||||
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
|
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
|
||||||
version: PRESENTATION_MAP_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
pollInterval: 1200,
|
pollInterval: 1200,
|
||||||
authType: "AppLink",
|
authType: "AppLink",
|
||||||
@@ -142,11 +141,11 @@ describe("sonos", () => {
|
|||||||
secureUri: `http://bonob.example.com/ws/sonos`,
|
secureUri: `http://bonob.example.com/ws/sonos`,
|
||||||
strings: {
|
strings: {
|
||||||
uri: `http://bonob.example.com/sonos/strings.xml`,
|
uri: `http://bonob.example.com/sonos/strings.xml`,
|
||||||
version: STRINGS_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
presentation: {
|
presentation: {
|
||||||
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
|
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
|
||||||
version: PRESENTATION_MAP_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
pollInterval: 1200,
|
pollInterval: 1200,
|
||||||
authType: "AppLink",
|
authType: "AppLink",
|
||||||
@@ -165,11 +164,11 @@ describe("sonos", () => {
|
|||||||
secureUri: `http://bonob.example.com/ws/sonos`,
|
secureUri: `http://bonob.example.com/ws/sonos`,
|
||||||
strings: {
|
strings: {
|
||||||
uri: `http://bonob.example.com/sonos/strings.xml`,
|
uri: `http://bonob.example.com/sonos/strings.xml`,
|
||||||
version: STRINGS_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
presentation: {
|
presentation: {
|
||||||
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
|
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
|
||||||
version: PRESENTATION_MAP_VERSION,
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||||
},
|
},
|
||||||
pollInterval: 1200,
|
pollInterval: 1200,
|
||||||
authType: "DeviceLink",
|
authType: "DeviceLink",
|
||||||
|
|||||||
Reference in New Issue
Block a user