diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c26d8cc..769b479 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -16,5 +16,12 @@ "version": "latest", "moby": true } + }, + "customizations": { + "vscode": { + "extensions": [ + "esbenp.prettier-vscode" + ] + } } } diff --git a/README.md b/README.md index 38b5f31..3fe9dab 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Support for Subsonic API clones (tested against Navidrome and Gonic). - Discovery of sonos devices using seed IP address - Auto registration with sonos on start - Multiple registrations within a single household. -- Transcoding support for flacs using a specific player for the flac mimeType bonob/sonos +- Transcoding within subsonic clone +- Custom players by mime type, allowing custom transcoding rules for different file types ## Running @@ -163,7 +164,6 @@ BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos BNB_SECRET | bonob | secret used for encrypting credentials BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error'] -BNB_DISABLE_PLAYLIST_ART | undefined | Disables playlist art generation, ie. when there are many playlists and art generation takes too long BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified. @@ -171,7 +171,7 @@ BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommit BNB_SONOS_SERVICE_NAME | bonob | service name for sonos BNB_SONOS_SERVICE_ID | 246 | service id for sonos BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone -BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. +BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. Must specify by the source mime type and the transcoded mime type. For example;

If you want to simply re-encode some flacs, then you could specify just "audio/flac".

However; if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"

If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.

!!! Getting this configuration wrong will confuse SONOS as it will expect the wrong mime type for a track, as a result it will not play. Use with care... BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache. BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing diff --git a/package.json b/package.json index eaa1bc1..c138bd2 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,8 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "devr": "BNB_DISABLE_PLAYLIST_ART=true BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534", "test": "jest", "testw": "jest --watch", diff --git a/src/app.ts b/src/app.ts index 4b4c645..c91cb0b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,11 +4,11 @@ import server from "./server"; import logger from "./logger"; import { - appendMimeTypeToClientFor, axiosImageFetcher, cachingImageFetcher, - DEFAULT, Subsonic, + TranscodingCustomPlayers, + NO_CUSTOM_PLAYERS } from "./subsonic"; import { InMemoryAPITokens, sha256 } from "./api_tokens"; import { InMemoryLinkCodes } from "./link_codes"; @@ -32,9 +32,9 @@ const bonob = bonobService( const sonosSystem = sonos(config.sonos.discovery); -const streamUserAgent = config.subsonic.customClientsFor - ? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(",")) - : DEFAULT; +const customPlayers = config.subsonic.customClientsFor + ? TranscodingCustomPlayers.from(config.subsonic.customClientsFor) + : NO_CUSTOM_PLAYERS; const artistImageFetcher = config.subsonic.artistImageCache ? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher) @@ -42,7 +42,7 @@ const artistImageFetcher = config.subsonic.artistImageCache const subsonic = new Subsonic( config.subsonic.url, - streamUserAgent, + customPlayers, artistImageFetcher ); diff --git a/src/music_service.ts b/src/music_service.ts index 02c5021..0e879a0 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -51,10 +51,15 @@ export type Rating = { stars: number; } +export type Encoding = { + player: string, + mimeType: string +} + export type Track = { id: string; name: string; - mimeType: string; + encoding: Encoding, duration: number; number: number | undefined; genre: Genre | undefined; diff --git a/src/smapi.ts b/src/smapi.ts index 70ccaac..77fa0b1 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -302,7 +302,7 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ export const track = (bonobUrl: URLBuilder, track: Track) => ({ itemType: "track", id: `track:${track.id}`, - mimeType: sonosifyMimeType(track.mimeType), + mimeType: sonosifyMimeType(track.encoding.mimeType), title: track.name, trackMetadata: { diff --git a/src/subsonic.ts b/src/subsonic.ts index 611d9d4..af46e83 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -20,7 +20,8 @@ import { AlbumQueryType, Artist, AuthFailure, - PlaylistSummary + PlaylistSummary, + Encoding, } from "./music_service"; import sharp from "sharp"; import _ from "underscore"; @@ -163,7 +164,7 @@ export type song = { duration: number | undefined; bitRate: number | undefined; suffix: string | undefined; - contentType: string | undefined; + contentType: string; transcodedContentType: string | undefined; type: string | undefined; userRating: number | undefined; @@ -275,10 +276,16 @@ export const artistImageURN = ( } }; -export const asTrack = (album: Album, song: song): Track => ({ +export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({ id: song.id, name: song.title, - mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType!, + encoding: pipe( + customPlayers.encodingFor({ mimeType: song.contentType }), + O.getOrElse(() => ({ + player: DEFAULT_CLIENT_APPLICATION, + mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType + })) + ), duration: song.duration || 0, number: song.track || 0, genre: maybeAsGenre(song.genre), @@ -311,11 +318,11 @@ const asAlbum = (album: album): Album => ({ }); // coverArtURN -const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({ - id: playlist.id, +const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({ + id: playlist.id, name: playlist.name, - coverArt: coverArtURN(playlist.coverArt) -}) + coverArt: coverArtURN(playlist.coverArt), +}); export const asGenre = (genreName: string) => ({ id: b64Encode(genreName), @@ -330,19 +337,53 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => O.getOrElseW(() => undefined) ); -export type StreamClientApplication = (track: Track) => string; +export interface CustomPlayers { + encodingFor({ mimeType }: { mimeType: string }): O.Option +} -const DEFAULT_CLIENT_APPLICATION = "bonob"; -const USER_AGENT = "bonob"; +export type CustomClient = { + mimeType: string; + transcodedMimeType: string; +}; + +export class TranscodingCustomPlayers implements CustomPlayers { + transcodings: Map; -export const DEFAULT: StreamClientApplication = (_: Track) => - DEFAULT_CLIENT_APPLICATION; + constructor(transcodings: Map) { + this.transcodings = transcodings; + } + + static from(config: string): TranscodingCustomPlayers { + const parts: [string, string][] = config + .split(",") + .map((it) => it.split(">")) + .map((pair) => { + if (pair.length == 1) return [pair[0]!, pair[0]!]; + else if (pair.length == 2) return [pair[0]!, pair[1]!]; + else throw new Error(`Invalid configuration item ${config}`); + }); + return new TranscodingCustomPlayers(new Map(parts)); + } + + encodingFor = ({ mimeType }: { mimeType: string }): O.Option => pipe( + this.transcodings.get(mimeType), + O.fromNullable, + O.map(transcodedMimeType => ({ + player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`, + mimeType: transcodedMimeType + })) + ) +} -export function appendMimeTypeToClientFor(mimeTypes: string[]) { - return (track: Track) => - mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; +export const NO_CUSTOM_PLAYERS: CustomPlayers = { + encodingFor(_) { + return O.none + }, } +const DEFAULT_CLIENT_APPLICATION = "bonob"; +const USER_AGENT = "bonob"; + export const asURLSearchParams = (q: any) => { const urlSearchParams = new URLSearchParams(); Object.keys(q).forEach((k) => { @@ -357,28 +398,28 @@ export type ImageFetcher = (url: string) => Promise; export const cachingImageFetcher = (cacheDir: string, delegate: ImageFetcher) => - async (url: string): Promise => { - const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); - return fse - .readFile(filename) - .then((data) => ({ contentType: "image/png", data })) - .catch(() => - delegate(url).then((image) => { - if (image) { - return sharp(image.data) - .png() - .toBuffer() - .then((png) => { - return fse - .writeFile(filename, png) - .then(() => ({ contentType: "image/png", data: png })); - }); - } else { - return undefined; - } - }) - ); - }; + async (url: string): Promise => { + const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); + return fse + .readFile(filename) + .then((data) => ({ contentType: "image/png", data })) + .catch(() => + delegate(url).then((image) => { + if (image) { + return sharp(image.data) + .png() + .toBuffer() + .then((png) => { + return fse + .writeFile(filename, png) + .then(() => ({ contentType: "image/png", data: png })); + }); + } else { + return undefined; + } + }) + ); + }; export const axiosImageFetcher = (url: string): Promise => axios @@ -426,16 +467,16 @@ interface SubsonicMusicLibrary extends MusicLibrary { export class Subsonic implements MusicService { url: URLBuilder; - streamClientApplication: StreamClientApplication; + customPlayers: CustomPlayers; externalImageFetcher: ImageFetcher; constructor( url: URLBuilder, - streamClientApplication: StreamClientApplication = DEFAULT, + customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS, externalImageFetcher: ImageFetcher = axiosImageFetcher ) { this.url = url; - this.streamClientApplication = streamClientApplication; + this.customPlayers = customPlayers; this.externalImageFetcher = externalImageFetcher; } @@ -630,7 +671,7 @@ export class Subsonic implements MusicService { .then((it) => it.song) .then((song) => this.getAlbum(credentials, song.albumId!).then((album) => - asTrack(album, song) + asTrack(album, song, this.customPlayers) ) ); @@ -733,7 +774,7 @@ export class Subsonic implements MusicService { }) .then((it) => it.album) .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song)) + (album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers)) ), track: (trackId: string) => subsonic.getTrack(credentials, trackId), rate: (trackId: string, rating: Rating) => @@ -784,7 +825,7 @@ export class Subsonic implements MusicService { `/rest/stream`, { id: trackId, - c: this.streamClientApplication(track), + c: track.encoding.player, }, { headers: pipe( @@ -801,15 +842,15 @@ export class Subsonic implements MusicService { responseType: "stream", } ) - .then((res) => ({ - status: res.status, + .then((stream) => ({ + status: stream.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"], + "content-type": stream.headers["content-type"], + "content-length": stream.headers["content-length"], + "content-range": stream.headers["content-range"], + "accept-ranges": stream.headers["accept-ranges"], }, - stream: res.data, + stream: stream.data, })) ), coverArt: async (coverArtURN: BUrn, size?: number) => @@ -872,9 +913,7 @@ export class Subsonic implements MusicService { subsonic .getJSON(credentials, "/rest/getPlaylists") .then((it) => it.playlists.playlist || []) - .then((playlists) => - playlists.map(asPlayListSummary) - ), + .then((playlists) => playlists.map(asPlayListSummary)), playlist: async (id: string) => subsonic .getJSON(credentials, "/rest/getPlaylist", { @@ -898,7 +937,8 @@ export class Subsonic implements MusicService { artistId: entry.artistId, coverArt: coverArtURN(entry.coverArt), }, - entry + entry, + this.customPlayers ), number: trackNumber++, })), @@ -911,7 +951,11 @@ export class Subsonic implements MusicService { }) .then((it) => it.playlist) // todo: why is this line so similar to other playlist lines?? - .then((it) => ({ id: it.id, name: it.name, coverArt: coverArtURN(it.coverArt) })), + .then((it) => ({ + id: it.id, + name: it.name, + coverArt: coverArtURN(it.coverArt), + })), deletePlaylist: async (id: string) => subsonic .getJSON(credentials, "/rest/deletePlaylist", { @@ -945,7 +989,7 @@ export class Subsonic implements MusicService { songs.map((song) => subsonic .getAlbum(credentials, song.albumId!) - .then((album) => asTrack(album, song)) + .then((album) => asTrack(album, song, this.customPlayers)) ) ) ), @@ -962,7 +1006,7 @@ export class Subsonic implements MusicService { songs.map((song) => subsonic .getAlbum(credentials, song.albumId!) - .then((album) => asTrack(album, song)) + .then((album) => asTrack(album, song, this.customPlayers)) ) ) ) @@ -979,7 +1023,7 @@ export class Subsonic implements MusicService { TE.tryCatch( () => axios.post( - this.url.append({ pathname: '/auth/login' }).href(), + this.url.append({ pathname: "/auth/login" }).href(), _.pick(credentials, "username", "password") ), () => new AuthFailure("Failed to get bearerToken") diff --git a/tests/builders.ts b/tests/builders.ts index 83c1c7e..5dadc61 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -173,7 +173,10 @@ export function aTrack(fields: Partial = {}): Track { return { id, name: `Track ${id}`, - mimeType: `audio/mp3-${id}`, + encoding: { + player: "bonob", + mimeType: `audio/mp3-${id}` + }, duration: randomInt(500), number: randomInt(100), genre, diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 2496a35..bdc1e48 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -352,7 +352,10 @@ describe("track", () => { const someTrack = aTrack({ id: uuid(), // audio/x-flac should be mapped to audio/flac - mimeType: "audio/x-flac", + encoding: { + player: "something", + mimeType: "audio/x-flac" + }, name: "great song", duration: randomInt(1000), number: randomInt(100), @@ -407,7 +410,10 @@ describe("track", () => { const someTrack = aTrack({ id: uuid(), // audio/x-flac should be mapped to audio/flac - mimeType: "audio/x-flac", + encoding: { + player: "something", + mimeType: "audio/x-flac" + }, name: "great song", duration: randomInt(1000), number: randomInt(100), @@ -2579,7 +2585,7 @@ describe("wsdl api", () => { id: `track:${track.id}`, itemType: "track", title: track.name, - mimeType: track.mimeType, + mimeType: track.encoding.mimeType, trackMetadata: { artistId: `artist:${track.artist.id}`, artist: track.artist.name, @@ -2627,7 +2633,7 @@ describe("wsdl api", () => { id: `track:${track.id}`, itemType: "track", title: track.name, - mimeType: track.mimeType, + mimeType: track.encoding.mimeType, trackMetadata: { artistId: `artist:${track.artist.id}`, artist: track.artist.name, diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 1d77cb1..06e0c45 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -12,7 +12,6 @@ import { t, DODGY_IMAGE_NAME, asGenre, - appendMimeTypeToClientFor, asURLSearchParams, cachingImageFetcher, asTrack, @@ -22,6 +21,9 @@ import { PingResponse, parseToken, asToken, + TranscodingCustomPlayers, + CustomPlayers, + NO_CUSTOM_PLAYERS } from "../src/subsonic"; import axios from "axios"; @@ -47,7 +49,7 @@ import { SimilarArtist, Rating, Credentials, - AuthFailure, + AuthFailure } from "../src/music_service"; import { aGenre, @@ -92,38 +94,26 @@ describe("isValidImage", () => { }); }); -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("StreamClient(s)", () => { + describe("CustomStreamClientApplications", () => { + const customClients = TranscodingCustomPlayers.from("audio/flac,audio/mp3>audio/ogg") + + describe("clientFor", () => { + describe("when there is a match", () => { + it("should return the match", () => { + expect(customClients.encodingFor({ mimeType: "audio/flac" })).toEqual(O.of({player: "bonob+audio/flac", mimeType:"audio/flac"})) + expect(customClients.encodingFor({ mimeType: "audio/mp3" })).toEqual(O.of({player: "bonob+audio/mp3", mimeType:"audio/ogg"})) + }); }); - }); - - describe("and the track mimeType is not in the array", () => { - it("should return bonob", () => { - expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual( - "bonob" - ); + + describe("when there is no match", () => { + it("should return undefined", () => { + expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual(O.none) + }); }); }); - }); + }); }); describe("asURLSearchParams", () => { @@ -321,7 +311,7 @@ const asSongJson = (track: Track) => ({ bitRate: 128, size: "5624132", suffix: "mp3", - contentType: track.mimeType, + contentType: track.encoding.mimeType, transcodedContentType: undefined, isVideo: "false", path: "ACDC/High voltage/ACDC - The Jack.mp3", @@ -448,7 +438,7 @@ const getPlayListJson = (playlist: Playlist) => genre: it.album.genre?.name, coverArt: maybeIdFromCoverArtUrn(it.coverArt), size: 123, - contentType: it.mimeType, + contentType: it.encoding.mimeType, suffix: "mp3", duration: it.duration, bitRate: 128, @@ -646,12 +636,17 @@ describe("artistURN", () => { }); describe("asTrack", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + describe("when the song has no artistId", () => { const album = anAlbum(); const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); it("should provide no artistId", () => { - const result = asTrack(album, { ...asSongJson(track) }); + const result = asTrack(album, { ...asSongJson(track) }, NO_CUSTOM_PLAYERS); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("Not in library so no id"); expect(result.artist.image).toBeUndefined(); @@ -662,7 +657,7 @@ describe("asTrack", () => { const album = anAlbum(); it("should provide a ? to sonos", () => { - const result = asTrack(album, { id: '1' } as any as song); + const result = asTrack(album, { id: '1' } as any as song, NO_CUSTOM_PLAYERS); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("?"); expect(result.artist.image).toBeUndefined(); @@ -675,53 +670,118 @@ describe("asTrack", () => { describe("a value greater than 5", () => { it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); + const result = asTrack(album, { ...asSongJson(track), userRating: 6 }, NO_CUSTOM_PLAYERS); expect(result.rating.stars).toEqual(0); }); }); describe("a value less than 0", () => { it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: -1 }); + const result = asTrack(album, { ...asSongJson(track), userRating: -1 }, NO_CUSTOM_PLAYERS); expect(result.rating.stars).toEqual(0); }); }); }); - describe("when the song has a transcodedContentType", () => { + describe("content types", () => { const album = anAlbum(); + const track = aTrack(); + + describe("when there are no custom players", () => { + describe("when subsonic reports no transcodedContentType", () => { + it("should use the default client and default contentType", () => { + const result = asTrack(album, { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: undefined + }, NO_CUSTOM_PLAYERS); - describe("with an undefined value", () => { - const track = aTrack({ mimeType: "sourced-from/mimeType" }); + expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" }) + }); + }); - it("should fall back on the default mime", () => { - const result = asTrack(album, { ...asSongJson(track), transcodedContentType: undefined }); - expect(result.mimeType).toEqual("sourced-from/mimeType") + describe("when subsonic reports a transcodedContentType", () => { + it("should use the default client and transcodedContentType", () => { + const result = asTrack(album, { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: "transcodedContentType" + }, NO_CUSTOM_PLAYERS); + + expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType" }) + }); }); }); - - describe("with a value", () => { - const track = aTrack({ mimeType: "sourced-from/mimeType" }); - it("should use the transcoded value", () => { - const result = asTrack(album, { ...asSongJson(track), transcodedContentType: "sourced-from/transcodedContentType" }); - expect(result.mimeType).toEqual("sourced-from/transcodedContentType") + describe("when there are custom players registered", () => { + const streamClient = { + encodingFor: jest.fn() + } + + describe("however no player is found for the default mimeType", () => { + describe("and there is no transcodedContentType", () => { + it("should use the default player with the default content type", () => { + streamClient.encodingFor.mockReturnValue(O.none) + + const result = asTrack(album, { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: undefined + }, streamClient as unknown as CustomPlayers); + + expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" }); + }); + }); + + describe("and there is a transcodedContentType", () => { + it("should use the default player with the transcodedContentType", () => { + streamClient.encodingFor.mockReturnValue(O.none) + + const result = asTrack(album, { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: "transcodedContentType1" + }, streamClient as unknown as CustomPlayers); + + expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType1" }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" }); + }); + }); + }); + + describe("there is a player with the matching content type", () => { + it("should use it", () => { + const customEncoding = { player: "custom-player", mimeType: "audio/some-mime-type" }; + streamClient.encodingFor.mockReturnValue(O.of(customEncoding)); + + const result = asTrack(album, { + ...asSongJson(track), + contentType: "sourced-from/subsonic", + transcodedContentType: "sourced-from/subsonic2" + }, streamClient as unknown as CustomPlayers); + + expect(result.encoding).toEqual(customEncoding); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "sourced-from/subsonic" }); + }); }); }); }); }); - describe("Subsonic", () => { const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); const username = `user1-${uuid()}`; const password = `pass1-${uuid()}`; const salt = "saltysalty"; - const streamClientApplication = jest.fn(); + const customPlayers = { + encodingFor: jest.fn() + }; + const subsonic = new Subsonic( url, - streamClientApplication + customPlayers as unknown as CustomPlayers ); const mockRandomstring = jest.fn(); @@ -761,8 +821,7 @@ describe("Subsonic", () => { TE.fold(e => { throw e }, T.of) ) - const login = (credentials: Credentials) => tokenFor(credentials)() - .then((it) => subsonic.login(it.serviceToken)) + const login = (credentials: Credentials) => tokenFor(credentials)().then((it) => subsonic.login(it.serviceToken)) describe("generateToken", () => { describe("when the credentials are valid", () => { @@ -2645,6 +2704,10 @@ describe("Subsonic", () => { }); describe("getting an album", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + describe("when it exists", () => { const genre = asGenre("Pop"); @@ -2686,153 +2749,262 @@ describe("Subsonic", () => { describe("getting tracks", () => { describe("for an album", () => { - describe("when the album has multiple tracks, some of which are rated", () => { + describe("when there are no custom players", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when the album has multiple tracks, some of which are rated", () => { + const hipHop = asGenre("Hip-Hop"); + const tripHop = asGenre("Trip-Hop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: true, + stars: 3, + }, + }); + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: false, + stars: 0, + }, + }); + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: true, + stars: 5, + }, + }); + const track4 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: false, + stars: 1, + }, + }); + + const tracks = [track1, track2, track3, track4]; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await login({ username, password }) + .then((it) => it.tracks(album.id)); + + expect(result).toEqual([track1, track2, track3, track4]); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("when the album has only 1 track", () => { + const flipFlop = asGenre("Flip-Flop"); + + const album = anAlbum({ + id: "album1", + name: "Burnin", + genre: flipFlop, + }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: flipFlop, + }); + + const tracks = [track]; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await login({ username, password }) + .then((it) => it.tracks(album.id)); + + expect(result).toEqual([track]); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("when the album has only no tracks", () => { + const album = anAlbum({ id: "album1", name: "Burnin" }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const tracks: Track[] = []; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should empty array", async () => { + const result = await login({ username, password }) + .then((it) => it.tracks(album.id)); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + + describe("when a custom player is configured for the mime type", () => { const hipHop = asGenre("Hip-Hop"); const tripHop = asGenre("Trip-Hop"); - + const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); - + const artist = anArtist({ id: "artist1", name: "Bob Marley", albums: [album], }); - - const track1 = aTrack({ + + const alac = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), + encoding: { + player: "bonob", + mimeType: "audio/alac" + }, genre: hipHop, rating: { love: true, stars: 3, }, }); - const track2 = aTrack({ + const m4a = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), + encoding: { + player: "bonob", + mimeType: "audio/m4a" + }, genre: hipHop, rating: { love: false, stars: 0, }, }); - const track3 = aTrack({ + const mp3 = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), + encoding: { + player: "bonob", + mimeType: "audio/mp3" + }, genre: tripHop, rating: { love: true, stars: 5, }, }); - const track4 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: false, - stars: 1, - }, - }); - - const tracks = [track1, track2, track3, track4]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([track1, track2, track3, track4]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only 1 track", () => { - const flipFlop = asGenre("Flip-Flop"); - - const album = anAlbum({ - id: "album1", - name: "Burnin", - genre: flipFlop, - }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: flipFlop, - }); - - const tracks = [track]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([track]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only no tracks", () => { - const album = anAlbum({ id: "album1", name: "Burnin" }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const tracks: Track[] = []; - + beforeEach(() => { + customPlayers.encodingFor + .mockReturnValueOnce(O.of({ player: "bonob+audio/alac", mimeType: "audio/flac" })) + .mockReturnValueOnce(O.of({ player: "bonob+audio/m4a", mimeType: "audio/opus" })) + .mockReturnValueOnce(O.none) + mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + Promise.resolve(ok(getAlbumJson(artist, album, [alac, m4a, mp3]))) ); }); - - it("should empty array", async () => { + + it("should return the album with custom players applied", async () => { const result = await login({ username, password }) .then((it) => it.tracks(album.id)); - - expect(result).toEqual([]); - + + expect(result).toEqual([ + { + ...alac, + encoding: { + player: "bonob+audio/alac", + mimeType: "audio/flac" + } + }, + { + ...m4a, + encoding: { + player: "bonob+audio/m4a", + mimeType: "audio/opus" + } + }, + { + ...mp3, + encoding: { + player: "bonob", + mimeType: "audio/mp3" + } + }, + ]); + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { params: asURLSearchParams({ ...authParamsPlusJson, @@ -2840,8 +3012,13 @@ describe("Subsonic", () => { }), headers, }); + + expect(customPlayers.encodingFor).toHaveBeenCalledTimes(3); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(1, { mimeType: "audio/alac" }) + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(2, { mimeType: "audio/m4a" }) + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(3, { mimeType: "audio/mp3" }) }); - }); + }); }); describe("a single track", () => { @@ -2855,96 +3032,102 @@ describe("Subsonic", () => { albums: [album], }); - describe("that is starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: true, - stars: 4, - }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.track(track.id)); - - expect(result).toEqual({ - ...track, - rating: { love: true, stars: 4 }, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); + describe("when there are no custom players", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); }); - }); - - describe("that is not starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: false, - stars: 0, - }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.track(track.id)); - - expect(result).toEqual({ - ...track, - rating: { love: false, stars: 0 }, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, + + describe("that is starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: true, + stars: 4, + }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await login({ username, password }) + .then((it) => it.track(track.id)); + + expect(result).toEqual({ + ...track, + rating: { love: true, stars: 4 }, + }); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, + }); + + describe("that is not starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: false, + stars: 0, + }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await login({ username, password }) + .then((it) => it.track(track.id)); + + expect(result).toEqual({ + ...track, + rating: { love: false, stars: 0 }, + }); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); }); }); }); @@ -2952,6 +3135,7 @@ describe("Subsonic", () => { }); describe("streaming a track", () => { + const trackId = uuid(); const genre = aGenre("foo"); @@ -2966,105 +3150,26 @@ describe("Subsonic", () => { genre, }); - describe("content-range, accept-ranges or content-length", () => { + describe("when there are no custom players registered", () => { beforeEach(() => { - streamClientApplication.mockReturnValue("bonob"); - }); - - 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", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); + customPlayers.encodingFor.mockReturnValue(O.none); }); - 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(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .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 () => { + describe("content-range, accept-ranges or content-length", () => { + 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: stream, }; - + mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => @@ -3074,43 +3179,36 @@ describe("Subsonic", () => { Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); - + const result = await login({ username, password }) .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.append({ pathname: '/rest/stream' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, }); }); }); - - describe("navidrome returns something other than a 200", () => { - it("should fail", async () => { - const trackId = "track123"; - + + 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: 400, + status: 200, headers: { - 'content-type': 'text/html', - 'content-length': '33' - } + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }, + data: stream, }; - + mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => @@ -3120,19 +3218,144 @@ describe("Subsonic", () => { Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const musicLibrary = await login({ username, password }); - - return expect( - musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with a 400 status`); + + const result = await login({ username, password }) + .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("io exception occurs", () => { - it("should fail", async () => { - const trackId = "track123"; - + + 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(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await login({ username, password }) + .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.append({ pathname: '/rest/stream' }).href(), { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); + }); + }); + + describe("navidrome returns something other than a 200", () => { + it("should fail", async () => { + const trackId = "track123"; + + const streamResponse = { + status: 400, + headers: { + 'content-type': 'text/html', + 'content-length': '33' + } + }; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const musicLibrary = await login({ username, password }); + + return expect( + musicLibrary.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with a 400 status`); + }); + }); + + describe("io exception occurs", () => { + it("should fail", async () => { + const trackId = "track123"; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.reject("IO error occured")); + + const musicLibrary = await login({ username, password }); + + return expect( + musicLibrary.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with: IO error occured`); + }); + }); + }); + + 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(() => @@ -3141,78 +3364,51 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve(ok(getAlbumJson(artist, album, []))) ) - .mockImplementationOnce(() => Promise.reject("IO error occured")); - - const musicLibrary = await login({ username, password }); - - return expect( - musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with: IO error occured`); - }); - }); - }); - - 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: { + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await login({ username, password }) + .then((it) => it.stream({ trackId, range })); + + expect(result.headers).toEqual({ "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(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .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.append({ pathname: '/rest/stream' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + }); }); }); }); }); - 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); + describe("when there are custom players registered", () => { + const customEncoding = { + player: `bonob-${uuid()}`, + mimeType: "transocodedMimeType" + }; + const trackWithCustomPlayer: Track = { + ...track, + encoding: customEncoding + }; + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.of(customEncoding)); + }); + + describe("when no range specified", () => { + it("should user the custom client specified by the stream client", async () => { const streamResponse = { status: 200, headers: { @@ -3224,22 +3420,21 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) + Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [track]))) + Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer]))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); await login({ username, password }) .then((it) => it.stream({ trackId, range: undefined })); - expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { params: asURLSearchParams({ ...authParams, id: trackId, - c: clientApplication, + c: trackWithCustomPlayer.encoding.player, }), headers: { "User-Agent": "bonob", @@ -3250,10 +3445,8 @@ describe("Subsonic", () => { }); describe("when range specified", () => { - it("should user the custom StreamUserAgent when calling navidrome", async () => { + it("should user the custom client specified by the stream client", async () => { const range = "1000-2000"; - const clientApplication = `bonob-${uuid()}`; - streamClientApplication.mockReturnValue(clientApplication); const streamResponse = { status: 200, @@ -3266,22 +3459,21 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) + Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [track]))) + Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer]))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); await login({ username, password }) .then((it) => it.stream({ trackId, range })); - expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { params: asURLSearchParams({ ...authParams, id: trackId, - c: clientApplication, + c: trackWithCustomPlayer.encoding.player, }), headers: { "User-Agent": "bonob", @@ -3524,6 +3716,10 @@ describe("Subsonic", () => { const artist = anArtist(); const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + describe("rating a track", () => { describe("loving a track that isnt already loved", () => { it("should mark the track as loved", async () => { @@ -4067,6 +4263,10 @@ describe("Subsonic", () => { }); describe("searchSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + describe("when there is 1 search results", () => { it("should return true", async () => { const pop = asGenre("Pop"); @@ -4211,6 +4411,10 @@ describe("Subsonic", () => { }); describe("playlists", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + describe("getting playlists", () => { describe("when there is 1 playlist results", () => { it("should return it", async () => { @@ -4500,6 +4704,10 @@ describe("Subsonic", () => { }); describe("similarSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + describe("when there is one similar songs", () => { it("should return it", async () => { const id = "idWithTracks"; @@ -4529,7 +4737,7 @@ describe("Subsonic", () => { ); const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); + .then((it) => it.similarSongs(id)); expect(result).toEqual([track1]); @@ -4661,6 +4869,10 @@ describe("Subsonic", () => { }); describe("topSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + describe("when there is one top song", () => { it("should return it", async () => { const artistId = "bobMarleyId";