diff --git a/README.md b/README.md index 2c6a4d9..ffcc5bc 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ BNB_SONOS_SERVICE_NAME | bonob | service name for sonos BNB_SONOS_SERVICE_ID | 246 | service id for sonos BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. +BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images as are sourced externally. ie. Navidrome provides spotify URLs BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) diff --git a/package.json b/package.json index 1da64ea..8ee5b27 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dependencies": { "@svrooij/sonos": "^2.4.0", "@types/express": "^4.17.13", + "@types/fs-extra": "^9.0.13", "@types/morgan": "^1.9.3", "@types/node": "^16.7.13", "@types/sharp": "^0.28.6", @@ -18,6 +19,7 @@ "eta": "^1.12.3", "express": "^4.17.1", "fp-ts": "^2.11.1", + "fs-extra": "^10.0.0", "libxmljs2": "^0.28.0", "morgan": "^1.10.0", "node-html-parser": "^4.1.4", @@ -35,12 +37,14 @@ "@types/jest": "^27.0.1", "@types/mocha": "^9.0.0", "@types/supertest": "^2.0.11", + "@types/tmp": "^0.2.1", "chai": "^4.3.4", "get-port": "^5.1.1", "image-js": "^0.33.0", "jest": "^27.1.0", "nodemon": "^2.0.12", "supertest": "^6.1.6", + "tmp": "^0.2.1", "ts-jest": "^27.0.5", "ts-mockito": "^2.6.1", "ts-node": "^10.2.1", diff --git a/src/app.ts b/src/app.ts index 46ae934..6bae963 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,13 @@ import path from "path"; import fs from "fs"; import server from "./server"; import logger from "./logger"; -import { appendMimeTypeToClientFor, DEFAULT, Subsonic } from "./subsonic"; +import { + appendMimeTypeToClientFor, + axiosImageFetcher, + cachingImageFetcher, + DEFAULT, + Subsonic, +} from "./subsonic"; import encryption from "./encryption"; import { InMemoryAccessTokens, sha256 } from "./access_tokens"; import { InMemoryLinkCodes } from "./link_codes"; @@ -28,10 +34,15 @@ const streamUserAgent = config.subsonic.customClientsFor ? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(",")) : DEFAULT; +const artistImageFetcher = config.subsonic.artistImageCache + ? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher) + : axiosImageFetcher; + const subsonic = new Subsonic( config.subsonic.url, encryption(config.secret), - streamUserAgent + streamUserAgent, + artistImageFetcher ); const featureFlagAwareMusicService: MusicService = { @@ -60,7 +71,9 @@ const featureFlagAwareMusicService: MusicService = { export const GIT_INFO = path.join(__dirname, "..", ".gitinfo"); -const version = fs.existsSync(GIT_INFO) ? fs.readFileSync(GIT_INFO).toString().trim() : "v??" +const version = fs.existsSync(GIT_INFO) + ? fs.readFileSync(GIT_INFO).toString().trim() + : "v??"; const app = server( sonosSystem, @@ -69,12 +82,12 @@ const app = server( featureFlagAwareMusicService, { linkCodes: () => new InMemoryLinkCodes(), - accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)), + accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)), clock: SystemClock, iconColors: config.icons, applyContextPath: true, logRequests: true, - version + version, } ); @@ -90,12 +103,12 @@ if (config.sonos.autoRegister) { ); } }); -} else if(config.sonos.discovery.enabled) { - sonosSystem.devices().then(devices => { - devices.forEach(d => { - logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`) - }) - }) +} else if (config.sonos.discovery.enabled) { + sonosSystem.devices().then((devices) => { + devices.forEach((d) => { + logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`); + }); + }); } export default app; diff --git a/src/config.ts b/src/config.ts index 57f9e9d..d545698 100644 --- a/src/config.ts +++ b/src/config.ts @@ -84,6 +84,7 @@ export default function () { subsonic: { url: bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!, customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }), + artistImageCache: bnbEnvVar("SUBSONIC_ARTIST_IMAGE_CACHE"), }, scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: "true" }) == "true", reportNowPlaying: diff --git a/src/subsonic.ts b/src/subsonic.ts index 1d724bf..645581c 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -18,10 +18,13 @@ import { AlbumSummary, Genre, Track, + CoverArt, } from "./music_service"; import X2JS from "x2js"; import sharp from "sharp"; import _ from "underscore"; +import fse from "fs-extra"; +import path from "path"; import axios, { AxiosRequestConfig } from "axios"; import { Encryption } from "./encryption"; @@ -311,19 +314,61 @@ export const asURLSearchParams = (q: any) => { return urlSearchParams; }; +export type ImageFetcher = (url: string) => Promise; + +export const cachingImageFetcher = + (cacheDir: string, delegate: ImageFetcher) => + (url: string): Promise => { + const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); + return fse + .readFile(filename) + .then((data) => ({ contentType: "image/png", data })) + .catch(() => + delegate(url).then((image) => { + if (image) { + return sharp(image.data) + .png() + .toBuffer() + .then((png) => { + return fse + .writeFile(filename, png) + .then(() => ({ contentType: "image/png", data: png })); + }); + } else { + return undefined; + } + }) + ); + }; + +export const axiosImageFetcher = (url: string): Promise => + axios + .get(url, { + headers: BROWSER_HEADERS, + responseType: "arraybuffer", + }) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch(() => undefined); + export class Subsonic implements MusicService { url: string; encryption: Encryption; streamClientApplication: StreamClientApplication; + externalImageFetcher: ImageFetcher; constructor( url: string, encryption: Encryption, - streamClientApplication: StreamClientApplication = DEFAULT + streamClientApplication: StreamClientApplication = DEFAULT, + externalImageFetcher: ImageFetcher = axiosImageFetcher ) { this.url = url; this.encryption = encryption; this.streamClientApplication = streamClientApplication; + this.externalImageFetcher = externalImageFetcher; } get = async ( @@ -630,28 +675,21 @@ export class Subsonic implements MusicService { (it) => it.coverArt ); if (artist.image.large) { - return axios - .get(artist.image.large!, { - headers: BROWSER_HEADERS, - responseType: "arraybuffer", - }) - .then((res) => { - const image = Buffer.from(res.data, "binary"); - if (size) { - return sharp(image) + return this.externalImageFetcher(artist.image.large!).then( + (image) => { + if (image && size) { + return sharp(image.data) .resize(size) .toBuffer() .then((resized) => ({ - contentType: res.headers["content-type"], + contentType: image.contentType, data: resized, })); } else { - return { - contentType: res.headers["content-type"], - data: image, - }; + return image; } - }); + } + ); } else if (albumsWithCoverArt.length > 0) { return subsonic .getCoverArt( diff --git a/tests/builders.ts b/tests/builders.ts index d8fb3a5..608dcc8 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -3,7 +3,15 @@ import { v4 as uuid } from "uuid"; import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; -import { Album, Artist, Track, albumToAlbumSummary, artistToArtistSummary, PlaylistSummary, Playlist } from "../src/music_service"; +import { + Album, + Artist, + Track, + albumToAlbumSummary, + artistToArtistSummary, + PlaylistSummary, + Playlist, +} from "../src/music_service"; import randomString from "../src/random_string"; import { b64Encode } from "../src/b64"; @@ -29,12 +37,14 @@ export const aService = (fields: Partial = {}): Service => ({ ...fields, }); -export function aPlaylistSummary(fields: Partial = {}): PlaylistSummary { +export function aPlaylistSummary( + fields: Partial = {} +): PlaylistSummary { return { id: `playlist-${uuid()}`, name: `playlistname-${randomString()}`, - ...fields - } + ...fields, + }; } export function aPlaylist(fields: Partial = {}): Playlist { @@ -42,9 +52,9 @@ export function aPlaylist(fields: Partial = {}): Playlist { id: `playlist-${uuid()}`, name: `playlist-${randomString()}`, entries: [aTrack(), aTrack()], - ...fields - } -} + ...fields, + }; +} export function aDevice(fields: Partial = {}): Device { return { @@ -105,14 +115,14 @@ export function anArtist(fields: Partial = {}): Artist { ], ...fields, }; - artist.albums.forEach(album => { + artist.albums.forEach((album) => { album.artistId = artist.id; album.artistName = artist.name; - }) + }); return artist; } -export const aGenre = (name: string) => ({ id: b64Encode(name), name }) +export const aGenre = (name: string) => ({ id: b64Encode(name), name }); export const HIP_HOP = aGenre("Hip-Hop"); export const METAL = aGenre("Metal"); @@ -125,7 +135,16 @@ export const SKA = aGenre("Ska"); export const PUNK = aGenre("Punk"); export const TRIP_HOP = aGenre("Trip Hop"); -export const SAMPLE_GENRES = [HIP_HOP, METAL, NEW_WAVE, POP, POP_ROCK, REGGAE, ROCK, SKA]; +export const SAMPLE_GENRES = [ + HIP_HOP, + METAL, + NEW_WAVE, + POP, + POP_ROCK, + REGGAE, + ROCK, + SKA, +]; export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]; export function aTrack(fields: Partial = {}): Track { @@ -140,7 +159,9 @@ export function aTrack(fields: Partial = {}): Track { number: randomInt(100), genre, artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })), + album: albumToAlbumSummary( + anAlbum({ artistId: artist.id, artistName: artist.name, genre }) + ), coverArt: `coverArt:${uuid()}`, ...fields, }; @@ -173,7 +194,7 @@ export const BLONDIE: Artist = { genre: NEW_WAVE, artistId: BLONDIE_ID, artistName: BLONDIE_NAME, - coverArt: `coverArt:${uuid()}` + coverArt: `coverArt:${uuid()}`, }, { id: uuid(), @@ -182,7 +203,7 @@ export const BLONDIE: Artist = { genre: POP_ROCK, artistId: BLONDIE_ID, artistName: BLONDIE_NAME, - coverArt: `coverArt:${uuid()}` + coverArt: `coverArt:${uuid()}`, }, ], image: { @@ -199,9 +220,33 @@ export const BOB_MARLEY: Artist = { id: BOB_MARLEY_ID, name: BOB_MARLEY_NAME, albums: [ - { id: uuid(), name: "Burin'", year: "1973", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` }, - { id: uuid(), name: "Exodus", year: "1977", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` }, - { id: uuid(), name: "Kaya", year: "1978", genre: SKA, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` }, + { + id: uuid(), + name: "Burin'", + year: "1973", + genre: REGGAE, + artistId: BOB_MARLEY_ID, + artistName: BOB_MARLEY_NAME, + coverArt: `coverArt:${uuid()}`, + }, + { + id: uuid(), + name: "Exodus", + year: "1977", + genre: REGGAE, + artistId: BOB_MARLEY_ID, + artistName: BOB_MARLEY_NAME, + coverArt: `coverArt:${uuid()}`, + }, + { + id: uuid(), + name: "Kaya", + year: "1978", + genre: SKA, + artistId: BOB_MARLEY_ID, + artistName: BOB_MARLEY_NAME, + coverArt: `coverArt:${uuid()}`, + }, ], image: { small: "http://localhost/BOB_MARLEY/sml", @@ -238,7 +283,7 @@ export const METALLICA: Artist = { genre: METAL, artistId: METALLICA_ID, artistName: METALLICA_NAME, - coverArt: `coverArt:${uuid()}` + coverArt: `coverArt:${uuid()}`, }, { id: uuid(), @@ -246,8 +291,8 @@ export const METALLICA: Artist = { year: "1986", genre: METAL, artistId: METALLICA_ID, - artistName: METALLICA_NAME, - coverArt: `coverArt:${uuid()}` + artistName: METALLICA_NAME, + coverArt: `coverArt:${uuid()}`, }, ], image: { @@ -261,3 +306,4 @@ export const METALLICA: Artist = { export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA]; export const ALL_ALBUMS = ALL_ARTISTS.flatMap((it) => it.albums || []); + diff --git a/tests/config.test.ts b/tests/config.test.ts index d4a7e17..5090438 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -66,11 +66,13 @@ describe("envVar", () => { describe("validationPattern", () => { it("should fail when the value does not match the pattern", () => { - expect( - () => envVar("bnb-var", { + expect(() => + envVar("bnb-var", { validationPattern: /^foobar$/, }) - ).toThrowError(`Invalid value specified for 'bnb-var', must match ${/^foobar$/}`) + ).toThrowError( + `Invalid value specified for 'bnb-var', must match ${/^foobar$/}` + ); }); }); }); @@ -117,7 +119,7 @@ describe("config", () => { } describe("bonobUrl", () => { - ["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach(key => { + ["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach((key) => { describe(`when ${key} is specified`, () => { it("should be used", () => { const url = "http://bonob1.example.com:8877/"; @@ -126,7 +128,7 @@ describe("config", () => { process.env["BONOB_URL"] = ""; process.env["BONOB_WEB_ADDRESS"] = ""; process.env[key] = url; - + expect(config().bonobUrl.href()).toEqual(url); }); }); @@ -163,69 +165,73 @@ describe("config", () => { describe("icons", () => { describe("foregroundColor", () => { - ["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach(k => { - describe(`when ${k} is not specified`, () => { - it(`should default to undefined`, () => { - expect(config().icons.foregroundColor).toEqual(undefined); + ["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach( + (k) => { + describe(`when ${k} is not specified`, () => { + it(`should default to undefined`, () => { + expect(config().icons.foregroundColor).toEqual(undefined); + }); }); - }); - - describe(`when ${k} is ''`, () => { - it(`should default to undefined`, () => { - process.env[k] = ""; - expect(config().icons.foregroundColor).toEqual(undefined); + + describe(`when ${k} is ''`, () => { + it(`should default to undefined`, () => { + process.env[k] = ""; + expect(config().icons.foregroundColor).toEqual(undefined); + }); }); - }); - - describe(`when ${k} is specified`, () => { - it(`should use it`, () => { - process.env[k] = "pink"; - expect(config().icons.foregroundColor).toEqual("pink"); + + describe(`when ${k} is specified`, () => { + it(`should use it`, () => { + process.env[k] = "pink"; + expect(config().icons.foregroundColor).toEqual("pink"); + }); }); - }); - - describe(`when ${k} is an invalid string`, () => { - it(`should blow up`, () => { - process.env[k] = "#dfasd"; - expect(() => config()).toThrow( - `Invalid value specified for 'BNB_ICON_FOREGROUND_COLOR', must match ${WORD}` - ); + + describe(`when ${k} is an invalid string`, () => { + it(`should blow up`, () => { + process.env[k] = "#dfasd"; + expect(() => config()).toThrow( + `Invalid value specified for 'BNB_ICON_FOREGROUND_COLOR', must match ${WORD}` + ); + }); }); - }); - }); + } + ); }); describe("backgroundColor", () => { - ["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach(k => { - describe(`when ${k} is not specified`, () => { - it(`should default to undefined`, () => { - expect(config().icons.backgroundColor).toEqual(undefined); + ["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach( + (k) => { + describe(`when ${k} is not specified`, () => { + it(`should default to undefined`, () => { + expect(config().icons.backgroundColor).toEqual(undefined); + }); }); - }); - - describe(`when ${k} is ''`, () => { - it(`should default to undefined`, () => { - process.env[k] = ""; - expect(config().icons.backgroundColor).toEqual(undefined); + + describe(`when ${k} is ''`, () => { + it(`should default to undefined`, () => { + process.env[k] = ""; + expect(config().icons.backgroundColor).toEqual(undefined); + }); }); - }); - - describe(`when ${k} is specified`, () => { - it(`should use it`, () => { - process.env[k] = "blue"; - expect(config().icons.backgroundColor).toEqual("blue"); + + describe(`when ${k} is specified`, () => { + it(`should use it`, () => { + process.env[k] = "blue"; + expect(config().icons.backgroundColor).toEqual("blue"); + }); }); - }); - - describe(`when ${k} is an invalid string`, () => { - it(`should blow up`, () => { - process.env[k] = "#red"; - expect(() => config()).toThrow( - `Invalid value specified for 'BNB_ICON_BACKGROUND_COLOR', must match ${WORD}` - ); + + describe(`when ${k} is an invalid string`, () => { + it(`should blow up`, () => { + process.env[k] = "#red"; + expect(() => config()).toThrow( + `Invalid value specified for 'BNB_ICON_BACKGROUND_COLOR', must match ${WORD}` + ); + }); }); - }); - }); + } + ); }); }); @@ -234,7 +240,7 @@ describe("config", () => { expect(config().secret).toEqual("bonob"); }); - ["BNB_SECRET", "BONOB_SECRET"].forEach(key => { + ["BNB_SECRET", "BONOB_SECRET"].forEach((key) => { it(`should be overridable using ${key}`, () => { process.env[key] = "new secret"; expect(config().secret).toEqual("new secret"); @@ -248,7 +254,7 @@ describe("config", () => { expect(config().sonos.serviceName).toEqual("bonob"); }); - ["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach(k => { + ["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach((k) => { it("should be overridable", () => { process.env[k] = "foobar1000"; expect(config().sonos.serviceName).toEqual("foobar1000"); @@ -256,21 +262,23 @@ describe("config", () => { }); }); - ["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(k => { - describeBooleanConfigValue( - "deviceDiscovery", - k, - true, - (config) => config.sonos.discovery.enabled - ); - }); + ["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach( + (k) => { + describeBooleanConfigValue( + "deviceDiscovery", + k, + true, + (config) => config.sonos.discovery.enabled + ); + } + ); describe("seedHost", () => { it("should default to undefined", () => { expect(config().sonos.discovery.seedHost).toBeUndefined(); }); - ["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach(k => { + ["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach((k) => { it("should be overridable", () => { process.env[k] = "123.456.789.0"; expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); @@ -278,7 +286,7 @@ describe("config", () => { }); }); - ["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach(k => { + ["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach((k) => { describeBooleanConfigValue( "autoRegister", k, @@ -287,13 +295,12 @@ describe("config", () => { ); }); - describe("sid", () => { it("should default to 246", () => { expect(config().sonos.sid).toEqual(246); }); - ["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach(k => { + ["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach((k) => { it("should be overridable", () => { process.env[k] = "786"; expect(config().sonos.sid).toEqual(786); @@ -304,28 +311,34 @@ describe("config", () => { describe("subsonic", () => { describe("url", () => { - ["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach(k => { - describe(`when ${k} is not specified`, () => { - it(`should default to http://${hostname()}:4533`, () => { - expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`); + ["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach( + (k) => { + describe(`when ${k} is not specified`, () => { + it(`should default to http://${hostname()}:4533`, () => { + expect(config().subsonic.url).toEqual( + `http://${hostname()}:4533` + ); + }); }); - }); - - describe(`when ${k} is ''`, () => { - it(`should default to http://${hostname()}:4533`, () => { - process.env[k] = ""; - expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`); + + describe(`when ${k} is ''`, () => { + it(`should default to http://${hostname()}:4533`, () => { + process.env[k] = ""; + expect(config().subsonic.url).toEqual( + `http://${hostname()}:4533` + ); + }); }); - }); - - describe(`when ${k} is specified`, () => { - it(`should use it for ${k}`, () => { - const url = "http://navidrome.example.com:1234"; - process.env[k] = url; - expect(config().subsonic.url).toEqual(url); + + describe(`when ${k} is specified`, () => { + it(`should use it for ${k}`, () => { + const url = "http://navidrome.example.com:1234"; + process.env[k] = url; + expect(config().subsonic.url).toEqual(url); + }); }); - }); - }); + } + ); }); describe("customClientsFor", () => { @@ -333,17 +346,31 @@ describe("config", () => { expect(config().subsonic.customClientsFor).toBeUndefined(); }); - ["BNB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_NAVIDROME_CUSTOM_CLIENTS"].forEach(k => { + [ + "BNB_SUBSONIC_CUSTOM_CLIENTS", + "BONOB_SUBSONIC_CUSTOM_CLIENTS", + "BONOB_NAVIDROME_CUSTOM_CLIENTS", + ].forEach((k) => { it(`should be overridable for ${k}`, () => { process.env[k] = "whoop/whoop"; expect(config().subsonic.customClientsFor).toEqual("whoop/whoop"); }); }); }); - }); + describe("artistImageCache", () => { + it("should default to undefined", () => { + expect(config().subsonic.artistImageCache).toBeUndefined(); + }); - ["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach(k => { + it(`should be overridable for BNB_SUBSONIC_ARTIST_IMAGE_CACHE`, () => { + process.env["BNB_SUBSONIC_ARTIST_IMAGE_CACHE"] = "/some/path"; + expect(config().subsonic.artistImageCache).toEqual("/some/path"); + }); + }); + }); + + ["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach((k) => { describeBooleanConfigValue( "scrobbleTracks", k, @@ -352,7 +379,7 @@ describe("config", () => { ); }); - ["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach(k => { + ["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach((k) => { describeBooleanConfigValue( "reportNowPlaying", k, diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 7d0dcc5..5dd342b 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,5 +1,8 @@ import { Md5 } from "ts-md5/dist/md5"; import { v4 as uuid } from "uuid"; +import tmp from "tmp"; +import fse from "fs-extra"; +import path from "path"; import { isDodgyImage, @@ -11,6 +14,7 @@ import { appendMimeTypeToClientFor, asURLSearchParams, splitCoverArtId, + cachingImageFetcher, } from "../src/subsonic"; import encryption from "../src/encryption"; @@ -158,6 +162,74 @@ describe("asURLSearchParams", () => { }); }); +describe("cachingImageFetcher", () => { + const delegate = jest.fn(); + const url = "http://test.example.com/someimage.jpg"; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe("when there is no image in the cache", () => { + it.only("should fetch the image from the source and then cache and return it", async () => { + const dir = tmp.dirSync(); + const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const jpgImage = Buffer.from("jpg-image", 'utf-8'); + const pngImage = Buffer.from("png-image", 'utf-8'); + + delegate.mockResolvedValue({ contentType: "image/jpeg", data: jpgImage }); + const png = jest.fn(); + (sharp as unknown as jest.Mock).mockReturnValue({ png }); + png.mockReturnValue({ + toBuffer: () => Promise.resolve(pngImage), + }); + + const result = await cachingImageFetcher(dir.name, delegate)(url); + + expect(result!.contentType).toEqual("image/png"); + expect(result!.data).toEqual(pngImage); + + expect(delegate).toHaveBeenCalledWith(url); + expect(fse.existsSync(cacheFile)).toEqual(true); + expect(fse.readFileSync(cacheFile)).toEqual(pngImage); + }); + }); + + describe("when the image is already in the cache", () => { + it.only("should fetch the image from the cache and return it", async () => { + const dir = tmp.dirSync(); + const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const data = Buffer.from("foobar2", "utf-8"); + + fse.writeFileSync(cacheFile, data); + + const result = await cachingImageFetcher(dir.name, delegate)(url); + + expect(result!.contentType).toEqual("image/png"); + expect(result!.data).toEqual(data); + + expect(delegate).not.toHaveBeenCalled(); + }); + }); + + describe("when the delegate returns undefined", () => { + it.only("should return undefined", async () => { + const dir = tmp.dirSync(); + const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + + delegate.mockResolvedValue(undefined); + + const result = await cachingImageFetcher(dir.name, delegate)(url); + + expect(result).toBeUndefined(); + + expect(delegate).toHaveBeenCalledWith(url); + expect(fse.existsSync(cacheFile)).toEqual(false); + }); + }); +}); + const ok = (data: string) => ({ status: 200, data, diff --git a/yarn.lock b/yarn.lock index bf184d7..e27839a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1131,6 +1131,15 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^9.0.13": + version: 9.0.13 + resolution: "@types/fs-extra@npm:9.0.13" + dependencies: + "@types/node": "*" + checksum: add79e212acd5ac76b97b9045834e03a7996aef60a814185e0459088fd290519a3c1620865d588fa36c4498bf614210d2a703af5cf80aa1dbc125db78f6edac3 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.2": version: 4.1.5 resolution: "@types/graceful-fs@npm:4.1.5" @@ -1303,6 +1312,13 @@ __metadata: languageName: node linkType: hard +"@types/tmp@npm:^0.2.1": + version: 0.2.1 + resolution: "@types/tmp@npm:0.2.1" + checksum: 2617d2a04811ca78a8d21f5ffc3bd7c392e03c440053a615b091f3e3726540d36babffc750614a803c81b9f2c5f218cdafc748d8cf4638eade2962f8ccddd2fa + languageName: node + linkType: hard + "@types/underscore@npm:^1.11.3": version: 1.11.3 resolution: "@types/underscore@npm:1.11.3" @@ -1764,12 +1780,14 @@ __metadata: "@svrooij/sonos": ^2.4.0 "@types/chai": ^4.2.21 "@types/express": ^4.17.13 + "@types/fs-extra": ^9.0.13 "@types/jest": ^27.0.1 "@types/mocha": ^9.0.0 "@types/morgan": ^1.9.3 "@types/node": ^16.7.13 "@types/sharp": ^0.28.6 "@types/supertest": ^2.0.11 + "@types/tmp": ^0.2.1 "@types/underscore": ^1.11.3 "@types/uuid": ^8.3.1 axios: ^0.21.4 @@ -1778,6 +1796,7 @@ __metadata: eta: ^1.12.3 express: ^4.17.1 fp-ts: ^2.11.1 + fs-extra: ^10.0.0 get-port: ^5.1.1 image-js: ^0.33.0 jest: ^27.1.0 @@ -1788,6 +1807,7 @@ __metadata: sharp: ^0.29.1 soap: ^0.42.0 supertest: ^6.1.6 + tmp: ^0.2.1 ts-jest: ^27.0.5 ts-md5: ^1.2.9 ts-mockito: ^2.6.1 @@ -3162,6 +3182,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^10.0.0": + version: 10.0.0 + resolution: "fs-extra@npm:10.0.0" + dependencies: + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: 5285a3d8f34b917cf2b66af8c231a40c1623626e9d701a20051d3337be16c6d7cac94441c8b3732d47a92a2a027886ca93c69b6a4ae6aee3c89650d2a8880c0a + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -3362,7 +3393,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": version: 4.2.8 resolution: "graceful-fs@npm:4.2.8" checksum: 5d224c8969ad0581d551dfabdb06882706b31af2561bd5e2034b4097e67cc27d05232849b8643866585fd0a41c7af152950f8776f4dd5579e9853733f31461c6 @@ -4598,6 +4629,19 @@ __metadata: languageName: node linkType: hard +"jsonfile@npm:^6.0.1": + version: 6.1.0 + resolution: "jsonfile@npm:6.1.0" + dependencies: + graceful-fs: ^4.1.6 + universalify: ^2.0.0 + dependenciesMeta: + graceful-fs: + optional: true + checksum: 7af3b8e1ac8fe7f1eccc6263c6ca14e1966fcbc74b618d3c78a0a2075579487547b94f72b7a1114e844a1e15bb00d440e5d1720bfc4612d790a6f285d5ea8354 + languageName: node + linkType: hard + "keyv@npm:^3.0.0": version: 3.1.0 resolution: "keyv@npm:3.1.0" @@ -6685,6 +6729,15 @@ resolve@^1.20.0: languageName: node linkType: hard +"tmp@npm:^0.2.1": + version: 0.2.1 + resolution: "tmp@npm:0.2.1" + dependencies: + rimraf: ^3.0.0 + checksum: 8b1214654182575124498c87ca986ac53dc76ff36e8f0e0b67139a8d221eaecfdec108c0e6ec54d76f49f1f72ab9325500b246f562b926f85bcdfca8bf35df9e + languageName: node + linkType: hard + "tmpl@npm:1.0.x": version: 1.0.4 resolution: "tmpl@npm:1.0.4" @@ -6992,6 +7045,13 @@ typescript@^4.4.2: languageName: node linkType: hard +"universalify@npm:^2.0.0": + version: 2.0.0 + resolution: "universalify@npm:2.0.0" + checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44 + languageName: node + linkType: hard + "unpipe@npm:1.0.0, unpipe@npm:~1.0.0": version: 1.0.0 resolution: "unpipe@npm:1.0.0"