import express, { Express } from "express"; import * as Eta from "eta"; import morgan from "morgan"; import { Sonos, Service } from "./sonos"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, LOGIN_ROUTE, } 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"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; function server( sonos: Sonos, service: Service, webAddress: string | "http://localhost:4534", musicService: MusicService, linkCodes: LinkCodes = new InMemoryLinkCodes(), accessTokens: AccessTokens = new AccessTokenPerAuthToken(), clock: Clock = SystemClock ): Express { const app = express(); app.use(morgan("combined")); app.use(express.urlencoded({ extended: false })); app.use(express.static("./web/public")); app.engine("eta", Eta.renderFile); app.set("view engine", "eta"); app.set("views", "./web/views"); app.get("/", (_, res) => { Promise.all([sonos.devices(), sonos.services()]).then( ([devices, services]) => { const registeredBonobService = services.find( (it) => it.sid == service.sid ); res.render("index", { devices, services, bonobService: service, registeredBonobService, }); } ); }); app.post("/register", (_, res) => { sonos.register(service).then((success) => { if (success) { res.render("success", { message: `Successfully registered`, }); } else { res.status(500).render("failure", { message: `Registration failed!`, }); } }); }); app.get(LOGIN_ROUTE, (req, res) => { res.render("login", { bonobService: service, linkCode: req.query.linkCode, loginRoute: LOGIN_ROUTE, }); }); app.post(LOGIN_ROUTE, async (req, res) => { const { username, password, linkCode } = req.body; if (!linkCodes.has(linkCode)) { res.status(400).render("failure", { message: `Invalid linkCode!`, }); } else { const authResult = await musicService.generateToken({ username, password, }); if (isSuccess(authResult)) { linkCodes.associate(linkCode, authResult); res.render("success", { message: `Login successful!`, }); } else { res.status(403).render("failure", { message: `Login failed! ${authResult.message}!`, }); } } }); app.get(STRINGS_ROUTE, (_, res) => { res.type("application/xml").send(` Linking sonos with ${service.name} Lier les sonos à la ${service.name} `); }); app.get(PRESENTATION_MAP_ROUTE, (_, res) => { res.type("application/xml").send(` ${SONOS_RECOMMENDED_IMAGE_SIZES.map( (size) => `` )} `); }); app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; const accessToken = req.headers[BONOB_ACCESS_TOKEN_HEADER] as string; const authToken = accessTokens.authTokenFor(accessToken); if (!authToken) { return res.status(401).send(); } else { return musicService .login(authToken) .then((it) => it.scrobble(id).then((scrobbleSuccess) => { if (scrobbleSuccess) logger.info(`Scrobbled ${id}`); else logger.warn(`Failed to scrobble ${id}....`); return it; }) ) .then((it) => it.stream({ trackId: id, range: req.headers["range"] || undefined }) ) .then((trackStream) => { res.status(trackStream.status); Object.entries(trackStream.headers) .filter(([_, v]) => v !== undefined) .forEach(([header, value]) => res.setHeader(header, value)); trackStream.stream.pipe(res); }); } }); app.get("/:type/:id/art/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"]!); if (!authToken) { return res.status(401).send(); } else if (type != "artist" && type != "album") { return res.status(400).send(); } else { return musicService .login(authToken) .then((it) => it.coverArt(id, type, size)) .then((coverArt) => { if (coverArt) { res.status(200); res.setHeader("content-type", coverArt.contentType); res.send(coverArt.data); } else { res.status(404).send(); } }) .catch((e: Error) => { logger.error( `Failed fetching image ${type}/${id}/size/${size}: ${e.message}`, e ); res.status(500).send(); }); } }); bindSmapiSoapServiceToExpress( app, SOAP_PATH, webAddress, linkCodes, musicService, accessTokens, clock ); return app; } export default server;