Penta před 2 týdny
revize
db75543d20
2 změnil soubory, kde provedl 333 přidání a 0 odebrání
  1. 13 0
      Dockerfile
  2. 320 0
      server.mjs

+ 13 - 0
Dockerfile

@@ -0,0 +1,13 @@
+FROM registry.access.redhat.com/ubi9/nodejs-22:latest
+
+USER 0
+
+COPY server.mjs /opt/app-root/src/server.mjs
+
+RUN chown 1001:0 /opt/app-root/src/server.mjs
+
+USER 1001
+
+EXPOSE 9443
+
+ENTRYPOINT ["node", "/opt/app-root/src/server.mjs"]

+ 320 - 0
server.mjs

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