diff --git a/package.json b/package.json index 3239821..cb8e8fb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@types/jws": "^3.2.4", "@types/morgan": "^1.9.3", "@types/node": "^16.7.13", + "@types/randomstring": "^1.1.8", "@types/sharp": "^0.28.6", "@types/underscore": "^1.11.3", "@types/uuid": "^8.3.1", @@ -27,11 +28,13 @@ "libxmljs2": "^0.28.0", "morgan": "^1.10.0", "node-html-parser": "^4.1.4", + "randomstring": "^1.2.1", "sharp": "^0.29.1", "soap": "^0.42.0", "ts-md5": "^1.2.9", "typescript": "^4.4.2", "underscore": "^1.13.1", + "urn-lib": "^2.0.0", "uuid": "^8.3.2", "winston": "^3.3.3" }, diff --git a/src/app.ts b/src/app.ts index 5022ab8..ab46b73 100644 --- a/src/app.ts +++ b/src/app.ts @@ -88,7 +88,8 @@ const app = server( applyContextPath: true, logRequests: true, version, - tokenSigner: jwtSigner(config.secret) + tokenSigner: jwtSigner(config.secret), + externalImageResolver: artistImageFetcher } ); diff --git a/src/burn.ts b/src/burn.ts new file mode 100644 index 0000000..c4b2c4e --- /dev/null +++ b/src/burn.ts @@ -0,0 +1,90 @@ +import _ from "underscore"; +import { createUrnUtil } from "urn-lib"; +import randomstring from "randomstring"; + +import jwsEncryption from "./encryption"; + +const BURN = createUrnUtil("bnb", { + components: ["system", "resource"], + separator: ":", + allowEmpty: false, +}); + +export type BUrn = { + system: string; + resource: string; +}; + +const DEFAULT_FORMAT_OPTS = { + shorthand: false, + encrypt: false, +} + +const SHORTHAND_MAPPINGS: Record = { + "internal" : "i", + "external": "e", + "subsonic": "s", + "navidrome": "n", + "encrypted": "x" +} +const REVERSE_SHORTHAND_MAPPINGS: Record = Object.keys(SHORTHAND_MAPPINGS).reduce((ret, key) => { + ret[SHORTHAND_MAPPINGS[key] as unknown as string] = key; + return ret; +}, {} as Record) +if(SHORTHAND_MAPPINGS.length != REVERSE_SHORTHAND_MAPPINGS.length) { + throw `Invalid SHORTHAND_MAPPINGS, must be duplicate!` +} + +export const BURN_SALT = randomstring.generate(5); +const encryptor = jwsEncryption(BURN_SALT); + +export const format = ( + burn: BUrn, + opts: Partial<{ shorthand: boolean; encrypt: boolean }> = {} +): string => { + const o = { ...DEFAULT_FORMAT_OPTS, ...opts } + let toBurn = burn; + if(o.shorthand) { + toBurn = { + ...toBurn, + system: SHORTHAND_MAPPINGS[toBurn.system] || toBurn.system + } + } + if(o.encrypt) { + const encryptedToBurn = { + system: "encrypted", + resource: encryptor.encrypt(BURN.format(toBurn)) + } + return format(encryptedToBurn, { ...opts, encrypt: false }) + } else { + return BURN.format(toBurn); + } +}; + +export const formatForURL = (burn: BUrn) => { + if(burn.system == "external") return format(burn, { shorthand: true, encrypt: true }) + else return format(burn, { shorthand: true }) +} + +export const parse = (burn: string): BUrn => { + const result = BURN.parse(burn)!; + const validationErrors = BURN.validate(result) || []; + if (validationErrors.length > 0) { + throw new Error(`Invalid burn: '${burn}'`); + } + const system = result.system as string; + const x = { + system: REVERSE_SHORTHAND_MAPPINGS[system] || system, + resource: result.resource as string, + }; + if(x.system == "encrypted") { + return parse(encryptor.decrypt(x.resource)); + } else { + return x; + } +} + +export function assertSystem(urn: BUrn, system: string): BUrn { + if (urn.system != system) throw `Unsupported urn: '${format(urn)}'`; + else return urn; +} \ No newline at end of file diff --git a/src/music_service.ts b/src/music_service.ts index 792fc88..78bfb5a 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -1,3 +1,5 @@ +import { BUrn } from "./burn"; + export type Credentials = { username: string; password: string }; export function isSuccess( @@ -25,24 +27,12 @@ export type AuthFailure = { export type ArtistSummary = { id: string; name: string; -}; - -export type Images = { - small: string | undefined; - medium: string | undefined; - large: string | undefined; -}; - -export const NO_IMAGES: Images = { - small: undefined, - medium: undefined, - large: undefined, + image: BUrn | undefined; }; export type SimilarArtist = ArtistSummary & { inLibrary: boolean }; export type Artist = ArtistSummary & { - image: Images albums: AlbumSummary[]; similarArtists: SimilarArtist[] }; @@ -52,7 +42,7 @@ export type AlbumSummary = { name: string; year: string | undefined; genre: Genre | undefined; - coverArt: string | undefined; + coverArt: BUrn | undefined; artistName: string | undefined; artistId: string | undefined; @@ -77,7 +67,7 @@ export type Track = { duration: number; number: number | undefined; genre: Genre | undefined; - coverArt: string | undefined; + coverArt: BUrn | undefined; album: AlbumSummary; artist: ArtistSummary; rating: Rating; @@ -117,6 +107,7 @@ export type AlbumQuery = Paging & { export const artistToArtistSummary = (it: Artist): ArtistSummary => ({ id: it.id, name: it.name, + image: it.image }); export const albumToAlbumSummary = (it: Album): AlbumSummary => ({ @@ -184,7 +175,7 @@ export interface MusicLibrary { range: string | undefined; }): Promise; rate(trackId: string, rating: Rating): Promise; - coverArt(id: string, size?: number): Promise; + coverArt(coverArtURN: BUrn, size?: number): Promise; nowPlaying(id: string): Promise scrobble(id: string): Promise searchArtists(query: string): Promise; diff --git a/src/random_string.ts b/src/random_string.ts deleted file mode 100644 index 8386609..0000000 --- a/src/random_string.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { randomBytes } from "crypto"; - -const randomString = () => randomBytes(32).toString('hex') - -export default randomString - diff --git a/src/server.ts b/src/server.ts index 7d4693f..0e878b1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,8 @@ import _, { shuffle } from "underscore"; import morgan from "morgan"; import { takeWithRepeats } from "./utils"; import { jwtSigner, Signer } from "./encryption"; +import { parse } from "./burn"; +import { axiosImageFetcher, ImageFetcher } from "./subsonic"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -87,6 +89,7 @@ export type ServerOpts = { logRequests: boolean; version: string; tokenSigner: Signer; + externalImageResolver: ImageFetcher; }; const DEFAULT_SERVER_OPTS: ServerOpts = { @@ -98,6 +101,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { logRequests: false, version: "v?", tokenSigner: jwtSigner(`bonob-${uuid()}`), + externalImageResolver: axiosImageFetcher }; function server( @@ -527,11 +531,11 @@ function server( "centre", ]; - app.get("/art/:ids/size/:size", (req, res) => { + app.get("/art/:burns/size/:size", (req, res) => { const authToken = accessTokens.authTokenFor( req.query[BONOB_ACCESS_TOKEN_HEADER] as string ); - const ids = req.params["ids"]!.split("&"); + const urns = req.params["burns"]!.split("&").map(parse); const size = Number.parseInt(req.params["size"]!); if (!authToken) { @@ -542,7 +546,13 @@ function server( return musicService .login(authToken) - .then((it) => Promise.all(ids.map((id) => it.coverArt(id, size)))) + .then((musicLibrary) => Promise.all(urns.map((it) => { + if(it.system == "external") { + return serverOpts.externalImageResolver(it.resource); + } else { + return musicLibrary.coverArt(it, size); + } + }))) .then((coverArts) => coverArts.filter((it) => it)) .then(shuffle) .then((coverArts) => { @@ -580,7 +590,7 @@ function server( } }) .catch((e: Error) => { - logger.error(`Failed fetching image ${ids.join("&")}/size/${size}`, { + logger.error(`Failed fetching image ${urns.join("&")}/size/${size}`, { cause: e, }); return res.status(500).send(); diff --git a/src/smapi.ts b/src/smapi.ts index 49cb9b1..b88a72d 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -3,6 +3,9 @@ import { Express, Request } from "express"; import { listen } from "soap"; import { readFileSync } from "fs"; import path from "path"; +import { pipe } from "fp-ts/lib/function"; +import { option as O } from "fp-ts"; + import logger from "./logger"; import { LinkCodes } from "./link_codes"; @@ -23,8 +26,9 @@ import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; import { asLANGs, I8N } from "./i8n"; import { ICON, iconForGenre } from "./icon"; -import { uniq } from "underscore"; +import _, { uniq } from "underscore"; import { pSigner, Signer } from "./encryption"; +import { BUrn, formatForURL } from "./burn"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -245,25 +249,26 @@ export const playlistAlbumArtURL = ( bonobUrl: URLBuilder, playlist: Playlist ) => { - const ids = uniq( - playlist.entries.map((it) => it.coverArt).filter((it) => it) - ); - if (ids.length == 0) { + const burns: BUrn[] = uniq(playlist.entries.filter(it => it.coverArt != undefined), it => it.album.id).map((it) => it.coverArt!); + console.log(`### playlist ${playlist.name} burns -> ${JSON.stringify(burns)}`) + if (burns.length == 0) { return iconArtURI(bonobUrl, "error"); } else { return bonobUrl.append({ - pathname: `/art/${ids.slice(0, 9).join("&")}/size/180`, + pathname: `/art/${burns.slice(0, 9).map(it => encodeURIComponent(formatForURL(it))).join("&")}/size/180`, }); } }; export const defaultAlbumArtURI = ( bonobUrl: URLBuilder, - { coverArt }: { coverArt: string | undefined } -) => - coverArt - ? bonobUrl.append({ pathname: `/art/${coverArt}/size/180` }) - : iconArtURI(bonobUrl, "vinyl"); + { coverArt }: { coverArt: BUrn | undefined } +) => pipe( + coverArt, + O.fromNullable, + O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })), + O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) +); export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => bonobUrl.append({ @@ -273,7 +278,12 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => export const defaultArtistArtURI = ( bonobUrl: URLBuilder, artist: ArtistSummary -) => bonobUrl.append({ pathname: `/art/artist:${artist.id}/size/180` }); +) => pipe( + artist.image, + O.fromNullable, + O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })), + O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) +); export const sonosifyMimeType = (mimeType: string) => mimeType == "audio/x-flac" ? "audio/flac" : mimeType; diff --git a/src/subsonic.ts b/src/subsonic.ts index 0823f57..8633355 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -7,20 +7,18 @@ import { Credentials, MusicService, Album, - Artist, - ArtistSummary, Result, slice2, AlbumQuery, ArtistQuery, MusicLibrary, - Images, AlbumSummary, Genre, Track, CoverArt, Rating, AlbumQueryType, + Artist, } from "./music_service"; import sharp from "sharp"; import _ from "underscore"; @@ -28,9 +26,11 @@ import fse from "fs-extra"; import path from "path"; import axios, { AxiosRequestConfig } from "axios"; -import randomString from "./random_string"; +import randomstring from "randomstring"; import { b64Encode, b64Decode } from "./b64"; import logger from "./logger"; +import { assertSystem, BUrn } from "./burn"; +import { artist } from "./smapi"; export const BROWSER_HEADERS = { accept: @@ -46,7 +46,7 @@ export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); export const t_and_s = (password: string) => { - const s = randomString(); + const s = randomstring.generate(); return { t: t(password, s), s, @@ -55,20 +55,18 @@ export const t_and_s = (password: string) => { export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png"; -export const isDodgyImage = (url: string) => url.endsWith(DODGY_IMAGE_NAME); +export const isValidImage = (url: string | undefined) => + url != undefined && !url.endsWith(DODGY_IMAGE_NAME); -export const validate = (url: string | undefined) => - url && !isDodgyImage(url) ? url : undefined; - -export type SubsonicEnvelope = { +type SubsonicEnvelope = { "subsonic-response": SubsonicResponse; }; -export type SubsonicResponse = { +type SubsonicResponse = { status: string; }; -export type album = { +type album = { id: string; name: string; artist: string | undefined; @@ -78,73 +76,75 @@ export type album = { year: string | undefined; }; -export type artistSummary = { +type artist = { id: string; name: string; albumCount: number; artistImageUrl: string | undefined; }; -export type GetArtistsResponse = SubsonicResponse & { +type GetArtistsResponse = SubsonicResponse & { artists: { index: { - artist: artistSummary[]; + artist: artist[]; name: string; }[]; }; }; -export type GetAlbumListResponse = SubsonicResponse & { +type GetAlbumListResponse = SubsonicResponse & { albumList2: { album: album[]; }; }; -export type genre = { +type genre = { songCount: number; albumCount: number; value: string; }; -export type GetGenresResponse = SubsonicResponse & { +type GetGenresResponse = SubsonicResponse & { genres: { genre: genre[]; }; }; -export type SubsonicError = SubsonicResponse & { +type SubsonicError = SubsonicResponse & { error: { code: string; message: string; }; }; -export type artistInfo = { - biography: string | undefined; - musicBrainzId: string | undefined; - lastFmUrl: string | undefined; +export type images = { smallImageUrl: string | undefined; mediumImageUrl: string | undefined; largeImageUrl: string | undefined; - similarArtist: artistSummary[]; }; -export type ArtistInfo = { - image: Images; - similarArtist: (ArtistSummary & { inLibrary: boolean })[]; +type artistInfo = images & { + biography: string | undefined; + musicBrainzId: string | undefined; + lastFmUrl: string | undefined; + similarArtist: artist[]; }; -export type GetArtistInfoResponse = SubsonicResponse & { +type ArtistSummary = IdName & { + image: BUrn | undefined; +}; + +type GetArtistInfoResponse = SubsonicResponse & { artistInfo2: artistInfo; }; -export type GetArtistResponse = SubsonicResponse & { - artist: artistSummary & { +type GetArtistResponse = SubsonicResponse & { + artist: artist & { album: album[]; }; }; -export type song = { +type song = { id: string; parent: string | undefined; title: string; @@ -166,18 +166,18 @@ export type song = { starred: string | undefined; }; -export type GetAlbumResponse = { +type GetAlbumResponse = { album: album & { song: song[]; }; }; -export type playlist = { +type playlist = { id: string; name: string; }; -export type GetPlaylistResponse = { +type GetPlaylistResponse = { playlist: { id: string; name: string; @@ -185,32 +185,32 @@ export type GetPlaylistResponse = { }; }; -export type GetPlaylistsResponse = { +type GetPlaylistsResponse = { playlists: { playlist: playlist[] }; }; -export type GetSimilarSongsResponse = { +type GetSimilarSongsResponse = { similarSongs2: { song: song[] }; }; -export type GetTopSongsResponse = { +type GetTopSongsResponse = { topSongs: { song: song[] }; }; -export type GetSongResponse = { +type GetSongResponse = { song: song; }; -export type GetStarredResponse = { +type GetStarredResponse = { starred2: { song: song[]; album: album[]; }; }; -export type Search3Response = SubsonicResponse & { +type Search3Response = SubsonicResponse & { searchResult3: { - artist: artistSummary[]; + artist: artist[]; album: album[]; song: song[]; }; @@ -222,31 +222,44 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } -export const splitCoverArtId = (coverArt: string): [string, string] => { - const parts = coverArt.split(":").filter((it) => it.length > 0); - if (parts.length < 2) throw `'${coverArt}' is an invalid coverArt id'`; - return [parts[0]!, parts.slice(1).join(":")]; -}; - -export type IdName = { +type IdName = { id: string; name: string; }; -export type getAlbumListParams = { - type: string; - size?: number; - offet?: number; - fromYear?: string; - toYear?: string; - genre?: string; +const coverArtURN = (coverArt: string | undefined): BUrn | undefined => pipe( + coverArt, + O.fromNullable, + O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })), + O.getOrElseW(() => undefined) +) + +export const artistImageURN = ( + spec: Partial<{ + artistId: string | undefined; + artistImageURL: string | undefined; + }> +): BUrn | undefined => { + const deets = { + artistId: undefined, + artistImageURL: undefined, + ...spec, + }; + if (deets.artistImageURL && isValidImage(deets.artistImageURL)) { + return { + system: "external", + resource: deets.artistImageURL + }; + } else if (artistIsInLibrary(deets.artistId)) { + return { + system: "subsonic", + resource: `art:${deets.artistId!}`, + }; + } else { + return undefined; + } }; -export const MAX_ALBUM_LIST = 500; - -const maybeAsCoverArt = (coverArt: string | undefined) => - coverArt ? `coverArt:${coverArt}` : undefined; - export const asTrack = (album: Album, song: song): Track => ({ id: song.id, name: song.title, @@ -254,11 +267,12 @@ export const asTrack = (album: Album, song: song): Track => ({ duration: song.duration || 0, number: song.track || 0, genre: maybeAsGenre(song.genre), - coverArt: maybeAsCoverArt(song.coverArt), + coverArt: coverArtURN(song.coverArt), album, artist: { id: `${song.artistId!}`, name: song.artist!, + image: artistImageURN({ artistId: song.artistId }), }, rating: { love: song.starred != undefined, @@ -276,7 +290,7 @@ const asAlbum = (album: album): Album => ({ genre: maybeAsGenre(album.genre), artistId: album.artistId, artistName: album.artist, - coverArt: maybeAsCoverArt(album.coverArt), + coverArt: coverArtURN(album.coverArt), }); export const asGenre = (genreName: string) => ({ @@ -294,8 +308,8 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => export type StreamClientApplication = (track: Track) => string; -export const DEFAULT_CLIENT_APPLICATION = "bonob"; -export const USER_AGENT = "bonob"; +const DEFAULT_CLIENT_APPLICATION = "bonob"; +const USER_AGENT = "bonob"; export const DEFAULT: StreamClientApplication = (_: Track) => DEFAULT_CLIENT_APPLICATION; @@ -366,6 +380,9 @@ const AlbumQueryTypeToSubsonicType: Record = { starred: "highest", }; +const artistIsInLibrary = (artistId: string | undefined) => + artistId != undefined && artistId != "-1"; + export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; @@ -400,7 +417,8 @@ export class Subsonic implements MusicService { "User-Agent": USER_AGENT, }, ...config, - }).catch(e => { + }) + .catch((e) => { throw `Subsonic failed with: ${e}`; }) .then((response) => { @@ -425,9 +443,7 @@ export class Subsonic implements MusicService { generateToken = async (credentials: Credentials) => this.getJSON(credentials, "/rest/ping.view") .then(() => ({ - authToken: b64Encode( - JSON.stringify(credentials) - ), + authToken: b64Encode(JSON.stringify(credentials)), userId: credentials.username, nickname: credentials.username, })) @@ -437,7 +453,7 @@ export class Subsonic implements MusicService { getArtists = ( credentials: Credentials - ): Promise<(IdName & { albumCount: number })[]> => + ): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => this.getJSON(credentials, "/rest/getArtists") .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) .then((artists) => @@ -445,26 +461,46 @@ export class Subsonic implements MusicService { id: `${artist.id}`, name: artist.name, albumCount: artist.albumCount, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), })) ); - getArtistInfo = (credentials: Credentials, id: string): Promise => + getArtistInfo = ( + credentials: Credentials, + id: string + ): Promise<{ + similarArtist: (ArtistSummary & { inLibrary: boolean })[]; + images: { + s: string | undefined; + m: string | undefined; + l: string | undefined; + }; + }> => this.getJSON(credentials, "/rest/getArtistInfo2", { id, count: 50, includeNotPresent: true, - }).then((it) => ({ - image: { - small: validate(it.artistInfo2.smallImageUrl), - medium: validate(it.artistInfo2.mediumImageUrl), - large: validate(it.artistInfo2.largeImageUrl), - }, - similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({ - id: `${artist.id}`, - name: artist.name, - inLibrary: artist.id != "-1", - })), - })); + }) + .then((it) => it.artistInfo2) + .then((it) => ({ + images: { + s: it.smallImageUrl, + m: it.mediumImageUrl, + l: it.largeImageUrl, + }, + similarArtist: (it.similarArtist || []).map((artist) => ({ + id: `${artist.id}`, + name: artist.name, + inLibrary: artistIsInLibrary(artist.id), + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })), + })); getAlbum = (credentials: Credentials, id: string): Promise => this.getJSON(credentials, "/rest/getAlbum", { id }) @@ -476,13 +512,15 @@ export class Subsonic implements MusicService { genre: maybeAsGenre(album.genre), artistId: album.artistId, artistName: album.artist, - coverArt: maybeAsCoverArt(album.coverArt), + coverArt: coverArtURN(album.coverArt), })); getArtist = ( credentials: Credentials, id: string - ): Promise => + ): Promise< + IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } + > => this.getJSON(credentials, "/rest/getArtist", { id, }) @@ -490,6 +528,7 @@ export class Subsonic implements MusicService { .then((it) => ({ id: it.id, name: it.name, + artistImageUrl: it.artistImageUrl, albums: this.toAlbumSummary(it.album || []), })); @@ -500,7 +539,15 @@ export class Subsonic implements MusicService { ]).then(([artist, artistInfo]) => ({ id: artist.id, name: artist.name, - image: artistInfo.image, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: [ + artist.artistImageUrl, + artistInfo.images.l, + artistInfo.images.m, + artistInfo.images.s, + ].find(isValidImage), + }), albums: artist.albums, similarArtists: artistInfo.similarArtist, })); @@ -535,7 +582,7 @@ export class Subsonic implements MusicService { genre: maybeAsGenre(album.genre), artistId: album.artistId, artistName: album.artist, - coverArt: maybeAsCoverArt(album.coverArt), + coverArt: coverArtURN(album.coverArt), })); search3 = (credentials: Credentials, q: any) => @@ -586,7 +633,11 @@ export class Subsonic implements MusicService { .then(slice2(q)) .then(([page, total]) => ({ total, - results: page.map((it) => ({ id: it.id, name: it.name })), + results: page.map((it) => ({ + id: it.id, + name: it.name, + image: it.image, + })), })), artist: async (id: string): Promise => subsonic.getArtistWithInfo(credentials, id), @@ -691,63 +742,27 @@ export class Subsonic implements MusicService { stream: res.data, })) ), - coverArt: async (coverArt: string, size?: number) => { - const [type, id] = splitCoverArtId(coverArt); - if (type == "coverArt") { - return subsonic - .getCoverArt(credentials, id, size) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch((e) => { - logger.error(`Failed getting coverArt ${coverArt}: ${e}`); - return undefined; - }); - } else { - return subsonic - .getArtistWithInfo(credentials, id) - .then((artist) => { - const albumsWithCoverArt = artist.albums.filter( - (it) => it.coverArt - ); - if (artist.image.large) { - return this.externalImageFetcher(artist.image.large!).then( - (image) => { - if (image && size) { - return sharp(image.data) - .resize(size) - .toBuffer() - .then((resized) => ({ - contentType: image.contentType, - data: resized, - })); - } else { - return image; - } - } - ); - } else if (albumsWithCoverArt.length > 0) { - return subsonic - .getCoverArt( - credentials, - splitCoverArtId(albumsWithCoverArt[0]!.coverArt!)[1], - size - ) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })); - } else { - return undefined; - } - }) - .catch((e) => { - logger.error(`Failed getting coverArt ${coverArt}: ${e}`); - return undefined; - }); - } - }, + coverArt: async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => it.resource.split(":")[1]!) + .then((it) => + subsonic.getCoverArt( + credentials, + it, + size + ) + ) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error( + `Failed getting coverArt for urn:'${coverArtURN}': ${e}` + ); + return undefined; + }), scrobble: async (id: string) => subsonic .getJSON(credentials, `/rest/scrobble`, { @@ -771,6 +786,10 @@ export class Subsonic implements MusicService { artists.map((artist) => ({ id: artist.id, name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), })) ), searchAlbums: async (query: string) => @@ -812,7 +831,7 @@ export class Subsonic implements MusicService { genre: maybeAsGenre(entry.genre), artistName: entry.artist, artistId: entry.artistId, - coverArt: maybeAsCoverArt(entry.coverArt), + coverArt: coverArtURN(entry.coverArt), }, entry ), diff --git a/tests/builders.ts b/tests/builders.ts index 2d29fda..9bcaedc 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,5 +1,6 @@ import { SonosDevice } from "@svrooij/sonos/lib"; import { v4 as uuid } from "uuid"; +import randomstring from "randomstring"; import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; @@ -11,9 +12,12 @@ import { artistToArtistSummary, PlaylistSummary, Playlist, + SimilarArtist, + AlbumSummary, } from "../src/music_service"; -import randomString from "../src/random_string"; + import { b64Encode } from "../src/b64"; +import { artistImageURN } from "../src/subsonic"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; @@ -42,7 +46,7 @@ export function aPlaylistSummary( ): PlaylistSummary { return { id: `playlist-${uuid()}`, - name: `playlistname-${randomString()}`, + name: `playlistname-${randomstring.generate()}`, ...fields, }; } @@ -50,7 +54,7 @@ export function aPlaylistSummary( export function aPlaylist(fields: Partial = {}): Playlist { return { id: `playlist-${uuid()}`, - name: `playlist-${randomString()}`, + name: `playlist-${randomstring.generate()}`, entries: [aTrack(), aTrack()], ...fields, }; @@ -97,21 +101,34 @@ export function someCredentials(token: string): Credentials { }; } +export function aSimilarArtist( + fields: Partial = {} +): SimilarArtist { + const id = fields.id || uuid(); + return { + id, + name: `Similar Artist ${id}`, + image: artistImageURN({ artistId: id }), + inLibrary: true, + ...fields, + }; +} + export function anArtist(fields: Partial = {}): Artist { - const id = uuid(); + const id = fields.id || uuid(); const artist = { id, name: `Artist ${id}`, albums: [anAlbum(), anAlbum(), anAlbum()], - image: { - small: `/artist/art/${id}/small`, - medium: `/artist/art/${id}/small`, - large: `/artist/art/${id}/large`, - }, + image: { system: "subsonic", resource: `art:${id}` }, similarArtists: [ - { id: uuid(), name: "Similar artist1", inLibrary: true }, - { id: uuid(), name: "Similar artist2", inLibrary: true }, - { id: "-1", name: "Artist not in library", inLibrary: false }, + aSimilarArtist({ id: uuid(), name: "Similar artist1", inLibrary: true }), + aSimilarArtist({ id: uuid(), name: "Similar artist2", inLibrary: true }), + aSimilarArtist({ + id: "-1", + name: "Artist not in library", + inLibrary: false, + }), ], ...fields, }; @@ -163,11 +180,11 @@ export function aTrack(fields: Partial = {}): Track { album: albumToAlbumSummary( anAlbum({ artistId: artist.id, artistName: artist.name, genre }) ), - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, rating, ...fields, }; -}; +} export function anAlbum(fields: Partial = {}): Album { const id = uuid(); @@ -177,11 +194,25 @@ export function anAlbum(fields: Partial = {}): Album { genre: randomGenre(), year: `19${randomInt(99)}`, artistId: `Artist ${uuid()}`, - artistName: `Artist ${randomString()}`, - coverArt: `coverArt:${uuid()}`, + artistName: `Artist ${randomstring.generate()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}` }, ...fields, }; -} +}; + +export function anAlbumSummary(fields: Partial = {}): AlbumSummary { + const id = uuid(); + return { + id, + name: `Album ${id}`, + year: `19${randomInt(99)}`, + genre: randomGenre(), + coverArt: { system: "subsonic", resource: `art:${uuid()}` }, + artistId: `Artist ${uuid()}`, + artistName: `Artist ${randomstring.generate()}`, + ...fields + } +}; export const BLONDIE_ID = uuid(); export const BLONDIE_NAME = "Blondie"; @@ -196,7 +227,7 @@ export const BLONDIE: Artist = { genre: NEW_WAVE, artistId: BLONDIE_ID, artistName: BLONDIE_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, { id: uuid(), @@ -205,14 +236,10 @@ export const BLONDIE: Artist = { genre: POP_ROCK, artistId: BLONDIE_ID, artistName: BLONDIE_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, ], - image: { - small: undefined, - medium: undefined, - large: undefined, - }, + image: { system: "external", resource: "http://localhost:1234/images/blondie.jpg" }, similarArtists: [], }; @@ -229,7 +256,7 @@ export const BOB_MARLEY: Artist = { genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, { id: uuid(), @@ -238,7 +265,7 @@ export const BOB_MARLEY: Artist = { genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, { id: uuid(), @@ -247,14 +274,10 @@ export const BOB_MARLEY: Artist = { genre: SKA, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, ], - image: { - small: "http://localhost/BOB_MARLEY/sml", - medium: "http://localhost/BOB_MARLEY/med", - large: "http://localhost/BOB_MARLEY/lge", - }, + image: { system: "subsonic", resource: BOB_MARLEY_ID }, similarArtists: [], }; @@ -265,9 +288,8 @@ export const MADONNA: Artist = { name: MADONNA_NAME, albums: [], image: { - small: "http://localhost/MADONNA/sml", - medium: undefined, - large: "http://localhost/MADONNA/lge", + system: "external", + resource: "http://localhost:1234/images/madonna.jpg", }, similarArtists: [], }; @@ -285,7 +307,7 @@ export const METALLICA: Artist = { genre: METAL, artistId: METALLICA_ID, artistName: METALLICA_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, { id: uuid(), @@ -294,18 +316,13 @@ export const METALLICA: Artist = { genre: METAL, artistId: METALLICA_ID, artistName: METALLICA_NAME, - coverArt: `coverArt:${uuid()}`, + coverArt: { system: "subsonic", resource: `art:${uuid()}`}, }, ], - image: { - small: "http://localhost/METALLICA/sml", - medium: "http://localhost/METALLICA/med", - large: "http://localhost/METALLICA/lge", - }, + image: { system: "subsonic", resource: METALLICA_ID }, similarArtists: [], }; export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA]; export const ALL_ALBUMS = ALL_ARTISTS.flatMap((it) => it.albums || []); - diff --git a/tests/burn.test.ts b/tests/burn.test.ts new file mode 100644 index 0000000..3d49c98 --- /dev/null +++ b/tests/burn.test.ts @@ -0,0 +1,114 @@ +import { assertSystem, BUrn, format, formatForURL, parse } from "../src/burn"; + +type BUrnSpec = { + burn: BUrn; + asString: string; + shorthand: string; +}; + +describe("BUrn", () => { + describe("format", () => { + ( + [ + { + burn: { system: "internal", resource: "icon:error" }, + asString: "bnb:internal:icon:error", + shorthand: "bnb:i:icon:error", + }, + { + burn: { + system: "external", + resource: "http://example.com/widget.jpg", + }, + asString: "bnb:external:http://example.com/widget.jpg", + shorthand: "bnb:e:http://example.com/widget.jpg", + }, + { + burn: { system: "subsonic", resource: "art:1234" }, + asString: "bnb:subsonic:art:1234", + shorthand: "bnb:s:art:1234", + }, + { + burn: { system: "navidrome", resource: "art:1234" }, + asString: "bnb:navidrome:art:1234", + shorthand: "bnb:n:art:1234", + }, + ] as BUrnSpec[] + ).forEach(({ burn, asString, shorthand }) => { + describe(asString, () => { + it("can be formatted as string and then roundtripped back into BUrn", () => { + const stringValue = format(burn); + expect(stringValue).toEqual(asString); + expect(parse(stringValue)).toEqual(burn); + }); + + it("can be formatted as shorthand string and then roundtripped back into BUrn", () => { + const stringValue = format(burn, { shorthand: true }); + expect(stringValue).toEqual(shorthand); + expect(parse(stringValue)).toEqual(burn); + }); + + describe(`encrypted ${asString}`, () => { + it("can be formatted as an encrypted string and then roundtripped back into BUrn", () => { + const stringValue = format(burn, { encrypt: true }); + expect(stringValue.startsWith("bnb:encrypted:")).toBeTruthy(); + expect(stringValue).not.toContain(burn.system); + expect(stringValue).not.toContain(burn.resource); + expect(parse(stringValue)).toEqual(burn); + }); + + it("can be formatted as an encrypted shorthand string and then roundtripped back into BUrn", () => { + const stringValue = format(burn, { + shorthand: true, + encrypt: true, + }); + expect(stringValue.startsWith("bnb:x:")).toBeTruthy(); + expect(stringValue).not.toContain(burn.system); + expect(stringValue).not.toContain(burn.resource); + expect(parse(stringValue)).toEqual(burn); + }); + }); + }); + }); + }); + + describe("formatForURL", () => { + describe("external", () => { + it("should be encrypted", () => { + const burn = { + system: "external", + resource: "http://example.com/foo.jpg", + }; + const formatted = formatForURL(burn); + expect(formatted.startsWith("bnb:x:")).toBeTruthy(); + expect(formatted).not.toContain("http://example.com/foo.jpg"); + + expect(parse(formatted)).toEqual(burn); + }); + }); + + describe("not external", () => { + it("should be shorthand form", () => { + expect(formatForURL({ system: "internal", resource: "foo" })).toEqual( + "bnb:i:foo" + ); + expect( + formatForURL({ system: "subsonic", resource: "foo:bar" }) + ).toEqual("bnb:s:foo:bar"); + }); + }); + }); + + describe("assertSystem", () => { + it("should fail if the system is not equal", () => { + const burn = { system: "external", resource: "something"}; + expect(() => assertSystem(burn, "subsonic")).toThrow(`Unsupported urn: '${format(burn)}'`) + }); + + it("should pass if the system is equal", () => { + const burn = { system: "external", resource: "something"}; + expect(assertSystem(burn, "external")).toEqual(burn); + }); + }); +}); + diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index 496a6a5..5fc342e 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -18,6 +18,7 @@ import { } from "./builders"; import _ from "underscore"; + describe("InMemoryMusicService", () => { const service = new InMemoryMusicService(); @@ -51,25 +52,7 @@ describe("InMemoryMusicService", () => { ); }); }); - - describe("artistToArtistSummary", () => { - it("should map fields correctly", () => { - const artist = anArtist({ - id: uuid(), - name: "The Artist", - image: { - small: "/path/to/small/jpg", - medium: "/path/to/medium/jpg", - large: "/path/to/large/jpg", - }, - }); - expect(artistToArtistSummary(artist)).toEqual({ - id: artist.id, - name: artist.name, - }); - }); - }); - + describe("Music Library", () => { const user = { username: "user100", password: "password100" }; let musicLibrary: MusicLibrary; diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 532c384..7c88066 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -24,6 +24,7 @@ import { Genre, Rating, } from "../src/music_service"; +import { BUrn } from "../src/burn"; export class InMemoryMusicService implements MusicService { users: Record = {}; @@ -131,8 +132,8 @@ export class InMemoryMusicService implements MusicService { ), stream: (_: { trackId: string; range: string | undefined }) => Promise.reject("unsupported operation"), - coverArt: (id: string, size?: number) => - Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`), + coverArt: (coverArtURN: BUrn, size?: number) => + Promise.reject(`Cannot retrieve coverArt for ${coverArtURN}, size ${size}`), scrobble: async (_: string) => { return Promise.resolve(true); }, diff --git a/tests/music_service.test.ts b/tests/music_service.test.ts new file mode 100644 index 0000000..f3f1d42 --- /dev/null +++ b/tests/music_service.test.ts @@ -0,0 +1,22 @@ +import { v4 as uuid } from "uuid"; + +import { anArtist } from "./builders"; +import { artistToArtistSummary } from "../src/music_service"; + +describe("artistToArtistSummary", () => { + it("should map fields correctly", () => { + const artist = anArtist({ + id: uuid(), + name: "The Artist", + image: { + system: "external", + resource: "http://example.com:1234/image.jpg", + }, + }); + expect(artistToArtistSummary(artist)).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + }); + }); +}); diff --git a/tests/random_string.test.ts b/tests/random_string.test.ts deleted file mode 100644 index d9646ba..0000000 --- a/tests/random_string.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import randomString from "../src/random_string"; - -describe('randomString', () => { - it('should produce different strings...', () => { - const s1 = randomString() - const s2 = randomString() - const s3 = randomString() - const s4 = randomString() - - expect(s1.length).toEqual(64) - - expect(s1).not.toEqual(s2); - expect(s1).not.toEqual(s3); - expect(s1).not.toEqual(s4); - }); -}); \ No newline at end of file diff --git a/tests/server.test.ts b/tests/server.test.ts index 7129eff..b259779 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -24,6 +24,7 @@ import url from "../src/url_builder"; import i8n, { randomLang } from "../src/i8n"; import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi"; import { Clock, SystemClock } from "../src/clock"; +import { formatForURL } from "../src/burn"; describe("rangeFilterFor", () => { describe("invalid range header string", () => { @@ -1190,7 +1191,7 @@ describe("server", () => { describe("when there is no access-token", () => { it("should return a 401", async () => { - const res = await request(server).get(`/art/coverArt:123/size/180`); + const res = await request(server).get(`/art/${encodeURIComponent(formatForURL({ system: "subsonic", resource: "art:whatever" }))}/size/180`); expect(res.status).toEqual(401); }); @@ -1201,7 +1202,7 @@ describe("server", () => { now = now.add(1, "day"); const res = await request(server).get( - `/art/coverArt:123/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/art/${encodeURIComponent(formatForURL({ system: "subsonic", resource: "art:whatever" }))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ); expect(res.status).toEqual(401); @@ -1209,14 +1210,16 @@ describe("server", () => { }); describe("when there is a valid access token", () => { - describe("artist art", () => { + describe("art", () => { ["0", "-1", "foo"].forEach((size) => { describe(`invalid size of ${size}`, () => { it(`should return a 400`, async () => { + const coverArtURN = { system: "subsonic", resource: "art:400" }; + musicService.login.mockResolvedValue(musicLibrary); const res = await request(server) .get( - `/art/artist:${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); @@ -1228,6 +1231,8 @@ describe("server", () => { describe("fetching a single image", () => { describe("when the images is available", () => { it("should return the image and a 200", async () => { + const coverArtURN = { system: "subsonic", resource: "art:200" }; + const coverArt = coverArtResponse({}); musicService.login.mockResolvedValue(musicLibrary); @@ -1236,7 +1241,7 @@ describe("server", () => { const res = await request(server) .get( - `/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); @@ -1247,7 +1252,7 @@ describe("server", () => { expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicLibrary.coverArt).toHaveBeenCalledWith( - `artist:${albumId}`, + coverArtURN, 180 ); }); @@ -1255,13 +1260,14 @@ describe("server", () => { describe("when the image is not available", () => { it("should return a 404", async () => { - musicService.login.mockResolvedValue(musicLibrary); + const coverArtURN = { system: "subsonic", resource: "art:404" }; + musicService.login.mockResolvedValue(musicLibrary); musicLibrary.coverArt.mockResolvedValue(undefined); const res = await request(server) .get( - `/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); @@ -1283,16 +1289,16 @@ describe("server", () => { describe("fetching a collage of 4 when all are available", () => { it("should return the image and a 200", async () => { - const ids = [ - "artist:1", - "artist:2", - "coverArt:3", - "coverArt:4", - ]; + const urns = [ + "art:1", + "art:2", + "art:3", + "art:4", + ].map(resource => ({ system:"subsonic", resource })); musicService.login.mockResolvedValue(musicLibrary); - ids.forEach((_) => { + urns.forEach((_) => { musicLibrary.coverArt.mockResolvedValueOnce( coverArtResponse({ data: png, @@ -1302,7 +1308,7 @@ describe("server", () => { const res = await request(server) .get( - `/art/${ids.join( + `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( "&" )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) @@ -1312,8 +1318,8 @@ describe("server", () => { expect(res.header["content-type"]).toEqual("image/png"); expect(musicService.login).toHaveBeenCalledWith(authToken); - ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 200); + urns.forEach((it) => { + expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 200); }); const image = await Image.load(res.body); @@ -1324,7 +1330,7 @@ describe("server", () => { describe("fetching a collage of 4, however only 1 is available", () => { it("should return the single image", async () => { - const ids = ["artist:1", "artist:2", "artist:3", "artist:4"]; + const urns = ["art:1", "art:2", "art:3", "art:4"].map(resource => ({ system:"subsonic", resource })); musicService.login.mockResolvedValue(musicLibrary); @@ -1340,7 +1346,7 @@ describe("server", () => { const res = await request(server) .get( - `/art/${ids.join( + `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( "&" )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) @@ -1355,17 +1361,17 @@ describe("server", () => { describe("fetching a collage of 4 and all are missing", () => { it("should return a 404", async () => { - const ids = ["artist:1", "artist:2", "artist:3", "artist:4"]; + const urns = ["art:1", "art:2", "art:3", "art:4"].map(resource => ({ system:"subsonic", resource })); musicService.login.mockResolvedValue(musicLibrary); - ids.forEach((_) => { + urns.forEach((_) => { musicLibrary.coverArt.mockResolvedValueOnce(undefined); }); const res = await request(server) .get( - `/art/${ids.join( + `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( "&" )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) @@ -1377,7 +1383,7 @@ describe("server", () => { describe("fetching a collage of 9 when all are available", () => { it("should return the image and a 200", async () => { - const ids = [ + const urns = [ "artist:1", "artist:2", "coverArt:3", @@ -1387,11 +1393,11 @@ describe("server", () => { "artist:7", "artist:8", "artist:9", - ]; + ].map(resource => ({ system:"subsonic", resource })); musicService.login.mockResolvedValue(musicLibrary); - ids.forEach((_) => { + urns.forEach((_) => { musicLibrary.coverArt.mockResolvedValueOnce( coverArtResponse({ data: png, @@ -1401,7 +1407,7 @@ describe("server", () => { const res = await request(server) .get( - `/art/${ids.join( + `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( "&" )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) @@ -1411,8 +1417,8 @@ describe("server", () => { expect(res.header["content-type"]).toEqual("image/png"); expect(musicService.login).toHaveBeenCalledWith(authToken); - ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180); + urns.forEach((it) => { + expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180); }); const image = await Image.load(res.body); @@ -1423,7 +1429,7 @@ describe("server", () => { describe("fetching a collage of 9 when only 2 are available", () => { it("should still return an image and a 200", async () => { - const ids = [ + const urns = [ "artist:1", "artist:2", "artist:3", @@ -1433,7 +1439,7 @@ describe("server", () => { "artist:7", "artist:8", "artist:9", - ]; + ].map(resource => ({ system:"subsonic", resource })); musicService.login.mockResolvedValue(musicLibrary); @@ -1457,7 +1463,7 @@ describe("server", () => { const res = await request(server) .get( - `/art/${ids.join( + `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( "&" )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) @@ -1467,8 +1473,8 @@ describe("server", () => { expect(res.header["content-type"]).toEqual("image/png"); expect(musicService.login).toHaveBeenCalledWith(authToken); - ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180); + urns.forEach((urn) => { + expect(musicLibrary.coverArt).toHaveBeenCalledWith(urn, 180); }); const image = await Image.load(res.body); @@ -1479,7 +1485,7 @@ describe("server", () => { describe("fetching a collage of 11", () => { it("should still return an image and a 200, though will only display 9", async () => { - const ids = [ + const urns = [ "artist:1", "artist:2", "artist:3", @@ -1491,11 +1497,11 @@ describe("server", () => { "artist:9", "artist:10", "artist:11", - ]; + ].map(resource => ({ system:"subsonic", resource })); musicService.login.mockResolvedValue(musicLibrary); - ids.forEach((_) => { + urns.forEach((_) => { musicLibrary.coverArt.mockResolvedValueOnce( coverArtResponse({ data: png, @@ -1505,7 +1511,7 @@ describe("server", () => { const res = await request(server) .get( - `/art/${ids.join( + `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( "&" )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) @@ -1515,8 +1521,8 @@ describe("server", () => { expect(res.header["content-type"]).toEqual("image/png"); expect(musicService.login).toHaveBeenCalledWith(authToken); - ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180); + urns.forEach((it) => { + expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180); }); const image = await Image.load(res.body); @@ -1527,13 +1533,14 @@ describe("server", () => { describe("when the image is not available", () => { it("should return a 404", async () => { - musicService.login.mockResolvedValue(musicLibrary); + const coverArtURN = { system:"subsonic", resource:"art:404"}; + musicService.login.mockResolvedValue(musicLibrary); musicLibrary.coverArt.mockResolvedValue(undefined); const res = await request(server) .get( - `/art/coverArt:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); @@ -1558,83 +1565,6 @@ describe("server", () => { }); }); }); - - describe("album art", () => { - ["0", "-1", "foo"].forEach((size) => { - describe(`when the size is ${size}`, () => { - it(`should return a 400`, async () => { - musicService.login.mockResolvedValue(musicLibrary); - const res = await request(server) - .get( - `/art/coverArt:${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(400); - }); - }); - }); - - describe("when there is some", () => { - it("should return the image and a 200", async () => { - const coverArt = { - status: 200, - contentType: "image/jpeg", - data: Buffer.from("some image", "ascii"), - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockResolvedValue(coverArt); - - const res = await request(server) - .get( - `/art/coverArt:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(coverArt.status); - expect(res.header["content-type"]).toEqual( - coverArt.contentType - ); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - `coverArt:${albumId}`, - 180 - ); - }); - }); - - describe("when there isnt any", () => { - it("should return a 404", async () => { - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockResolvedValue(undefined); - - const res = await request(server) - .get( - `/art/album:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(404); - }); - }); - - describe("when there is an error", () => { - it("should return a 500", async () => { - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockRejectedValue("Boooooom"); - - const res = await request(server) - .get( - `/art/album:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(500); - }); - }); - }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 348bf4f..47a94c5 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -42,6 +42,7 @@ import { TRIP_HOP, PUNK, aPlaylist, + anAlbumSummary, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -56,6 +57,8 @@ import dayjs from "dayjs"; import url, { URLBuilder } from "../src/url_builder"; import { iconForGenre } from "../src/icon"; import { jwtSigner } from "../src/encryption"; +import { formatForURL } from "../src/burn"; +import { range } from "underscore"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); @@ -359,7 +362,7 @@ describe("track", () => { genre: { id: "genre101", name: "some genre" }, }), artist: anArtist({ name: "great artist", id: uuid() }), - coverArt: "coverArt:887766", + coverArt: {system: "subsonic", resource: "887766"}, rating: { love: true, stars: 5 @@ -377,7 +380,7 @@ describe("track", () => { albumId: `album:${someTrack.album.id}`, albumArtist: someTrack.artist.name, albumArtistId: `artist:${someTrack.artist.id}`, - albumArtURI: `http://localhost:4567/foo/art/${someTrack.coverArt}/size/180?access-token=1234`, + albumArtURI: `http://localhost:4567/foo/art/${encodeURIComponent(formatForURL(someTrack.coverArt!))}/size/180?access-token=1234`, artist: someTrack.artist.name, artistId: `artist:${someTrack.artist.id}`, duration: someTrack.duration, @@ -431,6 +434,12 @@ describe("sonosifyMimeType", () => { }); describe("playlistAlbumArtURL", () => { + const coverArt1 = { system: "subsonic", resource: "1" }; + const coverArt2 = { system: "subsonic", resource: "2" }; + const coverArt3 = { system: "subsonic", resource: "3" }; + const coverArt4 = { system: "subsonic", resource: "4" }; + const coverArt5 = { system: "subsonic", resource: "5" }; + describe("when the playlist has no coverArt ids", () => { it("should return question mark icon", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); @@ -447,39 +456,97 @@ describe("playlistAlbumArtURL", () => { }); }); - describe("when the playlist has 2 distinct coverArt ids", () => { - it("should return them on the url to the image", () => { + describe("when the playlist has external ids", () => { + it("should format the url with encrypted urn", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); + const externalArt1 = { system: "external", resource: "http://example.com/image1.jpg" }; + const externalArt2 = { system: "external", resource: "http://example.com/image2.jpg" }; + const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: "1" }), - aTrack({ coverArt: "2" }), - aTrack({ coverArt: "1" }), - aTrack({ coverArt: "2" }), + aTrack({ coverArt: externalArt1, album: anAlbumSummary({id: "album1"}) }), + aTrack({ coverArt: externalArt2, album: anAlbumSummary({id: "album2"}) }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/1&2/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(externalArt1))}&${encodeURIComponent(formatForURL(externalArt2))}/size/180?search=yes` ); }); }); - describe("when the playlist has 4 distinct albumIds", () => { - it("should return them on the url to the image", () => { + describe("when the playlist has 4 tracks from 2 different albums, including some tracks that are missing coverArt urns", () => { + it("should use the cover art once per album", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: "1" }), - aTrack({ coverArt: "2" }), - aTrack({ coverArt: "2" }), - aTrack({ coverArt: "3" }), - aTrack({ coverArt: "4" }), + aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }), + aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album2" }) }), + aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album2" }) }), + aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album2" }) }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/1&2&3&4/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes` + ); + }); + }); + + describe("when the playlist has 4 tracks from 2 different albums", () => { + it("should use the cover art once per album", () => { + const bonobUrl = url("http://localhost:1234/context-path?search=yes"); + const playlist = aPlaylist({ + entries: [ + aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }), + aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album2" }) }), + ], + }); + + expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( + `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes` + ); + }); + }); + + describe("when the playlist has 4 tracks from 3 different albums", () => { + it("should use the cover art once per album", () => { + const bonobUrl = url("http://localhost:1234/context-path?search=yes"); + const playlist = aPlaylist({ + entries: [ + aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }), + aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }), + aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album3" }) }), + ], + }); + + expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( + `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes` + ); + }); + }); + + describe("when the playlist has 4 tracks from 4 different albums", () => { + it("should return them on the url to the image", () => { + const bonobUrl = url("http://localhost:1234/context-path?search=yes"); + const playlist = aPlaylist({ + entries: [ + aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1"} ) }), + aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2"} ) }), + aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album3"} ) }), + aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album4"} ) }), + aTrack({ coverArt: coverArt5, album: anAlbumSummary({id: "album1"} ) }), + ], + }); + + expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( + `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(formatForURL(coverArt3))}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes` ); }); }); @@ -489,24 +556,23 @@ describe("playlistAlbumArtURL", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: "1" }), - aTrack({ coverArt: "2" }), - aTrack({ coverArt: "2" }), - aTrack({ coverArt: "2" }), - aTrack({ coverArt: "3" }), - aTrack({ coverArt: "4" }), - aTrack({ coverArt: "5" }), - aTrack({ coverArt: "6" }), - aTrack({ coverArt: "7" }), - aTrack({ coverArt: "8" }), - aTrack({ coverArt: "9" }), - aTrack({ coverArt: "10" }), - aTrack({ coverArt: "11" }), + aTrack({ coverArt: { system: "subsonic", resource: "1" }, album: anAlbumSummary({ id:"1" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "2" }, album: anAlbumSummary({ id:"2" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "3" }, album: anAlbumSummary({ id:"3" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "4" }, album: anAlbumSummary({ id:"4" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "5" }, album: anAlbumSummary({ id:"5" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "6" }, album: anAlbumSummary({ id:"6" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "7" }, album: anAlbumSummary({ id:"7" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "8" }, album: anAlbumSummary({ id:"8" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "9" }, album: anAlbumSummary({ id:"9" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "10" }, album: anAlbumSummary({ id:"10" }) }), + aTrack({ coverArt: { system: "subsonic", resource: "11" }, album: anAlbumSummary({ id:"11" }) }), ], }); + const burns = range(1, 10).map(i => encodeURIComponent(formatForURL({ system: "subsonic", resource: `${i}` }))).join("&") expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/1&2&3&4&5&6&7&8&9/size/180?search=yes` + `http://localhost:1234/context-path/art/${burns}/size/180?search=yes` ); }); }); @@ -518,15 +584,32 @@ describe("defaultAlbumArtURI", () => { ); describe("when there is an album coverArt", () => { - it("should use it in the image url", () => { - expect( - defaultAlbumArtURI( - bonobUrl, - anAlbum({ coverArt: "coverArt:123" }) - ).href() - ).toEqual( - "http://bonob.example.com:8080/context/art/coverArt:123/size/180?search=yes" - ); + describe("from subsonic", () => { + it("should use it", () => { + const coverArt = { system: "subsonic", resource: "12345" } + expect( + defaultAlbumArtURI( + bonobUrl, + anAlbum({ coverArt }) + ).href() + ).toEqual( + `http://bonob.example.com:8080/context/art/${encodeURIComponent(formatForURL(coverArt))}/size/180?search=yes` + ); + }); + }); + + describe("that is external", () => { + it("should use encrypt it", () => { + const coverArt = { system: "external", resource: "http://example.com/someimage.jpg" } + expect( + defaultAlbumArtURI( + bonobUrl, + anAlbum({ coverArt }) + ).href() + ).toEqual( + `http://bonob.example.com:8080/context/art/${encodeURIComponent(formatForURL(coverArt))}/size/180?search=yes` + ); + }); }); }); @@ -542,13 +625,39 @@ describe("defaultAlbumArtURI", () => { }); describe("defaultArtistArtURI", () => { - it("should create the correct URI", () => { - const bonobUrl = url("http://localhost:1234/something?s=123"); - const artist = anArtist(); + describe("when the artist has no image", () => { + it("should return an icon", () => { + const bonobUrl = url("http://localhost:1234/something?s=123"); + const artist = anArtist({ image: undefined }); + + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( + `http://localhost:1234/something/icon/vinyl/size/legacy?s=123` + ); + }); + }); - expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( - `http://localhost:1234/something/art/artist:${artist.id}/size/180?s=123` - ); + describe("when the resource is subsonic", () => { + it("should use the resource", () => { + const bonobUrl = url("http://localhost:1234/something?s=123"); + const image = { system:"subsonic", resource: "art:1234"}; + const artist = anArtist({ image }); + + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( + `http://localhost:1234/something/art/${encodeURIComponent(formatForURL(image))}/size/180?s=123` + ); + }); + }); + + describe("when the resource is external", () => { + it("should encrypt the resource", () => { + const bonobUrl = url("http://localhost:1234/something?s=123"); + const image = { system:"external", resource: "http://example.com/something.jpg"}; + const artist = anArtist({ image }); + + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( + `http://localhost:1234/something/art/${encodeURIComponent(formatForURL(image))}/size/180?s=123` + ); + }); }); }); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 58f91df..fde58de 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -3,19 +3,21 @@ import { v4 as uuid } from "uuid"; import tmp from "tmp"; import fse from "fs-extra"; import path from "path"; +import { pipe } from "fp-ts/lib/function"; +import { option as O } from "fp-ts"; import { - isDodgyImage, + isValidImage, Subsonic, t, - BROWSER_HEADERS, DODGY_IMAGE_NAME, asGenre, appendMimeTypeToClientFor, asURLSearchParams, - splitCoverArtId, cachingImageFetcher, asTrack, + artistImageURN, + images, } from "../src/subsonic"; import axios from "axios"; @@ -24,14 +26,13 @@ jest.mock("axios"); import sharp from "sharp"; jest.mock("sharp"); -import randomString from "../src/random_string"; -jest.mock("../src/random_string"); +import randomstring from "randomstring"; +jest.mock("randomstring"); import { Album, Artist, AuthSuccess, - Images, albumToAlbumSummary, asArtistAlbumPairs, Track, @@ -49,11 +50,13 @@ import { anArtist, aPlaylist, aPlaylistSummary, + aSimilarArtist, aTrack, POP, ROCK, } from "./builders"; import { b64Encode } from "../src/b64"; +import { BUrn } from "../src/burn"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -63,22 +66,22 @@ describe("t", () => { }); }); -describe("isDodgyImage", () => { +describe("isValidImage", () => { describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { it("is dodgy", () => { expect( - isDodgyImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") - ).toEqual(true); + isValidImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") + ).toEqual(false); }); }); describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { it("is dodgy", () => { - expect(isDodgyImage("http://something/somethingelse.png")).toEqual(false); + expect(isValidImage("http://something/somethingelse.png")).toEqual(true); expect( - isDodgyImage( + isValidImage( "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" ) - ).toEqual(false); + ).toEqual(true); }); }); }); @@ -251,21 +254,30 @@ const asSimilarArtistJson = (similarArtist: SimilarArtist) => { }; }; -const getArtistInfoJson = (artist: Artist) => +const getArtistInfoJson = ( + artist: Artist, + images: images = { + smallImageUrl: undefined, + mediumImageUrl: undefined, + largeImageUrl: undefined, + } +) => subsonicOK({ artistInfo2: { - smallImageUrl: artist.image.small, - mediumImageUrl: artist.image.medium, - largeImageUrl: artist.image.large, + ...images, similarArtist: artist.similarArtists.map(asSimilarArtistJson), }, }); -const maybeIdFromCoverArtId = (coverArt: string | undefined) => - coverArt ? splitCoverArtId(coverArt)[1] : ""; +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( + coverArt, + O.fromNullable, + O.map(it => it.resource.split(":")[1]), + O.getOrElseW(() => "") +) const asAlbumJson = ( - artist: { id: string | undefined, name: string | undefined }, + artist: { id: string | undefined; name: string | undefined }, album: AlbumSummary, tracks: Track[] = [] ) => ({ @@ -277,7 +289,7 @@ const asAlbumJson = ( album: album.name, artist: artist.name, genre: album.genre?.name, - coverArt: maybeIdFromCoverArtId(album.coverArt), + coverArt: maybeIdFromCoverArtUrn(album.coverArt), duration: "123", playCount: "4", year: album.year, @@ -297,7 +309,7 @@ const asSongJson = (track: Track) => ({ track: track.number, genre: track.genre?.name, isDir: "false", - coverArt: maybeIdFromCoverArtId(track.coverArt), + coverArt: maybeIdFromCoverArtUrn(track.coverArt), created: "2004-11-08T23:36:11", duration: track.duration, bitRate: 128, @@ -311,7 +323,7 @@ const asSongJson = (track: Track) => ({ type: "music", starred: track.rating.love ? "sometime" : undefined, userRating: track.rating.stars, - year: "" + year: "", }); const getAlbumListJson = (albums: [Artist, Album][]) => @@ -321,17 +333,22 @@ const getAlbumListJson = (albums: [Artist, Album][]) => }, }); -const asArtistJson = (artist: Artist) => ({ +type ArtistExtras = { artistImageUrl: string | undefined } + +const asArtistJson = ( + artist: Artist, + extras: ArtistExtras = { artistImageUrl: undefined } +) => ({ id: artist.id, name: artist.name, albumCount: artist.albums.length, - artistImageUrl: "...", album: artist.albums.map((it) => asAlbumJson(artist, it)), + ...extras, }); -const getArtistJson = (artist: Artist) => +const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => subsonicOK({ - artist: asArtistJson(artist), + artist: asArtistJson(artist, extras), }); const asGenreJson = (genre: { name: string; albumCount: number }) => ({ @@ -422,7 +439,7 @@ const getPlayListJson = (playlist: Playlist) => track: it.number, year: it.album.year, genre: it.album.genre?.name, - coverArt: splitCoverArtId(it.coverArt!)[1], + coverArt: maybeIdFromCoverArtUrn(it.coverArt), size: 123, contentType: it.mimeType, suffix: "mp3", @@ -542,23 +559,70 @@ const FAILURE = { const PING_OK = subsonicOK({}); -describe("splitCoverArtId", () => { - it("should split correctly", () => { - expect(splitCoverArtId("foo:bar")).toEqual(["foo", "bar"]); - expect(splitCoverArtId("foo:bar:car:jar")).toEqual(["foo", "bar:car:jar"]); - }); +describe("artistURN", () => { + describe("when artist URL is", () => { + describe("a valid external URL", () => { + it("should return an external URN", () => { + expect( + artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) + ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); + }); + }); - it("should blow up when the id is invalid", () => { - expect(() => splitCoverArtId("")).toThrow(`'' is an invalid coverArt id`); - expect(() => splitCoverArtId("foo:")).toThrow( - `'foo:' is an invalid coverArt id` - ); - expect(() => splitCoverArtId("foo:")).toThrow( - `'foo:' is an invalid coverArt id` - ); - expect(() => splitCoverArtId(":dog")).toThrow( - `':dog' is an invalid coverArt id` - ); + describe("an invalid external URL", () => { + describe("and artistId is valid", () => { + it("should return an external URN", () => { + expect( + artistImageURN({ + artistId: "someArtistId", + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); + }); + }); + + describe("and artistId is -1", () => { + it("should return an error icon urn", () => { + expect( + artistImageURN({ + artistId: "-1", + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toBeUndefined(); + }); + }); + + describe("and artistId is undefined", () => { + it("should return an error icon urn", () => { + expect( + artistImageURN({ + artistId: undefined, + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toBeUndefined(); + }); + }); + }); + + describe("undefined", () => { + describe("and artistId is valid", () => { + it("should return artist art by artist id URN", () => { + expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); + }); + }); + + describe("and artistId is -1", () => { + it("should return error icon", () => { + expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); + }); + }); + + describe("and artistId is undefined", () => { + it("should return error icon", () => { + expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); + }); + }); + }); }); }); @@ -570,14 +634,14 @@ describe("asTrack", () => { describe("a value greater than 5", () => { it("should be returned as 0", () => { const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); - expect(result.rating.stars).toEqual(0) + 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 }); - expect(result.rating.stars).toEqual(0) + expect(result.rating.stars).toEqual(0); }); }); }); @@ -595,7 +659,7 @@ describe("Subsonic", () => { streamClientApplication ); - const mockedRandomString = randomString as unknown as jest.Mock; + const mockRandomstring = jest.fn(); const mockGET = jest.fn(); const mockPOST = jest.fn(); @@ -603,10 +667,11 @@ describe("Subsonic", () => { jest.clearAllMocks(); jest.resetAllMocks(); + randomstring.generate = mockRandomstring; axios.get = mockGET; axios.post = mockPOST; - mockedRandomString.mockReturnValue(salt); + mockRandomstring.mockReturnValue(salt); }); const authParams = { @@ -764,16 +829,19 @@ describe("Subsonic", () => { const artist: Artist = anArtist({ albums: [album1, album2], - image: { - small: `http://localhost:80/${DODGY_IMAGE_NAME}`, - medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, - large: `http://localhost:80/${DODGY_IMAGE_NAME}`, - }, similarArtists: [ - { id: "similar1.id", name: "similar1", inLibrary: true }, - { id: "-1", name: "similar2", inLibrary: false }, - { id: "similar3.id", name: "similar3", inLibrary: true }, - { id: "-1", name: "similar4", inLibrary: false }, + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), + aSimilarArtist({ + id: "similar3.id", + name: "similar3", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), ], }); @@ -798,11 +866,7 @@ describe("Subsonic", () => { expect(result).toEqual({ id: `${artist.id}`, name: artist.name, - image: { - small: undefined, - medium: undefined, - large: undefined, - }, + image: { system:"subsonic", resource:`art:${artist.id}` }, albums: artist.albums, similarArtists: artist.similarArtists, }); @@ -834,13 +898,12 @@ describe("Subsonic", () => { const artist: Artist = anArtist({ albums: [album1, album2], - image: { - small: `http://localhost:80/${DODGY_IMAGE_NAME}`, - medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, - large: `http://localhost:80/${DODGY_IMAGE_NAME}`, - }, similarArtists: [ - { id: "similar1.id", name: "similar1", inLibrary: true }, + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), ], }); @@ -865,11 +928,7 @@ describe("Subsonic", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { - small: undefined, - medium: undefined, - large: undefined, - }, + image: { system:"subsonic", resource:`art:${artist.id}` }, albums: artist.albums, similarArtists: artist.similarArtists, }); @@ -901,11 +960,6 @@ describe("Subsonic", () => { const artist: Artist = anArtist({ albums: [album1, album2], - image: { - small: `http://localhost:80/${DODGY_IMAGE_NAME}`, - medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, - large: `http://localhost:80/${DODGY_IMAGE_NAME}`, - }, similarArtists: [], }); @@ -930,11 +984,7 @@ describe("Subsonic", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { - small: undefined, - medium: undefined, - large: undefined, - }, + image: { system:"subsonic", resource: `art:${artist.id}` }, albums: artist.albums, similarArtists: artist.similarArtists, }); @@ -960,32 +1010,25 @@ describe("Subsonic", () => { }); describe("and has dodgy looking artist image uris", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - - const album2: Album = anAlbum({ genre: asGenre("Flop") }); - const artist: Artist = anArtist({ - albums: [album1, album2], - image: { - small: `http://localhost:80/${DODGY_IMAGE_NAME}`, - medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, - large: `http://localhost:80/${DODGY_IMAGE_NAME}`, - }, + albums: [], similarArtists: [], }); + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) ); }); - it("should return remove the dodgy looking image uris and return undefined", async () => { + it("should return remove the dodgy looking image uris and return urn for artist:id", async () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) @@ -996,9 +1039,8 @@ describe("Subsonic", () => { id: artist.id, name: artist.name, image: { - small: undefined, - medium: undefined, - large: undefined, + system: "subsonic", + resource: `art:${artist.id}`, }, albums: artist.albums, similarArtists: [], @@ -1024,6 +1066,169 @@ describe("Subsonic", () => { }); }); + describe("and has a good external image uri from getArtist route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.artist(artist.id)); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has a good large external image uri from getArtistInfo route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.artist(artist.id)); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + + describe("and has a good medium external image uri from getArtistInfo route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.artist(artist.id)); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + describe("and has multiple albums", () => { const album1: Album = anAlbum({ genre: asGenre("Pop") }); @@ -1260,7 +1465,7 @@ describe("Subsonic", () => { }); describe("when there is one index and one artist", () => { - const artist1 = anArtist(); + const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); const asArtistsJson = subsonicOK({ artists: { @@ -1271,7 +1476,7 @@ describe("Subsonic", () => { { id: artist1.id, name: artist1.name, - albumCount: 22, + albumCount: artist1.albums.length, }, ], }, @@ -1293,10 +1498,11 @@ describe("Subsonic", () => { .then((it) => navidrome.login(it.authToken)) .then((it) => it.artists({ _index: 0, _count: 100 })); - const expectedResults = [artist1].map((it) => ({ - id: it.id, - name: it.name, - })); + const expectedResults = [{ + id: artist1.id, + image: artist1.image, + name: artist1.name, + }]; expect(artists).toEqual({ results: expectedResults, @@ -1312,7 +1518,7 @@ describe("Subsonic", () => { }); describe("when there are artists", () => { - const artist1 = anArtist({ name: "A Artist" }); + const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); const artist2 = anArtist({ name: "B Artist" }); const artist3 = anArtist({ name: "C Artist" }); const artist4 = anArtist({ name: "D Artist" }); @@ -1337,6 +1543,7 @@ describe("Subsonic", () => { const expectedResults = [artist1, artist2, artist3, artist4].map( (it) => ({ id: it.id, + image: it.image, name: it.name, }) ); @@ -1371,6 +1578,7 @@ describe("Subsonic", () => { const expectedResults = [artist2, artist3].map((it) => ({ id: it.id, + image: it.image, name: it.name, })); @@ -1393,7 +1601,9 @@ describe("Subsonic", () => { const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); - const artist = anArtist({ albums: [album1, album2, album3, album4, album5] }); + const artist = anArtist({ + albums: [album1, album2, album3, album4, album5], + }); describe("by genre", () => { beforeEach(() => { @@ -1472,7 +1682,11 @@ describe("Subsonic", () => { }); it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "recentlyAdded" }; + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyAdded", + }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) @@ -1522,7 +1736,11 @@ describe("Subsonic", () => { }); it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "recentlyPlayed" }; + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyPlayed", + }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) @@ -2603,8 +2821,7 @@ describe("Subsonic", () => { const album = anAlbum({ genre }); const artist = anArtist({ - albums: [album], - image: { large: "foo", medium: undefined, small: undefined }, + albums: [album] }); const track = aTrack({ id: trackId, @@ -2977,6 +3194,7 @@ describe("Subsonic", () => { data: Buffer.from("the image", "ascii"), }; const coverArtId = "someCoverArt"; + const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) @@ -2986,7 +3204,7 @@ describe("Subsonic", () => { .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`coverArt:${coverArtId}`)); + .then((it) => it.coverArt(coverArtURN)); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], @@ -3013,7 +3231,8 @@ describe("Subsonic", () => { }, data: Buffer.from("the image", "ascii"), }; - const coverArtId = "someCoverArt"; + const coverArtId = uuid(); + const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } const size = 1879; mockGET @@ -3024,7 +3243,7 @@ describe("Subsonic", () => { .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`coverArt:${coverArtId}`, size)); + .then((it) => it.coverArt(coverArtURN, size)); expect(result).toEqual({ contentType: streamResponse.headers["content-type"], @@ -3045,7 +3264,6 @@ describe("Subsonic", () => { describe("when an unexpected error occurs", () => { it("should return undefined", async () => { - const coverArtId = "someCoverArt"; const size = 1879; mockGET @@ -3056,434 +3274,88 @@ describe("Subsonic", () => { .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`coverArt:${coverArtId}`, size)); + .then((it) => it.coverArt({ system: "external", resource: "http://localhost:404" }, size)); expect(result).toBeUndefined(); }); }); }); - describe("fetching artist art", () => { + describe("fetching cover art", () => { + describe("when urn.resource is not subsonic", () => { + it("should be undefined", async () => { + const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.coverArt(covertArtURN, 190)); + + expect(result).toBeUndefined(); + }); + }); + describe("when no size is specified", () => { - describe("when the artist has a valid artist uri", () => { - it("should fetch the image from the artist uri", async () => { - const artistId = "someArtist123"; + it("should fetch the image", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - const images: Images = { - small: "http://example.com/images/small", - medium: "http://example.com/images/medium", - large: "http://example.com/images/large", - }; + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); - const artist = anArtist({ id: artistId, image: images }); + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.coverArt(covertArtURN)); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid() + const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + .mockImplementationOnce(() => Promise.reject("BOOOM")); const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); + .then((it) => it.coverArt(covertArtURN)); - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith(images.large, { - headers: BROWSER_HEADERS, - responseType: "arraybuffer", - }); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const artistId = "someArtist123"; - - const images: Images = { - small: "http://example.com/images/small", - medium: "http://example.com/images/medium", - large: "http://example.com/images/large", - }; - - const artist = anArtist({ id: artistId, image: images }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("when the artist doest not have a valid artist uri", () => { - describe("however has some albums", () => { - const artistId = "someArtist123"; - - const images: Images = { - small: undefined, - medium: undefined, - large: undefined, - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - describe("no albums have coverArt", () => { - it("should return undefined", async () => { - const album1 = anAlbum({ coverArt: undefined }); - const album2 = anAlbum({ coverArt: undefined }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(streamResponse) - ); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toEqual(undefined); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtist`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - }); - }); - - describe("some albums have coverArt", () => { - describe("all albums have coverArt", () => { - it("should fetch the coverArt from the first album", async () => { - const album1 = anAlbum({ - coverArt: `coverArt:album1CoverArt`, - }); - const album2 = anAlbum({ - coverArt: `coverArt:album2CoverArt`, - }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(streamResponse) - ); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtist`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: splitCoverArtId(album1.coverArt!)[1], - }), - headers, - responseType: "arraybuffer", - } - ); - }); - }); - - describe("the first album does not have coverArt", () => { - it("should fetch the coverArt from the first album with coverArt", async () => { - const album1 = anAlbum({ coverArt: undefined }); - const album2 = anAlbum({ - coverArt: `coverArt:album2CoverArt`, - }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(streamResponse) - ); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtist`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: splitCoverArtId(album2.coverArt!)[1], - }), - headers, - responseType: "arraybuffer", - } - ); - }); - }); - - describe("an unexpected error occurs getting the albums coverArt", () => { - it("should fetch the coverArt from the first album", async () => { - const album1 = anAlbum({ - coverArt: `coverArt:album1CoverArt`, - }); - const album2 = anAlbum({ - coverArt: `coverArt:album2CoverArt`, - }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toBeUndefined(); - }); - }); - }); - }); - - describe("and has no albums", () => { - it("should return undefined", async () => { - const artistId = "someArtist123"; - - const images: Images = { - small: undefined, - medium: undefined, - large: undefined, - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - const artist = anArtist({ - id: artistId, - albums: [], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toBeUndefined(); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - }); + expect(result).toBeUndefined(); }); }); }); @@ -3491,387 +3363,63 @@ describe("Subsonic", () => { describe("when size is specified", () => { const size = 189; - describe("when the artist has a valid artist uri", () => { - it("should fetch the image from the artist uri and resize it", async () => { - const artistId = "someArtist123"; + it("should fetch the image", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - const images: Images = { - small: "http://example.com/images/small", - medium: "http://example.com/images/medium", - large: "http://example.com/images/large", - }; + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; - const originalImage = Buffer.from("original image", "ascii"); - const resizedImage = Buffer.from("resized image", "ascii"); + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: originalImage, - }; + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.coverArt(covertArtURN, size)); - const artist = anArtist({ id: artistId, image: images }); + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const resize = jest.fn(); - (sharp as unknown as jest.Mock).mockReturnValue({ resize }); - resize.mockReturnValue({ - toBuffer: () => Promise.resolve(resizedImage), - }); + .mockImplementationOnce(() => Promise.reject("BOOOM")); const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`, size)); + .then((it) => it.coverArt(covertArtURN, size)); - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: resizedImage, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith(images.large, { - headers: BROWSER_HEADERS, - responseType: "arraybuffer", - }); - - expect(sharp).toHaveBeenCalledWith(streamResponse.data); - expect(resize).toHaveBeenCalledWith(size); - }); - }); - - describe("when the artist does not have a valid artist uri", () => { - describe("however has some albums", () => { - it("should fetch the artists first album image", async () => { - const artistId = "someArtist123"; - - const images: Images = { - small: undefined, - medium: undefined, - large: undefined, - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - const album1 = anAlbum({ id: "album1Id" }); - const album2 = anAlbum({ id: "album2Id" }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`, size)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: splitCoverArtId(album1.coverArt!)[1], - size, - }), - headers, - responseType: "arraybuffer", - } - ); - }); - }); - - describe("and has no albums", () => { - it("should return undefined", async () => { - const artistId = "someArtist123"; - - const images: Images = { - small: undefined, - medium: undefined, - large: undefined, - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - const artist = anArtist({ - id: artistId, - albums: [], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toBeUndefined(); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - }); - }); - }); - - describe("when the artist has a dodgy looking artist uri", () => { - describe("however has some albums", () => { - it("should fetch the artists first album image", async () => { - const artistId = "someArtist123"; - - const images: Images = { - small: `http://localhost:111/${DODGY_IMAGE_NAME}`, - medium: `http://localhost:111/${DODGY_IMAGE_NAME}`, - large: `http://localhost:111/${DODGY_IMAGE_NAME}`, - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - const album1 = anAlbum({ - id: "album1Id", - coverArt: "coverArt:album1CoverArt", - }); - const album2 = anAlbum({ - id: "album2Id", - coverArt: "coverArt:album2CoverArt", - }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`, size)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: splitCoverArtId(album1.coverArt!)[1], - size, - }), - headers, - responseType: "arraybuffer", - } - ); - }); - }); - - describe("and has no albums", () => { - it("should return undefined", async () => { - const artistId = "someArtist123"; - - const images: Images = { - small: `http://localhost:111/${DODGY_IMAGE_NAME}`, - medium: `http://localhost:111/${DODGY_IMAGE_NAME}`, - large: `http://localhost:111/${DODGY_IMAGE_NAME}`, - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - const artist = anArtist({ - id: artistId, - albums: [], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toBeUndefined(); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - }); + expect(result).toBeUndefined(); }); }); }); @@ -4089,7 +3637,7 @@ describe("Subsonic", () => { describe("invalid star values", () => { describe("stars of -1", () => { it("should return false", async () => { - mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); const result = await rate(trackId, { love: true, stars: -1 }); expect(result).toEqual(false); @@ -4098,7 +3646,7 @@ describe("Subsonic", () => { describe("stars of 6", () => { it("should return false", async () => { - mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); const result = await rate(trackId, { love: true, stars: -1 }); expect(result).toEqual(false); diff --git a/yarn.lock b/yarn.lock index b722a48..ae3ec75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1269,6 +1269,13 @@ __metadata: languageName: node linkType: hard +"@types/randomstring@npm:^1.1.8": + version: 1.1.8 + resolution: "@types/randomstring@npm:1.1.8" + checksum: 22a9e4b09583ad8e7fa7ca214133abc014636d7d6eb49ca9ee671c09b241311107b0a6ea48205bf795ac61fbe5b185ac415aed2dd27c7f5806235bfea0e5532f + languageName: node + linkType: hard + "@types/range-parser@npm:*": version: 1.2.3 resolution: "@types/range-parser@npm:1.2.3" @@ -1600,6 +1607,13 @@ __metadata: languageName: node linkType: hard +"array-uniq@npm:1.0.2": + version: 1.0.2 + resolution: "array-uniq@npm:1.0.2" + checksum: 8c4beb94aa183791da1e155935aba4df3fe2eeb6f491c69e666ca7351f897b9b260fa04d016e0ce766ae8280129c16f11071e17359c81c01741289009bb5ac6d + languageName: node + linkType: hard + "assertion-error@npm:^1.1.0": version: 1.1.0 resolution: "assertion-error@npm:1.1.0" @@ -1805,6 +1819,7 @@ __metadata: "@types/mocha": ^9.0.0 "@types/morgan": ^1.9.3 "@types/node": ^16.7.13 + "@types/randomstring": ^1.1.8 "@types/sharp": ^0.28.6 "@types/supertest": ^2.0.11 "@types/tmp": ^0.2.1 @@ -1826,6 +1841,7 @@ __metadata: morgan: ^1.10.0 node-html-parser: ^4.1.4 nodemon: ^2.0.12 + randomstring: ^1.2.1 sharp: ^0.29.1 soap: ^0.42.0 supertest: ^6.1.6 @@ -1836,6 +1852,7 @@ __metadata: ts-node: ^10.2.1 typescript: ^4.4.2 underscore: ^1.13.1 + urn-lib: ^2.0.0 uuid: ^8.3.2 winston: ^3.3.3 xmldom-ts: ^0.3.1 @@ -6013,6 +6030,25 @@ __metadata: languageName: node linkType: hard +"randombytes@npm:2.0.3": + version: 2.0.3 + resolution: "randombytes@npm:2.0.3" + checksum: 13e1abd143404dd87024bf345fb1a446b2e2ee46d8e1a5a073e8370c9b1e58000d81a97d4327ba7089087213eb6d8c77fa67ab4e91aa00605126d634fcccb9d4 + languageName: node + linkType: hard + +"randomstring@npm:^1.2.1": + version: 1.2.1 + resolution: "randomstring@npm:1.2.1" + dependencies: + array-uniq: 1.0.2 + randombytes: 2.0.3 + bin: + randomstring: bin/randomstring + checksum: 501da2ec59638d502dbb66c237ab80790dbb0b50b493347cbf6abc2dfbf6fe08f195d85e37911689bc406f85d31cf9826757398b5af8c9cae7c0ad4f808f3ac0 + languageName: node + linkType: hard + "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -7235,6 +7271,13 @@ typescript@^4.4.2: languageName: node linkType: hard +"urn-lib@npm:^2.0.0": + version: 2.0.0 + resolution: "urn-lib@npm:2.0.0" + checksum: fde3f4b8c38483d6229fe49e23cbf9cc012e0b4459d6aacb9bb2f3f1a32992b0e1115122401cca3708598ffa279355182158c7f7c248881ff72dc0f9e9f76d82 + languageName: node + linkType: hard + "utf8@npm:^2.1.2": version: 2.1.2 resolution: "utf8@npm:2.1.2"