Fix issue where transcoded files would not play, provide support for custom clients to transcode (#194)

This commit is contained in:
Simon J
2024-02-07 16:21:28 +11:00
committed by GitHub
parent 6bf89b87e2
commit 9b9a348b20
10 changed files with 820 additions and 543 deletions

View File

@@ -16,5 +16,12 @@
"version": "latest",
"moby": true
}
},
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode"
]
}
}
}

View File

@@ -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; <p>If you want to simply re-encode some flacs, then you could specify just "audio/flac". <p>However; if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3" <p>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. <p>!!! 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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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: {

View File

@@ -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),
@@ -314,8 +321,8 @@ const asAlbum = (album: album): Album => ({
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<Encoding>
}
export type CustomClient = {
mimeType: string;
transcodedMimeType: string;
};
export class TranscodingCustomPlayers implements CustomPlayers {
transcodings: Map<string, string>;
constructor(transcodings: Map<string, string>) {
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<Encoding> => pipe(
this.transcodings.get(mimeType),
O.fromNullable,
O.map(transcodedMimeType => ({
player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`,
mimeType: transcodedMimeType
}))
)
}
export const NO_CUSTOM_PLAYERS: CustomPlayers = {
encodingFor(_) {
return O.none
},
}
const DEFAULT_CLIENT_APPLICATION = "bonob";
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 const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams();
Object.keys(q).forEach((k) => {
@@ -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<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || [])
.then((playlists) =>
playlists.map(asPlayListSummary)
),
.then((playlists) => playlists.map(asPlayListSummary)),
playlist: async (id: string) =>
subsonic
.getJSON<GetPlaylistResponse>(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<GetPlaylistResponse>(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")

View File

@@ -173,7 +173,10 @@ export function aTrack(fields: Partial<Track> = {}): Track {
return {
id,
name: `Track ${id}`,
mimeType: `audio/mp3-${id}`,
encoding: {
player: "bonob",
mimeType: `audio/mp3-${id}`
},
duration: randomInt(500),
number: randomInt(100),
genre,

View File

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

View File

@@ -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,36 +94,24 @@ describe("isValidImage", () => {
});
});
describe("appendMimeTypeToUserAgentFor", () => {
describe("when empty array", () => {
it("should return bonob", () => {
expect(appendMimeTypeToClientFor([])(aTrack())).toEqual("bonob");
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("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("when there is no match", () => {
it("should return undefined", () => {
expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual(O.none)
});
});
describe("and the track mimeType is not in the array", () => {
it("should return bonob", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual(
"bonob"
);
});
});
});
});
@@ -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,42 +670,104 @@ 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("with an undefined value", () => {
const track = aTrack({ mimeType: "sourced-from/mimeType" });
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);
it("should fall back on the default mime", () => {
const result = asTrack(album, { ...asSongJson(track), transcodedContentType: undefined });
expect(result.mimeType).toEqual("sourced-from/mimeType")
expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" })
});
});
describe("with a value", () => {
const track = aTrack({ mimeType: "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);
it("should use the transcoded value", () => {
const result = asTrack(album, { ...asSongJson(track), transcodedContentType: "sourced-from/transcodedContentType" });
expect(result.mimeType).toEqual("sourced-from/transcodedContentType")
});
expect(result.encoding).toEqual({ player: "bonob", mimeType: "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");
@@ -718,10 +775,13 @@ describe("Subsonic", () => {
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,6 +2749,11 @@ describe("Subsonic", () => {
describe("getting tracks", () => {
describe("for an album", () => {
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");
@@ -2844,6 +2912,115 @@ describe("Subsonic", () => {
});
});
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 alac = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
encoding: {
player: "bonob",
mimeType: "audio/alac"
},
genre: hipHop,
rating: {
love: true,
stars: 3,
},
});
const m4a = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
encoding: {
player: "bonob",
mimeType: "audio/m4a"
},
genre: hipHop,
rating: {
love: false,
stars: 0,
},
});
const mp3 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
encoding: {
player: "bonob",
mimeType: "audio/mp3"
},
genre: tripHop,
rating: {
love: true,
stars: 5,
},
});
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, [alac, m4a, mp3])))
);
});
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([
{
...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,
id: album.id,
}),
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", () => {
const pop = asGenre("Pop");
@@ -2855,6 +3032,11 @@ describe("Subsonic", () => {
albums: [album],
});
describe("when there are no custom players", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("that is starred", () => {
it("should return the track", async () => {
const track = aTrack({
@@ -2950,8 +3132,10 @@ describe("Subsonic", () => {
});
});
});
});
describe("streaming a track", () => {
const trackId = uuid();
const genre = aGenre("foo");
@@ -2966,11 +3150,12 @@ describe("Subsonic", () => {
genre,
});
describe("content-range, accept-ranges or content-length", () => {
describe("when there are no custom players registered", () => {
beforeEach(() => {
streamClientApplication.mockReturnValue("bonob");
customPlayers.encodingFor.mockReturnValue(O.none);
});
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 = {
@@ -3206,13 +3391,24 @@ describe("Subsonic", () => {
});
});
});
});
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 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);
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";
@@ -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";