mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability to stream a track from navidrome
This commit is contained in:
@@ -37,8 +37,8 @@ export type Images = {
|
|||||||
export const NO_IMAGES: Images = {
|
export const NO_IMAGES: Images = {
|
||||||
small: undefined,
|
small: undefined,
|
||||||
medium: undefined,
|
medium: undefined,
|
||||||
large: undefined
|
large: undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Artist = ArtistSummary & {
|
export type Artist = ArtistSummary & {
|
||||||
albums: AlbumSummary[];
|
albums: AlbumSummary[];
|
||||||
@@ -51,18 +51,17 @@ export type AlbumSummary = {
|
|||||||
genre: string | undefined;
|
genre: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Album = AlbumSummary & {
|
export type Album = AlbumSummary & {};
|
||||||
};
|
|
||||||
|
|
||||||
export type Track = {
|
export type Track = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
duration: string;
|
duration: number;
|
||||||
number: string | undefined;
|
number: number | undefined;
|
||||||
genre: string | undefined;
|
genre: string | undefined;
|
||||||
album: AlbumSummary;
|
album: AlbumSummary;
|
||||||
artist: ArtistSummary
|
artist: ArtistSummary;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Paging = {
|
export type Paging = {
|
||||||
@@ -106,6 +105,14 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
|||||||
genre: it.genre,
|
genre: it.genre,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
||||||
|
|
||||||
|
export type Stream = {
|
||||||
|
status: number;
|
||||||
|
headers: Record<StreamingHeader, string>;
|
||||||
|
data: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
export const range = (size: number) => [...Array(size).keys()];
|
export const range = (size: number) => [...Array(size).keys()];
|
||||||
|
|
||||||
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
||||||
@@ -124,5 +131,13 @@ export interface MusicLibrary {
|
|||||||
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
||||||
album(id: string): Promise<Album>;
|
album(id: string): Promise<Album>;
|
||||||
tracks(albumId: string): Promise<Track[]>;
|
tracks(albumId: string): Promise<Track[]>;
|
||||||
|
track(trackId: string): Promise<Track>;
|
||||||
genres(): Promise<string[]>;
|
genres(): Promise<string[]>;
|
||||||
|
stream({
|
||||||
|
trackId,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
range: string | undefined;
|
||||||
|
}): Promise<Stream>;
|
||||||
}
|
}
|
||||||
|
|||||||
173
src/navidrome.ts
173
src/navidrome.ts
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import X2JS from "x2js";
|
import X2JS from "x2js";
|
||||||
|
|
||||||
import axios from "axios";
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
import { Encryption } from "./encryption";
|
import { Encryption } from "./encryption";
|
||||||
import randomString from "./random_string";
|
import randomString from "./random_string";
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export type album = {
|
|||||||
_name: string;
|
_name: string;
|
||||||
_genre: string | undefined;
|
_genre: string | undefined;
|
||||||
_year: string | undefined;
|
_year: string | undefined;
|
||||||
_coverArt: string;
|
_coverArt: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type artistSummary = {
|
export type artistSummary = {
|
||||||
@@ -124,11 +124,11 @@ export type song = {
|
|||||||
_title: string;
|
_title: string;
|
||||||
_album: string;
|
_album: string;
|
||||||
_artist: string;
|
_artist: string;
|
||||||
_track: string;
|
_track: string | undefined;
|
||||||
_genre: string;
|
_genre: string;
|
||||||
_coverArt: string;
|
_coverArt: string;
|
||||||
_created: "2004-11-08T23:36:11";
|
_created: "2004-11-08T23:36:11";
|
||||||
_duration: string;
|
_duration: string | undefined;
|
||||||
_bitRate: "128";
|
_bitRate: "128";
|
||||||
_suffix: "mp3";
|
_suffix: "mp3";
|
||||||
_contentType: string;
|
_contentType: string;
|
||||||
@@ -138,15 +138,15 @@ export type song = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GetAlbumResponse = {
|
export type GetAlbumResponse = {
|
||||||
album: {
|
album: album & {
|
||||||
_id: string;
|
|
||||||
_name: string;
|
|
||||||
_genre: string;
|
|
||||||
_year: string;
|
|
||||||
song: song[];
|
song: song[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetSongResponse = {
|
||||||
|
song: song;
|
||||||
|
};
|
||||||
|
|
||||||
export function isError(
|
export function isError(
|
||||||
subsonicResponse: SubsonicResponse
|
subsonicResponse: SubsonicResponse
|
||||||
): subsonicResponse is SubsonicError {
|
): subsonicResponse is SubsonicError {
|
||||||
@@ -169,6 +169,28 @@ export type getAlbumListParams = {
|
|||||||
|
|
||||||
const MAX_ALBUM_LIST = 500;
|
const MAX_ALBUM_LIST = 500;
|
||||||
|
|
||||||
|
const asTrack = (album: Album, song: song) => ({
|
||||||
|
id: song._id,
|
||||||
|
name: song._title,
|
||||||
|
mimeType: song._contentType,
|
||||||
|
duration: parseInt(song._duration || "0"),
|
||||||
|
number: parseInt(song._track || "0"),
|
||||||
|
genre: song._genre,
|
||||||
|
album,
|
||||||
|
artist: {
|
||||||
|
id: song._artistId,
|
||||||
|
name: song._artist,
|
||||||
|
image: NO_IMAGES,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const asAlbum = (album: album) => ({
|
||||||
|
id: album._id,
|
||||||
|
name: album._name,
|
||||||
|
year: album._year,
|
||||||
|
genre: album._genre,
|
||||||
|
});
|
||||||
|
|
||||||
export class Navidrome implements MusicService {
|
export class Navidrome implements MusicService {
|
||||||
url: string;
|
url: string;
|
||||||
encryption: Encryption;
|
encryption: Encryption;
|
||||||
@@ -178,11 +200,12 @@ export class Navidrome implements MusicService {
|
|||||||
this.encryption = encryption;
|
this.encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
get = async <T>(
|
get = async (
|
||||||
{ username, password }: Credentials,
|
{ username, password }: Credentials,
|
||||||
path: string,
|
path: string,
|
||||||
q: {} = {}
|
q: {} = {},
|
||||||
): Promise<T> =>
|
config: AxiosRequestConfig | undefined = {}
|
||||||
|
) =>
|
||||||
axios
|
axios
|
||||||
.get(`${this.url}${path}`, {
|
.get(`${this.url}${path}`, {
|
||||||
params: {
|
params: {
|
||||||
@@ -192,7 +215,23 @@ export class Navidrome implements MusicService {
|
|||||||
v: "1.16.1",
|
v: "1.16.1",
|
||||||
c: "bonob",
|
c: "bonob",
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
},
|
||||||
|
...config,
|
||||||
})
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status != 200 && response.status != 206)
|
||||||
|
throw `Navidrome failed with a ${response.status}`;
|
||||||
|
else return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
getJSON = async <T>(
|
||||||
|
{ username, password }: Credentials,
|
||||||
|
path: string,
|
||||||
|
q: {} = {}
|
||||||
|
): Promise<T> =>
|
||||||
|
this.get({ username, password }, path, q)
|
||||||
.then((response) => new X2JS().xml2js(response.data) as SubconicEnvelope)
|
.then((response) => new X2JS().xml2js(response.data) as SubconicEnvelope)
|
||||||
.then((json) => json["subsonic-response"])
|
.then((json) => json["subsonic-response"])
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
@@ -201,7 +240,7 @@ export class Navidrome implements MusicService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
generateToken = async (credentials: Credentials) =>
|
generateToken = async (credentials: Credentials) =>
|
||||||
this.get(credentials, "/rest/ping.view")
|
this.getJSON(credentials, "/rest/ping.view")
|
||||||
.then(() => ({
|
.then(() => ({
|
||||||
authToken: Buffer.from(
|
authToken: Buffer.from(
|
||||||
JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials)))
|
JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials)))
|
||||||
@@ -219,7 +258,7 @@ export class Navidrome implements MusicService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
getArtists = (credentials: Credentials): Promise<IdName[]> =>
|
getArtists = (credentials: Credentials): Promise<IdName[]> =>
|
||||||
this.get<GetArtistsResponse>(credentials, "/rest/getArtists")
|
this.getJSON<GetArtistsResponse>(credentials, "/rest/getArtists")
|
||||||
.then((it) => it.artists.index.flatMap((it) => it.artist || []))
|
.then((it) => it.artists.index.flatMap((it) => it.artist || []))
|
||||||
.then((artists) =>
|
.then((artists) =>
|
||||||
artists.map((artist) => ({
|
artists.map((artist) => ({
|
||||||
@@ -229,7 +268,7 @@ export class Navidrome implements MusicService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
getArtistInfo = (credentials: Credentials, id: string): Promise<ArtistInfo> =>
|
getArtistInfo = (credentials: Credentials, id: string): Promise<ArtistInfo> =>
|
||||||
this.get<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", {
|
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", {
|
||||||
id,
|
id,
|
||||||
}).then((it) => ({
|
}).then((it) => ({
|
||||||
image: {
|
image: {
|
||||||
@@ -239,11 +278,21 @@ export class Navidrome implements MusicService {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
|
||||||
|
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
||||||
|
.then((it) => it.album)
|
||||||
|
.then((album) => ({
|
||||||
|
id: album._id,
|
||||||
|
name: album._name,
|
||||||
|
year: album._year,
|
||||||
|
genre: album._genre,
|
||||||
|
}));
|
||||||
|
|
||||||
getArtist = (
|
getArtist = (
|
||||||
credentials: Credentials,
|
credentials: Credentials,
|
||||||
id: string
|
id: string
|
||||||
): Promise<IdName & { albums: AlbumSummary[] }> =>
|
): Promise<IdName & { albums: AlbumSummary[] }> =>
|
||||||
this.get<GetArtistResponse>(credentials, "/rest/getArtist", {
|
this.getJSON<GetArtistResponse>(credentials, "/rest/getArtist", {
|
||||||
id,
|
id,
|
||||||
})
|
})
|
||||||
.then((it) => it.artist)
|
.then((it) => it.artist)
|
||||||
@@ -312,7 +361,7 @@ export class Navidrome implements MusicService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return navidrome
|
return navidrome
|
||||||
.get<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
||||||
...p,
|
...p,
|
||||||
size: MAX_ALBUM_LIST,
|
size: MAX_ALBUM_LIST,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -333,24 +382,10 @@ export class Navidrome implements MusicService {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
album: (id: string): Promise<Album> =>
|
album: (id: string): Promise<Album> =>
|
||||||
navidrome
|
navidrome.getAlbum(credentials, id),
|
||||||
.get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
|
||||||
.then((it) => it.album)
|
|
||||||
.then((album) => ({
|
|
||||||
id: album._id,
|
|
||||||
name: album._name,
|
|
||||||
year: album._year,
|
|
||||||
genre: album._genre,
|
|
||||||
// tracks: album.song.map(track => ({
|
|
||||||
// id: track._id,
|
|
||||||
// name: track._title,
|
|
||||||
// mimeType: track._contentType,
|
|
||||||
// duration: track._duration,
|
|
||||||
// }))
|
|
||||||
})),
|
|
||||||
genres: () =>
|
genres: () =>
|
||||||
navidrome
|
navidrome
|
||||||
.get<GenGenresResponse>(credentials, "/rest/getGenres")
|
.getJSON<GenGenresResponse>(credentials, "/rest/getGenres")
|
||||||
.then((it) =>
|
.then((it) =>
|
||||||
pipe(
|
pipe(
|
||||||
it.genres.genre,
|
it.genres.genre,
|
||||||
@@ -360,29 +395,61 @@ export class Navidrome implements MusicService {
|
|||||||
),
|
),
|
||||||
tracks: (albumId: string) =>
|
tracks: (albumId: string) =>
|
||||||
navidrome
|
navidrome
|
||||||
.get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id: albumId })
|
.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", {
|
||||||
|
id: albumId,
|
||||||
|
})
|
||||||
.then((it) => it.album)
|
.then((it) => it.album)
|
||||||
.then((album) =>
|
.then((album) =>
|
||||||
album.song.map((song) => ({
|
album.song.map((song) => asTrack(asAlbum(album), song))
|
||||||
id: song._id,
|
|
||||||
name: song._title,
|
|
||||||
mimeType: song._contentType,
|
|
||||||
duration: song._duration,
|
|
||||||
number: song._track,
|
|
||||||
genre: song._genre,
|
|
||||||
album: {
|
|
||||||
id: album._id,
|
|
||||||
name: album._name,
|
|
||||||
year: album._year,
|
|
||||||
genre: album._genre,
|
|
||||||
},
|
|
||||||
artist: {
|
|
||||||
id: song._artistId,
|
|
||||||
name: song._artist,
|
|
||||||
image: NO_IMAGES,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
),
|
),
|
||||||
|
track: (trackId: string) =>
|
||||||
|
navidrome
|
||||||
|
.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
|
||||||
|
id: trackId,
|
||||||
|
})
|
||||||
|
.then((it) => it.song)
|
||||||
|
.then((song) =>
|
||||||
|
navidrome
|
||||||
|
.getAlbum(credentials, song._albumId)
|
||||||
|
.then((album) => asTrack(album, song))
|
||||||
|
),
|
||||||
|
stream: async ({
|
||||||
|
trackId,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
range: string | undefined;
|
||||||
|
}) =>
|
||||||
|
navidrome
|
||||||
|
.get(
|
||||||
|
credentials,
|
||||||
|
`/rest/stream`,
|
||||||
|
{ id: trackId },
|
||||||
|
{
|
||||||
|
headers: pipe(
|
||||||
|
range,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map((range) => ({
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
Range: range,
|
||||||
|
})),
|
||||||
|
O.getOrElse(() => ({
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => ({
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
"content-type": res.headers["content-type"],
|
||||||
|
"content-length": res.headers["content-length"],
|
||||||
|
"content-range": res.headers["content-range"],
|
||||||
|
"accept-ranges": res.headers["accept-ranges"],
|
||||||
|
},
|
||||||
|
data: Buffer.from(res.data, "binary"),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(musicLibrary);
|
return Promise.resolve(musicLibrary);
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ function server(
|
|||||||
res.send("");
|
res.send("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/stream/track/:id", async (req, res) => {
|
||||||
|
const id = req.params["id"]!;
|
||||||
|
const token = req.headers["bonob-token"] as string;
|
||||||
|
return musicService
|
||||||
|
.login(token)
|
||||||
|
.then((it) =>
|
||||||
|
it.stream({ trackId: id, range: req.headers["range"] || undefined })
|
||||||
|
)
|
||||||
|
.then((stream) => {
|
||||||
|
res.status(stream.status);
|
||||||
|
Object.entries(stream.headers).forEach(([header, value]) =>
|
||||||
|
res.setHeader(header, value)
|
||||||
|
);
|
||||||
|
res.send(stream.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// app.get("/album/:albumId/art", (req, res) => {
|
// app.get("/album/:albumId/art", (req, res) => {
|
||||||
// console.log(`Trying to load image for ${req.params["albumId"]}, token ${JSON.stringify(req.cookies)}`)
|
// console.log(`Trying to load image for ${req.params["albumId"]}, token ${JSON.stringify(req.cookies)}`)
|
||||||
// const authToken = req.headers["X-AuthToken"]! as string;
|
// const authToken = req.headers["X-AuthToken"]! as string;
|
||||||
|
|||||||
93
src/smapi.ts
93
src/smapi.ts
@@ -64,7 +64,8 @@ export type GetMetadataResponse = {
|
|||||||
count: number;
|
count: number;
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
mediaCollection: MediaCollection[];
|
mediaCollection: any[] | undefined;
|
||||||
|
mediaMetadata: any[] | undefined;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +84,27 @@ export function getMetadataResult({
|
|||||||
index,
|
index,
|
||||||
total,
|
total,
|
||||||
mediaCollection: mediaCollection || [],
|
mediaCollection: mediaCollection || [],
|
||||||
|
mediaMetadata: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetadataResult2({
|
||||||
|
mediaMetadata,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
mediaMetadata: any[] | undefined;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
}): GetMetadataResponse {
|
||||||
|
return {
|
||||||
|
getMetadataResult: {
|
||||||
|
count: mediaMetadata?.length || 0,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
mediaCollection: undefined,
|
||||||
|
mediaMetadata: mediaMetadata || [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -225,6 +247,71 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getAppLink: () => sonosSoap.getAppLink(),
|
getAppLink: () => sonosSoap.getAppLink(),
|
||||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||||
sonosSoap.getDeviceAuthToken({ linkCode }),
|
sonosSoap.getDeviceAuthToken({ linkCode }),
|
||||||
|
getMediaURI: async (
|
||||||
|
{ id }: { id: string },
|
||||||
|
_,
|
||||||
|
headers?: SoapyHeaders
|
||||||
|
) => {
|
||||||
|
if (!headers?.credentials) {
|
||||||
|
throw {
|
||||||
|
Fault: {
|
||||||
|
faultcode: "Client.LoginUnsupported",
|
||||||
|
faultstring: "Missing credentials...",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await musicService
|
||||||
|
.login(headers.credentials.loginToken.token)
|
||||||
|
.catch((_) => {
|
||||||
|
throw {
|
||||||
|
Fault: {
|
||||||
|
faultcode: "Client.LoginUnauthorized",
|
||||||
|
faultstring: "Credentials not found...",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const [type, typeId] = id.split(":");
|
||||||
|
return {
|
||||||
|
getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`,
|
||||||
|
httpHeaders: [
|
||||||
|
{
|
||||||
|
header: "bonob-token",
|
||||||
|
value: headers?.credentials?.loginToken.token,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getMediaMetadata: async (
|
||||||
|
{ id }: { id: string },
|
||||||
|
_,
|
||||||
|
headers?: SoapyHeaders
|
||||||
|
) => {
|
||||||
|
if (!headers?.credentials) {
|
||||||
|
throw {
|
||||||
|
Fault: {
|
||||||
|
faultcode: "Client.LoginUnsupported",
|
||||||
|
faultstring: "Missing credentials...",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const login = await musicService
|
||||||
|
.login(headers.credentials.loginToken.token)
|
||||||
|
.catch((_) => {
|
||||||
|
throw {
|
||||||
|
Fault: {
|
||||||
|
faultcode: "Client.LoginUnauthorized",
|
||||||
|
faultstring: "Credentials not found...",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeId = id.split(":")[1];
|
||||||
|
const musicLibrary = login as MusicLibrary;
|
||||||
|
return musicLibrary
|
||||||
|
.track(typeId!)
|
||||||
|
.then((it) => ({ getMediaMetadataResult: track(it) }));
|
||||||
|
},
|
||||||
getMetadata: async (
|
getMetadata: async (
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -320,8 +407,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
.tracks(typeId!)
|
.tracks(typeId!)
|
||||||
.then(slice2(paging))
|
.then(slice2(paging))
|
||||||
.then(([page, total]) =>
|
.then(([page, total]) =>
|
||||||
getMetadataResult({
|
getMetadataResult2({
|
||||||
mediaCollection: page.map(track),
|
mediaMetadata: page.map(track),
|
||||||
index: paging._index,
|
index: paging._index,
|
||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
|||||||
id,
|
id,
|
||||||
name: `Track ${id}`,
|
name: `Track ${id}`,
|
||||||
mimeType: `audio/mp3-${id}`,
|
mimeType: `audio/mp3-${id}`,
|
||||||
duration: `${randomInt(500)}`,
|
duration: randomInt(500),
|
||||||
number: `${randomInt(100)}`,
|
number: randomInt(100),
|
||||||
genre: randomGenre(),
|
genre: randomGenre(),
|
||||||
artist: anArtist(),
|
artist: anArtist(),
|
||||||
album: anAlbum(),
|
album: anAlbum(),
|
||||||
|
|||||||
@@ -148,33 +148,6 @@ describe("InMemoryMusicService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("album", () => {
|
|
||||||
describe("when it exists", () => {
|
|
||||||
const albumToLookFor = anAlbum({ id: "albumToLookFor" });
|
|
||||||
const artist1 = anArtist({ albums: [anAlbum(), anAlbum(), anAlbum()] });
|
|
||||||
const artist2 = anArtist({
|
|
||||||
albums: [anAlbum(), albumToLookFor, anAlbum()],
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service.hasArtists(artist1, artist2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide an artist", async () => {
|
|
||||||
expect(await musicLibrary.album(albumToLookFor.id)).toEqual(
|
|
||||||
albumToLookFor
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when it doesnt exist", () => {
|
|
||||||
it("should blow up", async () => {
|
|
||||||
return expect(musicLibrary.album("-1")).rejects.toEqual(
|
|
||||||
"No album with id '-1'"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("tracks", () => {
|
describe("tracks", () => {
|
||||||
const artist1Album1 = anAlbum();
|
const artist1Album1 = anAlbum();
|
||||||
@@ -193,13 +166,26 @@ describe("InMemoryMusicService", () => {
|
|||||||
|
|
||||||
describe("fetching tracks for an album", () => {
|
describe("fetching tracks for an album", () => {
|
||||||
it("should return only tracks on that album", async () => {
|
it("should return only tracks on that album", async () => {
|
||||||
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([track1, track2])
|
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([
|
||||||
|
track1,
|
||||||
|
track2,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetching tracks for an album that doesnt exist", () => {
|
describe("fetching tracks for an album that doesnt exist", () => {
|
||||||
it("should return empty array", async () => {
|
it("should return empty array", async () => {
|
||||||
expect(await musicLibrary.tracks("non existant album id")).toEqual([])
|
expect(await musicLibrary.tracks("non existant album id")).toEqual(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a single track", () => {
|
||||||
|
describe("when it exists", () => {
|
||||||
|
it("should return the track", async () => {
|
||||||
|
expect(await musicLibrary.track(track3.id)).toEqual(track3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -235,125 +221,145 @@ describe("InMemoryMusicService", () => {
|
|||||||
service.hasArtists(artist1, artist2, artist3, artistWithNoAlbums);
|
service.hasArtists(artist1, artist2, artist3, artistWithNoAlbums);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with no filtering", () => {
|
describe("fetching multiple albums", () => {
|
||||||
describe("fetching all on one page", () => {
|
describe("with no filtering", () => {
|
||||||
it("should return all the albums for all the artists", async () => {
|
describe("fetching all on one page", () => {
|
||||||
expect(
|
it("should return all the albums for all the artists", async () => {
|
||||||
await musicLibrary.albums({ _index: 0, _count: 100 })
|
expect(
|
||||||
).toEqual({
|
await musicLibrary.albums({ _index: 0, _count: 100 })
|
||||||
results: [
|
).toEqual({
|
||||||
albumToAlbumSummary(artist1_album1),
|
results: [
|
||||||
albumToAlbumSummary(artist1_album2),
|
albumToAlbumSummary(artist1_album1),
|
||||||
albumToAlbumSummary(artist1_album3),
|
albumToAlbumSummary(artist1_album2),
|
||||||
albumToAlbumSummary(artist1_album4),
|
albumToAlbumSummary(artist1_album3),
|
||||||
albumToAlbumSummary(artist1_album5),
|
albumToAlbumSummary(artist1_album4),
|
||||||
|
albumToAlbumSummary(artist1_album5),
|
||||||
albumToAlbumSummary(artist2_album1),
|
|
||||||
|
albumToAlbumSummary(artist2_album1),
|
||||||
albumToAlbumSummary(artist3_album1),
|
|
||||||
albumToAlbumSummary(artist3_album2),
|
albumToAlbumSummary(artist3_album1),
|
||||||
],
|
albumToAlbumSummary(artist3_album2),
|
||||||
total: totalAlbumCount,
|
],
|
||||||
|
total: totalAlbumCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a page", () => {
|
||||||
|
it("should return only that page", async () => {
|
||||||
|
expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual(
|
||||||
|
{
|
||||||
|
results: [
|
||||||
|
albumToAlbumSummary(artist1_album5),
|
||||||
|
albumToAlbumSummary(artist2_album1),
|
||||||
|
albumToAlbumSummary(artist3_album1),
|
||||||
|
],
|
||||||
|
total: totalAlbumCount,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching the last page", () => {
|
||||||
|
it("should return only that page", async () => {
|
||||||
|
expect(
|
||||||
|
await musicLibrary.albums({ _index: 6, _count: 100 })
|
||||||
|
).toEqual({
|
||||||
|
results: [
|
||||||
|
albumToAlbumSummary(artist3_album1),
|
||||||
|
albumToAlbumSummary(artist3_album2),
|
||||||
|
],
|
||||||
|
total: totalAlbumCount,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetching a page", () => {
|
describe("filtering by genre", () => {
|
||||||
it("should return only that page", async () => {
|
describe("fetching all on one page", () => {
|
||||||
expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual(
|
it("should return all the albums of that genre for all the artists", async () => {
|
||||||
{
|
expect(
|
||||||
|
await musicLibrary.albums({
|
||||||
|
genre: "Pop",
|
||||||
|
_index: 0,
|
||||||
|
_count: 100,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
results: [
|
results: [
|
||||||
|
albumToAlbumSummary(artist1_album1),
|
||||||
|
albumToAlbumSummary(artist1_album4),
|
||||||
albumToAlbumSummary(artist1_album5),
|
albumToAlbumSummary(artist1_album5),
|
||||||
albumToAlbumSummary(artist2_album1),
|
albumToAlbumSummary(artist3_album2),
|
||||||
albumToAlbumSummary(artist3_album1),
|
|
||||||
],
|
],
|
||||||
total: totalAlbumCount,
|
total: 4,
|
||||||
}
|
});
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
describe("when the genre has more albums than a single page", () => {
|
||||||
describe("fetching the last page", () => {
|
describe("can fetch a single page", () => {
|
||||||
it("should return only that page", async () => {
|
it("should return only the albums for that page", async () => {
|
||||||
|
expect(
|
||||||
|
await musicLibrary.albums({
|
||||||
|
genre: "Pop",
|
||||||
|
_index: 1,
|
||||||
|
_count: 2,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
results: [
|
||||||
|
albumToAlbumSummary(artist1_album4),
|
||||||
|
albumToAlbumSummary(artist1_album5),
|
||||||
|
],
|
||||||
|
total: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("can fetch the last page", () => {
|
||||||
|
it("should return only the albums for the last page", async () => {
|
||||||
|
expect(
|
||||||
|
await musicLibrary.albums({
|
||||||
|
genre: "Pop",
|
||||||
|
_index: 3,
|
||||||
|
_count: 100,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
results: [albumToAlbumSummary(artist3_album2)],
|
||||||
|
total: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty list if there are no albums for the genre", async () => {
|
||||||
expect(
|
expect(
|
||||||
await musicLibrary.albums({ _index: 6, _count: 100 })
|
await musicLibrary.albums({
|
||||||
|
genre: "genre with no albums",
|
||||||
|
_index: 0,
|
||||||
|
_count: 100,
|
||||||
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
results: [
|
results: [],
|
||||||
albumToAlbumSummary(artist3_album1),
|
total: 0,
|
||||||
albumToAlbumSummary(artist3_album2),
|
|
||||||
],
|
|
||||||
total: totalAlbumCount,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("filtering by genre", () => {
|
describe("fetching a single album", () => {
|
||||||
describe("fetching all on one page", () => {
|
describe("when it exists", () => {
|
||||||
it("should return all the albums of that genre for all the artists", async () => {
|
it("should provide an album", async () => {
|
||||||
expect(
|
expect(await musicLibrary.album(artist1_album5.id)).toEqual(
|
||||||
await musicLibrary.albums({
|
artist1_album5
|
||||||
genre: "Pop",
|
);
|
||||||
_index: 0,
|
|
||||||
_count: 100,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
results: [
|
|
||||||
albumToAlbumSummary(artist1_album1),
|
|
||||||
albumToAlbumSummary(artist1_album4),
|
|
||||||
albumToAlbumSummary(artist1_album5),
|
|
||||||
albumToAlbumSummary(artist3_album2),
|
|
||||||
],
|
|
||||||
total: 4,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when the genre has more albums than a single page", () => {
|
describe("when it doesnt exist", () => {
|
||||||
describe("can fetch a single page", () => {
|
it("should blow up", async () => {
|
||||||
it("should return only the albums for that page", async () => {
|
return expect(musicLibrary.album("-1")).rejects.toEqual(
|
||||||
expect(
|
"No album with id '-1'"
|
||||||
await musicLibrary.albums({
|
);
|
||||||
genre: "Pop",
|
|
||||||
_index: 1,
|
|
||||||
_count: 2,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
results: [
|
|
||||||
albumToAlbumSummary(artist1_album4),
|
|
||||||
albumToAlbumSummary(artist1_album5),
|
|
||||||
],
|
|
||||||
total: 4,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("can fetch the last page", () => {
|
|
||||||
it("should return only the albums for the last page", async () => {
|
|
||||||
expect(
|
|
||||||
await musicLibrary.albums({
|
|
||||||
genre: "Pop",
|
|
||||||
_index: 3,
|
|
||||||
_count: 100,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
results: [albumToAlbumSummary(artist3_album2)],
|
|
||||||
total: 4,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty list if there are no albums for the genre", async () => {
|
|
||||||
expect(
|
|
||||||
await musicLibrary.albums({
|
|
||||||
genre: "genre with no albums",
|
|
||||||
_index: 0,
|
|
||||||
_count: 100,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
results: [],
|
|
||||||
total: 0,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
this.users[username] == password
|
this.users[username] == password
|
||||||
) {
|
) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
authToken: JSON.stringify({ username, password }),
|
authToken: Buffer.from(JSON.stringify({ username, password })).toString('base64'),
|
||||||
userId: username,
|
userId: username,
|
||||||
nickname: username,
|
nickname: username,
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
login(token: string): Promise<MusicLibrary> {
|
login(token: string): Promise<MusicLibrary> {
|
||||||
const credentials = JSON.parse(token) as Credentials;
|
const credentials = JSON.parse(Buffer.from(token, "base64").toString("ascii")) as Credentials;
|
||||||
if (this.users[credentials.username] != credentials.password)
|
if (this.users[credentials.username] != credentials.password)
|
||||||
return Promise.reject("Invalid auth token");
|
return Promise.reject("Invalid auth token");
|
||||||
|
|
||||||
@@ -103,7 +103,17 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
A.sort(ordString)
|
A.sort(ordString)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId))
|
tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId)),
|
||||||
|
track: (trackId: string) => pipe(
|
||||||
|
this.tracks.find(it => it.id === trackId),
|
||||||
|
O.fromNullable,
|
||||||
|
O.map(it => Promise.resolve(it)),
|
||||||
|
O.getOrElse(() => Promise.reject(`Failed to find track with id ${trackId}`))
|
||||||
|
),
|
||||||
|
stream: (_: {
|
||||||
|
trackId: string;
|
||||||
|
range: string | undefined;
|
||||||
|
}) => Promise.reject("unsupported operation")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Md5 } from "ts-md5/dist/md5";
|
import { Md5 } from "ts-md5/dist/md5";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { isDodgyImage, Navidrome, t } from "../src/navidrome";
|
import { isDodgyImage, Navidrome, t } from "../src/navidrome";
|
||||||
import encryption from "../src/encryption";
|
import encryption from "../src/encryption";
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
NO_IMAGES
|
NO_IMAGES,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
import { anAlbum, anArtist, aTrack } from "./builders";
|
import { anAlbum, anArtist, aTrack } from "./builders";
|
||||||
|
|
||||||
@@ -70,7 +71,11 @@ const artistInfoXml = (
|
|||||||
</artistInfo>
|
</artistInfo>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const albumXml = (artist: Artist, album: AlbumSummary, tracks: Track[] = []) => `<album id="${album.id}"
|
const albumXml = (
|
||||||
|
artist: Artist,
|
||||||
|
album: AlbumSummary,
|
||||||
|
tracks: Track[] = []
|
||||||
|
) => `<album id="${album.id}"
|
||||||
parent="${artist.id}"
|
parent="${artist.id}"
|
||||||
isDir="true"
|
isDir="true"
|
||||||
title="${album.name}" name="${album.name}" album="${album.name}"
|
title="${album.name}" name="${album.name}" album="${album.name}"
|
||||||
@@ -83,7 +88,7 @@ const albumXml = (artist: Artist, album: AlbumSummary, tracks: Track[] = []) =>
|
|||||||
created="2021-01-07T08:19:55.834207205Z"
|
created="2021-01-07T08:19:55.834207205Z"
|
||||||
artistId="${artist.id}"
|
artistId="${artist.id}"
|
||||||
songCount="19"
|
songCount="19"
|
||||||
isVideo="false">${tracks.map(track => songXml(track))}</album>`;
|
isVideo="false">${tracks.map((track) => songXml(track))}</album>`;
|
||||||
|
|
||||||
const songXml = (track: Track) => `<song
|
const songXml = (track: Track) => `<song
|
||||||
id="${track.id}"
|
id="${track.id}"
|
||||||
@@ -112,16 +117,17 @@ const albumListXml = (
|
|||||||
) => `<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)">
|
||||||
<albumList>
|
<albumList>
|
||||||
${albums.map(([artist, album]) =>
|
${albums.map(([artist, album]) =>
|
||||||
albumXml(artist, album)
|
albumXml(artist, album)
|
||||||
)}
|
)}
|
||||||
</albumList>
|
</albumList>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const artistXml = (
|
const artistXml = (
|
||||||
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="${artist.albums.length
|
<artist id="${artist.id}" name="${artist.name}" albumCount="${
|
||||||
}" artistImageUrl="....">
|
artist.albums.length
|
||||||
|
}" artistImageUrl="....">
|
||||||
${artist.albums.map((album) => albumXml(artist, album))}
|
${artist.albums.map((album) => albumXml(artist, album))}
|
||||||
</artist>
|
</artist>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
@@ -131,15 +137,31 @@ const genresXml = (
|
|||||||
) => `<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)">
|
||||||
<genres>
|
<genres>
|
||||||
${genres.map(
|
${genres.map(
|
||||||
(it) =>
|
(it) =>
|
||||||
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
||||||
)}
|
)}
|
||||||
</genres>
|
</genres>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const getAlbumXml = (artist: Artist, album: Album, tracks: Track[]) => `<subsonic-response status="ok" version="1.8.0">
|
const getAlbumXml = (
|
||||||
${albumXml(artist, album, tracks)}
|
artist: Artist,
|
||||||
</subsonic-response>`
|
album: Album,
|
||||||
|
tracks: Track[]
|
||||||
|
) => `<subsonic-response status="ok" version="1.8.0">
|
||||||
|
${albumXml(
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
tracks
|
||||||
|
)}
|
||||||
|
</subsonic-response>`;
|
||||||
|
|
||||||
|
const getSongXml = (
|
||||||
|
track: Track
|
||||||
|
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
|
||||||
|
${songXml(
|
||||||
|
track
|
||||||
|
)}
|
||||||
|
</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>`;
|
||||||
|
|
||||||
@@ -169,6 +191,9 @@ describe("Navidrome", () => {
|
|||||||
v: "1.16.1",
|
v: "1.16.1",
|
||||||
c: "bonob",
|
c: "bonob",
|
||||||
};
|
};
|
||||||
|
const headers = {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
};
|
||||||
|
|
||||||
describe("generateToken", () => {
|
describe("generateToken", () => {
|
||||||
describe("when the credentials are valid", () => {
|
describe("when the credentials are valid", () => {
|
||||||
@@ -186,6 +211,7 @@ describe("Navidrome", () => {
|
|||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, {
|
||||||
params: authParams,
|
params: authParams,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -226,6 +252,7 @@ describe("Navidrome", () => {
|
|||||||
params: {
|
params: {
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -268,6 +295,7 @@ describe("Navidrome", () => {
|
|||||||
id: artist.id,
|
id: artist.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
@@ -275,6 +303,7 @@ describe("Navidrome", () => {
|
|||||||
id: artist.id,
|
id: artist.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -377,30 +406,35 @@ describe("Navidrome", () => {
|
|||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
|
||||||
params: authParams,
|
params: authParams,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
params: {
|
params: {
|
||||||
id: artist1.id,
|
id: artist1.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
params: {
|
params: {
|
||||||
id: artist2.id,
|
id: artist2.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
params: {
|
params: {
|
||||||
id: artist3.id,
|
id: artist3.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
params: {
|
params: {
|
||||||
id: artist4.id,
|
id: artist4.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -435,18 +469,21 @@ describe("Navidrome", () => {
|
|||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
|
||||||
params: authParams,
|
params: authParams,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
params: {
|
params: {
|
||||||
id: artist2.id,
|
id: artist2.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||||
params: {
|
params: {
|
||||||
id: artist3.id,
|
id: artist3.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -498,6 +535,7 @@ describe("Navidrome", () => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -544,6 +582,7 @@ describe("Navidrome", () => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -569,6 +608,7 @@ describe("Navidrome", () => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -620,6 +660,7 @@ describe("Navidrome", () => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -630,24 +671,20 @@ describe("Navidrome", () => {
|
|||||||
describe("when it exists", () => {
|
describe("when it exists", () => {
|
||||||
const album = anAlbum();
|
const album = anAlbum();
|
||||||
|
|
||||||
const artist = anArtist({ albums: [album] })
|
const artist = anArtist({ albums: [album] });
|
||||||
|
|
||||||
const tracks = [
|
const tracks = [
|
||||||
aTrack({ artist, album }),
|
aTrack({ artist, album }),
|
||||||
aTrack({ artist, album }),
|
aTrack({ artist, album }),
|
||||||
aTrack({ artist, album }),
|
aTrack({ artist, album }),
|
||||||
aTrack({ artist, album }),
|
aTrack({ artist, album }),
|
||||||
]
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(ok(getAlbumXml(artist, album, tracks)))
|
||||||
ok(
|
|
||||||
getAlbumXml(artist, album, tracks)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -665,6 +702,7 @@ describe("Navidrome", () => {
|
|||||||
id: album.id,
|
id: album.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -675,49 +713,223 @@ describe("Navidrome", () => {
|
|||||||
describe("when it exists", () => {
|
describe("when it exists", () => {
|
||||||
const album = anAlbum({ id: "album1", name: "Burnin" });
|
const album = anAlbum({ id: "album1", name: "Burnin" });
|
||||||
const albumSummary = albumToAlbumSummary(album);
|
const albumSummary = albumToAlbumSummary(album);
|
||||||
|
|
||||||
const artist = anArtist({ id: "artist1", name: "Bob Marley", albums: [album] })
|
const artist = anArtist({
|
||||||
|
id: "artist1",
|
||||||
|
name: "Bob Marley",
|
||||||
|
albums: [album],
|
||||||
|
});
|
||||||
const artistSummary = {
|
const artistSummary = {
|
||||||
...artistToArtistSummary(artist),
|
...artistToArtistSummary(artist),
|
||||||
image: NO_IMAGES
|
image: NO_IMAGES,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tracks = [
|
const tracks = [
|
||||||
aTrack({ artist: artistSummary, album: albumSummary }),
|
aTrack({ artist: artistSummary, album: albumSummary }),
|
||||||
aTrack({ artist: artistSummary, album: albumSummary }),
|
aTrack({ artist: artistSummary, album: albumSummary }),
|
||||||
aTrack({ artist: artistSummary, album: albumSummary }),
|
aTrack({ artist: artistSummary, album: albumSummary }),
|
||||||
aTrack({ artist: artistSummary, album: albumSummary }),
|
aTrack({ artist: artistSummary, album: albumSummary }),
|
||||||
]
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(ok(getAlbumXml(artist, album, tracks)))
|
||||||
ok(
|
|
||||||
getAlbumXml(artist, album, tracks)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return the album", async () => {
|
it("should return the album", async () => {
|
||||||
const result = await navidrome
|
const result = 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.tracks(album.id));
|
.then((it) => it.tracks(album.id));
|
||||||
|
|
||||||
expect(result).toEqual(tracks);
|
expect(result).toEqual(tracks);
|
||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, {
|
||||||
params: {
|
params: {
|
||||||
id: album.id,
|
id: album.id,
|
||||||
...authParams,
|
...authParams,
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a single track", () => {
|
||||||
|
const album = anAlbum({ id: "album1", name: "Burnin" });
|
||||||
|
const albumSummary = albumToAlbumSummary(album);
|
||||||
|
|
||||||
|
const artist = anArtist({
|
||||||
|
id: "artist1",
|
||||||
|
name: "Bob Marley",
|
||||||
|
albums: [album],
|
||||||
|
});
|
||||||
|
const artistSummary = {
|
||||||
|
...artistToArtistSummary(artist),
|
||||||
|
image: NO_IMAGES,
|
||||||
|
};
|
||||||
|
|
||||||
|
const track = aTrack({ artist: artistSummary, album: albumSummary });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the track", async () => {
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.track(track.id));
|
||||||
|
|
||||||
|
expect(result).toEqual(track);
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, {
|
||||||
|
params: {
|
||||||
|
id: track.id,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, {
|
||||||
|
params: {
|
||||||
|
id: album.id,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("streaming a track", () => {
|
||||||
|
const trackId = uuid();
|
||||||
|
|
||||||
|
describe("with no range specified", () => {
|
||||||
|
describe("navidrome returns a 200", () => {
|
||||||
|
it("should return the content", async () => {
|
||||||
|
const streamResponse = {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/mpeg",
|
||||||
|
"content-length": "1667",
|
||||||
|
"content-range": "-200",
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"some-other-header": "some-value"
|
||||||
|
},
|
||||||
|
data: Buffer.from("the track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.stream({ trackId, range: undefined }));
|
||||||
|
|
||||||
|
expect(result.headers).toEqual({
|
||||||
|
"content-type": "audio/mpeg",
|
||||||
|
"content-length": "1667",
|
||||||
|
"content-range": "-200",
|
||||||
|
"accept-ranges": "bytes"
|
||||||
|
});
|
||||||
|
expect(result.data.toString()).toEqual("the track");
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
|
params: {
|
||||||
|
id: trackId,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
},
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navidrome returns something other than a 200", () => {
|
||||||
|
it("should return the content", async () => {
|
||||||
|
const trackId = "track123";
|
||||||
|
|
||||||
|
const streamResponse = {
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
|
const musicLibrary = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken));
|
||||||
|
|
||||||
|
return expect(
|
||||||
|
musicLibrary.stream({ trackId, range: undefined })
|
||||||
|
).rejects.toEqual(`Navidrome failed with a 400`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with range specified", () => {
|
||||||
|
it("should send the range to navidrome", async () => {
|
||||||
|
const range = "1000-2000";
|
||||||
|
const streamResponse = {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/flac",
|
||||||
|
"content-length": "66",
|
||||||
|
"content-range": "100-200",
|
||||||
|
"accept-ranges": "none",
|
||||||
|
"some-other-header": "some-value"
|
||||||
|
},
|
||||||
|
data: Buffer.from("the track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.stream({ trackId, range }));
|
||||||
|
|
||||||
|
expect(result.headers).toEqual({
|
||||||
|
"content-type": "audio/flac",
|
||||||
|
"content-length": "66",
|
||||||
|
"content-range": "100-200",
|
||||||
|
"accept-ranges": "none"
|
||||||
|
});
|
||||||
|
expect(result.data.toString()).toEqual("the track");
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
|
params: {
|
||||||
|
id: trackId,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
Range: range,
|
||||||
|
},
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class LoggedInSonosDriver {
|
|||||||
let next = path.shift();
|
let next = path.shift();
|
||||||
while (next) {
|
while (next) {
|
||||||
if (next != "root") {
|
if (next != "root") {
|
||||||
const childIds = this.currentMetadata!.getMetadataResult.mediaCollection.map(
|
const childIds = this.currentMetadata!.getMetadataResult.mediaCollection!.map(
|
||||||
(it) => it.id
|
(it) => it.id
|
||||||
);
|
);
|
||||||
if (!childIds.includes(next)) {
|
if (!childIds.includes(next)) {
|
||||||
@@ -56,7 +56,7 @@ class LoggedInSonosDriver {
|
|||||||
|
|
||||||
expectTitles(titles: string[]) {
|
expectTitles(titles: string[]) {
|
||||||
expect(
|
expect(
|
||||||
this.currentMetadata!.getMetadataResult.mediaCollection.map(
|
this.currentMetadata!.getMetadataResult.mediaCollection!.map(
|
||||||
(it) => it.title
|
(it) => it.title
|
||||||
)
|
)
|
||||||
).toEqual(titles);
|
).toEqual(titles);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { v4 as uuid } from "uuid";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
import { MusicService } from "../src/music_service";
|
||||||
import makeServer from "../src/server";
|
import makeServer from "../src/server";
|
||||||
import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
|
import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
|
||||||
|
|
||||||
@@ -185,4 +187,187 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("/stream", () => {
|
||||||
|
const musicService = {
|
||||||
|
login: jest.fn(),
|
||||||
|
};
|
||||||
|
const musicLibrary = {
|
||||||
|
stream: jest.fn(),
|
||||||
|
};
|
||||||
|
const server = makeServer(
|
||||||
|
(jest.fn() as unknown) as Sonos,
|
||||||
|
aService(),
|
||||||
|
"http://localhost:1234",
|
||||||
|
(musicService as unknown) as MusicService
|
||||||
|
);
|
||||||
|
|
||||||
|
const authToken = uuid();
|
||||||
|
const trackId = uuid();
|
||||||
|
|
||||||
|
describe("when sonos does not ask for a range", () => {
|
||||||
|
describe("when the music service returns a 200", () => {
|
||||||
|
it("should return a 200 with the data", async () => {
|
||||||
|
const stream = {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/mp3",
|
||||||
|
"content-length": "222",
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"content-range": "-100",
|
||||||
|
},
|
||||||
|
data: Buffer.from("some track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
musicLibrary.stream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(`/stream/track/${trackId}`)
|
||||||
|
.set("bonob-token", authToken);
|
||||||
|
|
||||||
|
console.log("testing finished watiting");
|
||||||
|
|
||||||
|
expect(res.status).toEqual(stream.status);
|
||||||
|
expect(res.header["content-type"]).toEqual(
|
||||||
|
stream.headers["content-type"]
|
||||||
|
);
|
||||||
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
|
stream.headers["accept-ranges"]
|
||||||
|
);
|
||||||
|
expect(res.header["content-range"]).toEqual(
|
||||||
|
stream.headers["content-range"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the music service returns a 206", () => {
|
||||||
|
it("should return a 206 with the data", async () => {
|
||||||
|
const stream = {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/ogg",
|
||||||
|
"content-length": "333",
|
||||||
|
"accept-ranges": "bytez",
|
||||||
|
"content-range": "100-200",
|
||||||
|
},
|
||||||
|
data: Buffer.from("some other track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
musicLibrary.stream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(`/stream/track/${trackId}`)
|
||||||
|
.set("bonob-token", authToken);
|
||||||
|
|
||||||
|
console.log("testing finished watiting");
|
||||||
|
|
||||||
|
expect(res.status).toEqual(stream.status);
|
||||||
|
expect(res.header["content-type"]).toEqual(
|
||||||
|
stream.headers["content-type"]
|
||||||
|
);
|
||||||
|
// expect(res.header["content-length"]).toEqual(stream.headers["content-length"]);
|
||||||
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
|
stream.headers["accept-ranges"]
|
||||||
|
);
|
||||||
|
expect(res.header["content-range"]).toEqual(
|
||||||
|
stream.headers["content-range"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when sonos does ask for a range", () => {
|
||||||
|
describe("when the music service returns a 200", () => {
|
||||||
|
it("should return a 200 with the data", async () => {
|
||||||
|
const stream = {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/mp3",
|
||||||
|
"content-length": "222",
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"content-range": "-100",
|
||||||
|
},
|
||||||
|
data: Buffer.from("some track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
musicLibrary.stream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(`/stream/track/${trackId}`)
|
||||||
|
.set("bonob-token", authToken)
|
||||||
|
.set("Range", "3000-4000");
|
||||||
|
|
||||||
|
console.log("testing finished watiting");
|
||||||
|
|
||||||
|
expect(res.status).toEqual(stream.status);
|
||||||
|
expect(res.header["content-type"]).toEqual(
|
||||||
|
stream.headers["content-type"]
|
||||||
|
);
|
||||||
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
|
stream.headers["accept-ranges"]
|
||||||
|
);
|
||||||
|
expect(res.header["content-range"]).toEqual(
|
||||||
|
stream.headers["content-range"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
expect(musicLibrary.stream).toHaveBeenCalledWith({
|
||||||
|
trackId,
|
||||||
|
range: "3000-4000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the music service returns a 206", () => {
|
||||||
|
it("should return a 206 with the data", async () => {
|
||||||
|
const stream = {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/ogg",
|
||||||
|
"content-length": "333",
|
||||||
|
"accept-ranges": "bytez",
|
||||||
|
"content-range": "100-200",
|
||||||
|
},
|
||||||
|
data: Buffer.from("some other track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
musicLibrary.stream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(`/stream/track/${trackId}`)
|
||||||
|
.set("bonob-token", authToken)
|
||||||
|
.set("Range", "4000-5000");
|
||||||
|
|
||||||
|
console.log("testing finished watiting");
|
||||||
|
|
||||||
|
expect(res.status).toEqual(stream.status);
|
||||||
|
expect(res.header["content-type"]).toEqual(
|
||||||
|
stream.headers["content-type"]
|
||||||
|
);
|
||||||
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
|
stream.headers["accept-ranges"]
|
||||||
|
);
|
||||||
|
expect(res.header["content-range"]).toEqual(
|
||||||
|
stream.headers["content-range"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
expect(musicLibrary.stream).toHaveBeenCalledWith({
|
||||||
|
trackId,
|
||||||
|
range: "4000-5000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import crypto from "crypto";
|
|||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { Client, createClientAsync } from "soap";
|
import { Client, createClientAsync } from "soap";
|
||||||
import X2JS from "x2js";
|
import X2JS from "x2js";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
|
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
|
||||||
import makeServer from "../src/server";
|
import makeServer from "../src/server";
|
||||||
import { bonobService, SONOS_DISABLED } from "../src/sonos";
|
import { bonobService, SONOS_DISABLED } from "../src/sonos";
|
||||||
import { STRINGS_ROUTE, LOGIN_ROUTE, getMetadataResult } from "../src/smapi";
|
import {
|
||||||
|
STRINGS_ROUTE,
|
||||||
|
LOGIN_ROUTE,
|
||||||
|
getMetadataResult,
|
||||||
|
getMetadataResult2,
|
||||||
|
} from "../src/smapi";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
aService,
|
aService,
|
||||||
@@ -276,7 +282,7 @@ describe("api", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("when no credentials header provided", () => {
|
describe("when no credentials header provided", () => {
|
||||||
it("should return a fault of LoginUnauthorized", async () => {
|
it("should return a fault of LoginUnsupported", async () => {
|
||||||
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||||
endpoint: service.uri,
|
endpoint: service.uri,
|
||||||
httpClient: supersoap(server, rootUrl),
|
httpClient: supersoap(server, rootUrl),
|
||||||
@@ -295,7 +301,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("when invalid credentials are provided", () => {
|
describe("when invalid credentials are provided", () => {
|
||||||
it("should return a fault of LoginInvalid", async () => {
|
it("should return a fault of LoginUnauthorized", async () => {
|
||||||
const username = "userThatGetsDeleted";
|
const username = "userThatGetsDeleted";
|
||||||
const password = "password1";
|
const password = "password1";
|
||||||
musicService.hasUser({ username, password });
|
musicService.hasUser({ username, password });
|
||||||
@@ -640,11 +646,11 @@ describe("api", () => {
|
|||||||
albums: [album],
|
albums: [album],
|
||||||
});
|
});
|
||||||
|
|
||||||
const track1 = aTrack({ artist, album, number: "1" });
|
const track1 = aTrack({ artist, album, number: 1 });
|
||||||
const track2 = aTrack({ artist, album, number: "2" });
|
const track2 = aTrack({ artist, album, number: 2 });
|
||||||
const track3 = aTrack({ artist, album, number: "3" });
|
const track3 = aTrack({ artist, album, number: 3 });
|
||||||
const track4 = aTrack({ artist, album, number: "4" });
|
const track4 = aTrack({ artist, album, number: 4 });
|
||||||
const track5 = aTrack({ artist, album, number: "5" });
|
const track5 = aTrack({ artist, album, number: 5 });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
musicService.hasArtists(artist);
|
musicService.hasArtists(artist);
|
||||||
@@ -659,33 +665,29 @@ describe("api", () => {
|
|||||||
count: 100,
|
count: 100,
|
||||||
});
|
});
|
||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult2({
|
||||||
mediaCollection: [
|
mediaMetadata: [track1, track2, track3, track4, track5].map(
|
||||||
track1,
|
(track) => ({
|
||||||
track2,
|
itemType: "track",
|
||||||
track3,
|
id: `track:${track.id}`,
|
||||||
track4,
|
mimeType: track.mimeType,
|
||||||
track5,
|
title: track.name,
|
||||||
].map((track) => ({
|
|
||||||
itemType: "track",
|
|
||||||
id: `track:${track.id}`,
|
|
||||||
mimeType: track.mimeType,
|
|
||||||
title: track.name,
|
|
||||||
|
|
||||||
trackMetadata: {
|
trackMetadata: {
|
||||||
album: track.album.name,
|
album: track.album.name,
|
||||||
albumId: track.album.id,
|
albumId: track.album.id,
|
||||||
albumArtist: track.artist.name,
|
albumArtist: track.artist.name,
|
||||||
albumArtistId: track.artist.id,
|
albumArtistId: track.artist.id,
|
||||||
// albumArtURI
|
// albumArtURI
|
||||||
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,
|
genre: track.album.genre,
|
||||||
// genreId
|
// genreId
|
||||||
trackNumber: track.number,
|
trackNumber: track.number,
|
||||||
},
|
},
|
||||||
})),
|
})
|
||||||
|
),
|
||||||
index: 0,
|
index: 0,
|
||||||
total: 5,
|
total: 5,
|
||||||
})
|
})
|
||||||
@@ -701,11 +703,8 @@ describe("api", () => {
|
|||||||
count: 2,
|
count: 2,
|
||||||
});
|
});
|
||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult2({
|
||||||
mediaCollection: [
|
mediaMetadata: [track3, track4].map((track) => ({
|
||||||
track3,
|
|
||||||
track4,
|
|
||||||
].map((track) => ({
|
|
||||||
itemType: "track",
|
itemType: "track",
|
||||||
id: `track:${track.id}`,
|
id: `track:${track.id}`,
|
||||||
mimeType: track.mimeType,
|
mimeType: track.mimeType,
|
||||||
@@ -735,5 +734,217 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getMediaURI", () => {
|
||||||
|
const server = makeServer(
|
||||||
|
SONOS_DISABLED,
|
||||||
|
service,
|
||||||
|
rootUrl,
|
||||||
|
musicService,
|
||||||
|
linkCodes
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
.getMediaURIAsync({ id: "track:123" })
|
||||||
|
.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
|
||||||
|
.getMediaURIAsync({ id: "track:123" })
|
||||||
|
.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 a URI to stream a track", () => {
|
||||||
|
it("should return it with auth header", async () => {
|
||||||
|
const trackId = uuid();
|
||||||
|
|
||||||
|
const root = await ws.getMediaURIAsync({
|
||||||
|
id: `track:${trackId}`,
|
||||||
|
});
|
||||||
|
expect(root[0]).toEqual({
|
||||||
|
getMediaURIResult: `${rootUrl}/stream/track/${trackId}`,
|
||||||
|
httpHeaders: {
|
||||||
|
header: "bonob-token",
|
||||||
|
value: token.authToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMediaMetadata", () => {
|
||||||
|
const server = makeServer(
|
||||||
|
SONOS_DISABLED,
|
||||||
|
service,
|
||||||
|
rootUrl,
|
||||||
|
musicService,
|
||||||
|
linkCodes
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
.getMediaMetadataAsync({ id: "track:123" })
|
||||||
|
.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
|
||||||
|
.getMediaMetadataAsync({ id: "track:123" })
|
||||||
|
.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;
|
||||||
|
|
||||||
|
const album = anAlbum();
|
||||||
|
const artist = anArtist({
|
||||||
|
albums: [album],
|
||||||
|
});
|
||||||
|
const track = aTrack();
|
||||||
|
|
||||||
|
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) });
|
||||||
|
|
||||||
|
musicService.hasArtists(artist);
|
||||||
|
musicService.hasTracks(track);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asking for media metadata for a tack", () => {
|
||||||
|
it("should return it with auth header", async () => {
|
||||||
|
const root = await ws.getMediaMetadataAsync({
|
||||||
|
id: `track:${track.id}`,
|
||||||
|
});
|
||||||
|
expect(root[0]).toEqual({
|
||||||
|
getMediaMetadataResult: {
|
||||||
|
itemType: "track",
|
||||||
|
id: `track:${track.id}`,
|
||||||
|
mimeType: track.mimeType,
|
||||||
|
title: track.name,
|
||||||
|
|
||||||
|
trackMetadata: {
|
||||||
|
album: track.album.name,
|
||||||
|
albumId: track.album.id,
|
||||||
|
albumArtist: track.artist.name,
|
||||||
|
albumArtistId: track.artist.id,
|
||||||
|
// albumArtURI
|
||||||
|
artist: track.artist.name,
|
||||||
|
artistId: track.artist.id,
|
||||||
|
duration: track.duration,
|
||||||
|
genre: track.album.genre,
|
||||||
|
// genreId
|
||||||
|
trackNumber: track.number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user