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", "version": "latest",
"moby": true "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 - Discovery of sonos devices using seed IP address
- Auto registration with sonos on start - Auto registration with sonos on start
- Multiple registrations within a single household. - 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 ## 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_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_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_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_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_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. 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_NAME | bonob | service name for sonos
BNB_SONOS_SERVICE_ID | 246 | service id for sonos BNB_SONOS_SERVICE_ID | 246 | service id for sonos
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone 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_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_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 BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing

View File

@@ -68,8 +68,8 @@
"scripts": { "scripts": {
"clean": "rm -Rf build node_modules", "clean": "rm -Rf build node_modules",
"build": "tsc", "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", "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_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", "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", "register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
"test": "jest", "test": "jest",
"testw": "jest --watch", "testw": "jest --watch",

View File

@@ -4,11 +4,11 @@ import server from "./server";
import logger from "./logger"; import logger from "./logger";
import { import {
appendMimeTypeToClientFor,
axiosImageFetcher, axiosImageFetcher,
cachingImageFetcher, cachingImageFetcher,
DEFAULT,
Subsonic, Subsonic,
TranscodingCustomPlayers,
NO_CUSTOM_PLAYERS
} from "./subsonic"; } from "./subsonic";
import { InMemoryAPITokens, sha256 } from "./api_tokens"; import { InMemoryAPITokens, sha256 } from "./api_tokens";
import { InMemoryLinkCodes } from "./link_codes"; import { InMemoryLinkCodes } from "./link_codes";
@@ -32,9 +32,9 @@ const bonob = bonobService(
const sonosSystem = sonos(config.sonos.discovery); const sonosSystem = sonos(config.sonos.discovery);
const streamUserAgent = config.subsonic.customClientsFor const customPlayers = config.subsonic.customClientsFor
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(",")) ? TranscodingCustomPlayers.from(config.subsonic.customClientsFor)
: DEFAULT; : NO_CUSTOM_PLAYERS;
const artistImageFetcher = config.subsonic.artistImageCache const artistImageFetcher = config.subsonic.artistImageCache
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher) ? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
@@ -42,7 +42,7 @@ const artistImageFetcher = config.subsonic.artistImageCache
const subsonic = new Subsonic( const subsonic = new Subsonic(
config.subsonic.url, config.subsonic.url,
streamUserAgent, customPlayers,
artistImageFetcher artistImageFetcher
); );

View File

@@ -51,10 +51,15 @@ export type Rating = {
stars: number; stars: number;
} }
export type Encoding = {
player: string,
mimeType: string
}
export type Track = { export type Track = {
id: string; id: string;
name: string; name: string;
mimeType: string; encoding: Encoding,
duration: number; duration: number;
number: number | undefined; number: number | undefined;
genre: Genre | undefined; genre: Genre | undefined;

View File

@@ -302,7 +302,7 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
export const track = (bonobUrl: URLBuilder, track: Track) => ({ export const track = (bonobUrl: URLBuilder, track: Track) => ({
itemType: "track", itemType: "track",
id: `track:${track.id}`, id: `track:${track.id}`,
mimeType: sonosifyMimeType(track.mimeType), mimeType: sonosifyMimeType(track.encoding.mimeType),
title: track.name, title: track.name,
trackMetadata: { trackMetadata: {

View File

@@ -20,7 +20,8 @@ import {
AlbumQueryType, AlbumQueryType,
Artist, Artist,
AuthFailure, AuthFailure,
PlaylistSummary PlaylistSummary,
Encoding,
} from "./music_service"; } from "./music_service";
import sharp from "sharp"; import sharp from "sharp";
import _ from "underscore"; import _ from "underscore";
@@ -163,7 +164,7 @@ export type song = {
duration: number | undefined; duration: number | undefined;
bitRate: number | undefined; bitRate: number | undefined;
suffix: string | undefined; suffix: string | undefined;
contentType: string | undefined; contentType: string;
transcodedContentType: string | undefined; transcodedContentType: string | undefined;
type: string | undefined; type: string | undefined;
userRating: number | 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, id: song.id,
name: song.title, 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, duration: song.duration || 0,
number: song.track || 0, number: song.track || 0,
genre: maybeAsGenre(song.genre), genre: maybeAsGenre(song.genre),
@@ -314,8 +321,8 @@ const asAlbum = (album: album): Album => ({
const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({ const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
id: playlist.id, id: playlist.id,
name: playlist.name, name: playlist.name,
coverArt: coverArtURN(playlist.coverArt) coverArt: coverArtURN(playlist.coverArt),
}) });
export const asGenre = (genreName: string) => ({ export const asGenre = (genreName: string) => ({
id: b64Encode(genreName), id: b64Encode(genreName),
@@ -330,19 +337,53 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
O.getOrElseW(() => 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 DEFAULT_CLIENT_APPLICATION = "bonob";
const USER_AGENT = "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) => { export const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams(); const urlSearchParams = new URLSearchParams();
Object.keys(q).forEach((k) => { Object.keys(q).forEach((k) => {
@@ -426,16 +467,16 @@ interface SubsonicMusicLibrary extends MusicLibrary {
export class Subsonic implements MusicService { export class Subsonic implements MusicService {
url: URLBuilder; url: URLBuilder;
streamClientApplication: StreamClientApplication; customPlayers: CustomPlayers;
externalImageFetcher: ImageFetcher; externalImageFetcher: ImageFetcher;
constructor( constructor(
url: URLBuilder, url: URLBuilder,
streamClientApplication: StreamClientApplication = DEFAULT, customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS,
externalImageFetcher: ImageFetcher = axiosImageFetcher externalImageFetcher: ImageFetcher = axiosImageFetcher
) { ) {
this.url = url; this.url = url;
this.streamClientApplication = streamClientApplication; this.customPlayers = customPlayers;
this.externalImageFetcher = externalImageFetcher; this.externalImageFetcher = externalImageFetcher;
} }
@@ -630,7 +671,7 @@ export class Subsonic implements MusicService {
.then((it) => it.song) .then((it) => it.song)
.then((song) => .then((song) =>
this.getAlbum(credentials, song.albumId!).then((album) => 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((it) => it.album)
.then((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), track: (trackId: string) => subsonic.getTrack(credentials, trackId),
rate: (trackId: string, rating: Rating) => rate: (trackId: string, rating: Rating) =>
@@ -784,7 +825,7 @@ export class Subsonic implements MusicService {
`/rest/stream`, `/rest/stream`,
{ {
id: trackId, id: trackId,
c: this.streamClientApplication(track), c: track.encoding.player,
}, },
{ {
headers: pipe( headers: pipe(
@@ -801,15 +842,15 @@ export class Subsonic implements MusicService {
responseType: "stream", responseType: "stream",
} }
) )
.then((res) => ({ .then((stream) => ({
status: res.status, status: stream.status,
headers: { headers: {
"content-type": res.headers["content-type"], "content-type": stream.headers["content-type"],
"content-length": res.headers["content-length"], "content-length": stream.headers["content-length"],
"content-range": res.headers["content-range"], "content-range": stream.headers["content-range"],
"accept-ranges": res.headers["accept-ranges"], "accept-ranges": stream.headers["accept-ranges"],
}, },
stream: res.data, stream: stream.data,
})) }))
), ),
coverArt: async (coverArtURN: BUrn, size?: number) => coverArt: async (coverArtURN: BUrn, size?: number) =>
@@ -872,9 +913,7 @@ export class Subsonic implements MusicService {
subsonic subsonic
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists") .getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || []) .then((it) => it.playlists.playlist || [])
.then((playlists) => .then((playlists) => playlists.map(asPlayListSummary)),
playlists.map(asPlayListSummary)
),
playlist: async (id: string) => playlist: async (id: string) =>
subsonic subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
@@ -898,7 +937,8 @@ export class Subsonic implements MusicService {
artistId: entry.artistId, artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt), coverArt: coverArtURN(entry.coverArt),
}, },
entry entry,
this.customPlayers
), ),
number: trackNumber++, number: trackNumber++,
})), })),
@@ -911,7 +951,11 @@ export class Subsonic implements MusicService {
}) })
.then((it) => it.playlist) .then((it) => it.playlist)
// todo: why is this line so similar to other playlist lines?? // 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) => deletePlaylist: async (id: string) =>
subsonic subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
@@ -945,7 +989,7 @@ export class Subsonic implements MusicService {
songs.map((song) => songs.map((song) =>
subsonic subsonic
.getAlbum(credentials, song.albumId!) .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) => songs.map((song) =>
subsonic subsonic
.getAlbum(credentials, song.albumId!) .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( TE.tryCatch(
() => () =>
axios.post( axios.post(
this.url.append({ pathname: '/auth/login' }).href(), this.url.append({ pathname: "/auth/login" }).href(),
_.pick(credentials, "username", "password") _.pick(credentials, "username", "password")
), ),
() => new AuthFailure("Failed to get bearerToken") () => new AuthFailure("Failed to get bearerToken")

View File

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

View File

@@ -352,7 +352,10 @@ describe("track", () => {
const someTrack = aTrack({ const someTrack = aTrack({
id: uuid(), id: uuid(),
// audio/x-flac should be mapped to audio/flac // audio/x-flac should be mapped to audio/flac
mimeType: "audio/x-flac", encoding: {
player: "something",
mimeType: "audio/x-flac"
},
name: "great song", name: "great song",
duration: randomInt(1000), duration: randomInt(1000),
number: randomInt(100), number: randomInt(100),
@@ -407,7 +410,10 @@ describe("track", () => {
const someTrack = aTrack({ const someTrack = aTrack({
id: uuid(), id: uuid(),
// audio/x-flac should be mapped to audio/flac // audio/x-flac should be mapped to audio/flac
mimeType: "audio/x-flac", encoding: {
player: "something",
mimeType: "audio/x-flac"
},
name: "great song", name: "great song",
duration: randomInt(1000), duration: randomInt(1000),
number: randomInt(100), number: randomInt(100),
@@ -2579,7 +2585,7 @@ describe("wsdl api", () => {
id: `track:${track.id}`, id: `track:${track.id}`,
itemType: "track", itemType: "track",
title: track.name, title: track.name,
mimeType: track.mimeType, mimeType: track.encoding.mimeType,
trackMetadata: { trackMetadata: {
artistId: `artist:${track.artist.id}`, artistId: `artist:${track.artist.id}`,
artist: track.artist.name, artist: track.artist.name,
@@ -2627,7 +2633,7 @@ describe("wsdl api", () => {
id: `track:${track.id}`, id: `track:${track.id}`,
itemType: "track", itemType: "track",
title: track.name, title: track.name,
mimeType: track.mimeType, mimeType: track.encoding.mimeType,
trackMetadata: { trackMetadata: {
artistId: `artist:${track.artist.id}`, artistId: `artist:${track.artist.id}`,
artist: track.artist.name, artist: track.artist.name,

View File

@@ -12,7 +12,6 @@ import {
t, t,
DODGY_IMAGE_NAME, DODGY_IMAGE_NAME,
asGenre, asGenre,
appendMimeTypeToClientFor,
asURLSearchParams, asURLSearchParams,
cachingImageFetcher, cachingImageFetcher,
asTrack, asTrack,
@@ -22,6 +21,9 @@ import {
PingResponse, PingResponse,
parseToken, parseToken,
asToken, asToken,
TranscodingCustomPlayers,
CustomPlayers,
NO_CUSTOM_PLAYERS
} from "../src/subsonic"; } from "../src/subsonic";
import axios from "axios"; import axios from "axios";
@@ -47,7 +49,7 @@ import {
SimilarArtist, SimilarArtist,
Rating, Rating,
Credentials, Credentials,
AuthFailure, AuthFailure
} from "../src/music_service"; } from "../src/music_service";
import { import {
aGenre, aGenre,
@@ -92,36 +94,24 @@ describe("isValidImage", () => {
}); });
}); });
describe("appendMimeTypeToUserAgentFor", () => {
describe("when empty array", () => { describe("StreamClient(s)", () => {
it("should return bonob", () => { describe("CustomStreamClientApplications", () => {
expect(appendMimeTypeToClientFor([])(aTrack())).toEqual("bonob"); 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", () => { describe("when there is no match", () => {
const streamUserAgent = appendMimeTypeToClientFor([ it("should return undefined", () => {
"audio/flac", expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual(O.none)
"audio/ogg",
]);
describe("and the track mimeType is in the array", () => {
it("should return bonob+mimeType", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/flac" }))).toEqual(
"bonob+audio/flac"
);
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg" }))).toEqual(
"bonob+audio/ogg"
);
}); });
}); });
describe("and the track mimeType is not in the array", () => {
it("should return bonob", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual(
"bonob"
);
});
}); });
}); });
}); });
@@ -321,7 +311,7 @@ const asSongJson = (track: Track) => ({
bitRate: 128, bitRate: 128,
size: "5624132", size: "5624132",
suffix: "mp3", suffix: "mp3",
contentType: track.mimeType, contentType: track.encoding.mimeType,
transcodedContentType: undefined, transcodedContentType: undefined,
isVideo: "false", isVideo: "false",
path: "ACDC/High voltage/ACDC - The Jack.mp3", path: "ACDC/High voltage/ACDC - The Jack.mp3",
@@ -448,7 +438,7 @@ const getPlayListJson = (playlist: Playlist) =>
genre: it.album.genre?.name, genre: it.album.genre?.name,
coverArt: maybeIdFromCoverArtUrn(it.coverArt), coverArt: maybeIdFromCoverArtUrn(it.coverArt),
size: 123, size: 123,
contentType: it.mimeType, contentType: it.encoding.mimeType,
suffix: "mp3", suffix: "mp3",
duration: it.duration, duration: it.duration,
bitRate: 128, bitRate: 128,
@@ -646,12 +636,17 @@ describe("artistURN", () => {
}); });
describe("asTrack", () => { describe("asTrack", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe("when the song has no artistId", () => { describe("when the song has no artistId", () => {
const album = anAlbum(); const album = anAlbum();
const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }});
it("should provide no artistId", () => { 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.id).toBeUndefined();
expect(result.artist.name).toEqual("Not in library so no id"); expect(result.artist.name).toEqual("Not in library so no id");
expect(result.artist.image).toBeUndefined(); expect(result.artist.image).toBeUndefined();
@@ -662,7 +657,7 @@ describe("asTrack", () => {
const album = anAlbum(); const album = anAlbum();
it("should provide a ? to sonos", () => { 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.id).toBeUndefined();
expect(result.artist.name).toEqual("?"); expect(result.artist.name).toEqual("?");
expect(result.artist.image).toBeUndefined(); expect(result.artist.image).toBeUndefined();
@@ -675,53 +670,118 @@ describe("asTrack", () => {
describe("a value greater than 5", () => { describe("a value greater than 5", () => {
it("should be returned as 0", () => { 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); expect(result.rating.stars).toEqual(0);
}); });
}); });
describe("a value less than 0", () => { describe("a value less than 0", () => {
it("should be returned as 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); expect(result.rating.stars).toEqual(0);
}); });
}); });
}); });
describe("when the song has a transcodedContentType", () => { describe("content types", () => {
const album = anAlbum(); const album = anAlbum();
const track = aTrack();
describe("with an undefined value", () => { describe("when there are no custom players", () => {
const track = aTrack({ mimeType: "sourced-from/mimeType" }); 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", () => { expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" })
const result = asTrack(album, { ...asSongJson(track), transcodedContentType: undefined });
expect(result.mimeType).toEqual("sourced-from/mimeType")
}); });
}); });
describe("with a value", () => { describe("when subsonic reports a transcodedContentType", () => {
const track = aTrack({ mimeType: "sourced-from/mimeType" }); 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", () => { expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType" })
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", () => { describe("Subsonic", () => {
const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); const url = new URLBuilder("http://127.0.0.22:4567/some-context-path");
const username = `user1-${uuid()}`; const username = `user1-${uuid()}`;
const password = `pass1-${uuid()}`; const password = `pass1-${uuid()}`;
const salt = "saltysalty"; const salt = "saltysalty";
const streamClientApplication = jest.fn(); const customPlayers = {
encodingFor: jest.fn()
};
const subsonic = new Subsonic( const subsonic = new Subsonic(
url, url,
streamClientApplication customPlayers as unknown as CustomPlayers
); );
const mockRandomstring = jest.fn(); const mockRandomstring = jest.fn();
@@ -761,8 +821,7 @@ describe("Subsonic", () => {
TE.fold(e => { throw e }, T.of) TE.fold(e => { throw e }, T.of)
) )
const login = (credentials: Credentials) => tokenFor(credentials)() const login = (credentials: Credentials) => tokenFor(credentials)().then((it) => subsonic.login(it.serviceToken))
.then((it) => subsonic.login(it.serviceToken))
describe("generateToken", () => { describe("generateToken", () => {
describe("when the credentials are valid", () => { describe("when the credentials are valid", () => {
@@ -2645,6 +2704,10 @@ describe("Subsonic", () => {
}); });
describe("getting an album", () => { describe("getting an album", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("when it exists", () => { describe("when it exists", () => {
const genre = asGenre("Pop"); const genre = asGenre("Pop");
@@ -2686,6 +2749,11 @@ describe("Subsonic", () => {
describe("getting tracks", () => { describe("getting tracks", () => {
describe("for an album", () => { 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", () => { describe("when the album has multiple tracks, some of which are rated", () => {
const hipHop = asGenre("Hip-Hop"); const hipHop = asGenre("Hip-Hop");
const tripHop = asGenre("Trip-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", () => { describe("a single track", () => {
const pop = asGenre("Pop"); const pop = asGenre("Pop");
@@ -2855,6 +3032,11 @@ describe("Subsonic", () => {
albums: [album], albums: [album],
}); });
describe("when there are no custom players", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("that is starred", () => { describe("that is starred", () => {
it("should return the track", async () => { it("should return the track", async () => {
const track = aTrack({ const track = aTrack({
@@ -2950,8 +3132,10 @@ describe("Subsonic", () => {
}); });
}); });
}); });
});
describe("streaming a track", () => { describe("streaming a track", () => {
const trackId = uuid(); const trackId = uuid();
const genre = aGenre("foo"); const genre = aGenre("foo");
@@ -2966,11 +3150,12 @@ describe("Subsonic", () => {
genre, genre,
}); });
describe("content-range, accept-ranges or content-length", () => { describe("when there are no custom players registered", () => {
beforeEach(() => { 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", () => { describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => { it("should return undefined values", async () => {
const stream = { 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", () => { describe("when no 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 clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = { const streamResponse = {
status: 200, status: 200,
headers: { headers: {
@@ -3224,22 +3420,21 @@ describe("Subsonic", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getSongJson(track))) Promise.resolve(ok(getSongJson(trackWithCustomPlayer)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(artist, album, [track]))) Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer])))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
await login({ username, password }) await login({ username, password })
.then((it) => it.stream({ trackId, range: undefined })); .then((it) => it.stream({ trackId, range: undefined }));
expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: trackId, id: trackId,
c: clientApplication, c: trackWithCustomPlayer.encoding.player,
}), }),
headers: { headers: {
"User-Agent": "bonob", "User-Agent": "bonob",
@@ -3250,10 +3445,8 @@ describe("Subsonic", () => {
}); });
describe("when range specified", () => { 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 range = "1000-2000";
const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = { const streamResponse = {
status: 200, status: 200,
@@ -3266,22 +3459,21 @@ describe("Subsonic", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getSongJson(track))) Promise.resolve(ok(getSongJson(trackWithCustomPlayer)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumJson(artist, album, [track]))) Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer])))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
await login({ username, password }) await login({ username, password })
.then((it) => it.stream({ trackId, range })); .then((it) => it.stream({ trackId, range }));
expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: trackId, id: trackId,
c: clientApplication, c: trackWithCustomPlayer.encoding.player,
}), }),
headers: { headers: {
"User-Agent": "bonob", "User-Agent": "bonob",
@@ -3524,6 +3716,10 @@ describe("Subsonic", () => {
const artist = anArtist(); const artist = anArtist();
const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); const album = anAlbum({ id: "album1", name: "Burnin", genre: POP });
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("rating a track", () => { describe("rating a track", () => {
describe("loving a track that isnt already loved", () => { describe("loving a track that isnt already loved", () => {
it("should mark the track as loved", async () => { it("should mark the track as loved", async () => {
@@ -4067,6 +4263,10 @@ describe("Subsonic", () => {
}); });
describe("searchSongs", () => { describe("searchSongs", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("when there is 1 search results", () => { describe("when there is 1 search results", () => {
it("should return true", async () => { it("should return true", async () => {
const pop = asGenre("Pop"); const pop = asGenre("Pop");
@@ -4211,6 +4411,10 @@ describe("Subsonic", () => {
}); });
describe("playlists", () => { describe("playlists", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("getting playlists", () => { describe("getting playlists", () => {
describe("when there is 1 playlist results", () => { describe("when there is 1 playlist results", () => {
it("should return it", async () => { it("should return it", async () => {
@@ -4500,6 +4704,10 @@ describe("Subsonic", () => {
}); });
describe("similarSongs", () => { describe("similarSongs", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("when there is one similar songs", () => { describe("when there is one similar songs", () => {
it("should return it", async () => { it("should return it", async () => {
const id = "idWithTracks"; const id = "idWithTracks";
@@ -4661,6 +4869,10 @@ describe("Subsonic", () => {
}); });
describe("topSongs", () => { describe("topSongs", () => {
beforeEach(() => {
customPlayers.encodingFor.mockReturnValue(O.none);
});
describe("when there is one top song", () => { describe("when there is one top song", () => {
it("should return it", async () => { it("should return it", async () => {
const artistId = "bobMarleyId"; const artistId = "bobMarleyId";