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_ID | 246 | service id for sonos
|
||||
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
|
||||
|
||||
|
||||
21
src/app.ts
21
src/app.ts
@@ -1,7 +1,7 @@
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import server from "./server";
|
||||
import logger from "./logger";
|
||||
import { Navidrome } from "./navidrome";
|
||||
import { DEFAULT, Navidrome, appendMimeTypeToClientFor } from "./navidrome";
|
||||
import encryption from "./encryption";
|
||||
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
|
||||
import { InMemoryLinkCodes } from "./link_codes";
|
||||
@@ -23,21 +23,28 @@ const bonob = bonobService(
|
||||
const secret = process.env["BONOB_SECRET"] || "bonob";
|
||||
|
||||
const sonosSystem = sonos(SONOS_DEVICE_DISCOVERY, SONOS_SEED_HOST);
|
||||
if(process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
|
||||
sonosSystem.register(bonob).then(success => {
|
||||
if(success) {
|
||||
logger.info(`Successfully registered ${bonob.name}(SID:${bonob.sid}) with sonos`)
|
||||
if (process.env["BONOB_SONOS_AUTO_REGISTER"] == "true") {
|
||||
sonosSystem.register(bonob).then((success) => {
|
||||
if (success) {
|
||||
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(
|
||||
sonosSystem,
|
||||
bonob,
|
||||
WEB_ADDRESS,
|
||||
new Navidrome(
|
||||
process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533",
|
||||
encryption(secret)
|
||||
encryption(secret),
|
||||
streamUserAgent
|
||||
),
|
||||
new InMemoryLinkCodes(),
|
||||
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 Stream = {
|
||||
export type TrackStream = {
|
||||
status: number;
|
||||
headers: Record<StreamingHeader, string>;
|
||||
data: Buffer;
|
||||
stream: any;
|
||||
};
|
||||
|
||||
export type CoverArt = {
|
||||
@@ -152,7 +152,7 @@ export interface MusicLibrary {
|
||||
}: {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}): Promise<Stream>;
|
||||
}): Promise<TrackStream>;
|
||||
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
||||
scrobble(id: string): Promise<boolean>
|
||||
}
|
||||
|
||||
124
src/navidrome.ts
124
src/navidrome.ts
@@ -16,8 +16,8 @@ import {
|
||||
MusicLibrary,
|
||||
Images,
|
||||
AlbumSummary,
|
||||
NO_IMAGES,
|
||||
Genre,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
import X2JS from "x2js";
|
||||
import sharp from "sharp";
|
||||
@@ -199,7 +199,6 @@ const asTrack = (album: Album, song: song) => ({
|
||||
artist: {
|
||||
id: song._artistId,
|
||||
name: song._artist,
|
||||
image: NO_IMAGES,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,7 +209,10 @@ const asAlbum = (album: album) => ({
|
||||
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 =>
|
||||
pipe(
|
||||
@@ -219,13 +221,32 @@ const maybeAsGenre = (genreName: string | undefined): Genre | 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 {
|
||||
url: string;
|
||||
encryption: Encryption;
|
||||
streamClientApplication: StreamClientApplication;
|
||||
|
||||
constructor(url: string, encryption: Encryption) {
|
||||
constructor(
|
||||
url: string,
|
||||
encryption: Encryption,
|
||||
streamClientApplication: StreamClientApplication = DEFAULT
|
||||
) {
|
||||
this.url = url;
|
||||
this.encryption = encryption;
|
||||
this.streamClientApplication = streamClientApplication;
|
||||
}
|
||||
|
||||
get = async (
|
||||
@@ -237,14 +258,14 @@ export class Navidrome implements MusicService {
|
||||
axios
|
||||
.get(`${this.url}${path}`, {
|
||||
params: {
|
||||
...q,
|
||||
u: username,
|
||||
...t_and_s(password),
|
||||
v: "1.16.1",
|
||||
c: "bonob",
|
||||
c: DEFAULT_CLIENT_APPLICATION,
|
||||
...t_and_s(password),
|
||||
...q,
|
||||
},
|
||||
headers: {
|
||||
"User-Agent": "bonob",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
...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) {
|
||||
const navidrome = this;
|
||||
const credentials: Credentials = this.parseToken(token);
|
||||
@@ -391,7 +423,7 @@ export class Navidrome implements MusicService {
|
||||
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||
navidrome
|
||||
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
||||
...pick(q, 'type', 'genre'),
|
||||
...pick(q, "type", "genre"),
|
||||
size: Math.min(MAX_ALBUM_LIST, q._count),
|
||||
offset: q._index,
|
||||
})
|
||||
@@ -431,17 +463,7 @@ export class Navidrome implements MusicService {
|
||||
.then((album) =>
|
||||
(album.song || []).map((song) => asTrack(asAlbum(album), song))
|
||||
),
|
||||
track: (trackId: string) =>
|
||||
navidrome
|
||||
.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
|
||||
id: trackId,
|
||||
})
|
||||
.then((it) => it.song)
|
||||
.then((song) =>
|
||||
navidrome
|
||||
.getAlbum(credentials, song._albumId)
|
||||
.then((album) => asTrack(album, song))
|
||||
),
|
||||
track: (trackId: string) => navidrome.getTrack(credentials, trackId),
|
||||
stream: async ({
|
||||
trackId,
|
||||
range,
|
||||
@@ -449,36 +471,38 @@ export class Navidrome implements MusicService {
|
||||
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"),
|
||||
})),
|
||||
navidrome.getTrack(credentials, trackId).then((track) =>
|
||||
navidrome
|
||||
.get(
|
||||
credentials,
|
||||
`/rest/stream`,
|
||||
{ id: trackId, c: this.streamClientApplication(track) },
|
||||
{
|
||||
headers: pipe(
|
||||
range,
|
||||
O.fromNullable,
|
||||
O.map((range) => ({
|
||||
"User-Agent": USER_AGENT,
|
||||
Range: range,
|
||||
})),
|
||||
O.getOrElse(() => ({
|
||||
"User-Agent": USER_AGENT,
|
||||
}))
|
||||
),
|
||||
responseType: "stream",
|
||||
}
|
||||
)
|
||||
.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"],
|
||||
},
|
||||
stream: res.data,
|
||||
}))
|
||||
),
|
||||
coverArt: async (id: string, type: "album" | "artist", size?: number) => {
|
||||
if (type == "album") {
|
||||
return navidrome.getCoverArt(credentials, id, size).then((res) => ({
|
||||
|
||||
@@ -141,19 +141,19 @@ function server(
|
||||
it.scrobble(id).then((scrobbleSuccess) => {
|
||||
if (scrobbleSuccess) logger.info(`Scrobbled ${id}`);
|
||||
else logger.warn(`Failed to scrobble ${id}....`);
|
||||
|
||||
return it;
|
||||
})
|
||||
)
|
||||
.then((it) =>
|
||||
it.stream({ trackId: id, range: req.headers["range"] || undefined })
|
||||
)
|
||||
.then((stream) => {
|
||||
res.status(stream.status);
|
||||
Object.entries(stream.headers)
|
||||
.then((trackStream) => {
|
||||
res.status(trackStream.status);
|
||||
Object.entries(trackStream.headers)
|
||||
.filter(([_, v]) => v !== undefined)
|
||||
.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 { 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 randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||
@@ -110,8 +110,8 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
duration: randomInt(500),
|
||||
number: randomInt(100),
|
||||
genre: randomGenre(),
|
||||
artist: anArtist(),
|
||||
album: anAlbum(),
|
||||
artist: artistToArtistSummary(anArtist()),
|
||||
album: albumToAlbumSummary(anAlbum()),
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BROWSER_HEADERS,
|
||||
DODGY_IMAGE_NAME,
|
||||
asGenre,
|
||||
appendMimeTypeToClientFor
|
||||
} from "../src/navidrome";
|
||||
import encryption from "../src/encryption";
|
||||
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
Track,
|
||||
AlbumSummary,
|
||||
artistToArtistSummary,
|
||||
NO_IMAGES,
|
||||
AlbumQuery,
|
||||
} from "../src/music_service";
|
||||
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) => ({
|
||||
status: 200,
|
||||
data,
|
||||
@@ -188,7 +213,8 @@ describe("Navidrome", () => {
|
||||
const password = "pass1";
|
||||
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 mockGET = jest.fn();
|
||||
@@ -1312,10 +1338,7 @@ describe("Navidrome", () => {
|
||||
name: "Bob Marley",
|
||||
albums: [album],
|
||||
});
|
||||
const artistSummary = {
|
||||
...artistToArtistSummary(artist),
|
||||
image: NO_IMAGES,
|
||||
};
|
||||
const artistSummary = artistToArtistSummary(artist);
|
||||
|
||||
const tracks = [
|
||||
aTrack({ artist: artistSummary, album: albumSummary, genre: hipHop }),
|
||||
@@ -1374,10 +1397,7 @@ describe("Navidrome", () => {
|
||||
name: "Bob Marley",
|
||||
albums: [album],
|
||||
});
|
||||
const artistSummary = {
|
||||
...artistToArtistSummary(artist),
|
||||
image: NO_IMAGES,
|
||||
};
|
||||
const artistSummary = artistToArtistSummary(artist);
|
||||
|
||||
const tracks = [
|
||||
aTrack({
|
||||
@@ -1464,10 +1484,7 @@ describe("Navidrome", () => {
|
||||
name: "Bob Marley",
|
||||
albums: [album],
|
||||
});
|
||||
const artistSummary = {
|
||||
...artistToArtistSummary(artist),
|
||||
image: NO_IMAGES,
|
||||
};
|
||||
const artistSummary = artistToArtistSummary(artist);
|
||||
|
||||
const track = aTrack({
|
||||
artist: artistSummary,
|
||||
@@ -1514,85 +1531,47 @@ describe("Navidrome", () => {
|
||||
|
||||
describe("streaming a track", () => {
|
||||
const trackId = uuid();
|
||||
const genre = { id: "foo", name: "foo" };
|
||||
|
||||
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
|
||||
it("should return undefined values", async () => {
|
||||
const streamResponse = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mpeg",
|
||||
},
|
||||
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,
|
||||
});
|
||||
});
|
||||
const album = anAlbum({ genre });
|
||||
const artist = anArtist({
|
||||
albums: [album],
|
||||
image: { large: "foo", medium: undefined, small: undefined },
|
||||
});
|
||||
const track = aTrack({
|
||||
id: trackId,
|
||||
album: albumToAlbumSummary(album),
|
||||
artist: artistToArtistSummary(artist),
|
||||
genre,
|
||||
});
|
||||
|
||||
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
|
||||
it("should return undefined values", async () => {
|
||||
const streamResponse = {
|
||||
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("content-range, accept-ranges or content-length", () => {
|
||||
beforeEach(() => {
|
||||
streamClientApplication.mockReturnValue("bonob");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no range specified", () => {
|
||||
describe("navidrome returns a 200", () => {
|
||||
it("should return the content", async () => {
|
||||
describe("when navidrome doesnt return a 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": "1667",
|
||||
"content-range": "-200",
|
||||
"accept-ranges": "bytes",
|
||||
"some-other-header": "some-value",
|
||||
},
|
||||
data: Buffer.from("the track", "ascii"),
|
||||
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
|
||||
@@ -1603,11 +1582,183 @@ describe("Navidrome", () => {
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
"content-type": "audio/mpeg",
|
||||
"content-length": "1667",
|
||||
"content-range": "-200",
|
||||
"accept-ranges": "bytes",
|
||||
"content-length": undefined,
|
||||
"content-range": undefined,
|
||||
"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`, {
|
||||
params: {
|
||||
@@ -1616,79 +1767,98 @@ describe("Navidrome", () => {
|
||||
},
|
||||
headers: {
|
||||
"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", () => {
|
||||
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"),
|
||||
};
|
||||
describe("when navidrome has a custom StreamClientApplication registered", () => {
|
||||
describe("when no range specified", () => {
|
||||
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
||||
const clientApplication = `bonob-${uuid()}`;
|
||||
streamClientApplication.mockReturnValue(clientApplication);
|
||||
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||
const streamResponse = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mpeg",
|
||||
},
|
||||
data: Buffer.from("the track", "ascii"),
|
||||
};
|
||||
|
||||
const result = await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.stream({ trackId, range }));
|
||||
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));
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
"content-type": "audio/flac",
|
||||
"content-length": "66",
|
||||
"content-range": "100-200",
|
||||
"accept-ranges": "none",
|
||||
await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.stream({ trackId, range: undefined }));
|
||||
|
||||
expect(streamClientApplication).toHaveBeenCalledWith(track);
|
||||
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`, {
|
||||
params: {
|
||||
id: trackId,
|
||||
...authParams,
|
||||
},
|
||||
headers: {
|
||||
"User-Agent": "bonob",
|
||||
Range: range,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
describe("when range specified", () => {
|
||||
it("should user the custom StreamUserAgent when calling navidrome", async () => {
|
||||
const range = "1000-2000";
|
||||
const clientApplication = `bonob-${uuid()}`;
|
||||
streamClientApplication.mockReturnValue(clientApplication);
|
||||
|
||||
const streamResponse = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"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 { ExpiringAccessTokens } from "../src/access_tokens";
|
||||
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||
import { Response } from "express";
|
||||
|
||||
describe("server", () => {
|
||||
beforeEach(() => {
|
||||
@@ -243,46 +244,50 @@ describe("server", () => {
|
||||
describe("scrobbling", () => {
|
||||
describe("when scrobbling succeeds", () => {
|
||||
it("should scrobble the track", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when scrobbling succeeds", () => {
|
||||
it("should still return the track", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(false);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||
});
|
||||
});
|
||||
@@ -291,25 +296,26 @@ describe("server", () => {
|
||||
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", () => {
|
||||
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.headers["content-type"]).toEqual("audio/mp3")
|
||||
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
|
||||
expect(Object.keys(res.headers)).not.toContain("content-range")
|
||||
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", () => {
|
||||
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
@@ -325,20 +331,21 @@ describe("server", () => {
|
||||
"accept-ranges": undefined,
|
||||
"content-range": undefined,
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.headers["content-type"]).toEqual("audio/mp3")
|
||||
expect(res.headers["content-length"]).toEqual(`${stream.data.length}`)
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.headers["content-type"]).toEqual("audio/mp3; charset=utf-8")
|
||||
expect(Object.keys(res.headers)).not.toContain("content-range")
|
||||
expect(Object.keys(res.headers)).not.toContain("accept-ranges")
|
||||
});
|
||||
@@ -346,7 +353,7 @@ describe("server", () => {
|
||||
|
||||
describe("when the music service returns a 200", () => {
|
||||
it("should return a 200 with the data", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
@@ -354,26 +361,31 @@ describe("server", () => {
|
||||
"accept-ranges": "bytes",
|
||||
"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);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.header["content-type"]).toEqual(
|
||||
stream.headers["content-type"]
|
||||
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||
);
|
||||
expect(res.header["accept-ranges"]).toEqual(
|
||||
stream.headers["accept-ranges"]
|
||||
trackStream.headers["accept-ranges"]
|
||||
);
|
||||
expect(res.header["content-range"]).toEqual(
|
||||
stream.headers["content-range"]
|
||||
trackStream.headers["content-range"]
|
||||
);
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||
@@ -383,7 +395,7 @@ describe("server", () => {
|
||||
|
||||
describe("when the music service returns a 206", () => {
|
||||
it("should return a 206 with the data", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 206,
|
||||
headers: {
|
||||
"content-type": "audio/ogg",
|
||||
@@ -391,26 +403,28 @@ describe("server", () => {
|
||||
"accept-ranges": "bytez",
|
||||
"content-range": "100-200",
|
||||
},
|
||||
data: Buffer.from("some other track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.header["content-type"]).toEqual(
|
||||
stream.headers["content-type"]
|
||||
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||
);
|
||||
expect(res.header["accept-ranges"]).toEqual(
|
||||
stream.headers["accept-ranges"]
|
||||
trackStream.headers["accept-ranges"]
|
||||
);
|
||||
expect(res.header["content-range"]).toEqual(
|
||||
stream.headers["content-range"]
|
||||
trackStream.headers["content-range"]
|
||||
);
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||
@@ -422,7 +436,7 @@ describe("server", () => {
|
||||
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 = {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
@@ -430,11 +444,13 @@ describe("server", () => {
|
||||
"accept-ranges": "bytes",
|
||||
"content-range": "-100",
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
@@ -442,15 +458,15 @@ describe("server", () => {
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
||||
.set("Range", "3000-4000");
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.header["content-type"]).toEqual(
|
||||
stream.headers["content-type"]
|
||||
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||
);
|
||||
expect(res.header["accept-ranges"]).toEqual(
|
||||
stream.headers["accept-ranges"]
|
||||
trackStream.headers["accept-ranges"]
|
||||
);
|
||||
expect(res.header["content-range"]).toEqual(
|
||||
stream.headers["content-range"]
|
||||
trackStream.headers["content-range"]
|
||||
);
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||
@@ -463,7 +479,7 @@ describe("server", () => {
|
||||
|
||||
describe("when the music service returns a 206", () => {
|
||||
it("should return a 206 with the data", async () => {
|
||||
const stream = {
|
||||
const trackStream = {
|
||||
status: 206,
|
||||
headers: {
|
||||
"content-type": "audio/ogg",
|
||||
@@ -471,11 +487,13 @@ describe("server", () => {
|
||||
"accept-ranges": "bytez",
|
||||
"content-range": "100-200",
|
||||
},
|
||||
data: Buffer.from("some other track", "ascii"),
|
||||
stream: {
|
||||
pipe: (res: Response) => res.send("")
|
||||
}
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.stream.mockResolvedValue(trackStream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
@@ -483,15 +501,15 @@ describe("server", () => {
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
||||
.set("Range", "4000-5000");
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.header["content-type"]).toEqual(
|
||||
stream.headers["content-type"]
|
||||
`${trackStream.headers["content-type"]}; charset=utf-8`
|
||||
);
|
||||
expect(res.header["accept-ranges"]).toEqual(
|
||||
stream.headers["accept-ranges"]
|
||||
trackStream.headers["accept-ranges"]
|
||||
);
|
||||
expect(res.header["content-range"]).toEqual(
|
||||
stream.headers["content-range"]
|
||||
trackStream.headers["content-range"]
|
||||
);
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||
|
||||
Reference in New Issue
Block a user