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"; import { Sonos, Service, SONOS_LANG } from "./sonos"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, LOGIN_ROUTE, CREATE_REGISTRATION_ROUTE, REMOVE_REGISTRATION_ROUTE, ICON, } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; import bindSmapiSoapServiceToExpress from "./smapi"; import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import logger from "./logger"; 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"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; const icon = (name: string) => fs .readFileSync(path.resolve(".", "web", "icons", name)) .toString(); export type Icon = { svg: string; size: number }; export const ICONS: Record = { 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; } export function rangeFilterFor(rangeHeader: string): RangeFilter { // if (rangeHeader == undefined) return new PassThrough(); const match = rangeHeader.match(/^bytes=(\d+)-$/); if (match) return new RangeBytesFromFilter(Number.parseInt(match[1]!)); else throw `Unsupported range: ${rangeHeader}`; } export class RangeBytesFromFilter extends Transform { from: number; count: number = 0; constructor(f: number) { super(); this.from = f; } _transform(chunk: any, _: BufferEncoding, next: TransformCallback) { if (this.count + chunk.length <= this.from) { // before start next(); } else if (this.from <= this.count) { // off the end next(null, chunk); } else { // from somewhere in chunk next(null, chunk.slice(this.from - this.count)); } this.count = this.count + chunk.length; } range = (number: number) => `${this.from}-${number - 1}/${number}`; } function server( sonos: Sonos, service: Service, bonobUrl: URLBuilder, musicService: MusicService, linkCodes: LinkCodes = new InMemoryLinkCodes(), accessTokens: AccessTokens = new AccessTokenPerAuthToken(), clock: Clock = SystemClock, applyContextPath = true ): Express { const app = express(); const i8n = makeI8N(service.name); app.use(morgan("combined")); app.use(express.urlencoded({ extended: false })); // todo: pass options in here? app.use(express.static("./web/public")); app.engine("eta", Eta.renderFile); app.set("view engine", "eta"); app.set("views", "./web/views"); const langFor = (req: Request) => { 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); Promise.all([sonos.devices(), sonos.services()]).then( ([devices, services]) => { const registeredBonobService = services.find( (it) => it.sid == service.sid ); res.render("index", { lang, devices, services, bonobService: service, registeredBonobService, createRegistrationRoute: bonobUrl .append({ pathname: CREATE_REGISTRATION_ROUTE }) .pathname(), removeRegistrationRoute: bonobUrl .append({ pathname: REMOVE_REGISTRATION_ROUTE }) .pathname(), }); } ); }); app.get("/about", (_, res) => { return res.send({ service: { name: service.name, sid: service.sid, }, }); }); app.post(CREATE_REGISTRATION_ROUTE, (req, res) => { const lang = langFor(req); sonos.register(service).then((success) => { if (success) { res.render("success", { lang, message: lang("successfullyRegistered"), }); } else { res.status(500).render("failure", { lang, message: lang("registrationFailed"), }); } }); }); app.post(REMOVE_REGISTRATION_ROUTE, (req, res) => { const lang = langFor(req); sonos.remove(service.sid).then((success) => { if (success) { res.render("success", { lang, message: lang("successfullyRemovedRegistration"), }); } else { res.status(500).render("failure", { lang, message: lang("failedToRemoveRegistration"), }); } }); }); app.get(LOGIN_ROUTE, (req, res) => { const lang = langFor(req); res.render("login", { lang, linkCode: req.query.linkCode, loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(), }); }); app.post(LOGIN_ROUTE, async (req, res) => { const lang = langFor(req); const { username, password, linkCode } = req.body; if (!linkCodes.has(linkCode)) { res.status(400).render("failure", { lang, message: lang("invalidLinkCode"), }); } else { const authResult = await musicService.generateToken({ username, password, }); if (isSuccess(authResult)) { linkCodes.associate(linkCode, authResult); res.render("success", { lang, message: lang("loginSuccessful"), }); } else { res.status(403).render("failure", { lang, message: lang("loginFailed"), cause: authResult.message, }); } } }); app.get(STRINGS_ROUTE, (_, res) => { const stringNode = (id: string, value: string) => ``; const stringtableNode = (langName: string) => `${i8nKeys() .map((key) => stringNode(key, i8n(langName as LANG)(key as KEY))) .join("")}`; res.type("application/xml").send(` ${SONOS_LANG.map(stringtableNode).join("")} `); }); app.get(PRESENTATION_MAP_ROUTE, (_, res) => { res.type("application/xml").send(` ${SONOS_RECOMMENDED_IMAGE_SIZES.map( (size) => `` ).join("")} ${SONOS_RECOMMENDED_IMAGE_SIZES.map( (size) => `` ).join("")} `); }); app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; logger.info( `-> /stream/track/${id}, headers=${JSON.stringify(req.headers)}` ); const authToken = pipe( req.header(BONOB_ACCESS_TOKEN_HEADER), O.fromNullable, O.map((accessToken) => accessTokens.authTokenFor(accessToken)), O.getOrElseW(() => undefined) ); if (!authToken) { return res.status(401).send(); } else { return musicService .login(authToken) .then((it) => it .stream({ trackId: id, range: req.headers["range"] || undefined, }) .then((stream) => ({ musicLibrary: it, stream })) ) .then(({ musicLibrary, stream }) => { logger.info( `stream response from music service for ${id}, status=${ stream.status }, headers=(${JSON.stringify(stream.headers)})` ); const respondWith = ({ status, filter, headers, sendStream, nowPlaying, }: { status: number; filter: Transform; headers: Record; sendStream: boolean; nowPlaying: boolean; }) => { logger.info( `<- /stream/track/${id}, status=${status}, headers=${JSON.stringify( headers )}` ); (nowPlaying ? musicLibrary.nowPlaying(id) : Promise.resolve(true) ).then((_) => { res.status(status); Object.entries(stream.headers) .filter(([_, v]) => v !== undefined) .forEach(([header, value]) => res.setHeader(header, value)); if (sendStream) stream.stream.pipe(filter).pipe(res); else res.send(); }); }; if (stream.status == 200) { respondWith({ status: 200, filter: new PassThrough(), headers: { "content-type": stream.headers["content-type"], "content-length": stream.headers["content-length"], "accept-ranges": stream.headers["accept-ranges"], }, sendStream: req.method == "GET", nowPlaying: req.method == "GET", }); } else if (stream.status == 206) { respondWith({ status: 206, filter: new PassThrough(), headers: { "content-type": stream.headers["content-type"], "content-length": stream.headers["content-length"], "content-range": stream.headers["content-range"], "accept-ranges": stream.headers["accept-ranges"], }, sendStream: req.method == "GET", nowPlaying: req.method == "GET", }); } else { respondWith({ status: stream.status, filter: new PassThrough(), headers: {}, sendStream: req.method == "GET", nowPlaying: false, }); } }); } }); 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 => sharp(Buffer.from(svg)).png().toBuffer(), } : { outputSize: Number.parseInt(size), mimeType: "image/svg+xml", responseFormatter: (svg: string): Promise => 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 = 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, Number.parseInt(size))) .then((coverArt) => { if (coverArt) { res.status(200); res.setHeader("content-type", coverArt.contentType); return res.send(coverArt.data); } else { return res.status(404).send(); } }) .catch((e: Error) => { logger.error( `Failed fetching image ${type}/${id}/size/${size}`, { cause: e } ); return res.status(500).send(); }); } }); bindSmapiSoapServiceToExpress( app, SOAP_PATH, bonobUrl, linkCodes, musicService, accessTokens, clock, i8n ); if (applyContextPath) { const container = express(); container.use(bonobUrl.path(), app); return container; } else { return app; } } export default server;