|
@@ -0,0 +1,320 @@
|
|
|
|
|
+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}`,
|
|
|
|
|
+ );
|
|
|
|
|
+});
|