Fix now-playing scraper, quit hang, tray icon and tray race (v1.0.1) #6

Merged
jknapp merged 1 commits from fix/now-playing-scraper-and-quit into main 2026-10-04 18:37:51 +00:00
11 changed files with 192 additions and 54 deletions
+5 -2
View File
@@ -67,8 +67,8 @@ app.whenReady().then(async () => {
if (tray) tray.update({ title: next.title, artist: next.artist }); if (tray) tray.update({ title: next.title, artist: next.artist });
}); });
await createWindow(); // Create the tray before the window: the scraper can report a track as soon as
// the page loads, and state arriving while `tray` is null would be dropped.
tray = createTray({ tray = createTray({
port: serverHandle.port, port: serverHandle.port,
onShow: () => { onShow: () => {
@@ -78,6 +78,9 @@ app.whenReady().then(async () => {
}, },
onQuit: () => app.quit(), onQuit: () => app.quit(),
}); });
await createWindow();
}).catch((err) => { }).catch((err) => {
dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err)); dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err));
app.exit(1); app.exit(1);
+63
View File
@@ -0,0 +1,63 @@
// Extracts the current track from a YouTube Music page. `doc` and `nav` are
// injected so this stays testable without a browser.
function largestArtwork(artwork) {
if (!artwork || !artwork.length) return null;
const area = (a) => {
const [w, h] = String(a.sizes || '').split('x').map(Number);
return (w || 0) * (h || 0);
};
return artwork.reduce((best, a) => (area(a) > area(best) ? a : best)).src || null;
}
// Preferred source: YT Music publishes the track via the Media Session API
// even when its player-bar markup changes.
function fromMediaSession(nav) {
const session = nav && nav.mediaSession;
const meta = session && session.metadata;
if (!meta || !meta.title) return null;
return {
title: meta.title,
artist: meta.artist || null,
albumArt: largestArtwork(meta.artwork),
isPlaying: session.playbackState === 'playing',
};
}
// Fallback for older YT Music layouts that still render ytmusic-player-bar.
function fromPlayerBar(doc) {
const titleEl =
doc.querySelector('ytmusic-player-bar .title') ||
doc.querySelector('.ytmusic-player-bar .title');
const title = titleEl ? titleEl.textContent.trim() : null;
if (!title) return null;
const bylineEl =
doc.querySelector('ytmusic-player-bar .byline') ||
doc.querySelector('.ytmusic-player-bar .byline');
const artImg =
doc.querySelector('ytmusic-player-bar .middle-controls .thumbnail-image-wrapper img') ||
doc.querySelector('ytmusic-player-bar .image img') ||
doc.querySelector('ytmusic-player-bar img.image');
// The button's title is the action it will perform: "Pause" while playing.
const btn = doc.querySelector('#play-pause-button');
let isPlaying = false;
if (btn) {
const label = (btn.getAttribute('title') || btn.getAttribute('aria-label') || '').toLowerCase();
isPlaying = label.includes('pause');
}
return {
title,
artist: bylineEl ? bylineEl.textContent.trim() : null,
albumArt: artImg ? artImg.src : null,
isPlaying,
};
}
function scrapeNowPlaying(doc, nav) {
return fromMediaSession(nav) || fromPlayerBar(doc);
}
module.exports = { scrapeNowPlaying };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "whats-that-music-client", "name": "whats-that-music-client",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "whats-that-music-client", "name": "whats-that-music-client",
"version": "1.0.0", "version": "1.0.1",
"dependencies": { "dependencies": {
"express": "^5.2.1", "express": "^5.2.1",
"ws": "^8.20.0" "ws": "^8.20.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "whats-that-music-client", "name": "whats-that-music-client",
"version": "1.0.0", "version": "1.0.1",
"description": "Single-app YouTube Music player + now-playing overlay server for OBS", "description": "Single-app YouTube Music player + now-playing overlay server for OBS",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+3
View File
@@ -75,6 +75,9 @@ async function startServer({ port, overlayDir }) {
function stop() { function stop() {
return new Promise((resolve) => { 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(() => { wss.close(() => {
server.closeAllConnections(); server.closeAllConnections();
server.close(() => resolve()); server.close(() => resolve());
+60
View File
@@ -0,0 +1,60 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { scrapeNowPlaying } = require('../now-playing');
const emptyDoc = { querySelector: () => null };
function nav(metadata, playbackState) {
return { mediaSession: { metadata, playbackState } };
}
test('reads track info from the Media Session when the player bar is absent', () => {
const result = scrapeNowPlaying(
emptyDoc,
nav(
{
title: 'DOMINO',
artist: 'Stray Kids',
album: '',
artwork: [
{ src: 'small.jpg', sizes: '60x60' },
{ src: 'big.jpg', sizes: '544x544' },
],
},
'playing'
)
);
assert.deepStrictEqual(result, {
title: 'DOMINO',
artist: 'Stray Kids',
albumArt: 'big.jpg',
isPlaying: true,
});
});
test('reports isPlaying false when the Media Session is paused', () => {
const result = scrapeNowPlaying(emptyDoc, nav({ title: 'T', artist: 'A', artwork: [] }, 'paused'));
assert.strictEqual(result.isPlaying, false);
assert.strictEqual(result.albumArt, null);
});
test('returns null when nothing is loaded anywhere', () => {
assert.strictEqual(scrapeNowPlaying(emptyDoc, nav(null, 'none')), null);
assert.strictEqual(scrapeNowPlaying(emptyDoc, {}), null);
});
test('falls back to the legacy player-bar DOM when Media Session has no metadata', () => {
const els = {
'ytmusic-player-bar .title': { textContent: ' Old Song ' },
'ytmusic-player-bar .byline': { textContent: 'Old Artist' },
'ytmusic-player-bar img.image': { src: 'art.jpg' },
'#play-pause-button': { getAttribute: (n) => (n === 'title' ? 'Pause' : null) },
};
const doc = { querySelector: (s) => els[s] || null };
assert.deepStrictEqual(scrapeNowPlaying(doc, nav(null, 'none')), {
title: 'Old Song',
artist: 'Old Artist',
albumArt: 'art.jpg',
isPlaying: true,
});
});
+20
View File
@@ -0,0 +1,20 @@
const { test } = require('node:test');
const assert = require('node:assert');
const WebSocket = require('ws');
const { startServer } = require('../server');
test('stop() resolves even while an overlay WebSocket client is connected', async () => {
const handle = await startServer({ port: 9300 });
const client = new WebSocket(`ws://127.0.0.1:${handle.port}`);
client.on('error', () => {});
await new Promise((resolve) => client.on('open', resolve));
let timer;
const result = await Promise.race([
handle.stop().then(() => 'stopped'),
new Promise((resolve) => { timer = setTimeout(() => resolve('hung'), 2000); }),
]);
clearTimeout(timer);
assert.strictEqual(result, 'stopped');
});
+25
View File
@@ -0,0 +1,25 @@
const { test } = require('node:test');
const assert = require('node:assert');
const zlib = require('node:zlib');
const { TRAY_ICON_BASE64 } = require('../tray-icon');
test('tray icon is a structurally valid PNG (every chunk CRC checks out)', () => {
const prefix = 'data:image/png;base64,';
assert.ok(TRAY_ICON_BASE64.startsWith(prefix));
const png = Buffer.from(TRAY_ICON_BASE64.slice(prefix.length), 'base64');
assert.strictEqual(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
const types = [];
let off = 8;
while (off < png.length) {
const len = png.readUInt32BE(off);
const type = png.subarray(off + 4, off + 8);
const body = png.subarray(off + 8, off + 8 + len);
const crc = png.readUInt32BE(off + 8 + len);
assert.strictEqual(zlib.crc32(Buffer.concat([type, body])), crc, `bad CRC in ${type}`);
types.push(type.toString());
off += 12 + len;
}
assert.strictEqual(off, png.length, 'trailing bytes after last chunk');
assert.deepStrictEqual([types[0], types.at(-1)], ['IHDR', 'IEND']);
});
+10
View File
@@ -0,0 +1,10 @@
// 32x32 green circle (RGBA PNG) used for the tray icon.
const TRAY_ICON_BASE64 =
'data:image/png;base64,' +
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAt0lEQVR42u2XzQ2A' +
'IAxGGYFJPLID47gDQ7BNF3AQhiBRDjUxJlZQSnvg8C4G8j1+hGKWbTWSmCnQ2MEV' +
'QgEKqZCRhN8Ctuku4DFgrwSwTxeB2BB8J/4RsI2jpmbDfhHoEX6VaBKIHcPJ5Xja' +
'cDsTvkYAGAXgTcAxhp84SiAMEAiUAAwQAEogDRBIlEAeIJBVC4gvgfgmFP8NxQ8i' +
'8aNYxWUkfh2rKEhUlGTiRamaslzFw2S+Ddk4AA0mvKa9jODdAAAAAElFTkSuQmCC';
module.exports = { TRAY_ICON_BASE64 };
+1 -14
View File
@@ -1,18 +1,5 @@
const { Tray, Menu, nativeImage, shell, clipboard } = require('electron'); const { Tray, Menu, nativeImage, shell, clipboard } = require('electron');
const { TRAY_ICON_BASE64 } = require('./tray-icon');
// Minimal green-circle tray icon, kept from the previous version for visual continuity.
const TRAY_ICON_BASE64 =
'data:image/png;base64,' +
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAa' +
'RJREFUWEftlj1OxDAQhe0TcARuABUNDQUF4gRIFJyAG9BQcQJOwA1oKGgoOAI3wP' +
'vkWRnZjr1xsgVilCiy4/n85s14nMGO/wY79v/dAfwH4T8IpUNoSj9FxAPiS+IHRO' +
'nzEscvUz4gnkV8JF5C7BNfEe8j6pznxFvEp8RviM8RfyXOlxPVd0j5QAgOBaET5E' +
'xgXxBPIS5GzlMEMxIJ3kLwPPFT4gPEudC+ILYimlkuJKovoeSBaEuCg8CTiHPC0r' +
'sQnETM7Q7HqRdS70ygIHYb4p/Ee4hziT0cDx23BaGMwA3EF4nXEi8iz6fOF4Tae+' +
'LniOuJsxN5PxZ+JgTfIL5K7E4cJIiScr5LjA7lPmvuP0/ETvAEvQn4Tqy+HNNyTk' +
'fiWeT/0niDe4kqEkX5e5bOGwmhOsRsN2UEQ/qvY/ISxOuIq4k7tI2EcJ38NcT3iX' +
'PB7S4/EuoISgYrLKD1dOhOPFP6+4R7ieoQPNv9/ULZ+zfis+0Y1e+F7oF3H5F+Xt' +
'T5p0AqBYeEeEF8k/gtcS56z6f/E5UOLHL8A8a4yC0IHbH4AAAAASUVORK5CYII=';
/** /**
* @param {Object} options * @param {Object} options
+2 -35
View File
@@ -1,42 +1,9 @@
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
const { scrapeNowPlaying } = require('./now-playing');
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
let lastSent = null; let lastSent = null;
function scrapeNowPlaying() {
const titleEl =
document.querySelector('ytmusic-player-bar .title') ||
document.querySelector('.ytmusic-player-bar .title');
const title = titleEl ? titleEl.textContent.trim() : null;
const bylineEl =
document.querySelector('ytmusic-player-bar .byline') ||
document.querySelector('.ytmusic-player-bar .byline');
const artist = bylineEl ? bylineEl.textContent.trim() : null;
const artImg =
document.querySelector('ytmusic-player-bar .middle-controls .thumbnail-image-wrapper img') ||
document.querySelector('ytmusic-player-bar .image img') ||
document.querySelector('ytmusic-player-bar img.image');
const albumArt = artImg ? artImg.src : null;
// The play/pause button's title indicates state: when music is playing the
// button's action is "Pause", and vice versa.
const playPauseBtn = document.querySelector('#play-pause-button');
let isPlaying = false;
if (playPauseBtn) {
const btnTitle = (
playPauseBtn.getAttribute('title') ||
playPauseBtn.getAttribute('aria-label') ||
''
).toLowerCase();
isPlaying = btnTitle.includes('pause');
}
if (!title) return null;
return { title, artist, albumArt, isPlaying };
}
function changed(a, b) { function changed(a, b) {
if (a === null && b === null) return false; if (a === null && b === null) return false;
if (a === null || b === null) return true; if (a === null || b === null) return true;
@@ -49,7 +16,7 @@ function changed(a, b) {
} }
function poll() { function poll() {
const data = scrapeNowPlaying(); const data = scrapeNowPlaying(document, navigator);
if (!changed(data, lastSent)) return; if (!changed(data, lastSent)) return;
lastSent = data; lastSent = data;
ipcRenderer.send('wtm:state', data || { isPlaying: false }); ipcRenderer.send('wtm:state', data || { isPlaying: false });