From 71717e6e4662f7b60c2a709a5920150661bdad9c Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 7 Feb 2025 00:19:04 +0000 Subject: [PATCH] Icons for years --- package.json | 2 +- src/icon.ts | 49 ++++++++++++++++---------- src/server.ts | 16 +++++---- src/smapi.ts | 11 +++--- src/subsonic.ts | 2 +- tests/builders.ts | 8 +---- tests/icon.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++--- tests/server.test.ts | 69 +++++++++++++++++++++++++++++++++++-- tests/smapi.test.ts | 57 ++++++++++++++++++++---------- web/icons/yy.svg | 3 ++ web/icons/yyyy.svg | 3 ++ 11 files changed, 238 insertions(+), 64 deletions(-) create mode 100644 web/icons/yy.svg create mode 100644 web/icons/yyyy.svg diff --git a/package.json b/package.json index c98b84d..e7b4e5a 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "clean": "rm -Rf build node_modules", "build": "tsc", "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534", "test": "jest", "testw": "jest --watch", diff --git a/src/icon.ts b/src/icon.ts index eec0c80..2e739bb 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -48,8 +48,16 @@ export type IconFeatures = { viewPortIncreasePercent: number | undefined; backgroundColor: string | undefined; foregroundColor: string | undefined; + text: string | undefined; }; +export const NO_FEATURES: IconFeatures = { + viewPortIncreasePercent: undefined, + backgroundColor: undefined, + foregroundColor: undefined, + text: undefined +} + export type IconSpec = { svg: string | undefined; features: Partial | undefined; @@ -93,17 +101,11 @@ export class SvgIcon implements Icon { constructor( svg: string, - features: Partial = { - viewPortIncreasePercent: undefined, - backgroundColor: undefined, - foregroundColor: undefined, - } + features: Partial = {} ) { this.svg = svg; this.features = { - viewPortIncreasePercent: undefined, - backgroundColor: undefined, - foregroundColor: undefined, + ...NO_FEATURES, ...features, }; } @@ -131,6 +133,17 @@ export class SvgIcon implements Icon { viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent); element("//svg:svg").setAttribute("viewBox", viewBox.toString()); } + if(this.features.text) { + elements("//svg:text").forEach((text) => { + text.textContent = this.features.text! + }); + } + if (this.features.foregroundColor) { + elements("//svg:path|//svg:text").forEach((path) => { + if (path.getAttribute("fill")) path.setAttribute("stroke", this.features.foregroundColor!); + else path.setAttribute("fill", this.features.foregroundColor!); + }); + } if (this.features.backgroundColor) { const rect = doc.createElementNS(SVG_NS, "rect"); rect.setAttribute("x", `${viewBox.minX}`); @@ -142,12 +155,6 @@ export class SvgIcon implements Icon { const svg = element("//svg:svg") svg.insertBefore(rect, svg.childNodes[0]!); } - if (this.features.foregroundColor) { - elements("//svg:path").forEach((path) => { - if (path.getAttribute("fill")) path.setAttribute("stroke", this.features.foregroundColor!); - else path.setAttribute("fill", this.features.foregroundColor!); - }); - } return xmlTidy(doc as unknown as Node); }; @@ -230,20 +237,24 @@ export type ICON = | "yoda" | "heart" | "star" - | "solidStar"; + | "solidStar" + | "yy" + | "yyyy"; -const iconFrom = (name: string) => +const svgFrom = (name: string) => new SvgIcon( fs .readFileSync(path.resolve(__dirname, "..", "web", "icons", name)) .toString() ); +const iconFrom = (name: string) => svgFrom(name).with({ features: { viewPortIncreasePercent: 80 } }); + export const ICONS: Record = { artists: iconFrom("navidrome-artists.svg"), albums: iconFrom("navidrome-all.svg"), radio: iconFrom("navidrome-radio.svg"), - blank: iconFrom("blank.svg"), + blank: svgFrom("blank.svg"), playlists: iconFrom("navidrome-playlists.svg"), genres: iconFrom("Theatre-Mask-111172.svg"), random: iconFrom("navidrome-random.svg"), @@ -308,7 +319,9 @@ export const ICONS: Record = { yoda: iconFrom("Yoda-68107.svg"), heart: iconFrom("Heart-85038.svg"), star: iconFrom("Star-16101.svg"), - solidStar: iconFrom("Star-43879.svg") + solidStar: iconFrom("Star-43879.svg"), + yy: svgFrom("yy.svg"), + yyyy: svgFrom("yyyy.svg"), }; export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda]; diff --git a/src/server.ts b/src/server.ts index 8f3ad5e..8a90f37 100644 --- a/src/server.ts +++ b/src/server.ts @@ -498,16 +498,18 @@ function server( } }); - app.get("/icon/:type/size/:size", (req, res) => { - const type = req.params["type"]!; + app.get("/icon/:type_text/size/:size", (req, res) => { + const match = (req.params["type_text"] || "")!.match("^([A-Za-z0-9]+)(?:\:([A-Za-z0-9]+))?$") + if (!match) + return res.status(400).send(); + + const type = match[1]! + const text = match[2] const size = req.params["size"]!; if (!Object.keys(ICONS).includes(type)) { return res.status(404).send(); - } else if ( - size != "legacy" && - !SONOS_RECOMMENDED_IMAGE_SIZES.includes(size) - ) { + } else if (size != "legacy" && !SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)) { return res.status(400).send(); } else { let icon = (ICONS as any)[type]! as Icon; @@ -528,8 +530,8 @@ function server( icon .apply( features({ - viewPortIncreasePercent: 80, ...serverOpts.iconColors, + text: text }) ) .apply(festivals(clock)) diff --git a/src/smapi.ts b/src/smapi.ts index 3557d97..a4b0723 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -251,11 +251,12 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(), }); -const year = (bonobUrl: URLBuilder, year: Year) => ({ +const yyyy = (bonobUrl: URLBuilder, year: Year) => ({ itemType: "albumList", id: `year:${year.year}`, title: year.year, - albumArtURI: iconArtURI(bonobUrl, "music").href(), + // todo: maybe year.year should be nullable? + albumArtURI: year.year !== "?" ? iconArtURI(bonobUrl, "yyyy", year.year).href() : iconArtURI(bonobUrl, "music").href(), }); const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ @@ -286,9 +287,9 @@ export const coverArtURI = ( O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) ); -export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => +export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) => bonobUrl.append({ - pathname: `/icon/${icon}/size/legacy`, + pathname: `/icon/${text == undefined ? icon : `${icon}:${text}`}/size/legacy`, }); export const sonosifyMimeType = (mimeType: string) => @@ -888,7 +889,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => getMetadataResult({ mediaCollection: page.map((it) => - year(bonobUrl, it) + yyyy(bonobUrl, it) ), index: paging._index, total, diff --git a/src/subsonic.ts b/src/subsonic.ts index 7368ac5..aaaae6d 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -805,7 +805,7 @@ export class SubsonicMusicLibrary implements MusicLibrary { years = async () => { const q: AlbumQuery = { _index: 0, - _count: 100000, // FIXME: better than this ? + _count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something type: "alphabeticalByArtist", }; const years = this.subsonic.getAlbumList2(this.credentials, q) diff --git a/tests/builders.ts b/tests/builders.ts index ac1c2a4..13d4701 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -14,7 +14,7 @@ import { Playlist, SimilarArtist, AlbumSummary, - RadioStation, + RadioStation } from "../src/music_service"; import { b64Encode } from "../src/b64"; @@ -166,12 +166,6 @@ export const SAMPLE_GENRES = [ ]; export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]; -export const aYear = (year: string) => ({ id: year, year }); - -export const Y2024 = aYear("2024"); -export const Y2023 = aYear("2023"); -export const Y1969 = aYear("1969"); - export function aTrack(fields: Partial = {}): Track { const id = uuid(); const artist = anArtist(); diff --git a/tests/icon.test.ts b/tests/icon.test.ts index 06872b5..dd95f76 100644 --- a/tests/icon.test.ts +++ b/tests/icon.test.ts @@ -20,6 +20,7 @@ import { allOf, features, STAR_WARS, + NO_FEATURES, } from "../src/icon"; describe("SvgIcon", () => { @@ -27,7 +28,9 @@ describe("SvgIcon", () => { + 80's + 80's `; @@ -58,7 +61,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -107,7 +112,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -131,7 +138,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -149,7 +158,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -169,7 +180,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -179,7 +192,7 @@ describe("SvgIcon", () => { describe("foreground color", () => { describe("with no viewPort increase", () => { - it("should add a rectangle the same size as the original viewPort", () => { + it("should change the fill values", () => { expect( new SvgIcon(svgIcon24) .with({ features: { foregroundColor: "red" } }) @@ -189,7 +202,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -197,7 +212,7 @@ describe("SvgIcon", () => { }); describe("with a viewPort increase", () => { - it("should add a rectangle the same size as the original viewPort", () => { + it("should change the fill values", () => { expect( new SvgIcon(svgIcon24) .with({ @@ -212,7 +227,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -230,7 +247,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -249,7 +268,9 @@ describe("SvgIcon", () => { + 80's + 80's `) ); @@ -257,6 +278,48 @@ describe("SvgIcon", () => { }); }); + describe("text", () => { + describe("when text value specified", () => { + it("should change the text values", () => { + expect( + new SvgIcon(svgIcon24) + .with({ features: { text: "yipppeeee" } }) + .toString() + ).toEqual( + xmlTidy(` + + + + yipppeeee + + yipppeeee + + `) + ); + }); + }); + + describe("of undefined", () => { + it("should not do anything", () => { + expect( + new SvgIcon(svgIcon24) + .with({ features: { text: undefined } }) + .toString() + ).toEqual( + xmlTidy(` + + + + 80's + + 80's + + `) + ); + }); + }); + }); + describe("swapping the svg", () => { describe("with no other changes", () => { it("should swap out the svg, but maintain the IconFeatures", () => { @@ -315,10 +378,14 @@ describe("SvgIcon", () => { class DummyIcon implements Icon { svg: string; - features: Partial; + features: IconFeatures; + constructor(svg: string, features: Partial) { this.svg = svg; - this.features = features; + this.features = { + ...NO_FEATURES, + ...features + }; } public apply = (transformer: Transformer): Icon => transformer(this); @@ -347,6 +414,7 @@ describe("transform", () => { viewPortIncreasePercent: 100, foregroundColor: "blue", backgroundColor: "blue", + text: "a", }, }) .apply( @@ -354,6 +422,7 @@ describe("transform", () => { features: { foregroundColor: "override1", backgroundColor: "override2", + text: "b", }, }) ) as DummyIcon; @@ -363,6 +432,7 @@ describe("transform", () => { viewPortIncreasePercent: 100, foregroundColor: "override1", backgroundColor: "override2", + text: "b", }); }); }); @@ -379,6 +449,7 @@ describe("transform", () => { viewPortIncreasePercent: 100, foregroundColor: "blue", backgroundColor: "blue", + text: "bob", }, }) .apply( @@ -392,6 +463,7 @@ describe("transform", () => { viewPortIncreasePercent: 100, foregroundColor: "blue", backgroundColor: "blue", + text: "bob" }); }); }); @@ -408,6 +480,7 @@ describe("features", () => { viewPortIncreasePercent: 100, foregroundColor: "blue", backgroundColor: "blue", + text: "foobar" }) ) as DummyIcon; @@ -415,6 +488,7 @@ describe("features", () => { viewPortIncreasePercent: 100, foregroundColor: "blue", backgroundColor: "blue", + text: "foobar" }); }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 5efb90b..0a7e892 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1366,11 +1366,25 @@ describe("server", () => { "..%2F..%2Ffoo", "%2Fetc%2Fpasswd", ".%2Fbob.js", - ".", - "..", - "1", "%23%24", + ].forEach((type) => { + describe(`trying to retrieve an icon with name ${type}`, () => { + it(`should fail`, async () => { + const response = await request(server()).get( + `/icon/${type}/size/legacy` + ); + + expect(response.status).toEqual(400); + }); + }); + }); + }); + + describe("missing icons", () => { + [ + "1", "notAValidIcon", + "notAValidIcon:withSomeText" ].forEach((type) => { describe(`trying to retrieve an icon with name ${type}`, () => { it(`should fail`, async () => { @@ -1398,6 +1412,20 @@ describe("server", () => { }); }); + describe("invalid text", () => { + ["..", "foobar.123", "_dog_", "{ whoop }"].forEach((text) => { + describe(`trying to retrieve an icon with text ${text}`, () => { + it(`should fail`, async () => { + const response = await request(server()).get( + `/icon/yyyy:${text}/size/60` + ); + + expect(response.status).toEqual(400); + }); + }); + }); + }); + describe("fetching", () => { [ "artists", @@ -1527,6 +1555,41 @@ describe("server", () => { }); }); }); + + describe("specifing some text", () => { + const text = "somethingWicked" + + describe(`legacy icon`, () => { + it("should return the png image", async () => { + const response = await request(server()).get( + `/icon/yyyy:${text}/size/legacy` + ); + + expect(response.status).toEqual(200); + expect(response.header["content-type"]).toEqual("image/png"); + const image = await Image.load(response.body); + expect(image.width).toEqual(80); + expect(image.height).toEqual(80); + }); + }); + + describe("svg icon", () => { + it(`should return an svg image with the text replaced`, async () => { + const response = await request(server()).get( + `/icon/yyyy:${text}/size/60` + ); + + expect(response.status).toEqual(200); + expect(response.header["content-type"]).toEqual( + "image/svg+xml; charset=utf-8" + ); + const svg = Buffer.from(response.body).toString(); + expect(svg).toContain( + `>${text}` + ); + }); + }); + }); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index f5ee19a..3f64a5f 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -39,9 +39,6 @@ import { ROCK, TRIP_HOP, PUNK, - Y2024, - Y2023, - Y1969, aPlaylist, aRadioStation, } from "./builders"; @@ -562,6 +559,24 @@ describe("coverArtURI", () => { }); }); +describe("iconArtURI", () => { + const bonobUrl = new URLBuilder( + "http://bonob.example.com:8080/context?search=yes" + ); + + describe("with no text", () => { + it("should return just the icon uri", () => { + expect(iconArtURI(bonobUrl, "mushroom").href()).toEqual("http://bonob.example.com:8080/context/icon/mushroom/size/legacy?search=yes") + }); + }); + + describe("with text", () => { + it("should return just the icon uri", () => { + expect(iconArtURI(bonobUrl, "yyyy", "foobar10000").href()).toEqual("http://bonob.example.com:8080/context/icon/yyyy:foobar10000/size/legacy?search=yes") + }); + }); +}); + describe("wsdl api", () => { const musicService = { generateToken: jest.fn(), @@ -1383,7 +1398,7 @@ describe("wsdl api", () => { }); describe("asking for a year", () => { - const expectedYears = [Y1969, Y2023, Y2024]; + const expectedYears = [{ year: "?" }, { year: "1969" }, { year: "1980" }, { year: "2001" }, { year: "2010" }]; beforeEach(() => { musicLibrary.years.mockResolvedValue(expectedYears); @@ -1396,17 +1411,22 @@ describe("wsdl api", () => { index: 0, count: 100, }); + const albumListForYear = (year: string, icon: URLBuilder) => ({ + itemType: "albumList", + id: `year:${year}`, + title: year, + albumArtURI: icon.href(), + }); + expect(result[0]).toEqual( getMetadataResult({ - mediaCollection: expectedYears.map((year) => ({ - itemType: "albumList", - id: `year:${year.id}`, - title: year.year, - albumArtURI: iconArtURI( - bonobUrl, - "music", - ).href(), - })), + mediaCollection: [ + albumListForYear("?", iconArtURI(bonobUrl, "music")), + albumListForYear("1969", iconArtURI(bonobUrl, "yyyy", "1969")), + albumListForYear("1980", iconArtURI(bonobUrl, "yyyy", "1980")), + albumListForYear("2001", iconArtURI(bonobUrl, "yyyy", "2001")), + albumListForYear("2010", iconArtURI(bonobUrl, "yyyy", "2010")), + ], index: 0, total: expectedYears.length, }) @@ -1418,21 +1438,22 @@ describe("wsdl api", () => { it("should return just that page", async () => { const result = await ws.getMetadataAsync({ id: `years`, - index: 1, + index: 2, count: 2, }); expect(result[0]).toEqual( getMetadataResult({ - mediaCollection: [Y2023, Y2024].map((year) => ({ + mediaCollection: [{ year: "1980" }, { year: "2001" }].map((year) => ({ itemType: "albumList", - id: `year:${year.id}`, + id: `year:${year.year}`, title: year.year, albumArtURI: iconArtURI( bonobUrl, - "music" + "yyyy", + year.year ).href(), })), - index: 1, + index: 2, total: expectedYears.length, }) ); diff --git a/web/icons/yy.svg b/web/icons/yy.svg new file mode 100644 index 0000000..51cc8f1 --- /dev/null +++ b/web/icons/yy.svg @@ -0,0 +1,3 @@ + + 80s + \ No newline at end of file diff --git a/web/icons/yyyy.svg b/web/icons/yyyy.svg new file mode 100644 index 0000000..626aa01 --- /dev/null +++ b/web/icons/yyyy.svg @@ -0,0 +1,3 @@ + + 1980 + \ No newline at end of file