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;