diff --git a/src/app.ts b/src/app.ts index 970c18d..162ecbd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,8 +5,6 @@ import logger from "./logger"; import { appendMimeTypeToClientFor, - axiosImageFetcher, - cachingImageFetcher, DEFAULT, Subsonic, } from "./subsonic"; @@ -17,6 +15,7 @@ import sonos, { bonobService } from "./sonos"; import { MusicService } from "./music_service"; import { SystemClock } from "./clock"; import { JWTSmapiLoginTokens } from "./smapi_auth"; +import { axiosImageFetcher, cachingImageFetcher } from "./images"; const config = readConfig(); const clock = SystemClock; diff --git a/src/images.ts b/src/images.ts new file mode 100644 index 0000000..a5b1522 --- /dev/null +++ b/src/images.ts @@ -0,0 +1,48 @@ + +import sharp from "sharp"; +import fse from "fs-extra"; +import path from "path"; +import { Md5 } from "ts-md5/dist/md5"; +import axios from "axios"; + +import { CoverArt } from "./music_service"; +import { BROWSER_HEADERS } from "./utils"; + +export type ImageFetcher = (url: string) => Promise; + +export const cachingImageFetcher = + (cacheDir: string, delegate: ImageFetcher) => + async (url: string): Promise => { + const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); + return fse + .readFile(filename) + .then((data) => ({ contentType: "image/png", data })) + .catch(() => + delegate(url).then((image) => { + if (image) { + return sharp(image.data) + .png() + .toBuffer() + .then((png) => { + return fse + .writeFile(filename, png) + .then(() => ({ contentType: "image/png", data: png })); + }); + } else { + return undefined; + } + }) + ); + }; + +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); diff --git a/src/server.ts b/src/server.ts index fc6bd65..34d4f55 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,7 +35,7 @@ import _, { shuffle } from "underscore"; import morgan from "morgan"; import { takeWithRepeats } from "./utils"; import { parse } from "./burn"; -import { axiosImageFetcher, ImageFetcher } from "./subsonic"; +import { axiosImageFetcher, ImageFetcher } from "./images"; import { JWTSmapiLoginTokens, SmapiAuthTokens, diff --git a/src/subsonic.ts b/src/subsonic.ts index e9e5bbd..bfd82d3 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -15,17 +15,13 @@ import { AlbumSummary, Genre, Track, - CoverArt, Rating, AlbumQueryType, Artist, AuthFailure, Sortable, } from "./music_service"; -import sharp from "sharp"; import _ from "underscore"; -import fse from "fs-extra"; -import path from "path"; import axios, { AxiosRequestConfig } from "axios"; import randomstring from "randomstring"; @@ -33,16 +29,8 @@ import { b64Encode, b64Decode } from "./b64"; import logger from "./logger"; import { assertSystem, BUrn } from "./burn"; import { artist } from "./smapi"; +import { axiosImageFetcher, ImageFetcher } from "./images"; -export const BROWSER_HEADERS = { - accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "accept-encoding": "gzip, deflate, br", - "accept-language": "en-GB,en;q=0.5", - "upgrade-insecure-requests": "1", - "user-agent": - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", -}; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -353,45 +341,6 @@ export const asURLSearchParams = (q: any) => { return urlSearchParams; }; -export type ImageFetcher = (url: string) => Promise; - -export const cachingImageFetcher = - (cacheDir: string, delegate: ImageFetcher) => - async (url: string): Promise => { - const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); - return fse - .readFile(filename) - .then((data) => ({ contentType: "image/png", data })) - .catch(() => - delegate(url).then((image) => { - if (image) { - return sharp(image.data) - .png() - .toBuffer() - .then((png) => { - return fse - .writeFile(filename, png) - .then(() => ({ contentType: "image/png", data: png })); - }); - } else { - return undefined; - } - }) - ); - }; - -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); - const AlbumQueryTypeToSubsonicType: Record = { alphabeticalByArtist: "alphabeticalByArtist", alphabeticalByName: "alphabeticalByName", @@ -893,6 +842,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; + // todo: why is this in here? externalImageFetcher: ImageFetcher; constructor( diff --git a/src/utils.ts b/src/utils.ts index c886afc..33a4193 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,13 @@ +export const BROWSER_HEADERS = { + accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "accept-encoding": "gzip, deflate, br", + "accept-language": "en-GB,en;q=0.5", + "upgrade-insecure-requests": "1", + "user-agent": + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", +}; + export function takeWithRepeats(things:T[], count: number) { const result = []; for(let i = 0; i < count; i++) { diff --git a/tests/images.test.ts b/tests/images.test.ts new file mode 100644 index 0000000..b23ed45 --- /dev/null +++ b/tests/images.test.ts @@ -0,0 +1,78 @@ + +import tmp from "tmp"; +import fse from "fs-extra"; +import path from "path"; +import { Md5 } from "ts-md5"; + +import sharp from "sharp"; +jest.mock("sharp"); + +import { cachingImageFetcher } from "../src/images"; + +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("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("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("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); + }); + }); +}); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 13ba320..3c0ec91 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,8 +1,6 @@ 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 { pipe } from "fp-ts/lib/function"; import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; @@ -14,7 +12,6 @@ import { asGenre, appendMimeTypeToClientFor, asURLSearchParams, - cachingImageFetcher, asTrack, artistImageURN, images, @@ -29,8 +26,6 @@ import { import axios from "axios"; jest.mock("axios"); -import sharp from "sharp"; -jest.mock("sharp"); import randomstring from "randomstring"; jest.mock("randomstring"); @@ -172,74 +167,6 @@ 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("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("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("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 | object) => ({ status: 200, data,