mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-22 01:43:29 +01:00
Icons for root menu
This commit is contained in:
124
src/server.ts
124
src/server.ts
@@ -2,6 +2,10 @@ import { option as O } from "fp-ts";
|
||||
import express, { Express, Request } from "express";
|
||||
import * as Eta from "eta";
|
||||
import morgan from "morgan";
|
||||
import path from "path";
|
||||
import scale from "scale-that-svg";
|
||||
import sharp from "sharp";
|
||||
import fs from "fs";
|
||||
|
||||
import { PassThrough, Transform, TransformCallback } from "stream";
|
||||
|
||||
@@ -13,7 +17,8 @@ import {
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES,
|
||||
LOGIN_ROUTE,
|
||||
CREATE_REGISTRATION_ROUTE,
|
||||
REMOVE_REGISTRATION_ROUTE
|
||||
REMOVE_REGISTRATION_ROUTE,
|
||||
ICON,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
@@ -27,6 +32,26 @@ import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||
|
||||
const icon = (name: string) =>
|
||||
fs
|
||||
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
|
||||
.toString();
|
||||
|
||||
export type Icon = { svg: string; size: number };
|
||||
|
||||
export const ICONS: Record<ICON, Icon> = {
|
||||
artists: { svg: icon("navidrome-artists.svg"), size: 24 },
|
||||
albums: { svg: icon("navidrome-all.svg"), size: 24 },
|
||||
playlists: { svg: icon("navidrome-playlists.svg"), size: 24 },
|
||||
genres: { svg: icon("Theatre-Mask-111172.svg"), size: 128 },
|
||||
random: { svg: icon("navidrome-random.svg"), size: 24 },
|
||||
starred: { svg: icon("navidrome-topRated.svg"), size: 24 },
|
||||
recentlyAdded: { svg: icon("navidrome-recentlyAdded.svg"), size: 24 },
|
||||
recentlyPlayed: { svg: icon("navidrome-recentlyPlayed.svg"), size: 24 },
|
||||
mostPlayed: { svg: icon("navidrome-mostPlayed.svg"), size: 24 },
|
||||
discover: { svg: icon("Binoculars-14310.svg"), size: 32 },
|
||||
};
|
||||
|
||||
interface RangeFilter extends Transform {
|
||||
range: (length: number) => string;
|
||||
}
|
||||
@@ -88,9 +113,11 @@ function server(
|
||||
app.set("views", "./web/views");
|
||||
|
||||
const langFor = (req: Request) => {
|
||||
logger.debug(`${req.path} (req[accept-language]=${req.headers["accept-language"]})`);
|
||||
logger.debug(
|
||||
`${req.path} (req[accept-language]=${req.headers["accept-language"]})`
|
||||
);
|
||||
return i8n(...asLANGs(req.headers["accept-language"]));
|
||||
}
|
||||
};
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
const lang = langFor(req);
|
||||
@@ -105,8 +132,12 @@ function server(
|
||||
services,
|
||||
bonobService: service,
|
||||
registeredBonobService,
|
||||
createRegistrationRoute: bonobUrl.append({ pathname: CREATE_REGISTRATION_ROUTE }).pathname(),
|
||||
removeRegistrationRoute: bonobUrl.append({ pathname: REMOVE_REGISTRATION_ROUTE }).pathname(),
|
||||
createRegistrationRoute: bonobUrl
|
||||
.append({ pathname: CREATE_REGISTRATION_ROUTE })
|
||||
.pathname(),
|
||||
removeRegistrationRoute: bonobUrl
|
||||
.append({ pathname: REMOVE_REGISTRATION_ROUTE })
|
||||
.pathname(),
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -116,8 +147,8 @@ function server(
|
||||
return res.send({
|
||||
service: {
|
||||
name: service.name,
|
||||
sid: service.sid
|
||||
}
|
||||
sid: service.sid,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,15 +218,19 @@ function server(
|
||||
res.status(403).render("failure", {
|
||||
lang,
|
||||
message: lang("loginFailed"),
|
||||
cause: authResult.message
|
||||
cause: authResult.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get(STRINGS_ROUTE, (_, res) => {
|
||||
const stringNode = (id: string, value: string) => `<string stringId="${id}"><![CDATA[${value}]]></string>`
|
||||
const stringtableNode = (langName: string) => `<stringtable rev="1" xml:lang="${langName}">${i8nKeys().map(key => stringNode(key, i8n(langName as LANG)(key as KEY))).join("")}</stringtable>`
|
||||
const stringNode = (id: string, value: string) =>
|
||||
`<string stringId="${id}"><![CDATA[${value}]]></string>`;
|
||||
const stringtableNode = (langName: string) =>
|
||||
`<stringtable rev="1" xml:lang="${langName}">${i8nKeys()
|
||||
.map((key) => stringNode(key, i8n(langName as LANG)(key as KEY)))
|
||||
.join("")}</stringtable>`;
|
||||
|
||||
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
|
||||
<stringtables xmlns="http://sonos.com/sonosapi">
|
||||
@@ -211,12 +246,23 @@ function server(
|
||||
<Match>
|
||||
<imageSizeMap>
|
||||
${SONOS_RECOMMENDED_IMAGE_SIZES.map(
|
||||
(size) =>
|
||||
`<sizeEntry size="${size}" substitution="/art/size/${size}"/>`
|
||||
).join("")}
|
||||
(size) =>
|
||||
`<sizeEntry size="${size}" substitution="/size/${size}"/>`
|
||||
).join("")}
|
||||
</imageSizeMap>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
<PresentationMap type="BrowseIconSizeMap">
|
||||
<Match>
|
||||
<browseIconSizeMap>
|
||||
<sizeEntry size="0" substitution="/size/legacy"/>
|
||||
${SONOS_RECOMMENDED_IMAGE_SIZES.map(
|
||||
(size) =>
|
||||
`<sizeEntry size="${size}" substitution="/size/${size}"/>`
|
||||
).join("")}
|
||||
</browseIconSizeMap>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
<PresentationMap type="Search">
|
||||
<Match>
|
||||
<SearchCategories>
|
||||
@@ -255,7 +301,8 @@ function server(
|
||||
)
|
||||
.then(({ musicLibrary, stream }) => {
|
||||
logger.info(
|
||||
`stream response from music service for ${id}, status=${stream.status
|
||||
`stream response from music service for ${id}, status=${
|
||||
stream.status
|
||||
}, headers=(${JSON.stringify(stream.headers)})`
|
||||
);
|
||||
|
||||
@@ -328,28 +375,65 @@ function server(
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/:type/:id/art/size/:size", (req, res) => {
|
||||
app.get("/icon/:type/size/:size", (req, res) => {
|
||||
const type = req.params["type"]!;
|
||||
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)
|
||||
) {
|
||||
return res.status(400).send();
|
||||
} else {
|
||||
const icon = (ICONS as any)[type]! as Icon;
|
||||
const spec =
|
||||
size == "legacy"
|
||||
? {
|
||||
outputSize: 80,
|
||||
mimeType: "image/png",
|
||||
responseFormatter: (svg: string): Promise<Buffer | string> =>
|
||||
sharp(Buffer.from(svg)).png().toBuffer(),
|
||||
}
|
||||
: {
|
||||
outputSize: Number.parseInt(size),
|
||||
mimeType: "image/svg+xml",
|
||||
responseFormatter: (svg: string): Promise<Buffer | string> =>
|
||||
Promise.resolve(svg),
|
||||
};
|
||||
|
||||
return Promise.resolve(icon.svg)
|
||||
.then((svg) => scale(svg, { scale: spec.outputSize / icon.size }))
|
||||
.then(spec.responseFormatter)
|
||||
.then((data) => res.status(200).type(spec.mimeType).send(data));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/art/:type/:id/size/:size", (req, res) => {
|
||||
const authToken = accessTokens.authTokenFor(
|
||||
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
||||
);
|
||||
const type = req.params["type"]!;
|
||||
const id = req.params["id"]!;
|
||||
const size = Number.parseInt(req.params["size"]!);
|
||||
const size = req.params["size"]!;
|
||||
if (!authToken) {
|
||||
return res.status(401).send();
|
||||
} else if (type != "artist" && type != "album") {
|
||||
return res.status(400).send();
|
||||
} else if (!(size.match(/^\d+$/) && Number.parseInt(size) > 0)) {
|
||||
return res.status(400).send();
|
||||
} else {
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((it) => it.coverArt(id, type, size))
|
||||
.then((it) => it.coverArt(id, type, Number.parseInt(size)))
|
||||
.then((coverArt) => {
|
||||
if (coverArt) {
|
||||
res.status(200);
|
||||
res.setHeader("content-type", coverArt.contentType);
|
||||
res.send(coverArt.data);
|
||||
return res.send(coverArt.data);
|
||||
} else {
|
||||
res.status(404).send();
|
||||
return res.status(404).send();
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
@@ -357,7 +441,7 @@ function server(
|
||||
`Failed fetching image ${type}/${id}/size/${size}: ${e.message}`,
|
||||
e
|
||||
);
|
||||
res.status(500).send();
|
||||
return res.status(500).send();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
36
src/smapi.ts
36
src/smapi.ts
@@ -46,6 +46,8 @@ 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"
|
||||
@@ -226,12 +228,15 @@ const playlist = (playlist: PlaylistSummary) => ({
|
||||
});
|
||||
|
||||
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
||||
bonobUrl.append({ pathname: `/album/${album.id}/art/size/180` });
|
||||
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 defaultArtistArtURI = (
|
||||
bonobUrl: URLBuilder,
|
||||
artist: ArtistSummary
|
||||
) => bonobUrl.append({ pathname: `/artist/${artist.id}/art/size/180` });
|
||||
) => bonobUrl.append({ pathname: `/art/artist/${artist.id}/size/180` });
|
||||
|
||||
export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
|
||||
itemType: "album",
|
||||
@@ -558,19 +563,22 @@ function bindSmapiSoapServiceToExpress(
|
||||
return getMetadataResult({
|
||||
mediaCollection: [
|
||||
{
|
||||
itemType: "container",
|
||||
id: "artists",
|
||||
title: lang("artists"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "albums",
|
||||
title: lang("albums"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "playlist",
|
||||
id: "playlists",
|
||||
title: lang("playlists"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
|
||||
itemType: "playlist",
|
||||
attributes: {
|
||||
readOnly: false,
|
||||
userContent: true,
|
||||
@@ -578,34 +586,40 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
},
|
||||
{
|
||||
itemType: "container",
|
||||
id: "genres",
|
||||
title: lang("genres"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "randomAlbums",
|
||||
title: lang("random"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "random").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "starredAlbums",
|
||||
title: lang("starred"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "recentlyAdded").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyPlayed",
|
||||
title: lang("recentlyPlayed"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "recentlyPlayed").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "mostPlayed",
|
||||
title: lang("mostPlayed"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { URLBuilder } from "./url_builder";
|
||||
|
||||
export const SONOS_LANG = ["en-US", "da-DK", "de-DE", "es-ES", "fr-FR", "it-IT", "ja-JP", "nb-NO", "nl-NL", "pt-BR", "sv-SE", "zh-CN"]
|
||||
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "19";
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "21";
|
||||
|
||||
// NOTE: manifest requires https for the URL,
|
||||
// otherwise you will get an error trying to register
|
||||
|
||||
Reference in New Issue
Block a user