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",
|
"version": "latest",
|
||||||
"moby": true
|
"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
|
- 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
12
src/app.ts
12
src/app.ts
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
116
src/subsonic.ts
116
src/subsonic.ts
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user