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

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

View File

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

View File

@@ -21,7 +21,8 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
- Discovery of sonos devices using seed IP address - Discovery of sonos devices using seed IP address
- Auto registration with sonos on start - Auto registration with sonos on start
- Multiple registrations within a single household. - Multiple registrations within a single household.
- Transcoding support for flacs using a specific player for the flac mimeType bonob/sonos - Transcoding within subsonic clone
- Custom players by mime type, allowing custom transcoding rules for different file types
## Running ## Running
@@ -163,7 +164,6 @@ BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos
BNB_SECRET | bonob | secret used for encrypting credentials BNB_SECRET | bonob | secret used for encrypting credentials
BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT
BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error'] BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error']
BNB_DISABLE_PLAYLIST_ART | undefined | Disables playlist art generation, ie. when there are many playlists and art generation takes too long
BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests
BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup
BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified. BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified.
@@ -171,7 +171,7 @@ BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommit
BNB_SONOS_SERVICE_NAME | bonob | service name for sonos BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
BNB_SONOS_SERVICE_ID | 246 | service id for sonos BNB_SONOS_SERVICE_ID | 246 | service id for sonos
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. Must specify by the source mime type and the transcoded mime type. For example; <p>If you want to simply re-encode some flacs, then you could specify just "audio/flac". <p>However; if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3" <p>If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. <p>!!! Getting this configuration wrong will confuse SONOS as it will expect the wrong mime type for a track, as a result it will not play. Use with care...
BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache. BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache.
BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing

View File

@@ -68,8 +68,8 @@
"scripts": { "scripts": {
"clean": "rm -Rf build node_modules", "clean": "rm -Rf build node_modules",
"build": "tsc", "build": "tsc",
"dev": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"devr": "BNB_DISABLE_PLAYLIST_ART=true BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534", "register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
"test": "jest", "test": "jest",
"testw": "jest --watch", "testw": "jest --watch",

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,8 @@ import {
AlbumQueryType, AlbumQueryType,
Artist, Artist,
AuthFailure, AuthFailure,
PlaylistSummary PlaylistSummary,
Encoding,
} from "./music_service"; } from "./music_service";
import sharp from "sharp"; import sharp from "sharp";
import _ from "underscore"; import _ from "underscore";
@@ -163,7 +164,7 @@ export type song = {
duration: number | undefined; duration: number | undefined;
bitRate: number | undefined; bitRate: number | undefined;
suffix: string | undefined; suffix: string | undefined;
contentType: string | undefined; contentType: string;
transcodedContentType: string | undefined; transcodedContentType: string | undefined;
type: string | undefined; type: string | undefined;
userRating: number | undefined; userRating: number | undefined;
@@ -275,10 +276,16 @@ export const artistImageURN = (
} }
}; };
export const asTrack = (album: Album, song: song): Track => ({ export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({
id: song.id, id: song.id,
name: song.title, name: song.title,
mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType!, encoding: pipe(
customPlayers.encodingFor({ mimeType: song.contentType }),
O.getOrElse(() => ({
player: DEFAULT_CLIENT_APPLICATION,
mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType
}))
),
duration: song.duration || 0, duration: song.duration || 0,
number: song.track || 0, number: song.track || 0,
genre: maybeAsGenre(song.genre), genre: maybeAsGenre(song.genre),
@@ -311,11 +318,11 @@ const asAlbum = (album: album): Album => ({
}); });
// coverArtURN // coverArtURN
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) => {
@@ -357,28 +398,28 @@ export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
export const cachingImageFetcher = export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) => (cacheDir: string, delegate: ImageFetcher) =>
async (url: string): Promise<CoverArt | undefined> => { async (url: string): Promise<CoverArt | undefined> => {
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
return fse return fse
.readFile(filename) .readFile(filename)
.then((data) => ({ contentType: "image/png", data })) .then((data) => ({ contentType: "image/png", data }))
.catch(() => .catch(() =>
delegate(url).then((image) => { delegate(url).then((image) => {
if (image) { if (image) {
return sharp(image.data) return sharp(image.data)
.png() .png()
.toBuffer() .toBuffer()
.then((png) => { .then((png) => {
return fse return fse
.writeFile(filename, png) .writeFile(filename, png)
.then(() => ({ contentType: "image/png", data: png })); .then(() => ({ contentType: "image/png", data: png }));
}); });
} else { } else {
return undefined; return undefined;
} }
}) })
); );
}; };
export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> => export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
axios axios
@@ -426,16 +467,16 @@ interface SubsonicMusicLibrary extends MusicLibrary {
export class Subsonic implements MusicService { export class Subsonic implements MusicService {
url: URLBuilder; url: URLBuilder;
streamClientApplication: StreamClientApplication; customPlayers: CustomPlayers;
externalImageFetcher: ImageFetcher; externalImageFetcher: ImageFetcher;
constructor( constructor(
url: URLBuilder, url: URLBuilder,
streamClientApplication: StreamClientApplication = DEFAULT, customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS,
externalImageFetcher: ImageFetcher = axiosImageFetcher externalImageFetcher: ImageFetcher = axiosImageFetcher
) { ) {
this.url = url; this.url = url;
this.streamClientApplication = streamClientApplication; this.customPlayers = customPlayers;
this.externalImageFetcher = externalImageFetcher; this.externalImageFetcher = externalImageFetcher;
} }
@@ -630,7 +671,7 @@ export class Subsonic implements MusicService {
.then((it) => it.song) .then((it) => it.song)
.then((song) => .then((song) =>
this.getAlbum(credentials, song.albumId!).then((album) => this.getAlbum(credentials, song.albumId!).then((album) =>
asTrack(album, song) asTrack(album, song, this.customPlayers)
) )
); );
@@ -733,7 +774,7 @@ export class Subsonic implements MusicService {
}) })
.then((it) => it.album) .then((it) => it.album)
.then((album) => .then((album) =>
(album.song || []).map((song) => asTrack(asAlbum(album), song)) (album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
), ),
track: (trackId: string) => subsonic.getTrack(credentials, trackId), track: (trackId: string) => subsonic.getTrack(credentials, trackId),
rate: (trackId: string, rating: Rating) => rate: (trackId: string, rating: Rating) =>
@@ -784,7 +825,7 @@ export class Subsonic implements MusicService {
`/rest/stream`, `/rest/stream`,
{ {
id: trackId, id: trackId,
c: this.streamClientApplication(track), c: track.encoding.player,
}, },
{ {
headers: pipe( headers: pipe(
@@ -801,15 +842,15 @@ export class Subsonic implements MusicService {
responseType: "stream", responseType: "stream",
} }
) )
.then((res) => ({ .then((stream) => ({
status: res.status, status: stream.status,
headers: { headers: {
"content-type": res.headers["content-type"], "content-type": stream.headers["content-type"],
"content-length": res.headers["content-length"], "content-length": stream.headers["content-length"],
"content-range": res.headers["content-range"], "content-range": stream.headers["content-range"],
"accept-ranges": res.headers["accept-ranges"], "accept-ranges": stream.headers["accept-ranges"],
}, },
stream: res.data, stream: stream.data,
})) }))
), ),
coverArt: async (coverArtURN: BUrn, size?: number) => coverArt: async (coverArtURN: BUrn, size?: number) =>
@@ -872,9 +913,7 @@ export class Subsonic implements MusicService {
subsonic subsonic
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists") .getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || []) .then((it) => it.playlists.playlist || [])
.then((playlists) => .then((playlists) => playlists.map(asPlayListSummary)),
playlists.map(asPlayListSummary)
),
playlist: async (id: string) => playlist: async (id: string) =>
subsonic subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
@@ -898,7 +937,8 @@ export class Subsonic implements MusicService {
artistId: entry.artistId, artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt), coverArt: coverArtURN(entry.coverArt),
}, },
entry entry,
this.customPlayers
), ),
number: trackNumber++, number: trackNumber++,
})), })),
@@ -911,7 +951,11 @@ export class Subsonic implements MusicService {
}) })
.then((it) => it.playlist) .then((it) => it.playlist)
// todo: why is this line so similar to other playlist lines?? // todo: why is this line so similar to other playlist lines??
.then((it) => ({ id: it.id, name: it.name, coverArt: coverArtURN(it.coverArt) })), .then((it) => ({
id: it.id,
name: it.name,
coverArt: coverArtURN(it.coverArt),
})),
deletePlaylist: async (id: string) => deletePlaylist: async (id: string) =>
subsonic subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
@@ -945,7 +989,7 @@ export class Subsonic implements MusicService {
songs.map((song) => songs.map((song) =>
subsonic subsonic
.getAlbum(credentials, song.albumId!) .getAlbum(credentials, song.albumId!)
.then((album) => asTrack(album, song)) .then((album) => asTrack(album, song, this.customPlayers))
) )
) )
), ),
@@ -962,7 +1006,7 @@ export class Subsonic implements MusicService {
songs.map((song) => songs.map((song) =>
subsonic subsonic
.getAlbum(credentials, song.albumId!) .getAlbum(credentials, song.albumId!)
.then((album) => asTrack(album, song)) .then((album) => asTrack(album, song, this.customPlayers))
) )
) )
) )
@@ -979,7 +1023,7 @@ export class Subsonic implements MusicService {
TE.tryCatch( TE.tryCatch(
() => () =>
axios.post( axios.post(
this.url.append({ pathname: '/auth/login' }).href(), this.url.append({ pathname: "/auth/login" }).href(),
_.pick(credentials, "username", "password") _.pick(credentials, "username", "password")
), ),
() => new AuthFailure("Failed to get bearerToken") () => new AuthFailure("Failed to get bearerToken")

View File

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

View File

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

File diff suppressed because it is too large Load Diff