Files
whats-that-music/desktop-client/server.js
shadowdaoandClaude Sonnet 5.5 e49078ee03 Fix now-playing scraper, quit hang, tray icon, and tray race (v1.0.1)
- Read the track from the Media Session API, falling back to the legacy
  ytmusic-player-bar scrape. Current YT Music layouts render no player bar,
  so the API and tray stayed empty while music played. Artist is also clean
  now (no "• views • likes" suffix).
- Terminate connected WebSocket clients before closing the server. ws's
  close() waits for every client to disconnect, so quitting with an overlay
  open hung forever, leaving the window playing and the port bound.
- Replace the tray icon with a valid PNG (the old one had a bad IDAT CRC and
  decoded to an empty image) and test that its chunks are well formed.
- Create the tray before the window so early state updates are not dropped.
- Bump version to 1.0.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:37:38 -07:00

134 lines
4.5 KiB
JavaScript

const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
const { initialState, mergeState } = require('./state');
/**
* Start the now-playing server.
*
* @param {Object} opts
* @param {number} opts.port Port to listen on (e.g. 9095).
* @param {string} [opts.overlayDir] Absolute path to the overlay/ directory to serve as static files. Optional (omit for tests).
* @returns {Promise<{ setState: (update: object) => object, stop: () => Promise<void>, port: number }>}
*/
async function startServer({ port, overlayDir }) {
let currentState = { ...initialState };
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// CORS: overlay/OBS browser sources may be loaded from arbitrary origins.
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.get('/api/now-playing', (req, res) => res.json(currentState));
// Image proxy so overlay clients can fetch album art that may have CORS / referrer restrictions.
app.get('/api/image-proxy', async (req, res) => {
const url = req.query.url;
if (!url) return res.status(400).send('Missing url parameter');
let parsed;
try { parsed = new URL(url); }
catch { return res.status(400).send('Invalid url'); }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return res.status(400).send('Only http/https URLs are allowed');
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) return res.status(response.status).send('Upstream error');
res.set('Content-Type', response.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=3600');
const buffer = await response.arrayBuffer();
res.send(Buffer.from(buffer));
} catch (err) {
res.status(502).send('Failed to fetch image');
}
});
if (overlayDir) {
app.use(express.static(overlayDir));
}
wss.on('connection', (ws) => {
ws.on('error', () => {});
ws.send(JSON.stringify(currentState));
});
function setState(update) {
currentState = mergeState(currentState, update || {});
const message = JSON.stringify(currentState);
wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(message); // 1 === WebSocket.OPEN
});
return currentState;
}
function stop() {
return new Promise((resolve) => {
// wss.close() does not drop existing connections and its callback waits
// for every client to disconnect, so a connected overlay would hang quit.
wss.clients.forEach((client) => client.terminate());
wss.close(() => {
server.closeAllConnections();
server.close(() => resolve());
});
});
}
// Try the preferred port first, then up to 4 sequential fallbacks on EADDRINUSE.
const MAX_PORT_ATTEMPTS = 5;
let actualPort = null;
let lastErr = null;
// The WebSocketServer re-emits server 'error' events on itself.
// Add a no-op sink so Node doesn't crash on unhandled 'error' during port scanning.
const wssErrorSink = () => {};
wss.on('error', wssErrorSink);
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
const tryPort = port + i;
try {
await new Promise((resolve, reject) => {
const onError = (err) => {
server.removeListener('listening', onListening);
reject(err);
};
const onListening = () => {
server.removeListener('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(tryPort, '127.0.0.1');
});
actualPort = tryPort;
break;
} catch (err) {
lastErr = err;
if (err.code !== 'EADDRINUSE') throw err;
// EADDRINUSE — fall through and try the next port.
}
}
wss.removeListener('error', wssErrorSink);
if (actualPort === null) {
throw lastErr || new Error(`No free port in range ${port}..${port + MAX_PORT_ATTEMPTS - 1}`);
}
console.log(`[wtm] server listening on http://127.0.0.1:${actualPort}`);
return { setState, stop, port: actualPort };
}
module.exports = { startServer };