mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability for Navidrome to have custom client app per mime type, so can have custom transcoders per audio file type. Change stream to stream rather than buffer response in byte array
This commit is contained in:
@@ -61,6 +61,7 @@ BONOB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or omm
|
|||||||
BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
||||||
BONOB_SONOS_SERVICE_ID | 246 | service id for sonos
|
BONOB_SONOS_SERVICE_ID | 246 | service id for sonos
|
||||||
BONOB_NAVIDROME_URL | http://localhost:4533 | URL for navidrome
|
BONOB_NAVIDROME_URL | http://localhost:4533 | URL for navidrome
|
||||||
|
BONOB_STREAM_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
|
||||||
|
|
||||||
## Initialising service within sonos app
|
## Initialising service within sonos app
|
||||||
|
|
||||||
|
|||||||
21
src/app.ts
21
src/app.ts
@@ -1,7 +1,7 @@
|
|||||||
import sonos, { bonobService } from "./sonos";
|
import sonos, { bonobService } from "./sonos";
|
||||||
import server from "./server";
|
import server from "./server";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import { Navidrome } from "./navidrome";
|
import { DEFAULT, Navidrome, appendMimeTypeToClientFor } from "./navidrome";
|
||||||
import encryption from "./encryption";
|
import encryption from "./encryption";
|
||||||
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
|
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
|
||||||
import { InMemoryLinkCodes } from "./link_codes";
|
import { InMemoryLinkCodes } from "./link_codes";
|
||||||
@@ -23,21 +23,28 @@ const bonob = bonobService(
|
|||||||
const secret = process.env["BONOB_SECRET"] || "bonob";
|
const secret = process.env["BONOB_SECRET"] || "bonob";
|
||||||
|
|
||||||
const sonosSystem = sonos(SONOS_DEVICE_DISCOVERY, SONOS_SEED_HOST);
|
const sonosSystem = sonos(SONOS_DEVICE_DISCOVERY, SONOS_SEED_HOST);
|
||||||
if(process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
|
if (process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
|
||||||
sonosSystem.register(bonob).then(success => {
|
sonosSystem.register(bonob).then((success) => {
|
||||||
if(success) {
|
if (success) {
|
||||||
logger.info(`Successfully registered ${bonob.name}(SID:${bonob.sid}) with sonos`)
|
logger.info(
|
||||||
|
`Successfully registered ${bonob.name}(SID:${bonob.sid}) with sonos`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customClientsFor = process.env["BONOB_STREAM_CUSTOM_CLIENTS"] || "none";
|
||||||
|
const streamUserAgent =
|
||||||
|
customClientsFor == "none" ? DEFAULT : appendMimeTypeToClientFor(customClientsFor.split(","));
|
||||||
|
|
||||||
const app = server(
|
const app = server(
|
||||||
sonosSystem,
|
sonosSystem,
|
||||||
bonob,
|
bonob,
|
||||||
WEB_ADDRESS,
|
WEB_ADDRESS,
|
||||||
new Navidrome(
|
new Navidrome(
|
||||||
process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533",
|
process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533",
|
||||||
encryption(secret)
|
encryption(secret),
|
||||||
|
streamUserAgent
|
||||||
),
|
),
|
||||||
new InMemoryLinkCodes(),
|
new InMemoryLinkCodes(),
|
||||||
new InMemoryAccessTokens(sha256(secret))
|
new InMemoryAccessTokens(sha256(secret))
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
|||||||
|
|
||||||
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
||||||
|
|
||||||
export type Stream = {
|
export type TrackStream = {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<StreamingHeader, string>;
|
headers: Record<StreamingHeader, string>;
|
||||||
data: Buffer;
|
stream: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CoverArt = {
|
export type CoverArt = {
|
||||||
@@ -152,7 +152,7 @@ export interface MusicLibrary {
|
|||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
range: string | undefined;
|
range: string | undefined;
|
||||||
}): Promise<Stream>;
|
}): Promise<TrackStream>;
|
||||||
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
||||||
scrobble(id: string): Promise<boolean>
|
scrobble(id: string): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|||||||
124
src/navidrome.ts
124
src/navidrome.ts
@@ -16,8 +16,8 @@ import {
|
|||||||
MusicLibrary,
|
MusicLibrary,
|
||||||
Images,
|
Images,
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
NO_IMAGES,
|
|
||||||
Genre,
|
Genre,
|
||||||
|
Track,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import X2JS from "x2js";
|
import X2JS from "x2js";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@@ -199,7 +199,6 @@ const asTrack = (album: Album, song: song) => ({
|
|||||||
artist: {
|
artist: {
|
||||||
id: song._artistId,
|
id: song._artistId,
|
||||||
name: song._artist,
|
name: song._artist,
|
||||||
image: NO_IMAGES,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +209,10 @@ const asAlbum = (album: album) => ({
|
|||||||
genre: maybeAsGenre(album._genre),
|
genre: maybeAsGenre(album._genre),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const asGenre = (genreName: string) => ({ id: genreName, name: genreName });
|
export const asGenre = (genreName: string) => ({
|
||||||
|
id: genreName,
|
||||||
|
name: genreName,
|
||||||
|
});
|
||||||
|
|
||||||
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||||
pipe(
|
pipe(
|
||||||
@@ -219,13 +221,32 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
|||||||
O.getOrElseW(() => undefined)
|
O.getOrElseW(() => undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type StreamClientApplication = (track: Track) => string;
|
||||||
|
|
||||||
|
export const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||||
|
export const USER_AGENT = "bonob";
|
||||||
|
|
||||||
|
export const DEFAULT: StreamClientApplication = (_: Track) =>
|
||||||
|
DEFAULT_CLIENT_APPLICATION;
|
||||||
|
|
||||||
|
export function appendMimeTypeToClientFor(mimeTypes: string[]) {
|
||||||
|
return (track: Track) =>
|
||||||
|
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
|
||||||
|
}
|
||||||
|
|
||||||
export class Navidrome implements MusicService {
|
export class Navidrome implements MusicService {
|
||||||
url: string;
|
url: string;
|
||||||
encryption: Encryption;
|
encryption: Encryption;
|
||||||
|
streamClientApplication: StreamClientApplication;
|
||||||
|
|
||||||
constructor(url: string, encryption: Encryption) {
|
constructor(
|
||||||
|
url: string,
|
||||||
|
encryption: Encryption,
|
||||||
|
streamClientApplication: StreamClientApplication = DEFAULT
|
||||||
|
) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.encryption = encryption;
|
this.encryption = encryption;
|
||||||
|
this.streamClientApplication = streamClientApplication;
|
||||||
}
|
}
|
||||||
|
|
||||||
get = async (
|
get = async (
|
||||||
@@ -237,14 +258,14 @@ export class Navidrome implements MusicService {
|
|||||||
axios
|
axios
|
||||||
.get(`${this.url}${path}`, {
|
.get(`${this.url}${path}`, {
|
||||||
params: {
|
params: {
|
||||||
...q,
|
|
||||||
u: username,
|
u: username,
|
||||||
...t_and_s(password),
|
|
||||||
v: "1.16.1",
|
v: "1.16.1",
|
||||||
c: "bonob",
|
c: DEFAULT_CLIENT_APPLICATION,
|
||||||
|
...t_and_s(password),
|
||||||
|
...q,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "bonob",
|
"User-Agent": USER_AGENT,
|
||||||
},
|
},
|
||||||
...config,
|
...config,
|
||||||
})
|
})
|
||||||
@@ -373,6 +394,17 @@ export class Navidrome implements MusicService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
getTrack = (credentials: Credentials, id: string) =>
|
||||||
|
this.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((it) => it.song)
|
||||||
|
.then((song) =>
|
||||||
|
this.getAlbum(credentials, song._albumId).then((album) =>
|
||||||
|
asTrack(album, song)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async login(token: string) {
|
async login(token: string) {
|
||||||
const navidrome = this;
|
const navidrome = this;
|
||||||
const credentials: Credentials = this.parseToken(token);
|
const credentials: Credentials = this.parseToken(token);
|
||||||
@@ -391,7 +423,7 @@ export class Navidrome implements MusicService {
|
|||||||
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||||
navidrome
|
navidrome
|
||||||
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
||||||
...pick(q, 'type', 'genre'),
|
...pick(q, "type", "genre"),
|
||||||
size: Math.min(MAX_ALBUM_LIST, q._count),
|
size: Math.min(MAX_ALBUM_LIST, q._count),
|
||||||
offset: q._index,
|
offset: q._index,
|
||||||
})
|
})
|
||||||
@@ -431,17 +463,7 @@ export class Navidrome implements MusicService {
|
|||||||
.then((album) =>
|
.then((album) =>
|
||||||
(album.song || []).map((song) => asTrack(asAlbum(album), song))
|
(album.song || []).map((song) => asTrack(asAlbum(album), song))
|
||||||
),
|
),
|
||||||
track: (trackId: string) =>
|
track: (trackId: string) => navidrome.getTrack(credentials, trackId),
|
||||||
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 ({
|
stream: async ({
|
||||||
trackId,
|
trackId,
|
||||||
range,
|
range,
|
||||||
@@ -449,36 +471,38 @@ export class Navidrome implements MusicService {
|
|||||||
trackId: string;
|
trackId: string;
|
||||||
range: string | undefined;
|
range: string | undefined;
|
||||||
}) =>
|
}) =>
|
||||||
navidrome
|
navidrome.getTrack(credentials, trackId).then((track) =>
|
||||||
.get(
|
navidrome
|
||||||
credentials,
|
.get(
|
||||||
`/rest/stream`,
|
credentials,
|
||||||
{ id: trackId },
|
`/rest/stream`,
|
||||||
{
|
{ id: trackId, c: this.streamClientApplication(track) },
|
||||||
headers: pipe(
|
{
|
||||||
range,
|
headers: pipe(
|
||||||
O.fromNullable,
|
range,
|
||||||
O.map((range) => ({
|
O.fromNullable,
|
||||||
"User-Agent": "bonob",
|
O.map((range) => ({
|
||||||
Range: range,
|
"User-Agent": USER_AGENT,
|
||||||
})),
|
Range: range,
|
||||||
O.getOrElse(() => ({
|
})),
|
||||||
"User-Agent": "bonob",
|
O.getOrElse(() => ({
|
||||||
}))
|
"User-Agent": USER_AGENT,
|
||||||
),
|
}))
|
||||||
responseType: "arraybuffer",
|
),
|
||||||
}
|
responseType: "stream",
|
||||||
)
|
}
|
||||||
.then((res) => ({
|
)
|
||||||
status: res.status,
|
.then((res) => ({
|
||||||
headers: {
|
status: res.status,
|
||||||
"content-type": res.headers["content-type"],
|
headers: {
|
||||||
"content-length": res.headers["content-length"],
|
"content-type": res.headers["content-type"],
|
||||||
"content-range": res.headers["content-range"],
|
"content-length": res.headers["content-length"],
|
||||||
"accept-ranges": res.headers["accept-ranges"],
|
"content-range": res.headers["content-range"],
|
||||||
},
|
"accept-ranges": res.headers["accept-ranges"],
|
||||||
data: Buffer.from(res.data, "binary"),
|
},
|
||||||
})),
|
stream: res.data,
|
||||||
|
}))
|
||||||
|
),
|
||||||
coverArt: async (id: string, type: "album" | "artist", size?: number) => {
|
coverArt: async (id: string, type: "album" | "artist", size?: number) => {
|
||||||
if (type == "album") {
|
if (type == "album") {
|
||||||
return navidrome.getCoverArt(credentials, id, size).then((res) => ({
|
return navidrome.getCoverArt(credentials, id, size).then((res) => ({
|
||||||
|
|||||||
@@ -141,19 +141,19 @@ function server(
|
|||||||
it.scrobble(id).then((scrobbleSuccess) => {
|
it.scrobble(id).then((scrobbleSuccess) => {
|
||||||
if (scrobbleSuccess) logger.info(`Scrobbled ${id}`);
|
if (scrobbleSuccess) logger.info(`Scrobbled ${id}`);
|
||||||
else logger.warn(`Failed to scrobble ${id}....`);
|
else logger.warn(`Failed to scrobble ${id}....`);
|
||||||
|
|
||||||
return it;
|
return it;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.then((it) =>
|
.then((it) =>
|
||||||
it.stream({ trackId: id, range: req.headers["range"] || undefined })
|
it.stream({ trackId: id, range: req.headers["range"] || undefined })
|
||||||
)
|
)
|
||||||
.then((stream) => {
|
.then((trackStream) => {
|
||||||
res.status(stream.status);
|
res.status(trackStream.status);
|
||||||
Object.entries(stream.headers)
|
Object.entries(trackStream.headers)
|
||||||
.filter(([_, v]) => v !== undefined)
|
.filter(([_, v]) => v !== undefined)
|
||||||
.forEach(([header, value]) => res.setHeader(header, value));
|
.forEach(([header, value]) => res.setHeader(header, value));
|
||||||
res.send(stream.data);
|
|
||||||
|
trackStream.stream.pipe(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
|
|||||||
import { Credentials } from "../src/smapi";
|
import { Credentials } from "../src/smapi";
|
||||||
|
|
||||||
import { Service, Device } from "../src/sonos";
|
import { Service, Device } from "../src/sonos";
|
||||||
import { Album, Artist, Track } from "../src/music_service";
|
import { Album, Artist, Track, albumToAlbumSummary, artistToArtistSummary } from "../src/music_service";
|
||||||
|
|
||||||
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
||||||
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||||
@@ -110,8 +110,8 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
|||||||
duration: randomInt(500),
|
duration: randomInt(500),
|
||||||
number: randomInt(100),
|
number: randomInt(100),
|
||||||
genre: randomGenre(),
|
genre: randomGenre(),
|
||||||
artist: anArtist(),
|
artist: artistToArtistSummary(anArtist()),
|
||||||
album: anAlbum(),
|
album: albumToAlbumSummary(anAlbum()),
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
BROWSER_HEADERS,
|
BROWSER_HEADERS,
|
||||||
DODGY_IMAGE_NAME,
|
DODGY_IMAGE_NAME,
|
||||||
asGenre,
|
asGenre,
|
||||||
|
appendMimeTypeToClientFor
|
||||||
} from "../src/navidrome";
|
} from "../src/navidrome";
|
||||||
import encryption from "../src/encryption";
|
import encryption from "../src/encryption";
|
||||||
|
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
NO_IMAGES,
|
|
||||||
AlbumQuery,
|
AlbumQuery,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
import { anAlbum, anArtist, aTrack } from "./builders";
|
import { anAlbum, anArtist, aTrack } from "./builders";
|
||||||
@@ -64,6 +64,31 @@ describe("isDodgyImage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("appendMimeTypeToUserAgentFor", () => {
|
||||||
|
describe("when empty array", () => {
|
||||||
|
it("should return bonob", () => {
|
||||||
|
expect(appendMimeTypeToClientFor([])(aTrack())).toEqual("bonob");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when contains some mimeTypes", () => {
|
||||||
|
const streamUserAgent = appendMimeTypeToClientFor(["audio/flac", "audio/ogg"])
|
||||||
|
|
||||||
|
describe("and the track mimeType is in the array", () => {
|
||||||
|
it("should return bonob+mimeType", () => {
|
||||||
|
expect(streamUserAgent(aTrack({ mimeType: "audio/flac"}))).toEqual("bonob+audio/flac")
|
||||||
|
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg"}))).toEqual("bonob+audio/ogg")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("and the track mimeType is not in the array", () => {
|
||||||
|
it("should return bonob", () => {
|
||||||
|
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3"}))).toEqual("bonob")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const ok = (data: string) => ({
|
const ok = (data: string) => ({
|
||||||
status: 200,
|
status: 200,
|
||||||
data,
|
data,
|
||||||
@@ -188,7 +213,8 @@ describe("Navidrome", () => {
|
|||||||
const password = "pass1";
|
const password = "pass1";
|
||||||
const salt = "saltysalty";
|
const salt = "saltysalty";
|
||||||
|
|
||||||
const navidrome = new Navidrome(url, encryption("secret"));
|
const streamClientApplication = jest.fn();
|
||||||
|
const navidrome = new Navidrome(url, encryption("secret"), streamClientApplication);
|
||||||
|
|
||||||
const mockedRandomString = (randomString as unknown) as jest.Mock;
|
const mockedRandomString = (randomString as unknown) as jest.Mock;
|
||||||
const mockGET = jest.fn();
|
const mockGET = jest.fn();
|
||||||
@@ -1312,10 +1338,7 @@ describe("Navidrome", () => {
|
|||||||
name: "Bob Marley",
|
name: "Bob Marley",
|
||||||
albums: [album],
|
albums: [album],
|
||||||
});
|
});
|
||||||
const artistSummary = {
|
const artistSummary = artistToArtistSummary(artist);
|
||||||
...artistToArtistSummary(artist),
|
|
||||||
image: NO_IMAGES,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tracks = [
|
const tracks = [
|
||||||
aTrack({ artist: artistSummary, album: albumSummary, genre: hipHop }),
|
aTrack({ artist: artistSummary, album: albumSummary, genre: hipHop }),
|
||||||
@@ -1374,10 +1397,7 @@ describe("Navidrome", () => {
|
|||||||
name: "Bob Marley",
|
name: "Bob Marley",
|
||||||
albums: [album],
|
albums: [album],
|
||||||
});
|
});
|
||||||
const artistSummary = {
|
const artistSummary = artistToArtistSummary(artist);
|
||||||
...artistToArtistSummary(artist),
|
|
||||||
image: NO_IMAGES,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tracks = [
|
const tracks = [
|
||||||
aTrack({
|
aTrack({
|
||||||
@@ -1464,10 +1484,7 @@ describe("Navidrome", () => {
|
|||||||
name: "Bob Marley",
|
name: "Bob Marley",
|
||||||
albums: [album],
|
albums: [album],
|
||||||
});
|
});
|
||||||
const artistSummary = {
|
const artistSummary = artistToArtistSummary(artist);
|
||||||
...artistToArtistSummary(artist),
|
|
||||||
image: NO_IMAGES,
|
|
||||||
};
|
|
||||||
|
|
||||||
const track = aTrack({
|
const track = aTrack({
|
||||||
artist: artistSummary,
|
artist: artistSummary,
|
||||||
@@ -1514,85 +1531,47 @@ describe("Navidrome", () => {
|
|||||||
|
|
||||||
describe("streaming a track", () => {
|
describe("streaming a track", () => {
|
||||||
const trackId = uuid();
|
const trackId = uuid();
|
||||||
|
const genre = { id: "foo", name: "foo" };
|
||||||
|
|
||||||
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
|
const album = anAlbum({ genre });
|
||||||
it("should return undefined values", async () => {
|
const artist = anArtist({
|
||||||
const streamResponse = {
|
albums: [album],
|
||||||
status: 200,
|
image: { large: "foo", medium: undefined, small: undefined },
|
||||||
headers: {
|
});
|
||||||
"content-type": "audio/mpeg",
|
const track = aTrack({
|
||||||
},
|
id: trackId,
|
||||||
data: Buffer.from("the track", "ascii"),
|
album: albumToAlbumSummary(album),
|
||||||
};
|
artist: artistToArtistSummary(artist),
|
||||||
|
genre,
|
||||||
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": undefined,
|
|
||||||
"content-range": undefined,
|
|
||||||
"accept-ranges": undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
|
describe("content-range, accept-ranges or content-length", () => {
|
||||||
it("should return undefined values", async () => {
|
beforeEach(() => {
|
||||||
const streamResponse = {
|
streamClientApplication.mockReturnValue("bonob");
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "audio/mpeg",
|
|
||||||
"content-length": undefined,
|
|
||||||
"content-range": undefined,
|
|
||||||
"accept-ranges": undefined,
|
|
||||||
},
|
|
||||||
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": undefined,
|
|
||||||
"content-range": undefined,
|
|
||||||
"accept-ranges": undefined,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("with no range specified", () => {
|
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
|
||||||
describe("navidrome returns a 200", () => {
|
it("should return undefined values", async () => {
|
||||||
it("should return the content", async () => {
|
const stream = {
|
||||||
|
pipe: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
const streamResponse = {
|
const streamResponse = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mpeg",
|
"content-type": "audio/mpeg",
|
||||||
"content-length": "1667",
|
|
||||||
"content-range": "-200",
|
|
||||||
"accept-ranges": "bytes",
|
|
||||||
"some-other-header": "some-value",
|
|
||||||
},
|
},
|
||||||
data: Buffer.from("the track", "ascii"),
|
data: stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
mockGET
|
mockGET
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
)
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
const result = await navidrome
|
const result = await navidrome
|
||||||
@@ -1603,11 +1582,183 @@ describe("Navidrome", () => {
|
|||||||
|
|
||||||
expect(result.headers).toEqual({
|
expect(result.headers).toEqual({
|
||||||
"content-type": "audio/mpeg",
|
"content-type": "audio/mpeg",
|
||||||
"content-length": "1667",
|
"content-length": undefined,
|
||||||
"content-range": "-200",
|
"content-range": undefined,
|
||||||
"accept-ranges": "bytes",
|
"accept-ranges": undefined,
|
||||||
});
|
});
|
||||||
expect(result.data.toString()).toEqual("the track");
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
|
||||||
|
it("should return undefined values", async () => {
|
||||||
|
const stream = {
|
||||||
|
pipe: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamResponse = {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/mpeg",
|
||||||
|
"content-length": undefined,
|
||||||
|
"content-range": undefined,
|
||||||
|
"accept-ranges": undefined,
|
||||||
|
},
|
||||||
|
data: stream,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
)
|
||||||
|
.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": undefined,
|
||||||
|
"content-range": undefined,
|
||||||
|
"accept-ranges": undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with no range specified", () => {
|
||||||
|
describe("navidrome returns a 200", () => {
|
||||||
|
it("should return the content", async () => {
|
||||||
|
const stream = {
|
||||||
|
pipe: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamResponse = {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "audio/mpeg",
|
||||||
|
"content-length": "1667",
|
||||||
|
"content-range": "-200",
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"some-other-header": "some-value",
|
||||||
|
},
|
||||||
|
data: stream,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
)
|
||||||
|
.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.stream).toEqual(stream);
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
|
params: {
|
||||||
|
id: trackId,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
},
|
||||||
|
responseType: "stream",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
)
|
||||||
|
.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 stream = {
|
||||||
|
pipe: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
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: stream,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getSongXml(track)))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [])))
|
||||||
|
)
|
||||||
|
.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.stream).toEqual(stream);
|
||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
params: {
|
params: {
|
||||||
@@ -1616,79 +1767,98 @@ describe("Navidrome", () => {
|
|||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "bonob",
|
"User-Agent": "bonob",
|
||||||
|
Range: range,
|
||||||
},
|
},
|
||||||
responseType: "arraybuffer",
|
responseType: "stream",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
describe("when navidrome has a custom StreamClientApplication registered", () => {
|
||||||
it("should send the range to navidrome", async () => {
|
describe("when no range specified", () => {
|
||||||
const range = "1000-2000";
|
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
||||||
const streamResponse = {
|
const clientApplication = `bonob-${uuid()}`;
|
||||||
status: 200,
|
streamClientApplication.mockReturnValue(clientApplication);
|
||||||
headers: {
|
|
||||||
"content-type": "audio/flac",
|
const streamResponse = {
|
||||||
"content-length": "66",
|
status: 200,
|
||||||
"content-range": "100-200",
|
headers: {
|
||||||
"accept-ranges": "none",
|
"content-type": "audio/mpeg",
|
||||||
"some-other-header": "some-value",
|
},
|
||||||
},
|
data: Buffer.from("the track", "ascii"),
|
||||||
data: Buffer.from("the track", "ascii"),
|
};
|
||||||
};
|
|
||||||
|
mockGET
|
||||||
mockGET
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
|
||||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
|
||||||
const result = await navidrome
|
)
|
||||||
.generateToken({ username, password })
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
.then((it) => it as AuthSuccess)
|
|
||||||
.then((it) => navidrome.login(it.authToken))
|
await navidrome
|
||||||
.then((it) => it.stream({ trackId, range }));
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
expect(result.headers).toEqual({
|
.then((it) => navidrome.login(it.authToken))
|
||||||
"content-type": "audio/flac",
|
.then((it) => it.stream({ trackId, range: undefined }));
|
||||||
"content-length": "66",
|
|
||||||
"content-range": "100-200",
|
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
||||||
"accept-ranges": "none",
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
|
params: {
|
||||||
|
id: trackId,
|
||||||
|
...authParams,
|
||||||
|
c: clientApplication
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
},
|
||||||
|
responseType: "stream",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
expect(result.data.toString()).toEqual("the track");
|
});
|
||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
describe("when range specified", () => {
|
||||||
params: {
|
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
||||||
id: trackId,
|
const range = "1000-2000";
|
||||||
...authParams,
|
const clientApplication = `bonob-${uuid()}`;
|
||||||
},
|
streamClientApplication.mockReturnValue(clientApplication);
|
||||||
headers: {
|
|
||||||
"User-Agent": "bonob",
|
const streamResponse = {
|
||||||
Range: range,
|
status: 200,
|
||||||
},
|
headers: {
|
||||||
responseType: "arraybuffer",
|
"content-type": "audio/mpeg",
|
||||||
|
},
|
||||||
|
data: Buffer.from("the track", "ascii"),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||||
|
|
||||||
|
await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.stream({ trackId, range }));
|
||||||
|
|
||||||
|
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
|
||||||
|
params: {
|
||||||
|
id: trackId,
|
||||||
|
...authParams,
|
||||||
|
c: clientApplication
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "bonob",
|
||||||
|
Range: range,
|
||||||
|
},
|
||||||
|
responseType: "stream",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { aDevice, aService } from "./builders";
|
|||||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||||
import { ExpiringAccessTokens } from "../src/access_tokens";
|
import { ExpiringAccessTokens } from "../src/access_tokens";
|
||||||
import { InMemoryLinkCodes } from "../src/link_codes";
|
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
describe("server", () => {
|
describe("server", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -243,46 +244,50 @@ describe("server", () => {
|
|||||||
describe("scrobbling", () => {
|
describe("scrobbling", () => {
|
||||||
describe("when scrobbling succeeds", () => {
|
describe("when scrobbling succeeds", () => {
|
||||||
it("should scrobble the track", async () => {
|
it("should scrobble the track", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mp3",
|
"content-type": "audio/mp3",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when scrobbling succeeds", () => {
|
describe("when scrobbling succeeds", () => {
|
||||||
it("should still return the track", async () => {
|
it("should still return the track", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mp3",
|
"content-type": "audio/mp3",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(false);
|
musicLibrary.scrobble.mockResolvedValue(false);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -291,25 +296,26 @@ describe("server", () => {
|
|||||||
describe("when sonos does not ask for a range", () => {
|
describe("when sonos does not ask for a range", () => {
|
||||||
describe("when the music service does not return a content-range, content-length or accept-ranges", () => {
|
describe("when the music service does not return a content-range, content-length or accept-ranges", () => {
|
||||||
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mp3",
|
"content-type": "audio/mp3",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(res.headers["content-type"]).toEqual("audio/mp3")
|
expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
|
||||||
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
|
|
||||||
expect(Object.keys(res.headers)).not.toContain("content-range")
|
expect(Object.keys(res.headers)).not.toContain("content-range")
|
||||||
expect(Object.keys(res.headers)).not.toContain("accept-ranges")
|
expect(Object.keys(res.headers)).not.toContain("accept-ranges")
|
||||||
});
|
});
|
||||||
@@ -317,7 +323,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => {
|
describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => {
|
||||||
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mp3",
|
"content-type": "audio/mp3",
|
||||||
@@ -325,20 +331,21 @@ describe("server", () => {
|
|||||||
"accept-ranges": undefined,
|
"accept-ranges": undefined,
|
||||||
"content-range": undefined,
|
"content-range": undefined,
|
||||||
},
|
},
|
||||||
data: Buffer.from("some track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(res.headers["content-type"]).toEqual("audio/mp3")
|
expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
|
||||||
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
|
|
||||||
expect(Object.keys(res.headers)).not.toContain("content-range")
|
expect(Object.keys(res.headers)).not.toContain("content-range")
|
||||||
expect(Object.keys(res.headers)).not.toContain("accept-ranges")
|
expect(Object.keys(res.headers)).not.toContain("accept-ranges")
|
||||||
});
|
});
|
||||||
@@ -346,7 +353,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when the music service returns a 200", () => {
|
describe("when the music service returns a 200", () => {
|
||||||
it("should return a 200 with the data", async () => {
|
it("should return a 200 with the data", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mp3",
|
"content-type": "audio/mp3",
|
||||||
@@ -354,26 +361,31 @@ describe("server", () => {
|
|||||||
"accept-ranges": "bytes",
|
"accept-ranges": "bytes",
|
||||||
"content-range": "-100",
|
"content-range": "-100",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => {
|
||||||
|
console.log("calling send on response")
|
||||||
|
res.send("")
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
stream.headers["content-type"]
|
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||||
);
|
);
|
||||||
expect(res.header["accept-ranges"]).toEqual(
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
stream.headers["accept-ranges"]
|
trackStream.headers["accept-ranges"]
|
||||||
);
|
);
|
||||||
expect(res.header["content-range"]).toEqual(
|
expect(res.header["content-range"]).toEqual(
|
||||||
stream.headers["content-range"]
|
trackStream.headers["content-range"]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
@@ -383,7 +395,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when the music service returns a 206", () => {
|
describe("when the music service returns a 206", () => {
|
||||||
it("should return a 206 with the data", async () => {
|
it("should return a 206 with the data", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/ogg",
|
"content-type": "audio/ogg",
|
||||||
@@ -391,26 +403,28 @@ describe("server", () => {
|
|||||||
"accept-ranges": "bytez",
|
"accept-ranges": "bytez",
|
||||||
"content-range": "100-200",
|
"content-range": "100-200",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some other track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
stream.headers["content-type"]
|
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||||
);
|
);
|
||||||
expect(res.header["accept-ranges"]).toEqual(
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
stream.headers["accept-ranges"]
|
trackStream.headers["accept-ranges"]
|
||||||
);
|
);
|
||||||
expect(res.header["content-range"]).toEqual(
|
expect(res.header["content-range"]).toEqual(
|
||||||
stream.headers["content-range"]
|
trackStream.headers["content-range"]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
@@ -422,7 +436,7 @@ describe("server", () => {
|
|||||||
describe("when sonos does ask for a range", () => {
|
describe("when sonos does ask for a range", () => {
|
||||||
describe("when the music service returns a 200", () => {
|
describe("when the music service returns a 200", () => {
|
||||||
it("should return a 200 with the data", async () => {
|
it("should return a 200 with the data", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/mp3",
|
"content-type": "audio/mp3",
|
||||||
@@ -430,11 +444,13 @@ describe("server", () => {
|
|||||||
"accept-ranges": "bytes",
|
"accept-ranges": "bytes",
|
||||||
"content-range": "-100",
|
"content-range": "-100",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
@@ -442,15 +458,15 @@ describe("server", () => {
|
|||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
||||||
.set("Range", "3000-4000");
|
.set("Range", "3000-4000");
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
stream.headers["content-type"]
|
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||||
);
|
);
|
||||||
expect(res.header["accept-ranges"]).toEqual(
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
stream.headers["accept-ranges"]
|
trackStream.headers["accept-ranges"]
|
||||||
);
|
);
|
||||||
expect(res.header["content-range"]).toEqual(
|
expect(res.header["content-range"]).toEqual(
|
||||||
stream.headers["content-range"]
|
trackStream.headers["content-range"]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
@@ -463,7 +479,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when the music service returns a 206", () => {
|
describe("when the music service returns a 206", () => {
|
||||||
it("should return a 206 with the data", async () => {
|
it("should return a 206 with the data", async () => {
|
||||||
const stream = {
|
const trackStream = {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "audio/ogg",
|
"content-type": "audio/ogg",
|
||||||
@@ -471,11 +487,13 @@ describe("server", () => {
|
|||||||
"accept-ranges": "bytez",
|
"accept-ranges": "bytez",
|
||||||
"content-range": "100-200",
|
"content-range": "100-200",
|
||||||
},
|
},
|
||||||
data: Buffer.from("some other track", "ascii"),
|
stream: {
|
||||||
|
pipe: (res: Response) => res.send("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.stream.mockResolvedValue(stream);
|
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
@@ -483,15 +501,15 @@ describe("server", () => {
|
|||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
||||||
.set("Range", "4000-5000");
|
.set("Range", "4000-5000");
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(trackStream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
stream.headers["content-type"]
|
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||||
);
|
);
|
||||||
expect(res.header["accept-ranges"]).toEqual(
|
expect(res.header["accept-ranges"]).toEqual(
|
||||||
stream.headers["accept-ranges"]
|
trackStream.headers["accept-ranges"]
|
||||||
);
|
);
|
||||||
expect(res.header["content-range"]).toEqual(
|
expect(res.header["content-range"]).toEqual(
|
||||||
stream.headers["content-range"]
|
trackStream.headers["content-range"]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
|||||||
Reference in New Issue
Block a user