| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- import fs from 'node:fs';
- import https from 'node:https';
- import net from 'node:net';
- const MPD_HOST =
- process.env.MPD_HOST ?? '127.0.0.1';
- const MPD_PORT =
- Number(process.env.MPD_PORT ?? '6600');
- const MPD_TIMEOUT_MS =
- Number(process.env.MPD_TIMEOUT_MS ?? '700');
- const POLL_INTERVAL_MS =
- Number(process.env.POLL_INTERVAL_MS ?? '1000');
- const TLS_CERT_FILE =
- process.env.TLS_CERT_FILE ?? '/var/run/tls/tls.crt';
- const TLS_KEY_FILE =
- process.env.TLS_KEY_FILE ?? '/var/run/tls/tls.key';
- const state = {
- available: false,
- current: null,
- previous: [],
- error: 'Waiting for MPD',
- updatedAt: null,
- };
- let pollRunning = false;
- const songDisplay = (fields) => {
- const artist =
- fields.Artist ??
- fields.AlbumArtist ??
- '';
- const title =
- fields.Title ??
- fields.Name ??
- fields.file ??
- 'Unknown';
- return artist
- ? `${artist} - ${title}`
- : title;
- };
- const parseSong = (raw) => {
- const fields = {};
- for (const line of raw.split(/\r?\n/)) {
- if (
- !line ||
- line === 'OK' ||
- line.startsWith('OK MPD ')
- ) {
- continue;
- }
- if (line.startsWith('ACK ')) {
- throw new Error(line);
- }
- const separator = line.indexOf(': ');
- if (separator === -1) {
- continue;
- }
- const key = line.slice(0, separator);
- const value = line.slice(separator + 2);
- if (fields[key] === undefined) {
- fields[key] = value;
- }
- }
- if (
- !fields.file &&
- !fields.Title &&
- !fields.Name
- ) {
- return null;
- }
- const display = songDisplay(fields);
- return {
- id: [
- fields.file ?? '',
- fields.Artist ?? '',
- fields.Title ?? '',
- fields.Name ?? '',
- ].join('\u0000'),
- display,
- artist: fields.Artist ?? null,
- title: fields.Title ?? null,
- file: fields.file ?? null,
- };
- };
- const getCurrentSong = () =>
- new Promise((resolve, reject) => {
- const socket = net.createConnection({
- host: MPD_HOST,
- port: MPD_PORT,
- });
- socket.setEncoding('utf8');
- socket.setTimeout(MPD_TIMEOUT_MS);
- let buffer = '';
- let commandSent = false;
- let finished = false;
- const fail = (error) => {
- if (finished) {
- return;
- }
- finished = true;
- socket.destroy();
- reject(error);
- };
- const complete = (song) => {
- if (finished) {
- return;
- }
- finished = true;
- socket.end();
- resolve(song);
- };
- socket.on('timeout', () => {
- fail(
- new Error(
- `MPD did not respond within ${MPD_TIMEOUT_MS} ms`,
- ),
- );
- });
- socket.on('error', fail);
- socket.on('data', (chunk) => {
- buffer += chunk;
- if (!commandSent) {
- const newline = buffer.indexOf('\n');
- if (newline === -1) {
- return;
- }
- const greeting =
- buffer.slice(0, newline).trim();
- if (!greeting.startsWith('OK MPD ')) {
- fail(
- new Error(
- `Invalid MPD greeting: ${greeting}`,
- ),
- );
- return;
- }
- buffer = buffer.slice(newline + 1);
- commandSent = true;
- socket.write('currentsong\n');
- }
- if (
- buffer === 'OK\n' ||
- buffer.endsWith('\nOK\n')
- ) {
- try {
- complete(parseSong(buffer));
- } catch (error) {
- fail(error);
- }
- }
- if (buffer.includes('\nACK ')) {
- fail(new Error(buffer.trim()));
- }
- });
- socket.on('close', () => {
- if (!finished) {
- fail(
- new Error(
- 'MPD connection closed unexpectedly',
- ),
- );
- }
- });
- });
- const pollMPD = async () => {
- if (pollRunning) {
- return;
- }
- pollRunning = true;
- try {
- const nextSong = await getCurrentSong();
- const currentId =
- state.current?.id ?? null;
- const nextId =
- nextSong?.id ?? null;
- if (currentId !== nextId) {
- if (state.current) {
- state.previous = [
- {
- display: state.current.display,
- artist: state.current.artist,
- title: state.current.title,
- file: state.current.file,
- },
- ...state.previous,
- ].slice(0, 5);
- }
- state.current = nextSong;
- }
- state.available = true;
- state.error = null;
- state.updatedAt =
- new Date().toISOString();
- } catch (error) {
- state.available = false;
- state.error =
- error instanceof Error
- ? error.message
- : String(error);
- state.updatedAt =
- new Date().toISOString();
- } finally {
- pollRunning = false;
- }
- };
- await pollMPD();
- setInterval(
- pollMPD,
- POLL_INTERVAL_MS,
- );
- const server = https.createServer(
- {
- cert: fs.readFileSync(TLS_CERT_FILE),
- key: fs.readFileSync(TLS_KEY_FILE),
- },
- (req, res) => {
- res.setHeader(
- 'Content-Type',
- 'application/json',
- );
- if (req.url === '/healthz') {
- res.writeHead(200);
- res.end(
- JSON.stringify({
- status: 'ok',
- }),
- );
- return;
- }
- if (req.url === '/api/status') {
- const current = state.current
- ? {
- display: state.current.display,
- artist: state.current.artist,
- title: state.current.title,
- file: state.current.file,
- }
- : null;
- res.writeHead(200);
- res.end(
- JSON.stringify({
- available: state.available,
- current,
- previous: state.previous,
- error: state.error,
- updatedAt: state.updatedAt,
- }),
- );
- return;
- }
- res.writeHead(404);
- res.end(
- JSON.stringify({
- error: 'Not found',
- }),
- );
- },
- );
- server.listen(9443, '0.0.0.0', () => {
- console.log(
- `MPD status server listening on :9443, MPD=${MPD_HOST}:${MPD_PORT}`,
- );
- });
|