From eec331358727874ddb5183acece0fad115fa02cf Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 26 Jun 2021 18:11:26 +1000 Subject: [PATCH] Change playing of a track to mark nowPlaying rather than scrobble, refactor/tidy up track streaming --- src/music_service.ts | 1 + src/navidrome.ts | 12 +- src/server.ts | 171 +++++++++++---- tests/in_memory_music_service.ts | 3 + tests/navidrome.test.ts | 63 +++++- tests/server.test.ts | 347 +++++++++++++++++++------------ tests/setup.js | 4 +- 7 files changed, 418 insertions(+), 183 deletions(-) diff --git a/src/music_service.ts b/src/music_service.ts index 683b88a..382826d 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -168,6 +168,7 @@ export interface MusicLibrary { range: string | undefined; }): Promise; coverArt(id: string, type: "album" | "artist", size?: number): Promise; + nowPlaying(id: string): Promise scrobble(id: string): Promise searchArtists(query: string): Promise; searchAlbums(query: string): Promise; diff --git a/src/navidrome.ts b/src/navidrome.ts index ec1249c..f5a1847 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -549,7 +549,10 @@ export class Navidrome implements MusicService { .get( credentials, `/rest/stream`, - { id: trackId, c: this.streamClientApplication(track) }, + { + id: trackId, + c: this.streamClientApplication(track), + }, { headers: pipe( range, @@ -628,6 +631,13 @@ export class Navidrome implements MusicService { }) .then((_) => true) .catch(() => false), + nowPlaying: async (id: string) => + navidrome + .get(credentials, `/rest/scrobble`, { + id, + }) + .then((_) => true) + .catch(() => false), searchArtists: async (query: string) => navidrome .search3(credentials, { query, artistCount: 20 }) diff --git a/src/server.ts b/src/server.ts index 6bc719c..ba096a8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,10 @@ +import { option as O } from "fp-ts"; import express, { Express } from "express"; import * as Eta from "eta"; import morgan from "morgan"; +import { PassThrough, Transform, TransformCallback } from "stream"; + import { Sonos, Service } from "./sonos"; import { SOAP_PATH, @@ -16,13 +19,51 @@ import bindSmapiSoapServiceToExpress from "./smapi"; import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import logger from "./logger"; import { Clock, SystemClock } from "./clock"; +import { pipe } from "fp-ts/lib/function"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; +interface RangeFilter extends Transform { + range: (length: number) => string; +} + +export function rangeFilterFor(rangeHeader: string): RangeFilter { + // if (rangeHeader == undefined) return new PassThrough(); + const match = rangeHeader.match(/^bytes=(\d+)-$/); + if (match) return new RangeBytesFromFilter(Number.parseInt(match[1]!)); + else throw `Unsupported range: ${rangeHeader}`; +} + +export class RangeBytesFromFilter extends Transform { + from: number; + count: number = 0; + + constructor(f: number) { + super(); + this.from = f; + } + + _transform(chunk: any, _: BufferEncoding, next: TransformCallback) { + if (this.count + chunk.length <= this.from) { + // before start + next(); + } else if (this.from <= this.count) { + // off the end + next(null, chunk); + } else { + // from somewhere in chunk + next(null, chunk.slice(this.from - this.count)); + } + this.count = this.count + chunk.length; + } + + range = (number: number) => `${this.from}-${number - 1}/${number}`; +} + function server( sonos: Sonos, service: Service, - webAddress: string | "http://localhost:4534", + webAddress: string, musicService: MusicService, linkCodes: LinkCodes = new InMemoryLinkCodes(), accessTokens: AccessTokens = new AccessTokenPerAuthToken(), @@ -139,58 +180,102 @@ function server( `); }); - app.head("/stream/track/:id", async (req, res) => { - const id = req.params["id"]!; - const accessToken = req.headers[BONOB_ACCESS_TOKEN_HEADER] as string; - logger.info(`Stream HEAD requested for ${id}, accessToken=${accessToken}`) - const authToken = accessTokens.authTokenFor(accessToken); - if (!authToken) { - return res.status(401).send(); - } else { - return musicService - .login(authToken) - .then((it) => - it.stream({ trackId: id, range: req.headers["range"] || undefined }) - ) - .then((trackStream) => { - res.status(trackStream.status); - Object.entries(trackStream.headers) - .filter(([_, v]) => v !== undefined) - .forEach(([header, value]) => res.setHeader(header, value)); - - res.send(); - }); - } - }); - app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; - const accessToken = req.headers[BONOB_ACCESS_TOKEN_HEADER] as string; - logger.info(`Stream requested for ${id}, accessToken=${accessToken}`) - const authToken = accessTokens.authTokenFor(accessToken); + logger.info( + `-> /stream/track/${id}, headers=${JSON.stringify(req.headers)}` + ); + const authToken = pipe( + req.header(BONOB_ACCESS_TOKEN_HEADER), + O.fromNullable, + O.map((accessToken) => accessTokens.authTokenFor(accessToken)), + O.getOrElseW(() => undefined) + ); if (!authToken) { return res.status(401).send(); } else { return musicService .login(authToken) .then((it) => - it.scrobble(id).then((scrobbleSuccess) => { - if (scrobbleSuccess) logger.info(`Scrobbled ${id}`); - else logger.warn(`Failed to scrobble ${id}....`); - return it; - }) + it + .stream({ + trackId: id, + range: req.headers["range"] || undefined, + }) + .then((stream) => ({ musicLibrary: it, stream })) ) - .then((it) => - it.stream({ trackId: id, range: req.headers["range"] || undefined }) - ) - .then((trackStream) => { - logger.info(`Streaming ${id}, status=${trackStream.status}, headers=(${JSON.stringify(trackStream.headers)})`) - res.status(trackStream.status); - Object.entries(trackStream.headers) - .filter(([_, v]) => v !== undefined) - .forEach(([header, value]) => res.setHeader(header, value)); + .then(({ musicLibrary, stream }) => { + logger.info( + `stream response from music service for ${id}, status=${ + stream.status + }, headers=(${JSON.stringify(stream.headers)})` + ); - trackStream.stream.pipe(res); + const respondWith = ({ + status, + filter, + headers, + sendStream, + nowPlaying, + }: { + status: number; + filter: Transform; + headers: Record; + sendStream: boolean; + nowPlaying: boolean; + }) => { + logger.info( + `<- /stream/track/${id}, status=${status}, headers=${JSON.stringify( + headers + )}` + ); + (nowPlaying + ? musicLibrary.nowPlaying(id) + : Promise.resolve(true) + ).then((_) => { + res.status(status); + Object.entries(stream.headers) + .filter(([_, v]) => v !== undefined) + .forEach(([header, value]) => res.setHeader(header, value)); + if (sendStream) stream.stream.pipe(filter).pipe(res); + else res.send(); + }); + }; + + if (stream.status == 200) { + respondWith({ + status: 200, + filter: new PassThrough(), + headers: { + "content-type": stream.headers["content-type"], + "content-length": stream.headers["content-length"], + "accept-ranges": stream.headers["accept-ranges"], + }, + sendStream: req.method == "GET", + nowPlaying: req.method == "GET", + }); + } else if (stream.status == 206) { + respondWith({ + status: 206, + filter: new PassThrough(), + headers: { + "content-type": stream.headers["content-type"], + "content-length": stream.headers["content-length"], + "content-range": stream.headers["content-range"], + "accept-ranges": stream.headers["accept-ranges"], + }, + sendStream: req.method == "GET", + nowPlaying: req.method == "GET", + }); + } else { + respondWith({ + status: stream.status, + filter: new PassThrough(), + headers: {}, + sendStream: req.method == "GET", + nowPlaying: false, + }); + } }); } }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index f49662b..da0b12f 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -130,6 +130,9 @@ export class InMemoryMusicService implements MusicService { scrobble: async (_: string) => { return Promise.resolve(true); }, + nowPlaying: async (_: string) => { + return Promise.resolve(true); + }, searchArtists: async (_: string) => Promise.resolve([]), searchAlbums: async (_: string) => Promise.resolve([]), searchTracks: async (_: string) => Promise.resolve([]), diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 91b20e9..a4f0c42 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -2682,7 +2682,7 @@ describe("Navidrome", () => { }); describe("scrobble", () => { - describe("when scrobbling succeeds", () => { + describe("when succeeds", () => { it("should return true", async () => { const id = uuid(); @@ -2709,7 +2709,7 @@ describe("Navidrome", () => { }); }); - describe("when scrobbling fails", () => { + describe("when fails", () => { it("should return false", async () => { const id = uuid(); @@ -2742,6 +2742,65 @@ describe("Navidrome", () => { }); }); + describe("nowPlaying", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.nowPlaying(id)); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParams, + id, + }), + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.nowPlaying(id)); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParams, + id, + }), + headers, + }); + }); + }); + }); + describe("searchArtists", () => { describe("when there is 1 search results", () => { it("should return true", async () => { diff --git a/tests/server.test.ts b/tests/server.test.ts index 9ca9392..94a1b20 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -2,7 +2,7 @@ import { v4 as uuid } from "uuid"; import dayjs from "dayjs"; import request from "supertest"; import { MusicService } from "../src/music_service"; -import makeServer, { BONOB_ACCESS_TOKEN_HEADER } from "../src/server"; +import makeServer, { BONOB_ACCESS_TOKEN_HEADER, RangeBytesFromFilter, rangeFilterFor } from "../src/server"; import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; import { aDevice, aService } from "./builders"; @@ -10,6 +10,135 @@ import { InMemoryMusicService } from "./in_memory_music_service"; import { ExpiringAccessTokens } from "../src/access_tokens"; import { InMemoryLinkCodes } from "../src/link_codes"; import { Response } from "express"; +import { Transform } from "stream"; + +describe("rangeFilterFor", () => { + describe("invalid range header string", () => { + it("should fail", () => { + const cases = [ + "bytes", + "bytes=0", + "bytes=-", + "bytes=100-200,300-400", + "bytes=100-200, 300-400", + "seconds", + "seconds=0", + "seconds=-", + ] + + for (let range in cases) { + expect(() => rangeFilterFor(range)).toThrowError(`Unsupported range: ${range}`); + } + + }); + }); + + describe("bytes", () => { + describe("0-", () => { + it("should return a RangeBytesFromFilter", () => { + const filter = rangeFilterFor("bytes=0-"); + + expect(filter instanceof RangeBytesFromFilter).toEqual(true); + expect((filter as RangeBytesFromFilter).from).toEqual(0); + expect(filter.range(100)).toEqual("0-99/100"); + }); + }); + + describe("64-", () => { + it("should return a RangeBytesFromFilter", () => { + const filter = rangeFilterFor("bytes=64-") + + expect(filter instanceof RangeBytesFromFilter).toEqual(true); + expect((filter as RangeBytesFromFilter).from).toEqual(64); + expect(filter.range(8877)).toEqual("64-8876/8877"); + }); + }); + + describe("-900", () => { + it("should fail", () => { + expect(() => rangeFilterFor("bytes=-900")).toThrowError("Unsupported range: bytes=-900") + }); + }); + + describe("100-200", () => { + it("should fail", () => { + expect(() => rangeFilterFor("bytes=100-200")).toThrowError("Unsupported range: bytes=100-200") + }); + }); + + describe("100-200, 400-500", () => { + it("should fail", () => { + expect(() => rangeFilterFor("bytes=100-200, 400-500")).toThrowError("Unsupported range: bytes=100-200, 400-500") + }); + }); + }); + + describe("not bytes", () => { + it("should fail", () => { + const cases = [ + "seconds=0-", + "seconds=100-200", + "chickens=100-200, 400-500" + ] + + for (let range in cases) { + expect(() => rangeFilterFor(range)).toThrowError(`Unsupported range: ${range}`); + } + }); + }); +}); + +describe("RangeBytesFromFilter", () => { + describe("range from", () => { + describe("0-", () => { + it("should not filter at all", () => { + const filter = new RangeBytesFromFilter(0); + const result: any[] = [] + + const callback = (_?: Error | null, data?: any) => { + if(data) result.push(...data!) + } + + filter._transform(['a', 'b', 'c'], 'ascii', callback) + filter._transform(['d', 'e', 'f'], 'ascii', callback) + + expect(result).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + }); + }); + + describe("1-", () => { + it("should filter the first byte", () => { + const filter = new RangeBytesFromFilter(1); + const result: any[] = [] + + const callback = (_?: Error | null, data?: any) => { + if(data) result.push(...data!) + } + + filter._transform(['a', 'b', 'c'], 'ascii', callback) + filter._transform(['d', 'e', 'f'], 'ascii', callback) + + expect(result).toEqual(['b', 'c', 'd', 'e', 'f']) + }); + }); + + describe("5-", () => { + it("should filter the first byte", () => { + const filter = new RangeBytesFromFilter(5); + const result: any[] = [] + + const callback = (_?: Error | null, data?: any) => { + if(data) result.push(...data!) + } + + filter._transform(['a', 'b', 'c'], 'ascii', callback) + filter._transform(['d', 'e', 'f'], 'ascii', callback) + + expect(result).toEqual(['f']) + }); + }); + }); +}); describe("server", () => { beforeEach(() => { @@ -200,6 +329,7 @@ describe("server", () => { const musicLibrary = { stream: jest.fn(), scrobble: jest.fn(), + nowPlaying: jest.fn(), }; let now = dayjs(); const accessTokens = new ExpiringAccessTokens({ now: () => now }); @@ -221,6 +351,16 @@ describe("server", () => { accessToken = accessTokens.mint(authToken); }); + const streamContent = (content: string) => ({ + pipe: (_: Transform) => { + return { + pipe: (res: Response) => { + res.send(content); + } + } + }, + }) + describe("HEAD requests", () => { describe("when there is no access-token", () => { it("should return a 401", async () => { @@ -251,9 +391,7 @@ describe("server", () => { "content-type": "audio/mp3; charset=utf-8", "content-length": "123", }, - stream: { - pipe: (res: Response) => res.send(""), - }, + stream: streamContent(""), }; musicService.login.mockResolvedValue(musicLibrary); @@ -279,9 +417,7 @@ describe("server", () => { const trackStream = { status: 404, headers: {}, - stream: { - pipe: (res: Response) => res.send(""), - }, + stream: streamContent(""), }; musicService.login.mockResolvedValue(musicLibrary); @@ -319,78 +455,26 @@ describe("server", () => { }); }); - describe("scrobbling", () => { - describe("when scrobbling succeeds", () => { - it("should scrobble the track", async () => { - const trackStream = { - status: 200, - headers: { - "content-type": "audio/mp3", - }, - stream: { - pipe: (res: Response) => res.send(""), - }, - }; - - musicService.login.mockResolvedValue(musicLibrary); - 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(trackStream.status); - expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); - }); - }); - - describe("when scrobbling succeeds", () => { - it("should still return the track", async () => { - const trackStream = { - status: 200, - headers: { - "content-type": "audio/mp3", - }, - stream: { - pipe: (res: Response) => res.send(""), - }, - }; - - musicService.login.mockResolvedValue(musicLibrary); - 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(trackStream.status); - expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); - }); - }); - }); - describe("when the track doesnt exist", () => { it("should return a 404", async () => { - const trackStream = { + const stream = { status: 404, headers: { }, - stream: { - pipe: (res: Response) => res.send(""), - }, + stream: streamContent(""), }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(false); + musicLibrary.stream.mockResolvedValue(stream); const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); expect(res.status).toEqual(404); + + expect(musicLibrary.nowPlaying).not.toHaveBeenCalled(); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); }); }); @@ -399,41 +483,41 @@ describe("server", () => { it("should return a 200 with the data, without adding the undefined headers", async () => { const content = "some-track"; - const trackStream = { + const stream = { status: 200, headers: { "content-type": "audio/mp3", - // this content-length seems to be ignored for GET requests, stream.pipe must set its' own - "content-length": "666" - }, - stream: { - pipe: (res: Response) => res.send(content), }, + stream: streamContent(content), }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(true); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - expect(res.status).toEqual(trackStream.status); + expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( "audio/mp3; charset=utf-8" ); + expect(res.header["accept-ranges"]).toBeUndefined(); expect(res.headers["content-length"]).toEqual( `${content.length}` ); expect(Object.keys(res.headers)).not.toContain("content-range"); - expect(Object.keys(res.headers)).not.toContain("accept-ranges"); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); }); }); 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 trackStream = { + const stream = { status: 200, headers: { "content-type": "audio/mp3", @@ -441,73 +525,70 @@ describe("server", () => { "accept-ranges": undefined, "content-range": undefined, }, - stream: { - pipe: (res: Response) => res.send(""), - }, + stream: streamContent("") }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(true); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - expect(res.status).toEqual(trackStream.status); + expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( "audio/mp3; charset=utf-8" ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); expect(Object.keys(res.headers)).not.toContain("content-range"); - expect(Object.keys(res.headers)).not.toContain("accept-ranges"); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); }); }); describe("when the music service returns a 200", () => { it("should return a 200 with the data", async () => { - const trackStream = { + const stream = { status: 200, headers: { "content-type": "audio/mp3", "content-length": "222", "accept-ranges": "bytes", - "content-range": "-100", - }, - stream: { - pipe: (res: Response) => { - console.log("calling send on response"); - res.send(""); - }, }, + stream: streamContent("") }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(true); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - expect(res.status).toEqual(trackStream.status); + expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( - `${trackStream.headers["content-type"]}; charset=utf-8` + `${stream.headers["content-type"]}; charset=utf-8` ); expect(res.header["accept-ranges"]).toEqual( - trackStream.headers["accept-ranges"] - ); - expect(res.header["content-range"]).toEqual( - trackStream.headers["content-range"] + stream.headers["accept-ranges"] ); + expect(res.header["content-range"]).toBeUndefined(); expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); }); }); describe("when the music service returns a 206", () => { it("should return a 206 with the data", async () => { - const trackStream = { + const stream = { status: 206, headers: { "content-type": "audio/ogg", @@ -515,31 +596,30 @@ describe("server", () => { "accept-ranges": "bytez", "content-range": "100-200", }, - stream: { - pipe: (res: Response) => res.send(""), - }, + stream: streamContent("") }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(true); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - expect(res.status).toEqual(trackStream.status); + expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( - `${trackStream.headers["content-type"]}; charset=utf-8` + `${stream.headers["content-type"]}; charset=utf-8` ); expect(res.header["accept-ranges"]).toEqual( - trackStream.headers["accept-ranges"] + stream.headers["accept-ranges"] ); expect(res.header["content-range"]).toEqual( - trackStream.headers["content-range"] + stream.headers["content-range"] ); expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); }); }); @@ -548,50 +628,48 @@ 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 trackStream = { + const stream = { status: 200, headers: { "content-type": "audio/mp3", "content-length": "222", - "accept-ranges": "bytes", - "content-range": "-100", - }, - stream: { - pipe: (res: Response) => res.send(""), + "accept-ranges": "none", }, + stream: streamContent("") }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(true); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const requestedRange = "40-"; const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) - .set("Range", "3000-4000"); + .set("Range", requestedRange); - expect(res.status).toEqual(trackStream.status); + expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( - `${trackStream.headers["content-type"]}; charset=utf-8` + `${stream.headers["content-type"]}; charset=utf-8` ); expect(res.header["accept-ranges"]).toEqual( - trackStream.headers["accept-ranges"] - ); - expect(res.header["content-range"]).toEqual( - trackStream.headers["content-range"] + stream.headers["accept-ranges"] ); + expect(res.header["content-range"]).toBeUndefined(); expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId, - range: "3000-4000", + range: requestedRange, }); }); }); describe("when the music service returns a 206", () => { it("should return a 206 with the data", async () => { - const trackStream = { + const stream = { status: 206, headers: { "content-type": "audio/ogg", @@ -599,32 +677,31 @@ describe("server", () => { "accept-ranges": "bytez", "content-range": "100-200", }, - stream: { - pipe: (res: Response) => res.send(""), - }, + stream: streamContent("") }; musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - musicLibrary.scrobble.mockResolvedValue(true); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); const res = await request(server) .get(`/stream/track/${trackId}`) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) .set("Range", "4000-5000"); - expect(res.status).toEqual(trackStream.status); + expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( - `${trackStream.headers["content-type"]}; charset=utf-8` + `${stream.headers["content-type"]}; charset=utf-8` ); expect(res.header["accept-ranges"]).toEqual( - trackStream.headers["accept-ranges"] + stream.headers["accept-ranges"] ); expect(res.header["content-range"]).toEqual( - trackStream.headers["content-range"] + stream.headers["content-range"] ); expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId, range: "4000-5000", diff --git a/tests/setup.js b/tests/setup.js index a935db7..0e8a112 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,6 +1,6 @@ global.console = { - log: console.log, - //log: jest.fn(), // console.log are ignored in tests + // log: console.log, + log: jest.fn(), // console.log are ignored in tests // Keep native behaviour for other methods, use those to print out things in your own tests, not `console.log` error: console.error,