Ability to stream a track from navidrome

This commit is contained in:
simojenki
2021-03-11 19:27:50 +11:00
parent 081819f12b
commit 9f484d5c01
11 changed files with 1087 additions and 277 deletions

View File

@@ -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>;
} }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,
}) })

View File

@@ -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(),

View File

@@ -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,
}); });
}); });
}); });

View File

@@ -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")
}); });
} }

View File

@@ -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",
});
});
});
}); });
}); });

View File

@@ -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);

View File

@@ -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",
});
});
});
});
});
}); });

View File

@@ -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,
},
},
});
});
});
});
});
}); });
}); });