diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index c26d8cc..769b479 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -16,5 +16,12 @@
"version": "latest",
"moby": true
}
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "esbenp.prettier-vscode"
+ ]
+ }
}
}
diff --git a/README.md b/README.md
index 38b5f31..3fe9dab 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,8 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
- Discovery of sonos devices using seed IP address
- Auto registration with sonos on start
- Multiple registrations within a single household.
-- Transcoding support for flacs using a specific player for the flac mimeType bonob/sonos
+- Transcoding within subsonic clone
+- Custom players by mime type, allowing custom transcoding rules for different file types
## Running
@@ -163,7 +164,6 @@ BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos
BNB_SECRET | bonob | secret used for encrypting credentials
BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT
BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error']
-BNB_DISABLE_PLAYLIST_ART | undefined | Disables playlist art generation, ie. when there are many playlists and art generation takes too long
BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests
BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup
BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified.
@@ -171,7 +171,7 @@ BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommit
BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
BNB_SONOS_SERVICE_ID | 246 | service id for sonos
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
-BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
+BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. Must specify by the source mime type and the transcoded mime type. For example;
If you want to simply re-encode some flacs, then you could specify just "audio/flac".
However; if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"
If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
!!! Getting this configuration wrong will confuse SONOS as it will expect the wrong mime type for a track, as a result it will not play. Use with care...
BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache.
BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
diff --git a/package.json b/package.json
index eaa1bc1..c138bd2 100644
--- a/package.json
+++ b/package.json
@@ -68,8 +68,8 @@
"scripts": {
"clean": "rm -Rf build node_modules",
"build": "tsc",
- "dev": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
- "devr": "BNB_DISABLE_PLAYLIST_ART=true BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
+ "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
+ "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
"test": "jest",
"testw": "jest --watch",
diff --git a/src/app.ts b/src/app.ts
index 4b4c645..c91cb0b 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -4,11 +4,11 @@ import server from "./server";
import logger from "./logger";
import {
- appendMimeTypeToClientFor,
axiosImageFetcher,
cachingImageFetcher,
- DEFAULT,
Subsonic,
+ TranscodingCustomPlayers,
+ NO_CUSTOM_PLAYERS
} from "./subsonic";
import { InMemoryAPITokens, sha256 } from "./api_tokens";
import { InMemoryLinkCodes } from "./link_codes";
@@ -32,9 +32,9 @@ const bonob = bonobService(
const sonosSystem = sonos(config.sonos.discovery);
-const streamUserAgent = config.subsonic.customClientsFor
- ? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
- : DEFAULT;
+const customPlayers = config.subsonic.customClientsFor
+ ? TranscodingCustomPlayers.from(config.subsonic.customClientsFor)
+ : NO_CUSTOM_PLAYERS;
const artistImageFetcher = config.subsonic.artistImageCache
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
@@ -42,7 +42,7 @@ const artistImageFetcher = config.subsonic.artistImageCache
const subsonic = new Subsonic(
config.subsonic.url,
- streamUserAgent,
+ customPlayers,
artistImageFetcher
);
diff --git a/src/music_service.ts b/src/music_service.ts
index 02c5021..0e879a0 100644
--- a/src/music_service.ts
+++ b/src/music_service.ts
@@ -51,10 +51,15 @@ export type Rating = {
stars: number;
}
+export type Encoding = {
+ player: string,
+ mimeType: string
+}
+
export type Track = {
id: string;
name: string;
- mimeType: string;
+ encoding: Encoding,
duration: number;
number: number | undefined;
genre: Genre | undefined;
diff --git a/src/smapi.ts b/src/smapi.ts
index 70ccaac..77fa0b1 100644
--- a/src/smapi.ts
+++ b/src/smapi.ts
@@ -302,7 +302,7 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
export const track = (bonobUrl: URLBuilder, track: Track) => ({
itemType: "track",
id: `track:${track.id}`,
- mimeType: sonosifyMimeType(track.mimeType),
+ mimeType: sonosifyMimeType(track.encoding.mimeType),
title: track.name,
trackMetadata: {
diff --git a/src/subsonic.ts b/src/subsonic.ts
index 611d9d4..af46e83 100644
--- a/src/subsonic.ts
+++ b/src/subsonic.ts
@@ -20,7 +20,8 @@ import {
AlbumQueryType,
Artist,
AuthFailure,
- PlaylistSummary
+ PlaylistSummary,
+ Encoding,
} from "./music_service";
import sharp from "sharp";
import _ from "underscore";
@@ -163,7 +164,7 @@ export type song = {
duration: number | undefined;
bitRate: number | undefined;
suffix: string | undefined;
- contentType: string | undefined;
+ contentType: string;
transcodedContentType: string | undefined;
type: string | undefined;
userRating: number | undefined;
@@ -275,10 +276,16 @@ export const artistImageURN = (
}
};
-export const asTrack = (album: Album, song: song): Track => ({
+export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({
id: song.id,
name: song.title,
- mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType!,
+ encoding: pipe(
+ customPlayers.encodingFor({ mimeType: song.contentType }),
+ O.getOrElse(() => ({
+ player: DEFAULT_CLIENT_APPLICATION,
+ mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType
+ }))
+ ),
duration: song.duration || 0,
number: song.track || 0,
genre: maybeAsGenre(song.genre),
@@ -311,11 +318,11 @@ const asAlbum = (album: album): Album => ({
});
// coverArtURN
-const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
- id: playlist.id,
+const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
+ id: playlist.id,
name: playlist.name,
- coverArt: coverArtURN(playlist.coverArt)
-})
+ coverArt: coverArtURN(playlist.coverArt),
+});
export const asGenre = (genreName: string) => ({
id: b64Encode(genreName),
@@ -330,19 +337,53 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
O.getOrElseW(() => undefined)
);
-export type StreamClientApplication = (track: Track) => string;
+export interface CustomPlayers {
+ encodingFor({ mimeType }: { mimeType: string }): O.Option
+}
-const DEFAULT_CLIENT_APPLICATION = "bonob";
-const USER_AGENT = "bonob";
+export type CustomClient = {
+ mimeType: string;
+ transcodedMimeType: string;
+};
+
+export class TranscodingCustomPlayers implements CustomPlayers {
+ transcodings: Map;
-export const DEFAULT: StreamClientApplication = (_: Track) =>
- DEFAULT_CLIENT_APPLICATION;
+ constructor(transcodings: Map) {
+ this.transcodings = transcodings;
+ }
+
+ static from(config: string): TranscodingCustomPlayers {
+ const parts: [string, string][] = config
+ .split(",")
+ .map((it) => it.split(">"))
+ .map((pair) => {
+ if (pair.length == 1) return [pair[0]!, pair[0]!];
+ else if (pair.length == 2) return [pair[0]!, pair[1]!];
+ else throw new Error(`Invalid configuration item ${config}`);
+ });
+ return new TranscodingCustomPlayers(new Map(parts));
+ }
+
+ encodingFor = ({ mimeType }: { mimeType: string }): O.Option => pipe(
+ this.transcodings.get(mimeType),
+ O.fromNullable,
+ O.map(transcodedMimeType => ({
+ player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`,
+ mimeType: transcodedMimeType
+ }))
+ )
+}
-export function appendMimeTypeToClientFor(mimeTypes: string[]) {
- return (track: Track) =>
- mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
+export const NO_CUSTOM_PLAYERS: CustomPlayers = {
+ encodingFor(_) {
+ return O.none
+ },
}
+const DEFAULT_CLIENT_APPLICATION = "bonob";
+const USER_AGENT = "bonob";
+
export const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams();
Object.keys(q).forEach((k) => {
@@ -357,28 +398,28 @@ export type ImageFetcher = (url: string) => Promise;
export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) =>
- async (url: string): Promise => {
- const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
- return fse
- .readFile(filename)
- .then((data) => ({ contentType: "image/png", data }))
- .catch(() =>
- delegate(url).then((image) => {
- if (image) {
- return sharp(image.data)
- .png()
- .toBuffer()
- .then((png) => {
- return fse
- .writeFile(filename, png)
- .then(() => ({ contentType: "image/png", data: png }));
- });
- } else {
- return undefined;
- }
- })
- );
- };
+ async (url: string): Promise => {
+ const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
+ return fse
+ .readFile(filename)
+ .then((data) => ({ contentType: "image/png", data }))
+ .catch(() =>
+ delegate(url).then((image) => {
+ if (image) {
+ return sharp(image.data)
+ .png()
+ .toBuffer()
+ .then((png) => {
+ return fse
+ .writeFile(filename, png)
+ .then(() => ({ contentType: "image/png", data: png }));
+ });
+ } else {
+ return undefined;
+ }
+ })
+ );
+ };
export const axiosImageFetcher = (url: string): Promise =>
axios
@@ -426,16 +467,16 @@ interface SubsonicMusicLibrary extends MusicLibrary {
export class Subsonic implements MusicService {
url: URLBuilder;
- streamClientApplication: StreamClientApplication;
+ customPlayers: CustomPlayers;
externalImageFetcher: ImageFetcher;
constructor(
url: URLBuilder,
- streamClientApplication: StreamClientApplication = DEFAULT,
+ customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS,
externalImageFetcher: ImageFetcher = axiosImageFetcher
) {
this.url = url;
- this.streamClientApplication = streamClientApplication;
+ this.customPlayers = customPlayers;
this.externalImageFetcher = externalImageFetcher;
}
@@ -630,7 +671,7 @@ export class Subsonic implements MusicService {
.then((it) => it.song)
.then((song) =>
this.getAlbum(credentials, song.albumId!).then((album) =>
- asTrack(album, song)
+ asTrack(album, song, this.customPlayers)
)
);
@@ -733,7 +774,7 @@ export class Subsonic implements MusicService {
})
.then((it) => it.album)
.then((album) =>
- (album.song || []).map((song) => asTrack(asAlbum(album), song))
+ (album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
),
track: (trackId: string) => subsonic.getTrack(credentials, trackId),
rate: (trackId: string, rating: Rating) =>
@@ -784,7 +825,7 @@ export class Subsonic implements MusicService {
`/rest/stream`,
{
id: trackId,
- c: this.streamClientApplication(track),
+ c: track.encoding.player,
},
{
headers: pipe(
@@ -801,15 +842,15 @@ export class Subsonic implements MusicService {
responseType: "stream",
}
)
- .then((res) => ({
- status: res.status,
+ .then((stream) => ({
+ status: stream.status,
headers: {
- "content-type": res.headers["content-type"],
- "content-length": res.headers["content-length"],
- "content-range": res.headers["content-range"],
- "accept-ranges": res.headers["accept-ranges"],
+ "content-type": stream.headers["content-type"],
+ "content-length": stream.headers["content-length"],
+ "content-range": stream.headers["content-range"],
+ "accept-ranges": stream.headers["accept-ranges"],
},
- stream: res.data,
+ stream: stream.data,
}))
),
coverArt: async (coverArtURN: BUrn, size?: number) =>
@@ -872,9 +913,7 @@ export class Subsonic implements MusicService {
subsonic
.getJSON(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || [])
- .then((playlists) =>
- playlists.map(asPlayListSummary)
- ),
+ .then((playlists) => playlists.map(asPlayListSummary)),
playlist: async (id: string) =>
subsonic
.getJSON(credentials, "/rest/getPlaylist", {
@@ -898,7 +937,8 @@ export class Subsonic implements MusicService {
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
- entry
+ entry,
+ this.customPlayers
),
number: trackNumber++,
})),
@@ -911,7 +951,11 @@ export class Subsonic implements MusicService {
})
.then((it) => it.playlist)
// todo: why is this line so similar to other playlist lines??
- .then((it) => ({ id: it.id, name: it.name, coverArt: coverArtURN(it.coverArt) })),
+ .then((it) => ({
+ id: it.id,
+ name: it.name,
+ coverArt: coverArtURN(it.coverArt),
+ })),
deletePlaylist: async (id: string) =>
subsonic
.getJSON(credentials, "/rest/deletePlaylist", {
@@ -945,7 +989,7 @@ export class Subsonic implements MusicService {
songs.map((song) =>
subsonic
.getAlbum(credentials, song.albumId!)
- .then((album) => asTrack(album, song))
+ .then((album) => asTrack(album, song, this.customPlayers))
)
)
),
@@ -962,7 +1006,7 @@ export class Subsonic implements MusicService {
songs.map((song) =>
subsonic
.getAlbum(credentials, song.albumId!)
- .then((album) => asTrack(album, song))
+ .then((album) => asTrack(album, song, this.customPlayers))
)
)
)
@@ -979,7 +1023,7 @@ export class Subsonic implements MusicService {
TE.tryCatch(
() =>
axios.post(
- this.url.append({ pathname: '/auth/login' }).href(),
+ this.url.append({ pathname: "/auth/login" }).href(),
_.pick(credentials, "username", "password")
),
() => new AuthFailure("Failed to get bearerToken")
diff --git a/tests/builders.ts b/tests/builders.ts
index 83c1c7e..5dadc61 100644
--- a/tests/builders.ts
+++ b/tests/builders.ts
@@ -173,7 +173,10 @@ export function aTrack(fields: Partial