diff --git a/README.md b/README.md index b7e4b70..3d3de96 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,10 @@ BONOB_NAVIDROME_URL | http://$(hostname):4533 | URL for navidrome BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom navidrome clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. BONOB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BONOB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing -BONOB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [web color](https://www.december.com/html/spec/colorsvg.html) -BONOB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [web color](https://www.december.com/html/spec/colorsvg.html) +BONOB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) +BONOB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) +BONOB_ICON_FONT_COLOR | undefined | Icon font color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) +BONOB_ICON_FONT_FAMILY | undefined | Icon font family in sonos app ## Initialising service within sonos app @@ -163,7 +165,7 @@ BONOB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, mu ## Credits -- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/), and @jicho +- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho ## TODO diff --git a/nodemon.json b/nodemon.json deleted file mode 100644 index 61ad1a8..0000000 --- a/nodemon.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "watch": ["src/**/*.ts", "web/**/*.svg"], - "ignore": [], - "exec": "ts-node ./src/app.ts" -} diff --git a/package.json b/package.json index a166828..3799782 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "scripts": { "clean": "rm -Rf build", "build": "tsc", - "dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon", - "devr": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon", + "dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts", + "devr": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts http://$(hostname):4534", "test": "jest --testPathIgnorePatterns=build" } diff --git a/src/config.ts b/src/config.ts index da2358c..52afb89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ export default function () { process.exit(1); } - const colorFrom = (envVar: string) => { + const wordFrom = (envVar: string) => { const value = process.env[envVar]; if (value && value != "") { if (value.match(/^\w+$/)) return value; @@ -31,8 +31,10 @@ export default function () { bonobUrl: url(bonobUrl), secret: process.env["BONOB_SECRET"] || "bonob", icons: { - foregroundColor: colorFrom("BONOB_ICON_FOREGROUND_COLOR"), - backgroundColor: colorFrom("BONOB_ICON_BACKGROUND_COLOR"), + foregroundColor: wordFrom("BONOB_ICON_FOREGROUND_COLOR"), + backgroundColor: wordFrom("BONOB_ICON_BACKGROUND_COLOR"), + fontColor: wordFrom("BONOB_ICON_FONT_COLOR"), + fontFamily: wordFrom("BONOB_ICON_FONT_FAMILY") }, sonos: { serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob", diff --git a/src/icon.ts b/src/icon.ts index b8e2db2..33a8005 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -1,11 +1,24 @@ import libxmljs, { Element, Attribute } from "libxmljs2"; import _ from "underscore"; -import { Clock, isChristmas, isCNY, isHalloween, isHoli, SystemClock } from "./clock"; +import fs from "fs"; + +import { + Clock, + isChristmas, + isCNY, + isHalloween, + isHoli, + SystemClock, +} from "./clock"; +import path from "path"; export type Transformation = { viewPortIncreasePercent: number | undefined; backgroundColor: string | undefined; foregroundColor: string | undefined; + text: string | undefined; + fontColor: string | undefined; + fontFamily: string | undefined; }; const SVG_NS = { @@ -43,13 +56,23 @@ export interface Icon { export class ColorOverridingIcon implements Icon { rule: () => Boolean; - newColors: () => Pick; + newColors: () => Partial< + Pick< + Transformation, + "backgroundColor" | "foregroundColor" | "fontColor" | "fontFamily" + > + >; icon: Icon; constructor( icon: Icon, rule: () => Boolean, - newColors: () => Pick + newColors: () => Partial< + Pick< + Transformation, + "backgroundColor" | "foregroundColor" | "fontColor" | "fontFamily" + > + > ) { this.icon = icon; this.rule = rule; @@ -74,6 +97,9 @@ export class SvgIcon implements Icon { viewPortIncreasePercent: undefined, backgroundColor: undefined, foregroundColor: undefined, + text: undefined, + fontColor: undefined, + fontFamily: undefined, } ) { this.svg = svg; @@ -106,26 +132,62 @@ export class SvgIcon implements Icon { y: `${viewBox.minY}`, width: `${Math.abs(viewBox.minX) + viewBox.width}`, height: `${Math.abs(viewBox.minY) + viewBox.height}`, - style: `fill:${this.transformation.backgroundColor}`, + fill: this.transformation.backgroundColor, }) ); } if (this.transformation.foregroundColor) { - (xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) => - path.attr({ style: `fill:${this.transformation.foregroundColor}` }) + (xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) => { + if (path.attr("fill")) + path.attr({ stroke: this.transformation.foregroundColor! }); + else path.attr({ fill: this.transformation.foregroundColor! }); + }); + } + if (this.transformation.text) { + const w = Math.abs(viewBox.minX) + Math.abs(viewBox.width); + const h = Math.abs(viewBox.minY) + Math.abs(viewBox.height); + const i = Math.floor(0.1 * w); + let attr: any = { + "font-size": `${Math.floor(h / 4)}`, + "font-weight": "bold", + }; + if (this.transformation.fontFamily) + attr = { ...attr, "font-family": this.transformation.fontFamily }; + if (this.transformation.fontColor) + attr = { ...attr, style: `fill:${this.transformation.fontColor}` }; + const g = new Element(xml, "g"); + g.attr(attr); + (xml.get("//svg:svg", SVG_NS) as Element).addChild( + g.addChild( + new Element(xml, "text") + .attr({ + x: `${viewBox.minX + i}`, + y: `${viewBox.minY + Math.floor(0.8 * h)}`, + }) + .text(this.transformation.text) + ) ); } return xml.toString(); }; } -export const HOLI_COLORS = ["#06bceb", "#9fc717", "#fbdc10", "#f00b9a", "#fa9705"] +export const HOLI_COLORS = [ + "#06bceb", + "#9fc717", + "#fbdc10", + "#f00b9a", + "#fa9705", +]; export const makeFestive = (icon: Icon, clock: Clock = SystemClock): Icon => { const wrap = ( icon: Icon, rule: (clock: Clock) => boolean, - colors: Pick + colors: Pick< + Transformation, + "backgroundColor" | "foregroundColor" | "fontColor" + > ) => new ColorOverridingIcon( icon, @@ -133,22 +195,176 @@ export const makeFestive = (icon: Icon, clock: Clock = SystemClock): Icon => { () => colors ); - const xmas = wrap(icon, isChristmas, { + let result = icon; + + const apply = ( + rule: (clock: Clock) => boolean, + colors: Pick< + Transformation, + "backgroundColor" | "foregroundColor" | "fontColor" + > + ) => (result = wrap(result, rule, colors)); + + apply(isChristmas, { backgroundColor: "green", foregroundColor: "red", + fontColor: "white", }); + const randomHoliColors = _.shuffle([...HOLI_COLORS]); - const holi = wrap(xmas, isHoli, { + apply(isHoli, { backgroundColor: randomHoliColors.pop(), foregroundColor: randomHoliColors.pop(), + fontColor: randomHoliColors.pop(), }); - const cny = wrap(holi, isCNY, { + + apply(isCNY, { backgroundColor: "red", foregroundColor: "yellow", + fontColor: "crimson", }); - const halloween = wrap(cny, isHalloween, { + + apply(isHalloween, { backgroundColor: "orange", foregroundColor: "black", + fontColor: "orangered", }); - return halloween; + + return result; }; + +export type ICON = + | "artists" + | "albums" + | "playlists" + | "genres" + | "random" + | "starred" + | "recentlyAdded" + | "recentlyPlayed" + | "mostPlayed" + | "discover" + | "blank" + | "mushroom" + | "african" + | "rock" + | "metal" + | "punk" + | "americana" + | "guitar" + | "book" + | "oz" + | "rap" + | "horror" + | "hipHop" + | "pop" + | "blues" + | "classical" + | "comedy" + | "vinyl" + | "electronic" + | "pills" + | "trumpet" + | "conductor" + | "reggae" + | "music"; + +const iconFrom = (name: string) => + new SvgIcon( + fs + .readFileSync(path.resolve(__dirname, "..", "web", "icons", name)) + .toString() + ).with({ viewPortIncreasePercent: 50 }); + +export const ICONS: Record = { + artists: iconFrom("navidrome-artists.svg"), + albums: iconFrom("navidrome-all.svg"), + blank: iconFrom("blank.svg"), + playlists: iconFrom("navidrome-playlists.svg"), + genres: iconFrom("Theatre-Mask-111172.svg"), + random: iconFrom("navidrome-random.svg"), + starred: iconFrom("navidrome-topRated.svg"), + recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"), + recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"), + mostPlayed: iconFrom("navidrome-mostPlayed.svg"), + discover: iconFrom("Binoculars-14310.svg"), + mushroom: iconFrom("Mushroom-63864.svg"), + african: iconFrom("Africa-48087.svg"), + rock: iconFrom("Rock-Music-11007.svg"), + metal: iconFrom("Metal-Music-17763.svg"), + punk: iconFrom("Punk-40450.svg"), + americana: iconFrom("US-Capitol-104805.svg"), + guitar: iconFrom("Guitar-110433.svg"), + book: iconFrom("Book-453.svg"), + oz: iconFrom("Kangaroo-16730.svg"), + hipHop: iconFrom("Hip-Hop Music-17757.svg"), + rap: iconFrom("Rap-24851.svg"), + horror: iconFrom("Horror-4387.svg"), + pop: iconFrom("Ice-Pop Yellow-94532.svg"), + blues: iconFrom("Blues-113548.svg"), + classical: iconFrom("Classic-Music-11646.svg"), + comedy: iconFrom("Comedy-2-599.svg"), + vinyl: iconFrom("Music-Record-102104.svg"), + electronic: iconFrom("Electronic-Music-17745.svg"), + pills: iconFrom("Pills-112386.svg"), + trumpet: iconFrom("Trumpet-17823.svg"), + conductor: iconFrom("Music-Conductor-225.svg"), + reggae: iconFrom("Reggae-24843.svg"), + music: iconFrom("Music-14097.svg") +}; + +export type RULE = (genre: string) => boolean; + +const eq = + (expected: string): RULE => + (value: string) => + expected.toLowerCase() === value.toLowerCase(); + +const contains = + (expected: string): RULE => + (value: string) => + value.toLowerCase().includes(expected.toLowerCase()); + +const containsWithAllTheNonWordCharsRemoved = + (expected: string): RULE => + (value: string) => + value.replace(/\W+/, " ").toLowerCase().includes(expected.toLowerCase()); + +const GENRE_RULES: [RULE, ICON][] = [ + [eq("Acid House"), "mushroom"], + [contains("Goa"), "mushroom"], + [contains("Psy"), "mushroom"], + [eq("African"), "african"], + [eq("Americana"), "americana"], + [contains("Rock"), "rock"], + [contains("Folk"), "guitar"], + [contains("Book"), "book"], + [contains("Australian"), "oz"], + [contains("Rap"), "rap"], + [containsWithAllTheNonWordCharsRemoved("Hip Hop"), "hipHop"], + [contains("Horror"), "horror"], + [contains("Metal"), "metal"], + [contains("Punk"), "punk"], + [contains("Pop"), "pop"], + [contains("Blues"), "blues"], + [contains("Classical"), "classical"], + [contains("Comedy"), "comedy"], + [contains("Dub"), "vinyl"], + [contains("Turntable"), "vinyl"], + [contains("Electro"), "electronic"], + [contains("Trance"), "pills"], + [contains("Techno"), "pills"], + [contains("House"), "pills"], + [contains("Rave"), "pills"], + [contains("Jazz"), "trumpet"], + [contains("Orchestra"), "conductor"], + [contains("Reggae"), "reggae"], +]; + +export function iconForGenre(genre: string): ICON { + const [_, name] = GENRE_RULES.find(([rule, _]) => rule(genre)) || [ + "music", + "music", + ]; + return name! as ICON; +} diff --git a/src/server.ts b/src/server.ts index 2f94386..4e2ab63 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,6 @@ import * as Eta from "eta"; import morgan from "morgan"; import path from "path"; import sharp from "sharp"; -import fs from "fs"; import { PassThrough, Transform, TransformCallback } from "stream"; @@ -17,7 +16,6 @@ import { LOGIN_ROUTE, CREATE_REGISTRATION_ROUTE, REMOVE_REGISTRATION_ROUTE, - ICON, } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; @@ -28,7 +26,7 @@ import { Clock, SystemClock } from "./clock"; import { pipe } from "fp-ts/lib/function"; import { URLBuilder } from "./url_builder"; import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n"; -import { SvgIcon, Icon, makeFestive } from "./icon"; +import { Icon, makeFestive, ICONS } from "./icon"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; @@ -103,27 +101,6 @@ function server( return i8n(...asLANGs(req.headers["accept-language"])); }; - const iconFrom = (name: string) => - makeFestive( - new SvgIcon( - fs.readFileSync(path.resolve(__dirname, "..", "web", "icons", name)).toString() - ).with({ viewPortIncreasePercent: 50, ...iconColors }), - clock - ); - - const ICONS: Record = { - artists: iconFrom("navidrome-artists.svg"), - albums: iconFrom("navidrome-all.svg"), - playlists: iconFrom("navidrome-playlists.svg"), - genres: iconFrom("Theatre-Mask-111172.svg"), - random: iconFrom("navidrome-random.svg"), - starred: iconFrom("navidrome-topRated.svg"), - recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"), - recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"), - mostPlayed: iconFrom("navidrome-mostPlayed.svg"), - discover: iconFrom("Binoculars-14310.svg"), - }; - app.get("/", (req, res) => { const lang = langFor(req); Promise.all([sonos.devices(), sonos.services()]).then( @@ -383,6 +360,9 @@ function server( app.get("/icon/:type/size/:size", (req, res) => { const type = req.params["type"]!; const size = req.params["size"]!; + const text: string | undefined = req.query.text + ? (req.query.text as string) + : undefined; if (!Object.keys(ICONS).includes(type)) { return res.status(404).send(); @@ -392,7 +372,7 @@ function server( ) { return res.status(400).send(); } else { - const icon = (ICONS as any)[type]! as Icon; + let icon = (ICONS as any)[type]! as Icon; const spec = size == "legacy" ? { @@ -406,7 +386,9 @@ function server( Promise.resolve(svg), }; - return Promise.resolve(icon.toString()) + return Promise.resolve( + makeFestive(icon.with({ text, ...iconColors }), clock).toString() + ) .then(spec.responseFormatter) .then((data) => res.status(200).type(spec.mimeType).send(data)); } diff --git a/src/smapi.ts b/src/smapi.ts index ce01939..0a46f31 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -23,6 +23,7 @@ import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; import { asLANGs, I8N } from "./i8n"; +import { ICON, iconForGenre } from "./icon"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -46,8 +47,6 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [ "1500", ]; -export type ICON = "artists" | "albums" | "playlists" | "genres" | "random" | "starred" | "recentlyAdded" | "recentlyPlayed" | "mostPlayed" | "discover" - const WSDL_FILE = path.resolve( __dirname, "Sonoswsdl-1.19.4-20190411.142401-3.wsdl" @@ -209,10 +208,11 @@ export type Container = { displayType: string | undefined; }; -const genre = (genre: Genre) => ({ +const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ itemType: "container", id: `genre:${genre.id}`, title: genre.name, + albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(), }); const playlist = (playlist: PlaylistSummary) => ({ @@ -230,8 +230,8 @@ const playlist = (playlist: PlaylistSummary) => ({ export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) => bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` }); -export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => - bonobUrl.append({ pathname: `/icon/${icon}/size/legacy` }); +export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) => + bonobUrl.append({ pathname: `/icon/${icon}/size/legacy`, searchParams: text ? { text } : {} }); export const defaultArtistArtURI = ( bonobUrl: URLBuilder, @@ -688,7 +688,7 @@ function bindSmapiSoapServiceToExpress( .then(slice2(paging)) .then(([page, total]) => getMetadataResult({ - mediaCollection: page.map(genre), + mediaCollection: page.map(it => genre(bonobUrl, it)), index: paging._index, total, }) diff --git a/tests/config.test.ts b/tests/config.test.ts index 2db7293..5ee89c5 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -132,7 +132,9 @@ describe("config", () => { describe("when BONOB_ICON_FOREGROUND_COLOR is an invalid string", () => { it(`should blow up`, () => { process.env["BONOB_ICON_FOREGROUND_COLOR"] = "#dfasd"; - expect(() => config()).toThrow("Invalid color specified for BONOB_ICON_FOREGROUND_COLOR") + expect(() => config()).toThrow( + "Invalid color specified for BONOB_ICON_FOREGROUND_COLOR" + ); }); }); }); @@ -161,11 +163,75 @@ describe("config", () => { describe("when BONOB_ICON_BACKGROUND_COLOR is an invalid string", () => { it(`should blow up`, () => { process.env["BONOB_ICON_BACKGROUND_COLOR"] = "#red"; - expect(() => config()).toThrow("Invalid color specified for BONOB_ICON_BACKGROUND_COLOR") + expect(() => config()).toThrow( + "Invalid color specified for BONOB_ICON_BACKGROUND_COLOR" + ); }); }); }); -}); + + describe("fontColor", () => { + describe("when BONOB_ICON_FONT_COLOR is not specified", () => { + it(`should default to undefined`, () => { + expect(config().icons.fontColor).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_FONT_COLOR is ''", () => { + it(`should default to undefined`, () => { + process.env["BONOB_ICON_FONT_COLOR"] = ""; + expect(config().icons.fontColor).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_FONT_COLOR is specified", () => { + it(`should use it`, () => { + process.env["BONOB_ICON_FONT_COLOR"] = "pink"; + expect(config().icons.fontColor).toEqual("pink"); + }); + }); + + describe("when BONOB_ICON_FONT_COLOR is an invalid string", () => { + it(`should blow up`, () => { + process.env["BONOB_ICON_FONT_COLOR"] = "#dfasd"; + expect(() => config()).toThrow( + "Invalid color specified for BONOB_ICON_FONT_COLOR" + ); + }); + }); + }); + + describe("fontFamily", () => { + describe("when BONOB_ICON_FONT_FAMILY is not specified", () => { + it(`should default to undefined`, () => { + expect(config().icons.fontFamily).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_FONT_FAMILY is ''", () => { + it(`should default to undefined`, () => { + process.env["BONOB_ICON_FONT_FAMILY"] = ""; + expect(config().icons.fontFamily).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_FONT_FAMILY is specified", () => { + it(`should use it`, () => { + process.env["BONOB_ICON_FONT_FAMILY"] = "helveta"; + expect(config().icons.fontFamily).toEqual("helveta"); + }); + }); + + describe("when BONOB_ICON_FONT_FAMILY is an invalid string", () => { + it(`should blow up`, () => { + process.env["BONOB_ICON_FONT_FAMILY"] = "#dfasd"; + expect(() => config()).toThrow( + "Invalid color specified for BONOB_ICON_FONT_FAMILY" + ); + }); + }); + }); + }); describe("secret", () => { it("should default to bonob", () => { diff --git a/tests/icon.test.ts b/tests/icon.test.ts index 1b293b2..753c64d 100644 --- a/tests/icon.test.ts +++ b/tests/icon.test.ts @@ -5,6 +5,7 @@ import { ColorOverridingIcon, HOLI_COLORS, Icon, + iconForGenre, makeFestive, SvgIcon, Transformation, @@ -16,17 +17,17 @@ describe("SvgIcon", () => { const svgIcon24 = ` - - - + + + `; const svgIcon128 = ` - - - + + + `; @@ -47,9 +48,9 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - + + + `) ); @@ -64,9 +65,9 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - + + + `) ); @@ -91,10 +92,10 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - - + + + + `) ); @@ -110,10 +111,10 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - - + + + + `) ); @@ -123,15 +124,13 @@ describe("SvgIcon", () => { describe("of undefined", () => { it("should not do anything", () => { expect( - new SvgIcon(svgIcon24) - .with({ backgroundColor: undefined }) - .toString() + new SvgIcon(svgIcon24).with({ backgroundColor: undefined }).toString() ).toEqual( xmlTidy(` - - - + + + `) ); @@ -148,10 +147,10 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - - + + + + `) ); @@ -167,9 +166,9 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - + + + `) ); @@ -185,9 +184,9 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - + + + `) ); @@ -197,15 +196,13 @@ describe("SvgIcon", () => { describe("of undefined", () => { it("should not do anything", () => { expect( - new SvgIcon(svgIcon24) - .with({ foregroundColor: undefined }) - .toString() + new SvgIcon(svgIcon24).with({ foregroundColor: undefined }).toString() ).toEqual( xmlTidy(` - - - + + + `) ); @@ -222,9 +219,78 @@ describe("SvgIcon", () => { ).toEqual( xmlTidy(` - - - + + + + + `) + ); + }); + }); + }); + + describe("with some text", () => { + describe("with no font color or style", () => { + describe("with no viewPort increase", () => { + it("should render the line", () => { + expect( + new SvgIcon(svgIcon24).with({ text: "hello" }).toString() + ).toEqual( + xmlTidy(` + + + + + + hello + + + `) + ); + }); + }); + + describe("with a viewPort increase", () => { + it("should render the line", () => { + expect( + new SvgIcon(svgIcon24) + .with({ viewPortIncreasePercent: 50, text: "hello" }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + hello + + + `) + ); + }); + }); + }); + + describe("with no font color and style", () => { + it("should render the line", () => { + expect( + new SvgIcon(svgIcon24) + .with({ + text: "hello world", + fontColor: "red", + fontFamily: "helvetica", + }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + hello world + `) ); @@ -249,37 +315,92 @@ describe("ColorOverridingIcon", () => { const icon = new DummyIcon({ backgroundColor: "black", foregroundColor: "black", + fontColor: "black", + fontFamily: "plain", }); - const overriding = new ColorOverridingIcon( - icon, - () => true, - () => ({ backgroundColor: "blue", foregroundColor: "red" }) - ); - describe("with", () => { - it("should be the with of the underlieing icon with the overriden colors", () => { - const result = overriding.with({ - viewPortIncreasePercent: 99, - backgroundColor: "shouldBeIgnored", - foregroundColor: "shouldBeIgnored", - }) as DummyIcon; + describe("overriding some options", () => { + const overriding = new ColorOverridingIcon( + icon, + () => true, + () => ({ backgroundColor: "blue", foregroundColor: "red" }) + ); - expect(result.transformation).toEqual({ - viewPortIncreasePercent: 99, - backgroundColor: "blue", - foregroundColor: "red", + describe("with", () => { + it("should be the with of the underlieing icon with the overriden colors", () => { + const result = overriding.with({ + viewPortIncreasePercent: 99, + backgroundColor: "shouldBeIgnored", + foregroundColor: "shouldBeIgnored", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 99, + backgroundColor: "blue", + foregroundColor: "red", + fontColor: "black", + fontFamily: "plain", + }); + }); + }); + + describe("toString", () => { + it("should be the toString of the underlieing icon with the overriden colors", () => { + expect(overriding.toString()).toEqual( + new DummyIcon({ + backgroundColor: "blue", + foregroundColor: "red", + fontColor: "black", + fontFamily: "plain", + }).toString() + ); }); }); }); - describe("toString", () => { - it("should be the toString of the underlieing icon with the overriden colors", () => { - expect(overriding.toString()).toEqual( - new DummyIcon({ + describe("overriding all options", () => { + const overriding = new ColorOverridingIcon( + icon, + () => true, + () => ({ + backgroundColor: "blue", + foregroundColor: "red", + fontColor: "pink", + fontFamily: "fancy", + }) + ); + + describe("with", () => { + it("should be the with of the underlieing icon with the overriden colors", () => { + const result = overriding.with({ + viewPortIncreasePercent: 99, + backgroundColor: "shouldBeIgnored", + foregroundColor: "shouldBeIgnored", + fontColor: "shouldBeIgnored", + fontFamily: "shouldBeIgnored", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 99, backgroundColor: "blue", foregroundColor: "red", - }).toString() - ); + fontColor: "pink", + fontFamily: "fancy", + }); + }); + }); + + describe("toString", () => { + it("should be the toString of the underlieing icon with the overriden colors", () => { + expect(overriding.toString()).toEqual( + new DummyIcon({ + backgroundColor: "blue", + foregroundColor: "red", + fontColor: "pink", + fontFamily: "fancy", + }).toString() + ); + }); }); }); }); @@ -323,12 +444,13 @@ describe("makeFestive", () => { const icon = new DummyIcon({ backgroundColor: "black", foregroundColor: "black", + fontColor: "black", }); let now = dayjs(); - const festiveIcon = makeFestive(icon, { now: () => now }) - - describe("on a non special day", () => { + const festiveIcon = makeFestive(icon, { now: () => now }); + + describe("on a day that isn't festive", () => { beforeEach(() => { now = dayjs("2022/10/12"); }); @@ -338,12 +460,14 @@ describe("makeFestive", () => { viewPortIncreasePercent: 88, backgroundColor: "shouldBeUsed", foregroundColor: "shouldBeUsed", + fontColor: "shouldBeUsed", }) as DummyIcon; expect(result.transformation).toEqual({ viewPortIncreasePercent: 88, backgroundColor: "shouldBeUsed", foregroundColor: "shouldBeUsed", + fontColor: "shouldBeUsed", }); }); }); @@ -352,18 +476,20 @@ describe("makeFestive", () => { beforeEach(() => { now = dayjs("2022/12/25"); }); - - it("should use the given colors", () => { + + it("should use the christmas theme colors", () => { const result = festiveIcon.with({ viewPortIncreasePercent: 25, backgroundColor: "shouldNotBeUsed", foregroundColor: "shouldNotBeUsed", + fontColor: "shouldNotBeUsed", }) as DummyIcon; expect(result.transformation).toEqual({ viewPortIncreasePercent: 25, backgroundColor: "green", foregroundColor: "red", + fontColor: "white", }); }); }); @@ -372,18 +498,20 @@ describe("makeFestive", () => { beforeEach(() => { now = dayjs("2022/10/31"); }); - + it("should use the given colors", () => { const result = festiveIcon.with({ viewPortIncreasePercent: 12, backgroundColor: "shouldNotBeUsed", foregroundColor: "shouldNotBeUsed", + fontColor: "shouldNotBeUsed", }) as DummyIcon; expect(result.transformation).toEqual({ viewPortIncreasePercent: 12, backgroundColor: "orange", foregroundColor: "black", + fontColor: "orangered", }); }); }); @@ -392,18 +520,20 @@ describe("makeFestive", () => { beforeEach(() => { now = dayjs("2022/02/01"); }); - + it("should use the given colors", () => { const result = festiveIcon.with({ viewPortIncreasePercent: 12, backgroundColor: "shouldNotBeUsed", foregroundColor: "shouldNotBeUsed", + fontColor: "shouldNotBeUsed", }) as DummyIcon; expect(result.transformation).toEqual({ viewPortIncreasePercent: 12, backgroundColor: "red", foregroundColor: "yellow", + fontColor: "crimson", }); }); }); @@ -412,18 +542,64 @@ describe("makeFestive", () => { beforeEach(() => { now = dayjs("2022/03/18"); }); - + it("should use the given colors", () => { const result = festiveIcon.with({ viewPortIncreasePercent: 12, backgroundColor: "shouldNotBeUsed", foregroundColor: "shouldNotBeUsed", + fontColor: "shouldNotBeUsed", }) as DummyIcon; expect(result.transformation.viewPortIncreasePercent).toEqual(12); - expect(HOLI_COLORS.includes(result.transformation.backgroundColor!)).toEqual(true); - expect(HOLI_COLORS.includes(result.transformation.foregroundColor!)).toEqual(true); - expect(result.transformation.backgroundColor).not.toEqual(result.transformation.foregroundColor); + expect( + HOLI_COLORS.includes(result.transformation.backgroundColor!) + ).toEqual(true); + expect( + HOLI_COLORS.includes(result.transformation.foregroundColor!) + ).toEqual(true); + expect(HOLI_COLORS.includes(result.transformation.fontColor!)).toEqual( + true + ); + expect(result.transformation.backgroundColor).not.toEqual( + result.transformation.foregroundColor + ); + expect(result.transformation.backgroundColor).not.toEqual( + result.transformation.fontColor + ); + }); + }); +}); + +describe("iconForGenre", () => { + [ + ["Acid House", "mushroom"], + ["African", "african"], + ["Alternative Rock", "rock"], + ["Americana", "americana"], + ["Anti-Folk", "guitar"], + ["Audio-Book", "book"], + ["Australian Hip Hop", "oz"], + ["Rap", "rap"], + ["Hip Hop", "hipHop"], + ["Hip-Hop", "hipHop"], + ["Metal", "metal"], + ["Horrorcore", "horror"], + ["Punk", "punk"], + ["blah", "music"], + ].forEach(([genre, expected]) => { + describe(`a genre of ${genre}`, () => { + it(`should have an icon of ${expected}`, () => { + const name = iconForGenre(genre!)!; + expect(name).toEqual(expected); + }); + }); + + describe(`a genre of ${genre!.toLowerCase()}`, () => { + it(`should have an icon of ${expected}`, () => { + const name = iconForGenre(genre!)!; + expect(name).toEqual(expected); + }); }); }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index e3a0416..85a59d1 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1370,10 +1370,20 @@ describe("server", () => { expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); - expect(svg).toContain(`fill:brightblue`); - expect(svg).toContain(`fill:brightpink`); + expect(svg).toContain(`fill="brightblue"`); + expect(svg).toContain(`fill="brightpink"`); }); + it("should return an icon with text if requested", async () => { + const response = await request(server(SystemClock)).get( + `/icon/${type}/size/180?text=foobar1000` + ); + + expect(response.status).toEqual(200); + const svg = Buffer.from(response.body).toString(); + expect(svg).toContain(`foobar1000`); + }); + it("should return a christmas icon on christmas day", async () => { const response = await request(server({ now: () => dayjs("2022/12/25") })).get( `/icon/${type}/size/180` @@ -1381,8 +1391,8 @@ describe("server", () => { expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); - expect(svg).toContain(`fill:red`); - expect(svg).toContain(`fill:green`); + expect(svg).toContain(`fill="red"`); + expect(svg).toContain(`fill="green"`); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index a3a4b38..ff02fd7 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -47,6 +47,7 @@ import { import { AccessTokens } from "../src/access_tokens"; import dayjs from "dayjs"; import url from "../src/url_builder"; +import { iconForGenre } from "../src/icon"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); @@ -939,6 +940,7 @@ describe("api", () => { itemType: "container", id: `genre:${genre.id}`, title: genre.name, + albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(), })), index: 0, total: expectedGenres.length, @@ -960,6 +962,7 @@ describe("api", () => { itemType: "container", id: `genre:${genre.id}`, title: genre.name, + albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(), })), index: 1, total: expectedGenres.length, diff --git a/web/icons/Africa-48087.svg b/web/icons/Africa-48087.svg new file mode 100644 index 0000000..e8eb648 --- /dev/null +++ b/web/icons/Africa-48087.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/icons/Blues-113548.svg b/web/icons/Blues-113548.svg new file mode 100644 index 0000000..fd968aa --- /dev/null +++ b/web/icons/Blues-113548.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/icons/Book-453.svg b/web/icons/Book-453.svg new file mode 100644 index 0000000..c46be05 --- /dev/null +++ b/web/icons/Book-453.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/icons/Classic-Music-11646.svg b/web/icons/Classic-Music-11646.svg new file mode 100644 index 0000000..11983f6 --- /dev/null +++ b/web/icons/Classic-Music-11646.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/icons/Comedy-2-599.svg b/web/icons/Comedy-2-599.svg new file mode 100644 index 0000000..a958933 --- /dev/null +++ b/web/icons/Comedy-2-599.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/icons/Electronic-Music-17745.svg b/web/icons/Electronic-Music-17745.svg new file mode 100644 index 0000000..075a64c --- /dev/null +++ b/web/icons/Electronic-Music-17745.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/icons/Guitar-110433.svg b/web/icons/Guitar-110433.svg new file mode 100644 index 0000000..4158169 --- /dev/null +++ b/web/icons/Guitar-110433.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/icons/Hip-Hop Music-17757.svg b/web/icons/Hip-Hop Music-17757.svg new file mode 100644 index 0000000..622ab23 --- /dev/null +++ b/web/icons/Hip-Hop Music-17757.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/icons/Horror-4387.svg b/web/icons/Horror-4387.svg new file mode 100644 index 0000000..03ce28a --- /dev/null +++ b/web/icons/Horror-4387.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/icons/Ice-Pop Yellow-94532.svg b/web/icons/Ice-Pop Yellow-94532.svg new file mode 100644 index 0000000..e8a80a2 --- /dev/null +++ b/web/icons/Ice-Pop Yellow-94532.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/icons/Kangaroo-16730.svg b/web/icons/Kangaroo-16730.svg new file mode 100644 index 0000000..6c036ae --- /dev/null +++ b/web/icons/Kangaroo-16730.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/icons/Metal-Music-17763.svg b/web/icons/Metal-Music-17763.svg new file mode 100644 index 0000000..c95d18e --- /dev/null +++ b/web/icons/Metal-Music-17763.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/icons/Mushroom-63864.svg b/web/icons/Mushroom-63864.svg new file mode 100644 index 0000000..25331a1 --- /dev/null +++ b/web/icons/Mushroom-63864.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/icons/Music-14097.svg b/web/icons/Music-14097.svg new file mode 100644 index 0000000..ee3fe6f --- /dev/null +++ b/web/icons/Music-14097.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/icons/Music-Conductor-225.svg b/web/icons/Music-Conductor-225.svg new file mode 100644 index 0000000..178f1d2 --- /dev/null +++ b/web/icons/Music-Conductor-225.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/icons/Music-Record-102104.svg b/web/icons/Music-Record-102104.svg new file mode 100644 index 0000000..b958c85 --- /dev/null +++ b/web/icons/Music-Record-102104.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/icons/Music-Record-3397.svg b/web/icons/Music-Record-3397.svg new file mode 100644 index 0000000..758ce16 --- /dev/null +++ b/web/icons/Music-Record-3397.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/icons/Pills-112386.svg b/web/icons/Pills-112386.svg new file mode 100644 index 0000000..e920530 --- /dev/null +++ b/web/icons/Pills-112386.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/icons/Progressive-Rock-24862.svg b/web/icons/Progressive-Rock-24862.svg new file mode 100644 index 0000000..6907b17 --- /dev/null +++ b/web/icons/Progressive-Rock-24862.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/icons/Punk-40450.svg b/web/icons/Punk-40450.svg new file mode 100644 index 0000000..c618408 --- /dev/null +++ b/web/icons/Punk-40450.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/icons/Rap-24851.svg b/web/icons/Rap-24851.svg new file mode 100644 index 0000000..c307a97 --- /dev/null +++ b/web/icons/Rap-24851.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/web/icons/Reggae-24843.svg b/web/icons/Reggae-24843.svg new file mode 100644 index 0000000..1d11da5 --- /dev/null +++ b/web/icons/Reggae-24843.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/web/icons/Rock-Music-11007.svg b/web/icons/Rock-Music-11007.svg new file mode 100644 index 0000000..f848b12 --- /dev/null +++ b/web/icons/Rock-Music-11007.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/web/icons/Trumpet-17823.svg b/web/icons/Trumpet-17823.svg new file mode 100644 index 0000000..3d3bacd --- /dev/null +++ b/web/icons/Trumpet-17823.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/icons/US-Capitol-104805.svg b/web/icons/US-Capitol-104805.svg new file mode 100644 index 0000000..67e5a30 --- /dev/null +++ b/web/icons/US-Capitol-104805.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/web/icons/blank.svg b/web/icons/blank.svg new file mode 100644 index 0000000..29c7dae --- /dev/null +++ b/web/icons/blank.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/icons/index.html b/web/icons/index.html index d1508dc..5aaf649 100644 --- a/web/icons/index.html +++ b/web/icons/index.html @@ -28,6 +28,185 @@ - + hack 4
+
+ +
+ hack 5
+
+ + + + foobar1 + foobar2 + + +
+ hack 5 (3 lines)
+
+ + + + foobar1 + foobar2 + foobar3 + + +
+ hack 5 (4 lines)
+
+ + + + foobar1 + foobar2 + foobar3 + foobar4 + + +
+ hack 5 (4 lines with viewBox enlarge)
+
+ + + + foobar1 + foobar2 + foobar3 + foobar4 + + +
+ hack 6a (4 lines with viewBox enlarge)
+
+ + + + foobar1 + foobar2 + foobar3 + foobar4 + + +
+ hack 6b 1 line
+
+ + + + psy + + +
+ hack 6b 1 line %
+
+ + + + psy + + +
+ hack 6b 2 lines
+
+ + + + psy + trance + + +
+ hack 6b2
+
+ + + + alt + pop + + +
+ hack 6b3
+
+ + + + oz + rock + + +
+ hack 6c (3 lines with viewBox enlarge)
+
+ + + + oz + hip + hop + + +
+ hack 6c with an embedded svg (1 lines with viewBox enlarge)
+
+ + + + + oz + + + +
+ hack 6c with an embedded svg (3 lines with viewBox enlarge)
+
+ + + + + oz + hip + hop + + + +
+ hack 6c% (3 lines with viewBox enlarge)
+
+ + + + oz + hip + hop + + +
+ hack 6
+
+ + + + + First line. + Second line. + + + +
+ mushroom
+
+ + + + +
\ No newline at end of file