From e49078ee0367b28f40ff06ea1315ef5f753c28e0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 4 Oct 2026 11:37:38 -0700 Subject: [PATCH] Fix now-playing scraper, quit hang, tray icon, and tray race (v1.0.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- desktop-client/index.js | 7 ++- desktop-client/now-playing.js | 63 ++++++++++++++++++++++++ desktop-client/package-lock.json | 4 +- desktop-client/package.json | 2 +- desktop-client/server.js | 3 ++ desktop-client/tests/now-playing.test.js | 60 ++++++++++++++++++++++ desktop-client/tests/server.test.js | 20 ++++++++ desktop-client/tests/tray-icon.test.js | 25 ++++++++++ desktop-client/tray-icon.js | 10 ++++ desktop-client/tray.js | 15 +----- desktop-client/yt-scraper.js | 37 +------------- 11 files changed, 192 insertions(+), 54 deletions(-) create mode 100644 desktop-client/now-playing.js create mode 100644 desktop-client/tests/now-playing.test.js create mode 100644 desktop-client/tests/server.test.js create mode 100644 desktop-client/tests/tray-icon.test.js create mode 100644 desktop-client/tray-icon.js diff --git a/desktop-client/index.js b/desktop-client/index.js index 62ca9e6..90ad297 100644 --- a/desktop-client/index.js +++ b/desktop-client/index.js @@ -67,8 +67,8 @@ app.whenReady().then(async () => { 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({ port: serverHandle.port, onShow: () => { @@ -78,6 +78,9 @@ app.whenReady().then(async () => { }, onQuit: () => app.quit(), }); + + await createWindow(); + }).catch((err) => { dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err)); app.exit(1); diff --git a/desktop-client/now-playing.js b/desktop-client/now-playing.js new file mode 100644 index 0000000..8b5b856 --- /dev/null +++ b/desktop-client/now-playing.js @@ -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 }; diff --git a/desktop-client/package-lock.json b/desktop-client/package-lock.json index b470816..ba63d9a 100644 --- a/desktop-client/package-lock.json +++ b/desktop-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "whats-that-music-client", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "whats-that-music-client", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "express": "^5.2.1", "ws": "^8.20.0" diff --git a/desktop-client/package.json b/desktop-client/package.json index 0156af2..7f42eed 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,6 +1,6 @@ { "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", "main": "index.js", "scripts": { diff --git a/desktop-client/server.js b/desktop-client/server.js index 0f0b3b8..3738e41 100644 --- a/desktop-client/server.js +++ b/desktop-client/server.js @@ -75,6 +75,9 @@ async function startServer({ port, overlayDir }) { 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()); diff --git a/desktop-client/tests/now-playing.test.js b/desktop-client/tests/now-playing.test.js new file mode 100644 index 0000000..4cd679d --- /dev/null +++ b/desktop-client/tests/now-playing.test.js @@ -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, + }); +}); diff --git a/desktop-client/tests/server.test.js b/desktop-client/tests/server.test.js new file mode 100644 index 0000000..b3d5c36 --- /dev/null +++ b/desktop-client/tests/server.test.js @@ -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'); +}); diff --git a/desktop-client/tests/tray-icon.test.js b/desktop-client/tests/tray-icon.test.js new file mode 100644 index 0000000..c47bd94 --- /dev/null +++ b/desktop-client/tests/tray-icon.test.js @@ -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']); +}); diff --git a/desktop-client/tray-icon.js b/desktop-client/tray-icon.js new file mode 100644 index 0000000..f9c1e66 --- /dev/null +++ b/desktop-client/tray-icon.js @@ -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 }; diff --git a/desktop-client/tray.js b/desktop-client/tray.js index 034a18b..4e2e7b5 100644 --- a/desktop-client/tray.js +++ b/desktop-client/tray.js @@ -1,18 +1,5 @@ const { Tray, Menu, nativeImage, shell, clipboard } = require('electron'); - -// 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='; +const { TRAY_ICON_BASE64 } = require('./tray-icon'); /** * @param {Object} options diff --git a/desktop-client/yt-scraper.js b/desktop-client/yt-scraper.js index 4fa8a8c..6f22c6c 100644 --- a/desktop-client/yt-scraper.js +++ b/desktop-client/yt-scraper.js @@ -1,42 +1,9 @@ const { ipcRenderer } = require('electron'); +const { scrapeNowPlaying } = require('./now-playing'); const POLL_INTERVAL_MS = 3000; 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) { if (a === null && b === null) return false; if (a === null || b === null) return true; @@ -49,7 +16,7 @@ function changed(a, b) { } function poll() { - const data = scrapeNowPlaying(); + const data = scrapeNowPlaying(document, navigator); if (!changed(data, lastSent)) return; lastSent = data; ipcRenderer.send('wtm:state', data || { isPlaying: false }); -- 2.52.0