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 = {
|
||||
small: undefined,
|
||||
medium: undefined,
|
||||
large: undefined
|
||||
}
|
||||
large: undefined,
|
||||
};
|
||||
|
||||
export type Artist = ArtistSummary & {
|
||||
albums: AlbumSummary[];
|
||||
@@ -51,18 +51,17 @@ export type AlbumSummary = {
|
||||
genre: string | undefined;
|
||||
};
|
||||
|
||||
export type Album = AlbumSummary & {
|
||||
};
|
||||
export type Album = AlbumSummary & {};
|
||||
|
||||
export type Track = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
duration: string;
|
||||
number: string | undefined;
|
||||
duration: number;
|
||||
number: number | undefined;
|
||||
genre: string | undefined;
|
||||
album: AlbumSummary;
|
||||
artist: ArtistSummary
|
||||
artist: ArtistSummary;
|
||||
};
|
||||
|
||||
export type Paging = {
|
||||
@@ -106,6 +105,14 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||
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 asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
||||
@@ -124,5 +131,13 @@ export interface MusicLibrary {
|
||||
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
||||
album(id: string): Promise<Album>;
|
||||
tracks(albumId: string): Promise<Track[]>;
|
||||
track(trackId: string): Promise<Track>;
|
||||
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";
|
||||
import X2JS from "x2js";
|
||||
|
||||
import axios from "axios";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { Encryption } from "./encryption";
|
||||
import randomString from "./random_string";
|
||||
|
||||
@@ -51,7 +51,7 @@ export type album = {
|
||||
_name: string;
|
||||
_genre: string | undefined;
|
||||
_year: string | undefined;
|
||||
_coverArt: string;
|
||||
_coverArt: string | undefined;
|
||||
};
|
||||
|
||||
export type artistSummary = {
|
||||
@@ -124,11 +124,11 @@ export type song = {
|
||||
_title: string;
|
||||
_album: string;
|
||||
_artist: string;
|
||||
_track: string;
|
||||
_track: string | undefined;
|
||||
_genre: string;
|
||||
_coverArt: string;
|
||||
_created: "2004-11-08T23:36:11";
|
||||
_duration: string;
|
||||
_duration: string | undefined;
|
||||
_bitRate: "128";
|
||||
_suffix: "mp3";
|
||||
_contentType: string;
|
||||
@@ -138,15 +138,15 @@ export type song = {
|
||||
};
|
||||
|
||||
export type GetAlbumResponse = {
|
||||
album: {
|
||||
_id: string;
|
||||
_name: string;
|
||||
_genre: string;
|
||||
_year: string;
|
||||
album: album & {
|
||||
song: song[];
|
||||
};
|
||||
};
|
||||
|
||||
export type GetSongResponse = {
|
||||
song: song;
|
||||
};
|
||||
|
||||
export function isError(
|
||||
subsonicResponse: SubsonicResponse
|
||||
): subsonicResponse is SubsonicError {
|
||||
@@ -169,6 +169,28 @@ export type getAlbumListParams = {
|
||||
|
||||
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 {
|
||||
url: string;
|
||||
encryption: Encryption;
|
||||
@@ -178,11 +200,12 @@ export class Navidrome implements MusicService {
|
||||
this.encryption = encryption;
|
||||
}
|
||||
|
||||
get = async <T>(
|
||||
get = async (
|
||||
{ username, password }: Credentials,
|
||||
path: string,
|
||||
q: {} = {}
|
||||
): Promise<T> =>
|
||||
q: {} = {},
|
||||
config: AxiosRequestConfig | undefined = {}
|
||||
) =>
|
||||
axios
|
||||
.get(`${this.url}${path}`, {
|
||||
params: {
|
||||
@@ -192,7 +215,23 @@ export class Navidrome implements MusicService {
|
||||
v: "1.16.1",
|
||||
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((json) => json["subsonic-response"])
|
||||
.then((json) => {
|
||||
@@ -201,7 +240,7 @@ export class Navidrome implements MusicService {
|
||||
});
|
||||
|
||||
generateToken = async (credentials: Credentials) =>
|
||||
this.get(credentials, "/rest/ping.view")
|
||||
this.getJSON(credentials, "/rest/ping.view")
|
||||
.then(() => ({
|
||||
authToken: Buffer.from(
|
||||
JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials)))
|
||||
@@ -219,7 +258,7 @@ export class Navidrome implements MusicService {
|
||||
);
|
||||
|
||||
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((artists) =>
|
||||
artists.map((artist) => ({
|
||||
@@ -229,7 +268,7 @@ export class Navidrome implements MusicService {
|
||||
);
|
||||
|
||||
getArtistInfo = (credentials: Credentials, id: string): Promise<ArtistInfo> =>
|
||||
this.get<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", {
|
||||
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", {
|
||||
id,
|
||||
}).then((it) => ({
|
||||
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 = (
|
||||
credentials: Credentials,
|
||||
id: string
|
||||
): Promise<IdName & { albums: AlbumSummary[] }> =>
|
||||
this.get<GetArtistResponse>(credentials, "/rest/getArtist", {
|
||||
this.getJSON<GetArtistResponse>(credentials, "/rest/getArtist", {
|
||||
id,
|
||||
})
|
||||
.then((it) => it.artist)
|
||||
@@ -312,7 +361,7 @@ export class Navidrome implements MusicService {
|
||||
);
|
||||
|
||||
return navidrome
|
||||
.get<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
||||
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
||||
...p,
|
||||
size: MAX_ALBUM_LIST,
|
||||
offset: 0,
|
||||
@@ -333,24 +382,10 @@ export class Navidrome implements MusicService {
|
||||
}));
|
||||
},
|
||||
album: (id: string): Promise<Album> =>
|
||||
navidrome
|
||||
.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,
|
||||
// }))
|
||||
})),
|
||||
navidrome.getAlbum(credentials, id),
|
||||
genres: () =>
|
||||
navidrome
|
||||
.get<GenGenresResponse>(credentials, "/rest/getGenres")
|
||||
.getJSON<GenGenresResponse>(credentials, "/rest/getGenres")
|
||||
.then((it) =>
|
||||
pipe(
|
||||
it.genres.genre,
|
||||
@@ -360,29 +395,61 @@ export class Navidrome implements MusicService {
|
||||
),
|
||||
tracks: (albumId: string) =>
|
||||
navidrome
|
||||
.get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id: albumId })
|
||||
.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", {
|
||||
id: albumId,
|
||||
})
|
||||
.then((it) => it.album)
|
||||
.then((album) =>
|
||||
album.song.map((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,
|
||||
},
|
||||
}))
|
||||
album.song.map((song) => asTrack(asAlbum(album), song))
|
||||
),
|
||||
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);
|
||||
|
||||
@@ -113,6 +113,23 @@ function server(
|
||||
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) => {
|
||||
// console.log(`Trying to load image for ${req.params["albumId"]}, token ${JSON.stringify(req.cookies)}`)
|
||||
// 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;
|
||||
index: number;
|
||||
total: number;
|
||||
mediaCollection: MediaCollection[];
|
||||
mediaCollection: any[] | undefined;
|
||||
mediaMetadata: any[] | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -83,6 +84,27 @@ export function getMetadataResult({
|
||||
index,
|
||||
total,
|
||||
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(),
|
||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||
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 (
|
||||
{
|
||||
id,
|
||||
@@ -320,8 +407,8 @@ function bindSmapiSoapServiceToExpress(
|
||||
.tracks(typeId!)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map(track),
|
||||
getMetadataResult2({
|
||||
mediaMetadata: page.map(track),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
|
||||
@@ -92,8 +92,8 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
id,
|
||||
name: `Track ${id}`,
|
||||
mimeType: `audio/mp3-${id}`,
|
||||
duration: `${randomInt(500)}`,
|
||||
number: `${randomInt(100)}`,
|
||||
duration: randomInt(500),
|
||||
number: randomInt(100),
|
||||
genre: randomGenre(),
|
||||
artist: anArtist(),
|
||||
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", () => {
|
||||
const artist1Album1 = anAlbum();
|
||||
@@ -193,13 +166,26 @@ describe("InMemoryMusicService", () => {
|
||||
|
||||
describe("fetching tracks for an album", () => {
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
describe("with no filtering", () => {
|
||||
describe("fetching all on one page", () => {
|
||||
it("should return all the albums for all the artists", async () => {
|
||||
expect(
|
||||
await musicLibrary.albums({ _index: 0, _count: 100 })
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album2),
|
||||
albumToAlbumSummary(artist1_album3),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
describe("fetching multiple albums", () => {
|
||||
describe("with no filtering", () => {
|
||||
describe("fetching all on one page", () => {
|
||||
it("should return all the albums for all the artists", async () => {
|
||||
expect(
|
||||
await musicLibrary.albums({ _index: 0, _count: 100 })
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album2),
|
||||
albumToAlbumSummary(artist1_album3),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
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", () => {
|
||||
it("should return only that page", async () => {
|
||||
expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual(
|
||||
{
|
||||
|
||||
describe("filtering by genre", () => {
|
||||
describe("fetching all on one page", () => {
|
||||
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: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
}
|
||||
);
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching the last page", () => {
|
||||
it("should return only that page", async () => {
|
||||
|
||||
describe("when the genre has more albums than a single page", () => {
|
||||
describe("can fetch a single page", () => {
|
||||
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(
|
||||
await musicLibrary.albums({ _index: 6, _count: 100 })
|
||||
await musicLibrary.albums({
|
||||
genre: "genre with no albums",
|
||||
_index: 0,
|
||||
_count: 100,
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
results: [],
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtering by genre", () => {
|
||||
describe("fetching all on one page", () => {
|
||||
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: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
describe("fetching a single album", () => {
|
||||
describe("when it exists", () => {
|
||||
it("should provide an album", async () => {
|
||||
expect(await musicLibrary.album(artist1_album5.id)).toEqual(
|
||||
artist1_album5
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the genre has more albums than a single page", () => {
|
||||
describe("can fetch a single page", () => {
|
||||
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(
|
||||
await musicLibrary.albums({
|
||||
genre: "genre with no albums",
|
||||
_index: 0,
|
||||
_count: 100,
|
||||
})
|
||||
).toEqual({
|
||||
results: [],
|
||||
total: 0,
|
||||
|
||||
describe("when it doesnt exist", () => {
|
||||
it("should blow up", async () => {
|
||||
return expect(musicLibrary.album("-1")).rejects.toEqual(
|
||||
"No album with id '-1'"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export class InMemoryMusicService implements MusicService {
|
||||
this.users[username] == password
|
||||
) {
|
||||
return Promise.resolve({
|
||||
authToken: JSON.stringify({ username, password }),
|
||||
authToken: Buffer.from(JSON.stringify({ username, password })).toString('base64'),
|
||||
userId: username,
|
||||
nickname: username,
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export class InMemoryMusicService implements MusicService {
|
||||
}
|
||||
|
||||
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)
|
||||
return Promise.reject("Invalid auth token");
|
||||
|
||||
@@ -103,7 +103,17 @@ export class InMemoryMusicService implements MusicService {
|
||||
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 { v4 as uuid } from "uuid";
|
||||
|
||||
import { isDodgyImage, Navidrome, t } from "../src/navidrome";
|
||||
import encryption from "../src/encryption";
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
Track,
|
||||
AlbumSummary,
|
||||
artistToArtistSummary,
|
||||
NO_IMAGES
|
||||
NO_IMAGES,
|
||||
} from "../src/music_service";
|
||||
import { anAlbum, anArtist, aTrack } from "./builders";
|
||||
|
||||
@@ -70,7 +71,11 @@ const artistInfoXml = (
|
||||
</artistInfo>
|
||||
</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}"
|
||||
isDir="true"
|
||||
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"
|
||||
artistId="${artist.id}"
|
||||
songCount="19"
|
||||
isVideo="false">${tracks.map(track => songXml(track))}</album>`;
|
||||
isVideo="false">${tracks.map((track) => songXml(track))}</album>`;
|
||||
|
||||
const songXml = (track: Track) => `<song
|
||||
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)">
|
||||
<albumList>
|
||||
${albums.map(([artist, album]) =>
|
||||
albumXml(artist, album)
|
||||
)}
|
||||
albumXml(artist, album)
|
||||
)}
|
||||
</albumList>
|
||||
</subsonic-response>`;
|
||||
|
||||
const artistXml = (
|
||||
artist: Artist
|
||||
) => `<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
|
||||
}" artistImageUrl="....">
|
||||
<artist id="${artist.id}" name="${artist.name}" albumCount="${
|
||||
artist.albums.length
|
||||
}" artistImageUrl="....">
|
||||
${artist.albums.map((album) => albumXml(artist, album))}
|
||||
</artist>
|
||||
</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)">
|
||||
<genres>
|
||||
${genres.map(
|
||||
(it) =>
|
||||
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
||||
)}
|
||||
(it) =>
|
||||
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
||||
)}
|
||||
</genres>
|
||||
</subsonic-response>`;
|
||||
|
||||
const getAlbumXml = (artist: Artist, album: Album, tracks: Track[]) => `<subsonic-response status="ok" version="1.8.0">
|
||||
${albumXml(artist, album, tracks)}
|
||||
</subsonic-response>`
|
||||
const getAlbumXml = (
|
||||
artist: Artist,
|
||||
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>`;
|
||||
|
||||
@@ -169,6 +191,9 @@ describe("Navidrome", () => {
|
||||
v: "1.16.1",
|
||||
c: "bonob",
|
||||
};
|
||||
const headers = {
|
||||
"User-Agent": "bonob",
|
||||
};
|
||||
|
||||
describe("generateToken", () => {
|
||||
describe("when the credentials are valid", () => {
|
||||
@@ -186,6 +211,7 @@ describe("Navidrome", () => {
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, {
|
||||
params: authParams,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -226,6 +252,7 @@ describe("Navidrome", () => {
|
||||
params: {
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -268,6 +295,7 @@ describe("Navidrome", () => {
|
||||
id: artist.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
@@ -275,6 +303,7 @@ describe("Navidrome", () => {
|
||||
id: artist.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -377,30 +406,35 @@ describe("Navidrome", () => {
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
|
||||
params: authParams,
|
||||
headers,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
params: {
|
||||
id: artist1.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
params: {
|
||||
id: artist2.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
params: {
|
||||
id: artist3.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
params: {
|
||||
id: artist4.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -435,18 +469,21 @@ describe("Navidrome", () => {
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
|
||||
params: authParams,
|
||||
headers,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
params: {
|
||||
id: artist2.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
|
||||
params: {
|
||||
id: artist3.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -498,6 +535,7 @@ describe("Navidrome", () => {
|
||||
offset: 0,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -544,6 +582,7 @@ describe("Navidrome", () => {
|
||||
offset: 0,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -569,6 +608,7 @@ describe("Navidrome", () => {
|
||||
offset: 0,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -620,6 +660,7 @@ describe("Navidrome", () => {
|
||||
offset: 0,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -630,24 +671,20 @@ describe("Navidrome", () => {
|
||||
describe("when it exists", () => {
|
||||
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 }),
|
||||
]
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(
|
||||
ok(
|
||||
getAlbumXml(artist, album, tracks)
|
||||
)
|
||||
)
|
||||
Promise.resolve(ok(getAlbumXml(artist, album, tracks)))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -665,6 +702,7 @@ describe("Navidrome", () => {
|
||||
id: album.id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -675,49 +713,223 @@ describe("Navidrome", () => {
|
||||
describe("when it exists", () => {
|
||||
const album = anAlbum({ id: "album1", name: "Burnin" });
|
||||
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 = {
|
||||
...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 }),
|
||||
]
|
||||
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(
|
||||
ok(
|
||||
getAlbumXml(artist, album, tracks)
|
||||
)
|
||||
)
|
||||
Promise.resolve(ok(getAlbumXml(artist, album, tracks)))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it("should return the album", async () => {
|
||||
const result = await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.tracks(album.id));
|
||||
|
||||
|
||||
expect(result).toEqual(tracks);
|
||||
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, {
|
||||
params: {
|
||||
id: album.id,
|
||||
...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();
|
||||
while (next) {
|
||||
if (next != "root") {
|
||||
const childIds = this.currentMetadata!.getMetadataResult.mediaCollection.map(
|
||||
const childIds = this.currentMetadata!.getMetadataResult.mediaCollection!.map(
|
||||
(it) => it.id
|
||||
);
|
||||
if (!childIds.includes(next)) {
|
||||
@@ -56,7 +56,7 @@ class LoggedInSonosDriver {
|
||||
|
||||
expectTitles(titles: string[]) {
|
||||
expect(
|
||||
this.currentMetadata!.getMetadataResult.mediaCollection.map(
|
||||
this.currentMetadata!.getMetadataResult.mediaCollection!.map(
|
||||
(it) => it.title
|
||||
)
|
||||
).toEqual(titles);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import request from "supertest";
|
||||
import { MusicService } from "../src/music_service";
|
||||
import makeServer from "../src/server";
|
||||
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 { Client, createClientAsync } from "soap";
|
||||
import X2JS from "x2js";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
|
||||
import makeServer from "../src/server";
|
||||
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 {
|
||||
aService,
|
||||
@@ -276,7 +282,7 @@ describe("api", () => {
|
||||
);
|
||||
|
||||
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`, {
|
||||
endpoint: service.uri,
|
||||
httpClient: supersoap(server, rootUrl),
|
||||
@@ -295,7 +301,7 @@ describe("api", () => {
|
||||
});
|
||||
|
||||
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 password = "password1";
|
||||
musicService.hasUser({ username, password });
|
||||
@@ -640,11 +646,11 @@ describe("api", () => {
|
||||
albums: [album],
|
||||
});
|
||||
|
||||
const track1 = aTrack({ artist, album, number: "1" });
|
||||
const track2 = aTrack({ artist, album, number: "2" });
|
||||
const track3 = aTrack({ artist, album, number: "3" });
|
||||
const track4 = aTrack({ artist, album, number: "4" });
|
||||
const track5 = aTrack({ artist, album, number: "5" });
|
||||
const track1 = aTrack({ artist, album, number: 1 });
|
||||
const track2 = aTrack({ artist, album, number: 2 });
|
||||
const track3 = aTrack({ artist, album, number: 3 });
|
||||
const track4 = aTrack({ artist, album, number: 4 });
|
||||
const track5 = aTrack({ artist, album, number: 5 });
|
||||
|
||||
beforeEach(() => {
|
||||
musicService.hasArtists(artist);
|
||||
@@ -659,33 +665,29 @@ describe("api", () => {
|
||||
count: 100,
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [
|
||||
track1,
|
||||
track2,
|
||||
track3,
|
||||
track4,
|
||||
track5,
|
||||
].map((track) => ({
|
||||
itemType: "track",
|
||||
id: `track:${track.id}`,
|
||||
mimeType: track.mimeType,
|
||||
title: track.name,
|
||||
getMetadataResult2({
|
||||
mediaMetadata: [track1, track2, track3, track4, track5].map(
|
||||
(track) => ({
|
||||
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,
|
||||
},
|
||||
})),
|
||||
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,
|
||||
},
|
||||
})
|
||||
),
|
||||
index: 0,
|
||||
total: 5,
|
||||
})
|
||||
@@ -701,11 +703,8 @@ describe("api", () => {
|
||||
count: 2,
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [
|
||||
track3,
|
||||
track4,
|
||||
].map((track) => ({
|
||||
getMetadataResult2({
|
||||
mediaMetadata: [track3, track4].map((track) => ({
|
||||
itemType: "track",
|
||||
id: `track:${track.id}`,
|
||||
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