Files
bonob/tests/subsonic.test.ts

440 lines
14 KiB
TypeScript

import { Md5 } from "ts-md5";
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 {
isValidImage,
t,
DODGY_IMAGE_NAME,
asURLSearchParams,
cachingImageFetcher,
asTrack,
artistImageURN,
song,
TranscodingCustomPlayers,
CustomPlayers,
NO_CUSTOM_PLAYERS,
} from "../src/subsonic";
import sharp from "sharp";
jest.mock("sharp");
import {
Album,
Artist,
Track,
} from "../src/music_library";
import {
anAlbum,
aTrack,
} from "./builders";
import { BUrn } from "../src/burn";
describe("t", () => {
it("should be an md5 of the password and the salt", () => {
const p = "password123";
const s = "saltydog";
expect(t(p, s)).toEqual(Md5.hashStr(`${p}${s}`));
});
});
describe("isValidImage", () => {
describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => {
it("is dodgy", () => {
expect(
isValidImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png")
).toEqual(false);
});
});
describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => {
it("is dodgy", () => {
expect(isValidImage("http://something/somethingelse.png")).toEqual(true);
expect(
isValidImage(
"http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true"
)
).toEqual(true);
});
});
});
describe("StreamClient(s)", () => {
describe("CustomStreamClientApplications", () => {
const customClients = TranscodingCustomPlayers.from("audio/flac,audio/mp3>audio/ogg")
describe("clientFor", () => {
describe("when there is a match", () => {
it("should return the match", () => {
expect(customClients.encodingFor({ mimeType: "audio/flac" })).toEqual(O.of({player: "bonob+audio/flac", mimeType:"audio/flac"}))
expect(customClients.encodingFor({ mimeType: "audio/mp3" })).toEqual(O.of({player: "bonob+audio/mp3", mimeType:"audio/ogg"}))
});
});
describe("when there is no match", () => {
it("should return undefined", () => {
expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual(O.none)
});
});
});
});
});
describe("asURLSearchParams", () => {
describe("empty q", () => {
it("should return empty params", () => {
const q = {};
const expected = new URLSearchParams();
expect(asURLSearchParams(q)).toEqual(expected);
});
});
describe("singular params", () => {
it("should append each", () => {
const q = {
a: 1,
b: "bee",
c: false,
d: true,
};
const expected = new URLSearchParams();
expected.append("a", "1");
expected.append("b", "bee");
expected.append("c", "false");
expected.append("d", "true");
expect(asURLSearchParams(q)).toEqual(expected);
});
});
describe("list params", () => {
it("should append each", () => {
const q = {
a: [1, "two", false, true],
b: "yippee",
};
const expected = new URLSearchParams();
expected.append("a", "1");
expected.append("a", "two");
expected.append("a", "false");
expected.append("a", "true");
expected.append("b", "yippee");
expect(asURLSearchParams(q)).toEqual(expected);
});
});
});
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 maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe(
coverArt,
O.fromNullable,
O.map(it => it.resource.split(":")[1]),
O.getOrElseW(() => "")
)
const asSongJson = (track: Track) => ({
id: track.id,
parent: track.album.id,
title: track.name,
album: track.album.name,
artist: track.artist.name,
track: track.number,
genre: track.genre?.name,
isDir: "false",
coverArt: maybeIdFromCoverArtUrn(track.coverArt),
created: "2004-11-08T23:36:11",
duration: track.duration,
bitRate: 128,
size: "5624132",
suffix: "mp3",
contentType: track.encoding.mimeType,
transcodedContentType: undefined,
isVideo: "false",
path: "ACDC/High voltage/ACDC - The Jack.mp3",
albumId: track.album.id,
artistId: track.artist.id,
type: "music",
starred: track.rating.love ? "sometime" : undefined,
userRating: track.rating.stars,
year: "",
});
export type ArtistWithAlbum = {
artist: Artist;
album: Album;
};
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" });
});
});
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();
});
});
});
});
});
describe("asTrack", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe("when the song has no artistId", () => {
const album = anAlbum();
const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }});
it("should provide no artistId", () => {
const result = asTrack(album, { ...asSongJson(track) }, NO_CUSTOM_PLAYERS);
expect(result.artist.id).toBeUndefined();
expect(result.artist.name).toEqual("Not in library so no id");
expect(result.artist.image).toBeUndefined();
});
});
describe("when the song has no artist name", () => {
const album = anAlbum();
it("should provide a ? to sonos", () => {
const result = asTrack(album, { id: '1' } as any as song, NO_CUSTOM_PLAYERS);
expect(result.artist.id).toBeUndefined();
expect(result.artist.name).toEqual("?");
expect(result.artist.image).toBeUndefined();
});
});
describe("invalid rating.stars values", () => {
const album = anAlbum();
const track = aTrack();
describe("a value greater than 5", () => {
it("should be returned as 0", () => {
const result = asTrack(album, { ...asSongJson(track), userRating: 6 }, NO_CUSTOM_PLAYERS);
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 }, NO_CUSTOM_PLAYERS);
expect(result.rating.stars).toEqual(0);
});
});
});
describe("content types", () => {
const album = anAlbum();
const track = aTrack();
describe("when there are no custom players", () => {
describe("when subsonic reports no transcodedContentType", () => {
it("should use the default client and default contentType", () => {
const result = asTrack(album, {
...asSongJson(track),
contentType: "nonTranscodedContentType",
transcodedContentType: undefined
}, NO_CUSTOM_PLAYERS);
expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" })
});
});
describe("when subsonic reports a transcodedContentType", () => {
it("should use the default client and transcodedContentType", () => {
const result = asTrack(album, {
...asSongJson(track),
contentType: "nonTranscodedContentType",
transcodedContentType: "transcodedContentType"
}, NO_CUSTOM_PLAYERS);
expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType" })
});
});
});
describe("when there are custom players registered", () => {
const streamClient = {
encodingFor: jest.fn()
}
describe("however no player is found for the default mimeType", () => {
describe("and there is no transcodedContentType", () => {
it("should use the default player with the default content type", () => {
streamClient.encodingFor.mockReturnValue(O.none)
const result = asTrack(album, {
...asSongJson(track),
contentType: "nonTranscodedContentType",
transcodedContentType: undefined
}, streamClient as unknown as CustomPlayers);
expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" });
expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" });
});
});
describe("and there is a transcodedContentType", () => {
it("should use the default player with the transcodedContentType", () => {
streamClient.encodingFor.mockReturnValue(O.none)
const result = asTrack(album, {
...asSongJson(track),
contentType: "nonTranscodedContentType",
transcodedContentType: "transcodedContentType1"
}, streamClient as unknown as CustomPlayers);
expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType1" });
expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" });
});
});
});
describe("there is a player with the matching content type", () => {
it("should use it", () => {
const customEncoding = { player: "custom-player", mimeType: "audio/some-mime-type" };
streamClient.encodingFor.mockReturnValue(O.of(customEncoding));
const result = asTrack(album, {
...asSongJson(track),
contentType: "sourced-from/subsonic",
transcodedContentType: "sourced-from/subsonic2"
}, streamClient as unknown as CustomPlayers);
expect(result.encoding).toEqual(customEncoding);
expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "sourced-from/subsonic" });
});
});
});
});
});