mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Fix issue where transcoded files would not play, provide support for custom clients to transcode (#194)
This commit is contained in:
@@ -16,5 +16,12 @@
|
||||
"version": "latest",
|
||||
"moby": true
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
src/app.ts
12
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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
116
src/subsonic.ts
116
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),
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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("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");
|
||||
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,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";
|
||||
|
||||
Reference in New Issue
Block a user