Ondographics

The app's own README, published as it is. Source: ondographics-local/README.md

Ondographics

A desktop client for YouTube Music that runs on Windows even when Smart App Control is enforced.

Built for a Premium account: it does no ad-blocking and no client tampering, because that is the most common reason YouTube starts refusing to play.

If you like this, support me on social media @Joshtified.


Why the previous app stopped launching

This was never a YouTube problem. ytmdesktop v2.0.12 was current (published 8 Sept 2026, running Electron 44 / Chromium 152), and its own logs showed clean startups. What blocked it was Windows:

Event 3118: Smart App Control Block Deteails
Event 3077: Code Integrity determined that a process
  (...\youtube_music_desktop_app\youtube-music-desktop-app.exe) attempted to load
  ...\app-2.0.12\youtube-music-desktop-app.exe that did not meet the Enterprise
  signing level requirements (Policy ID:{0283ac0f-fff1-49ae-ada1-8a933130cad6})

That policy GUID is Smart App Control (VerifiedAndReputablePolicyState = 1, enforced), and Get-AuthenticodeSignature reports the app's executable as NotSigned.

Smart App Control is stricter than SmartScreen and has no exclusion list. You cannot allow an app. It blocks executables that are unsigned and have no reputation, and reputation is keyed to the exact file hash. This app's own measurements:

ExecutableSignatureLaunches?
old youtube-music-desktop-app.exeNotSignedblocked (8 events)
node.exe (OpenJS Foundation)Validyes
published electron.exeNotSigned, but widely distributedyes (0 events)

An unsigned binary with good reputation still runs. That single fact is the whole design.

Design consequences

Three rules follow from the table above, and the app is built around them:

  1. Never repackage or rename the executable. A repackaged binary gets a brand-new hash, no reputation, and is blocked with no appeal. The app launches the stock node_modules/electron/dist/electron.exe directly, nothing is ever wrapped or copied.
  2. Never load a native module. .node addons are unsigned DLLs of our own, which is exactly what gets blocked. This app has zero runtime dependencies beyond Electron itself.
  3. Never shell out to a script. Shortcuts point straight at the executable, so no .bat/.ps1 sits in the launch path.

Requirements

  • Windows 10/11
  • Node.js 20+
  • npm install (downloads Electron)

Running

npm install
npm run icons             # generates the PNG/ICO assets from code
npm start

To get a Start menu (and optionally desktop) shortcut:

npm run install-shortcut               # Start menu
npm run install-shortcut -- -Desktop   # Start menu and desktop
npm run install-shortcut -- -Remove    # undo

The wrapper finds PowerShell by path, because powershell is not guaranteed to be on PATH.

Closing the window quits the app, tray icon included, that is the default, and npm run smoke checks it. The one way to change it is Settings → Hide to tray when closed (also on the tray menu), after which the window hides and the tray's Quit is how you exit.

The shortcut targets electron.exe with the project folder as its argument. It writes two .lnk files and nothing else, no services, no drivers, no registry keys, no administrator rights.

Getting signed in

A brand-new profile hits Google's cookie-consent wall first, then the sign-in page. Two options:

Click through it. The consent page and the sign-in flow both render normally in the window, and the app deliberately does not treat them as failures.

Or carry your existing session over, which skips consent and the sign-in attempt:

npm run import-session
npm run import-session -- --force     # overwrite a profile that is already signed in

This works in two steps because the cookies cannot simply be copied:

  1. npm run export-session decrypts the old profile's cookies to session-import.json. The old app keeps its session in a persist: partition (%APPDATA%\YouTube Music Desktop App\Partitions\ytmview), not in the default profile, the default profile's cookie store is empty. Each value is unlocked with the profile's DPAPI key and unwrapped in src/main/cookie-crypto.js. The old folder is only ever read.
  2. The app installs those cookies through Chromium's own cookie API, verifies that the account cookies landed, and deletes the JSON again so live credentials are not left on disk. It refuses to overwrite a profile that is already signed in unless you pass --force.

Do not copy Cookies between profiles. It looks like it should work, same Chromium generation, key bound to the same Windows user, but Chromium loads the rows, cannot unlock the values, and deletes every one of them. That was measured, then worked around.

Your profile is stored in %APPDATA%\Ondographics, deliberately a different folder from the old app, so importing is a reversible copy rather than a takeover.

What it does

  • Player bridge over YouTube's own #movie_player API (playVideo, pauseVideo, seekTo, setVolume, nextVideo, previousVideo, getPlayerState), the stable control surface, not DOM clicking. Metadata comes from navigator.mediaSession with DOM fallbacks.
  • Global media keys (MediaPlayPause, MediaStop, MediaNextTrack, MediaPreviousTrack), with failed registrations reported rather than silently dropped.
  • Tray with transport controls, like/dislike, volume, and every setting toggled from one place.
  • Taskbar thumbnail transport buttons.
  • Now-playing notifications with real album art, fetched over Chromium's own network stack and cached. No toast while the window is focused.
  • Remember where you were, restores the last track or playlist, with an opt-out.
  • Keep the PC awake while playing.
  • Recovers from a failed load with an in-window error page and a Retry button. There is no "you will be signed out" surprise: retrying never touches your session.
  • Detects a stalled YouTube Music shell and self-heals once before offering a manual retry.
  • Serves its own now-playing overlay for OBS on http://127.0.0.1:9863/overlay, album art, title, artist, elapsed and remaining time, progress bar. One fixed box size for every track, and long titles scroll right to left in a loop instead of being cut off. Ten styles, card, minimal, vinyl, ticker, neon, poster, capsule, split, backdrop and ring, and ten colour palettes, midnight, daylight, mono, sunset, spring, summer, autumn, winter, christmas and halloween, picked from tray → Overlay → Choose a style…, a window of live previews that applies as you click and reaches an overlay already open in OBS without touching the browser source. The style, the colour, the width (a slider, for a box that is a little too wide for your scene) and an optional audio visualiser, eleven shapes from bars, mirror, peaks, blocks, wave, area, dots and ribbon to spikes, meter and blast, the three that take the whole card and are built to show a song at full volume, drawn under the words and reading the music you hear, are independent settings: any style can wear any palette, any width and any visualiser. Self-contained: no third-party page, no account, no token to keep alive. See OBS-OVERLAY.md.
  • Takes song requests from Twitch chat or a channel-point redemption, read straight from Twitch's EventSub, or through OBS if you prefer, and puts them into the queue of whichever surface you are listening on, YouTube Music's own queue or the hub, where a request takes its place straight behind the song that is on and plays when that song ends. The hub's queue is your own listening rather than a few songs, so a request is played next there whichever button the panel is set to, and behind a row that is not a song at all - a mix or playlist upload past a quarter of an hour, or a station - it is heard at once, because a redemption that waits behind a one-hour mix is a request that never arrives. A row whose length nobody reported is waited behind instead: a song whose listing omitted its length is the ordinary case, and cutting one on a guess is worse than waiting. The hub says who asked for what either way, so a redemption is never invisible. See Song requests.
  • Shows what you are listening to in Discord, track and artist in your own profile and every server you are in, with a progress bar Discord moves itself. Off by default, needs one Discord application id, and adds no dependency: it is a named pipe and four bytes of framing. See What you are listening to, in Discord.
  • Wears its own mark rather than Electron's atom, in the window, the tray, the Alt+Tab list and the Windows taskbar. See The icon.
  • Zoom, reload-ignoring-cache, clear cache, log out, dev tools, and an application menu.

Listening without an account, from the app's own catalogue

Menu → Music → Open the hub player opens a front end of this app's own: a catalogue you can browse by shelf or search, a queue, a seek bar, and instant playback with no account anywhere. No login, no sign-up, no listening history, nothing between pressing play and hearing the song. It is the one row in this app that the app serves end to end, and the reason is not cleverness on this app's part: it is a catalogue whose own artists licensed it for exactly this. The player's platform row also offers a video music window (this app's own, signed in as you), a streaming service and a music service, and a creator platform, each opening with whatever account you already have there. This app does not strip anything out of a service you sign in to, and it does not claim to. See HUB.md.

A shelf reads as a list or as panels, and the hub has a settings page. The switch is remembered across restarts, panels lay the same tracks out as artwork tiles with their tools under each one, and it changes the row, not the catalogue: every track is in both. Both of those live on the hub's own settings page, reached from the gear at the foot of the sidebar, together with what a list is allowed to show you, the duplicate filter and the AI-made tracks, which used to be two pills on the toolbar and nowhere else. The page holds no state of its own, on purpose: its layout pills call the same setLayout the toolbar does, its filters click the toolbar's own buttons, and the appearance controls are the theme box itself, moved into the page while it is open and put back when you leave, so there is one answer to each question rather than two that can disagree. Leaving the page is not something you have to remember either: opening any shelf closes it.

Everything the hub player plays feeds the same visualiser, so the scenes react to this music exactly as they react to the video music window.

A song you find goes next, without cutting off the one playing. Every list in the hub, a search result, a shelf, a playlist, an album, carries a Play next beside its play button, and it does what the request feature does with somebody else's song: the row lands one place after the one on air rather than at the back of a queue that can be two hundred songs long, the song that is playing is not interrupted, and the line in the transport row says where it went. A row that is not a song at all - a live station, or a mix of two hours - is not something to wait behind, so a song put next while one of those is on is heard now; a row whose length nobody reported is waited behind instead. It is the listener's own, so unlike a request it is not marked and is not carried across a queue that gets replaced.

A SoundCloud track that arrives as a playlist is joined up and played. SoundCloud stopped handing over a file for most tracks: yt-dlp resolves the track to an HLS playlist, and an audio element cannot decode a playlist, so the hub used to report "that track would not play" and skip on to the next song for a track that was there all along. src/main/hls-audio.js reads the playlist, fetches its init segment and its parts, and joins them into the one fragmented MP4 they already are - nothing is transcoded and the audio is the 96 kbps AAC SoundCloud sent - which the companion server then serves from the same origin as every other stream, byte ranges and all, so it plays, shows its real length and seeks. The result is kept in memory for an hour, keyed by the signed playlist URL that expires rather than by the track, so a track resolved again is fetched again and an aged-out URL is not cached; a live stream and a track sent as segmented transport streams are refused with a reason instead of being fetched forever, and a track past 256 MB is refused by name rather than held in memory. See HUB.md.

What it deliberately does not do

  • No ad blocking unless you ask for it. It exists and is switchable, but it stays off until you turn it on: it is unnecessary with Premium, and blanking requests in the player is the classic way to get YouTube Music refusing to play at all. Nothing is blocked by default. The one bug this feature shipped with is worth knowing about, because it looked exactly like a dead app rather than a broken filter: the request listener returned its verdict instead of calling the callback Electron hands it, and on Electron 44 a listener that never answers leaves that request paused for ever. Switching the ad blocker on therefore stopped the document request, and the window came up black with nothing logged and no retry, measured in dev/adblock-probe.js, which runs the three shapes (return, callback({cancel:false}), callback({cancel:true})) side by side. Every path in that listener now answers, including the paths that throw, because an unanswered request is indistinguishable from a network that never replies.
  • No patching, spoofing or stripping of the web app's own code or requests.
  • No auto-updater. Auto-update is the mechanism that produced an unsigned executable in the first place, and it is not coming back.
  • No repackaged binary, not even in the installer. npm run build-installer produces an archive that unpacks the stock Electron runtime next to these files and lets the project's own shortcut script register the identity. The executable inside is the same one Windows already allows; nothing is wrapped, renamed or rebuilt around it. See Installing it on another PC.

The one compatibility concession is the user agent: Electron advertises Electron/44.4.5 plus the app name, which the web app treats as an unknown embedded browser. The app reports the bundled Chromium version truthfully without those tokens:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.7977.130 Safari/537.36

Toggle it under Settings → Report as Chrome; nothing else about the request changes.

Now-playing overlay for OBS

npm run start, then point an OBS Browser source at:

http://127.0.0.1:9863/overlay

It only listens on loopback, sends no CORS headers (so a website you visit cannot read what you are playing), answers only GET, and cannot control playback. Turn the whole thing off under Tray → Overlay.

Every style is one fixed box, sized as a fraction of the source, so nothing on screen moves when the track changes, and text too long for its line scrolls right to left, forever, rather than shrinking or ending in an ellipsis. Styles are pure CSS over one DOM, so a new one cannot fork the state handling or miss a fix.

Colour is a second, independent layer over that: a style never names a colour, it names a palette variable, so one palette reaches every style at once and adding one is six variables rather than a rule per style. A test fails if a style hardcodes a colour of its own.

Choose a style… (tray or app menu) opens the picker: one window showing every style at once, each tile being this same overlay page running a sample track rather than a drawing of it, with a swatch row for the colours underneath, a width slider, and a row for the visualiser. A tile drawn while music is playing reads that music rather than a sample, so the shape you are choosing is the shape your music makes. Clicking applies immediately, so there is nothing to read through and nothing to keep in step, the picker asks the app for the styles, the palettes, the visualisers and even the range the width slider may offer, rather than holding any of them itself.

Preview with a sample track (same submenu) opens the overlay with ?demo=1: a sample track with a deliberately over-long title, for sizing and placing the source without music playing.

OBS-OVERLAY.md has the step-by-step for swapping out an existing widget source, the optional URL parameters, how to size the card, and troubleshooting.

Full-screen visualisers

Open tray/app menu → Visualiser → Open the visualiser window. It is an ordinary window, drag it, resize it, minimise it, and it does not sit above the player by default, so it is a window you can put where you like, including behind YouTube Music. Press F11 to take it full screen and F11 again to give the desktop back; Esc leaves full screen, and closes the window when it is not full screen. H toggles the track title.

Choosing what it draws is a window of its own, the same way the overlay keeps the page OBS loads apart from its style picker. Visualiser → Choose a scene…, the Scenes button in the visualiser, or G, opens a window of live previews: one tile per scene, each running the real served page with a sample track, so the tiles animate and the shape you are choosing is the shape you get. Previews load and animate only as they come into view, so a window of forty scenes costs roughly what the six you can see cost rather than a whole core. The window offers intensity (0.4–2.5×), colour, transparent/black/gradient fill, and gentle motion, and applies as you click; there is nothing to refresh, in the window or in OBS. In a browser the settings are retained in the URL. Colours can be chosen independently of the OBS card. Seasonal scenes have intentional seasonal colours; Mono makes them monochrome. Gentle motion follows the OS preference until explicitly overridden.

The same window is where a scene is tuned. Intensity (0.4–2.5×) is how far the music pushes it, speed (0.25–3×) how fast it travels, glow (0–2×) how much soft light it puts out, and detail low/medium/high how much of it is drawn at all, that last one is the dial to reach for when the encoder is already busy, since it trades richness for machine time and nothing else. The drifting backdrop and the camera drift are each a switch, and the render rate can be pinned to the display's own, 30 or 60 fps. Every one of them applies immediately and in place, so moving a slider never interrupts the audio reading.

Round your camera. One switch in the scene picker, Webcam opening in the middle, clears a 16:9 hole out of the middle of the picture for your camera and draws the scene in the band around it, with a lit rim between the two that answers the music. The hole is a real one: as an OBS browser source (?bg=transparent&webcam=1280x720) the middle of the page is transparent to the pixel, so a camera source sitting under it shows through, and the size you ask for is the size you get, 1280×720 in the middle of a 1920×1080 canvas, scaled down only when the window cannot hold it plus the band. Anything placed by a fraction of the frame, the meter, the pet, your logo, stands in that band rather than behind the camera. In the visualiser's own window the opening shows the fill instead, because that window is opaque.

Inside the liquid. Liquid and Lava Lamp are the one family drawn from a field rather than from shapes: every blob adds its falloff to a small buffer, the buffer is added to itself until the sum crosses over from nothing to everything, and the shape that survives is one body with a smooth neck where two blobs met. That is what makes circles run together like a drop of water instead of reading as two circles with an overlap, and it is the same field at two speeds, drifting and rising.

Your own logo. For a stream or a shop window, Visualiser → Choose a scene… takes an image (PNG with transparency works best) and draws it over every scene, corner, size, opacity, and whether it breathes gently with the music, all adjustable. Square or Circle is how the mark is cut out: a circle clears everything outside it, which is what an image on a white card (or one wearing a ring it does not want) needs before it can sit over a scene, and Crop closes in first so the ring itself can be left behind. Centre is one of the five placements, the one that makes the mark the subject rather than a stamp in a corner, and Lit circle behind the mark draws a dim wash, a ring and a ring of ticks that vibrate with the music. A corner is only ever roughly right, so the mark can also be dragged anywhere in the visualiser window, and the place it is dropped is kept for that scene alone: put it in the sun of Summer, clear of the lotus in Lotus, and every other scene stays on its corner. Choosing a corner, or Put it back on the corner in the picker, returns that scene to the corner. Where a scene has a sun or a moon of its own, fourteen of the forty do, In this scene binds the mark to it instead, and the scene draws it there: it moves with the picture, the scene's foreground paints over it, and no lit circle is drawn, because a ring round a mark that is already inside the sun is the thing to get rid of. A feature smaller than a mark can be fitted to gets a mark sized to a floor instead, so a logo bound to Pulsar's neutron star or the core of Galaxy is still a logo rather than a smudge on it.

A mark that walks the frame. Roam the frame like a screensaver sends the mark travelling instead of holding a place, and the scene answers to the light it carries: a hollow in the backdrop follows it, and every scene in the collection is lit as it passes, because the light goes through the same environment layer they all sit in. The music is what sends it, a quiet passage drifting and a loud one crossing the frame, and it bounces off the edges with all of itself rather than wrapping. While it runs it takes the place of the corner, the placement and the binding, all three of which are kept and come back when it is switched off. That circle belongs to the logo rather than to any scene, so it works in all of them, and in a scene that already draws a circle (Halo's radial spectrum, a lotus, a portal) the mark simply lands inside it. The window shows the cropped picture, so the result is chosen by looking at it, and the file on disk is never written to. The image is read and served by the app from its own address rather than opened from disk, so the app's window and a browser source in OBS both get it without either being given file access. A logo is deliberately not drawn in the scene previews, it goes over the whole frame, not inside a tile; open the visualiser window to place it by hand, and to drag it once it is there.

152 designs, and all but six of them are drawn rather than photographed:

  • Legacy (40): the first collection, Halo, Hyperspace, Tidal, Lotus, Prism, Horizon, Nova, Monolith, Laser, Skyline, Aurora, Ocean, Rainfall, Fireplace, Constellation, Silk, Alpine, Ink, Matrix, Vortex, Solar, Lattice, Abyss, Nebula, Synthwave, Pulsar, Plasma, Crystal, Mirage, Galaxy, and the seasonal ten, Christmas, Winter, Spring, Summer, Autumn, Halloween, Valentine, Graveyard, Ghosts and Fireworks.
  • Gaming (12), Streamer (10), Cyberpunk (10), Sci-Fi (11), Retro (10): arcade cabinets, boss fights and loot drops; channel points, sub alerts and raids; holo-walls, neural nets and rain-slick alleys; warp gates, tractor beams and cockpit telemetry; VHS tracking, CRT phosphor, wireframe CAD and an 80s outrun grid.
  • Fantasy (10), Neon (11), Anime (11): mana orbs, runestones, a potion and a dragon's breath; laser harps, a geodesic disco crystal and a blacklight rave; a transformation sequence, a spirit bomb and a kaiju charge.
  • Nature (11): volcano, thunderstorm, tsunami, sandstorm, eclipse, geyser, meteor, tornado, waterfall and wild woods.
  • Lofi (10): a cozy room, latte art, rain on a window, a rooftop skyline, a spinning vinyl, tape VU meters, a study with fairy lights and a bonsai garden.
  • Imagery (6), the only six photographed ones: Cosmos, Earthrise, Solstice, Auroral, Ring Light and Starfield are drawn over real pictures, public-domain work from the NASA image library and freely licensed photography, fetched at load. The audio drives the light, the grade and the reaction on top of the photograph rather than the photograph itself, so a scene is a photograph the music moves rather than a generated image.

Scenes are grouped in the picker, and the group sizes above are what the app offers rather than what this list remembers. Existing scene selections remain valid: Orbit has become Halo, Tunnel is Hyperspace, Waves is Tidal, Bloom is Lotus, and Spectrum is Prism, with their original URL identifiers preserved.

The audio drives the design: fast attack/release spectra, full-buffer RMS loudness, separate bass/mid/treble responses, and positive spectral/loudness changes for transients. Every band is then read against its own slow-decaying peak, an automatic gain per band, so a quiet track still fills the frame and a loud one cannot clip it, while an empty band still measures zero. Travel is integrated from that reading rather than multiplying time by volume. Two things are shared by every scene and are most of what makes it read as alive: a living backdrop of drifting lights that breathe with the low end, and a slow camera that drifts the whole frame with the music, so a scene whose own shapes are holding still still moves. With no audio at all a scene keeps a slow, beatless ambient drift, so a quiet room is a moving scene rather than a frozen screen; it never invents beats or transients. A gap in the reading is not the same thing as silence, and both readers say so: the levels cross three processes to reach a picture, and a garbage collection, a heavy frame or an encoder taking the machine for a moment is a gap of several frames rather than the end of a song. The last reading is therefore held and bled off across a gap (1.6 s, and 6 s while the app reports a track playing) before the ambient drift takes over, so the picture decays out of the music instead of switching to a pulse and back mid-track - and the app closes the gap from the other side at the same time: a frame that stops arriving for 1.5 s is a stalled reading rather than a hiccup, so the page that holds the audio is asked for a fresh one, and the log records how long the outage lasted once readings come back. Only a real silence reaches the drift, and it is never coasted on for longer than that. The waveform is low-pass filtered only for display before downsampling to avoid treble aliasing; RMS, sample peak, frequency bands, and the audible graph remain unfiltered.

For an OBS Browser source, use Visualiser → Copy URL for OBS, or:

http://127.0.0.1:9863/visualiser?scene=christmas&title=0
http://127.0.0.1:9863/visualiser?scene=aurora&motion=low&bg=transparent&title=0

Use a 1920×1080 source. ?demo=1 provides clearly labelled synthetic sample audio only when the app is not playing. Do not use it in a live source if you want silence to look like silence. See OBS-OVERLAY.md for all identifiers and parameters.

For an isolated Chromium rendering/IPC check (no real player or user profile), run:

node_modules/electron/dist/electron.exe dev/stage-check.js

It tests all 40 renderers with quiet/loud synthetic inputs, checks the real sandboxed preload and Escape IPC, and writes a contact sheet and measurements to dist/visualiser-check/. Draw timings are CPU canvas command submission, not a claim of GPU frame rate or measured sync with real music.

What you are listening to, in Discord

Discord Rich Presence: your profile, and every server you are in, shows the track and the artist, the same idea as the overlay above, for the places an OBS browser source cannot reach, and it keeps working while you are not streaming. Off by default.

One thing has to be set up, and it cannot be skipped: Rich Presence belongs to a Discord application, and there is no id this project could borrow, inventing one would put somebody else's name and icon on your profile. It is four steps, once:

  1. discord.com/developers/applications → New Application. Any name: that is the name Discord prints as the app line, next to your song.
  2. Copy its Application ID.
  3. Put it in %APPDATA%\Ondographics\config.json as discord.clientId.
  4. Settings → Discord: show what I am playing (or discord.enabled: true).

No bot, no token, no secret, nothing to invite to a server and no permission to grant. Until there is an id, the app refuses to pretend: the log names all four steps, and the Settings menu says the same thing beside the checkbox rather than leaving a switch that silently does nothing.

How it behaves, in the same spirit as the rest of this app:

  • No dependency. Discord's local RPC is a named pipe on Windows and a unix socket elsewhere; the protocol is a 32-bit opcode, a 32-bit length and JSON. So it is node:net, Buffer, and four bytes of framing.
  • Nothing is sent that has not changed. The player reports its state constantly, and Discord allows roughly one presence update per 15 seconds; updates are coalesced, the newest is the one that goes, and a track you skip through never appears at all.
  • Discord draws the progress bar. Start and end are sent once per track, in absolute seconds, so nothing is said while a song simply plays. discord.showProgress: false turns the bar off.
  • It tidies up after itself. Pausing drops the timer, and quitting, or unticking the box, clears the profile, because a stale song in your profile is a lie about what you are listening to.
  • It waits for Discord. If Discord is not running when the app starts, the app says so once and keeps looking; opening Discord later makes the current song appear with no restart. A connection that drops is re-stated, because Discord forgets a presence with the socket.
  • A refusal is explained, not hidden. Discord refuses the "Listening" activity type for applications that are not allowed it; the app falls back to "Playing", says so in the log, and remembers for the rest of the session instead of showing nothing.

The log tells you which of the three states it is in: Discord is listening for what is playing, no Discord client is listening on this machine, or Discord closed the connection without answering, the last one is what a real Discord client does when it does not recognise the application id, and it is why that line names the fix.

Song requests

Song requests… (tray, app menu, or npm start -- --open-requests) opens the panel. It listens to four places, any or all:

  • Twitch chat, IRC over WebSocket to wss://irc-ws.chat.twitch.tv, joined anonymously (justinfan…), watching for a trigger word (default !sr). No OAuth token and no bot account: it reads and never writes, so nothing it does is visible in chat. You can point it at any channel you can read, not only your own.
  • Twitch, directly, channel-point redemptions over Twitch's own EventSub WebSocket. No OBS, no stream state: the subscription is created the moment the app starts, so it is already listening while the channel is offline and the first redemption of the evening is caught. Needs one Twitch application Client ID and a device-code sign-in approved at twitch.tv/activate. This is the route to use when OBS is not signed in to Twitch, see below.
  • Twitch's own chat page, channel-point redemptions read from the notice Twitch broadcasts into chat, in a hidden copy of twitch.tv/popout/<channel>/chat the app hosts itself. No Twitch application, no account, no OBS, and it works whether or not the channel is live. See below.
  • OBS, obs-websocket v5 (OBS 28+), the socket OBS's own remote tools use (Tools → WebSocket Server Settings). A channel-point redemption arrives as a VendorEvent wrapping Twitch's payload, and the reward's title and the viewer's message are read out of it, so "500 points, message: play X" is the request. Point it at whichever machine runs OBS, 127.0.0.1 by default. Only works while OBS is signed in to Twitch; see OBS has to be signed in to Twitch.

All of it runs on Node's built-in WebSocket and fetch. This project has no runtime dependencies, and a song request reader was not going to change that.

Redemptions straight from Twitch, with nothing else in the middle

The OBS route below works, but only while OBS is signed in to the account it streams with, and OBS is a streaming tool: it may not be open, it may be signed in to a different account, and it has no notion of listening on the app's behalf. Twitch's own EventSub has none of those problems, so the panel offers it directly.

Turning it on is a one-time setup:

  1. Create a Twitch application at dev.twitch.tv/console/apps, any name, OAuth Redirect URL http://localhost, Client Type: Public. Public is the point: a public client has no secret, which is what makes device sign-in possible.
  2. Copy its Client ID into the panel's Twitch Client ID box.
  3. Press Sign in to Twitch. The app asks Twitch for a short code and shows it; Open twitch.tv/activate takes you to the page it is typed into. Approve the one permission it asks for, read your channel point redemptions.

Why a Client ID is needed at all: Twitch only issues a token to a registered application, and there is no way to read a channel's redemptions without an authorised token. The Client ID is a public identifier, not a secret, the panel never asks for a client secret and none is used. It is stored in config.json like any other setting.

What is stored, and where. The access and refresh tokens are written into this app's own config directory by src/main/twitch-session.js, encrypted with the operating system's key ring, DPAPI on Windows, through Electron's safeStorage, which is built in and needs no dependency. They never leave the machine, are never shown in the panel (a test asserts the panel's own page cannot even name them), and are never sent anywhere except Twitch. Where no key ring is available the sign-in is still saved, in the clear, and the log says so rather than the app refusing to work.

What it does while it runs. It connects to wss://eventsub.wss.twitch.tv/ws, waits for Twitch's session_welcome, and creates exactly one subscription, channel.channel_points_custom_reward_redemption.add for the account the token belongs to. Twitch's own rule is that a WebSocket subscription lives and dies with its connection, so the subscription is re-created on every reconnect rather than once at startup; session_reconnect is followed rather than waited out, and a keepalive that stops arriving is treated as a dead socket. Nothing is gated on the stream being live, a channel that is dark when the app starts is already subscribed when it goes live.

A redemption arrives as a request in the same shape as the OBS one, so the reward filter and the borrow the viewer's recent !sr fallback behave identically; only the badge in the list says which door it came through. Sign out forgets the saved sign-in.

Redemptions read from Twitch's own chat page, no app, no account

Both routes above ask something of somebody else: EventSub needs a Twitch application registered in your name, and OBS needs a Twitch integration that some builds do not have at all. Twitch does a third thing on its own, and this source uses it: every channel-point redemption is broadcast into the channel's chat as a highlighted notice, the same line you see in OBS's chat dock. So the app hosts that page itself, hidden, and reads the notice.

Setup is the channel name. Nothing else: no Client ID, no sign-in, no OBS, no stream. A channel's chat is readable anonymously, exactly as the IRC reader reads it, so this needs no account and leaves no trace in chat.

npm start -- --open-requests     # then turn on "Channel point redemptions, read from Twitch's own chat"

How a notice becomes a request. The reward is read from the words, and the split between the reward and what it cost comes from Twitch's own reward icon: the reward's wording sits before it, the point count after it. The viewer's line underneath is their message, read exactly as the OBS and EventSub sources read it, artist - song or artist song, both work, and the one catch is real: Twitch's page glues those pieces together in its text with no spaces (Redeemed song request500Joshtified: …), so the reward cannot be split off by whitespace. It is not, and the cost is reported with the request instead of being assumed to be nothing.

Two things this reader got wrong, both now pinned by tests. The first version looked for a container whose class contained user-notice; Twitch hashes those class names now, so it matched nothing and a real redemption sat unread on the page while the panel said "connected". The second looked for a childless node beginning "Redeemed"; the node holding that wording also holds the reward icon and the point cost, so there is no such node. Both failures were silent, which is why:

  • the notice's shape is what is matched, [data-test-selector="user-notice-line"], and the reward icon's class, not a hashed class name;
  • a line that mentions a redemption in a shape the reader does not understand is logged in full (a chat line mentioned a redemption but could not be read) rather than skipped;
  • every scan reports what it understood, so a redemption notice on the page reads as {reward, points, user, text} in logs\main.log is proof the reader can see this channel's notices, including a notice it deliberately does not act on, because it was already on screen when the app started (that backlog is history, and playing it would be a song nobody asked for now);
  • the panel has a button that shows the page itself.

The page reads itself. Main cannot see a page's DOM, so the first shape of this feature had main ask the page for a read every second and a half (webContents.executeJavaScript) and wait for the answer. That shape has a failure it cannot see: a renderer that has stopped answering never resolves the promise, neither with a value nor with an error, so a dead page is indistinguishable from a quiet chat. Measured, it sat on "connecting" for two hours while every redemption was spent and lost, and not one line of the log said why.

So the reading half now lives inside the page, in that window's own sandboxed preload (src/preload/twitch-chat.js), and pushes what it finds over IPC. There is no promise left to hang on: reports arrive, or they stop, and stopped is something main can see, wait on and act on. What the preload decides is deliberately tiny, when to read, what a failed read becomes, and that the first read of a document is a baseline. Every decision that matters (what a notice looks like, what the reward is, whether it becomes a request) stays in main, where it is tested; the reader itself travels to the page as source, because a sandboxed preload cannot require a relative file (measured: dev/twitch-preload-probe.js).

What main does with a silence, all live-verified: a page that reports nothing for eight seconds is reloaded; a browser process that dies is heard (render-process-gone) and the page reopened, measured at 3.3 seconds from crash to reading again; a page that reports promptly but has nothing readable on it (a login wall, an error page) is reloaded rather than trusted for ever; and a page with no chat on it is never reported as connected, because that is the one word the whole feature is judged by.

Redemptions that look identical. The reader's idea of "the same notice twice" used to be the notice's own words, so a viewer who spent the points twice with the same message had their second request swallowed as a repeat of the first, measured live, three identical redemptions in chat, one request played. Identity is now Twitch's own notice id where there is one, and otherwise the element's place among the identical notices on screen, which is stable from read to read (so a redraw is not a second request) and different for a genuine repeat (a repeat is a different element). Two identical redemptions now become two requests, live-verified with two identical notices injected into the real page; whether the queue takes both is its own rule, and a refused one says so (song request not taken … "reason":"duplicate").

Checking it without spending points. node dev/twitch-notice-check.mjs puts one notice into the very page the app is reading, in Twitch's real markup, and watches the app's own log for the app's own reaction. The notice carries no message on purpose, a notice with no message is reported and take() queues nothing, so it proves the whole chain (read, preload, parse, reward, request) without putting a track in anybody's queue.

node dev/twitch-notice-check.mjs      # with the app running and this source on

OBS can report a redemption only if it has a Twitch vendor, and some OBS builds have none

Redemptions are not an event OBS has of its own: its Twitch integration is registered as a vendor, and that vendor appears only once OBS is signed in to the Twitch account it streams with. The Twitch chat dock is not that, it is the Twitch website in a browser panel, and it carries nothing to obs-websocket. So a connection can be perfectly healthy, identified, receiving volume meters, and still deliver no redemption ever, which looks exactly like a quiet channel.

That was the actual reason redemptions "did nothing" here, measured rather than guessed: CallVendorRequest for the vendor Twitch answered 600 No vendor was found by that name, the same as a nonsense name, while the same session received 234 volume-meter events. OBS's own log agreed, no Twitch integration lines, no account token in global.ini, just a [Twitch] Type=Twitch stream key.

Looking further settled why, and the answer is worse than "not connected". 600 is what obs-websocket answers for any name it has no vendor for, and this build has no Twitch vendor to find: OBS 32.2.2's obs-websocket.dll 5.7.4 contains the vendor machinery, No vendor was found by that name is in there, and none of the Twitch side of it. No GetChannelPointRewards, no channel_points_custom_reward_redemption, no ChannelPointRedemption, not even the string Twitch. Nothing anywhere under C:\Program Files\obs-studio, across seventy-three modules, mentions channel point redemptions, and OBS's log shows no Twitch module loading. So on a build like this one connecting the account changes nothing, there is no code on the other end to emit the event.

So the app asks, and reports what it was told rather than the usual cause. As soon as a session is identified it calls CallVendorRequest for Twitch, reads the answer, and re-asks every 30 seconds while the answer is no, so signing in to Twitch in OBS mid-stream is noticed on its own. When no vendor answers, the panel shows it in amber and the log carries OBS is not offering a Twitch vendor…, whose advice names both readings of that one answer: connecting the account is what registers the vendor (OBS → Settings → Stream → Twitch → Connect Account, and the chat dock is not it), but if the account is already connected and the message stays, this OBS build has no Twitch vendor to register and the advice says to use the direct source instead. The two cases answer identically, so the app cannot tell them apart, and it says so rather than sending you in a circle.

If signing OBS in to Twitch is not something you want to do, you do not have to: the direct Twitch source above reads the same redemptions without OBS being involved at all, and works whether or not the stream is live, and if you would rather not register a Twitch application either, reading Twitch's own chat page needs neither.

A redemption that carries no song still plays one

Viewers type the song into chat and redeem the reward with a joke, or with nothing at all. Requiring the song to be in the reward throws those away, and the viewer paid for them. So a redemption whose message is empty falls back to the song that same viewer last asked for in chat, by trigger word, within 5 minutes, matched case-insensitively against either the login or the display name Twitch sends. A redemption with a message of its own always uses that message; a viewer with nothing in either place is logged as such rather than guessed at.

Testing a redemption without OBS, without going live, and without points

dev/mock-obs.mjs is a fake OBS: it does the obs-websocket handshake, answers GetVersion and the vendor check, and then sends a real VendorEvent redemption payload. Point the panel's OBS host/port at it and watch the request go into YouTube Music's queue. It is dev/stand-in-obs.js with a command line in front of it, the same module npm run smoke and npm run redemption-check use, so what you poke at by hand and what the checks assert are the same fake OBS, not two that can drift apart.

node dev/mock-obs.mjs                                   # one redemption, 5s after connect
node dev/mock-obs.mjs --input "skepta shutdown" --at 2000
node dev/mock-obs.mjs --no-vendor                      # to see the warning the app gives
node dev/mock-obs.mjs --port 4460 --user SomeoneElse --cost 1000

A request goes into YouTube Music's queue, not over the top of it

The song is found by searching YouTube Music, and then handed to YouTube Music's own menu command on the result row:

  • Add to the queue appends it after everything already queued, so a playlist or autoplay keeps running and the request arrives at the end of it.
  • Play next puts it straight after the current song.

Both are recognised by the queueInsertPosition the row's own menu entry carries, INSERT_AT_END and INSERT_AFTER_CURRENT_VIDEO, and never by the words on it, so a translated or restyled YouTube Music keeps working. When it lands, YouTube shows its own "Song added to queue" or "Song will play next" confirmation: the request is answered in the same place the streamer is already looking.

A burst of requests keeps its order. "Play next" inserts directly after whatever is playing, so three requests sent at once would play in reverse, each one jumps in front of the last. They are therefore handed over one at a time: the next is sent when the previous request is heard (played), which puts it behind that song rather than in front of it. If the one that was sent never gets played, deleted in YouTube, or skipped past, the chain lets go on its own after 8 minutes, and the oldest waiting request goes next. Add to the queue is never held back: appending is ordered by definition, and hiding requests from the queue YouTube is showing would be worse than any ordering problem it solves. Whichever mode is in use, the line is checked every time one request is handed over, not only when another arrives or a song changes, the difference is a request waiting seconds instead of until the next track, which is how a request that looked dropped was found sitting in the panel.

Which of the two happens is the mode in the panel, and Only list them hands the choice to you: every request lands in the panel, and its row carries Play next and Add to queue buttons. The same buttons retry a request that could not be found.

The rules that refuse a request, queue limit, per-viewer wait, duplicate, length, are applied before anything is searched for, so a spam wave costs the page nothing. maxQueue counts what has been taken and not yet heard; a request that is never reached stops counting after 30 minutes, because YouTube's own queue can be edited by the streamer and this app is not told when it is.

The panel shows every request as waiting to be sent → in the queue → played, with what the search actually matched (which is often not exactly what was typed), and the last 25 in a history list. A request the chain is holding back says so rather than looking ignored, waiting its turn (#2 in line), sends when "…" is reached, because a request that is deliberately kept in line is otherwise indistinguishable, from the outside, from one the app dropped. Each one is written to logs\main.log with its outcome, including the refusals and their reason, so "my request didn't play" is answerable without guessing.

The queue is written on every change to song-requests.json next to the config, so restarting mid-stream does not lose requests people have spent points on. A request that was already in the player's queue comes back as waiting to be sent, because the player's queue did not survive the restart either.

The icon

The app is not repackaged, so its executable is Electron's and carries Electron's icon. Everything a user actually sees is set separately:

  • src/assets/icon.ico is the Ondographics mark, a warm amber tile carrying a sound written as five ink bars, at 16, 32, 48, 64, 128 and 256px, so Windows never rescales it.
  • The entries up to 64px are DIBs and only 128/256 are PNGs. That mix is deliberate: much of the shell still loads icons through GDI, which cannot read a PNG-compressed entry and returns noise or nothing, an icon file that is PNG all the way down shows up in the taskbar as a blank square.
  • The bars are not redrawn smaller by hand: every shape is a fraction of the tile, so 16px is the same mark as 256px and no size is a special case with its own numbers to drift.
  • The window and tray icons are applied at runtime; the shortcuts set their own IconLocation.
  • The taskbar button is not drawn from the window icon. Windows 11 resolves it through the window's AppUserModelID, and that ID resolves to whichever shortcut claims it, so a window can carry the right icon in every size and still get the launcher's atom on the button. npm run install-shortcut therefore writes the ID onto our own shortcut (through the shell's property store, because WScript.Shell cannot reach that property) and registers Joshu.Ondographics under HKCU\Software\Classes\AppUserModelId with DisplayName, IconUri and the Relaunch* values, which is what the shell falls back to when no shortcut is in reach. Per-user, no administrator rights, undone by -Remove.
  • The shell writes a rival shortcut by itself, on every run. Measured here rather than guessed, and timed against the app's own log: about 4.4 seconds after the window is shown, Programs\Electron.lnk appears claiming this app's ID while pointing at the bare Electron binary with no argument and no icon of its own, which is what puts the atom on the button. Deleting it once does not stick: it returns on the next run and is rewritten if it is altered, and it is not created while the app is closed. Which process writes it is still unknown, it is not this app, and not one of the shell's own pin or layout stores, so src/main/taskbar-identity.js reacts instead of preventing. The Start Menu and Desktop folders are watched, and a shortcut is deleted the moment it claims our ID and points at the plain launcher and passes it no application at all. All three have to hold, because a shortcut that satisfies them cannot open anything anyway, while one that can launch something is somebody's and is left alone. A widening schedule of sweeps (3s to 45s) backs the watcher up: a watcher cannot report on a folder it failed to watch, or on a write that produced no event.
  • Two shortcuts hold the ID on purpose. Ours are the Start Menu entry and the Desktop one, both pointing at the launcher with . and both carrying icon.ico. More than one is fine, they are identical, and the identity resolves to that icon either way, but a claimant that is not ours is what breaks the button, so npm run check-icon lists every shortcut claiming the ID with its target, arguments and icon. A third turned up during this work: a leftover copy in %LOCALAPPDATA%\Temp. That one was harmless, because the shell resolves an identity through the Start Menu and Desktop only, but it is exactly the sort of thing a scan should surface rather than hide.
  • The button was checked, not just the files. dev\taskbar-icon-probe.ps1 asks UI Automation where the button is, captures that rectangle off the screen, and counts the app's amber against the launcher's blue. dev\watch-taskbar-icon.ps1 repeats that on a timer across a launch and records the button's accessible name, which names the shortcut the identity resolved to. Over a launch where the shell's stray could be seen arriving and going, the button read amber and Ondographics in every sample, and its name never flipped to Electron. Reasoning about which shortcut Windows prefers only ever produced theories; photographing the button settled it.
  • Windows caches all of this per session. After changing it, close the app; if the button is still stale, restart Explorer and clear the iconcache* files in %LOCALAPPDATA%\Microsoft\Windows\Explorer.

If the icon is missing, run npm run icons and then npm run install-shortcut.

Verifying it works

npm test                 # 1008 unit tests: URL policy, config, IPC contract, icon pixels, cookie crypto, session import, overlay server, the split between the two builds (the marker, what the public payload leaves out, and the hub hiding it), styles/palettes/visualisers and the overlay's fixed box, the ad blocker's rule matching and the answer every request has to get, artwork, taskbar identity, song requests, all four request inputs (chat, OBS, Twitch EventSub, and Twitch's own chat page), the Twitch device-code sign-in, the recent-request fallback, the Discord presence (its framing, its rate limit, its fallbacks), the visualiser's tap and its pushed levels, including that stopping it cannot throw at the overlay that disconnected, and that a page script is never dispatched into a document that is still navigating, that every JavaScript file the app ships parses at all, the hub page's own play next (where the row lands, whether the song on air is cut off, and that a mix is not waited behind), the HLS playlist a SoundCloud track arrives as (the parts in the order the playlist names them, the refusals, and the ranges the assembled file is served with), and the installer check's own judgement
npm run smoke            # boots the real app and asserts the live page, ending with a redemption into YouTube's own queue
npm run check-icon       # what the taskbar button is really drawing (app closed or open)
npm run verify-installer # reads the artefacts in dist/ back out and matches them against this checkout
node dev/queue-check.mjs "daft punk one more time" queue   # one real request, against the live page
npm run redemption-check                                  # a channel point redemption, end to end, into YouTube's own queue
npm run release                                           # all of it in order, ending with both artefacts verified

npm run release is the sequence a release has to survive, and it is the one to use rather than remembering the order: the unit tests, then a real launch with the live page asserted and a redemption driven into YouTube's own queue, then the build, which ends by reading both finished artefacts back out and matching them against this checkout, so a build that ships something other than this code fails instead of being zipped up. It needs the app closed, because a second instance would hand its request to the first.

dev\queue-check.mjs is worth knowing about: it loads src/main/page-scripts.js, the same source the app sends to the page, and runs it in a running app over the DevTools protocol, so the half of this feature that depends on YouTube's own DOM can be checked against YouTube rather than a mock. It is how the queueInsertPosition matching and the result-row scoring were established, and it prints what the search matched.

The middle of the request feature is the part that can fail quietly: a redemption can arrive, be accepted, be searched for, and never reach the player, with every step reporting success. npm run redemption-check closes that gap, and npm run smoke ends with the same check. Both start dev/stand-in-obs.js as a stand-in OBS on a free port, point the app's OBS source at it through the panel's own bridge, the same call the settings fields make, wait for the redemption to be inserted, and assert the track is in the live page's queue rather than trusting the app's account of what it did. The assertion is by video id: the app records which video its search resolved, and the page's own queue is asked for that id, so a different song with a similar title cannot pass it. The queue is read from the page's queue model rather than its rendered rows, because the queue panel is virtualized, a track appended to a long queue is not in the DOM at all, and reading rows would report a correct append as a failure. Both respect the app's rules instead of tripping them: the song is chosen from a short list of ones the app is not already holding (a duplicate is refused by design), the viewer is unique per run (the cooldown is per viewer), and in "play next" mode the check waits out the chain that keeps requests in order, or --clear-unsent drops the unsent list first through the panel's own button, when you want the answer in seconds. Whatever happens, the OBS port is put back and the stand-in is killed.

npm test runs without Electron and covers the pure logic plus the IPC contract between the sandboxed preload and main (the preload cannot require shared files, so the channel names are duplicated and a test fails if they ever drift). The cookie crypto and import tests use synthetic keys and a stand-in cookie jar, so they need no real profile. One exception, and it is deliberate: an installed copy carries the signed runtime and not the npm wrapper, so the nine taskbar-identity tests, the one file that reaches for Electron's shell at load, report themselves as skipped there, with the reason, instead of failing a suite that would then look like a broken release.

The song request tests drive the real class with a stand-in player: rules, the order YouTube's two commands are handed over in (including the reverse-order trap that "Play next" sets), how a line of requests drains, one hand-over at a time, with "Play next" holding the next one back — and a surface that lands every request next, which the hub does, getting that same order whatever the mode says — and "Only list them" never sending anything by itself, failures and retries, what survives a restart, and the two page scripts, one of which is run against a fake page containing a real menu, to prove the command is picked by its endpoint and the wrong video id is refused. The request sources are tested over a fake WebSocket: the OBS handshake, the authentication string, both redemption payload shapes, the reward filter, the vendor check answering "no such vendor" versus anything else, and a message-less redemption borrowing the viewer's recent chat request. The recent-request store has its own tests with a clock that is moved by hand, so expiry is proved rather than waited for.

The EventSub source is driven end to end against a scripted fetch and a fake socket, and refuses any request a test did not script, so a call it should not be making is a failure rather than a quiet pass. Covered: the device flow (the code is asked for with the right scope, authorization_pending is waited through, the token is stored, and an expired code is given up on with a reason), a declined Client ID and a revoked refresh token, reconnecting from a saved sign-in and creating the subscription with the broadcaster and session ids Twitch requires, redemptions and other event types, the reward filter, the chat fallback, a 401 being renewed rather than reported, session_reconnect being followed with the old socket kept until the new one is welcomed, revocation being surfaced rather than rebuilt behind the user's back, and the three ways of configuring the same source twice not becoming two connections.

The installer check has tests of its own, and they are about judgement rather than plumbing: that the same file name with different bytes counts as a difference, that a file carried in the package but absent from the checkout counts too, that the runtime's 367 MB is deliberately not looked for by name in the checkout, and that an archive the check cannot open is a hard failure rather than a quiet pass. A verifier that passes when it should not is worse than none, because the release it waves through is the one that is wrong.

The overlay server test starts a real HTTP listener on an ephemeral port, streams /api/events, and asserts the first snapshot is sent exactly once and that a bare request gets a 404 rather than anything useful. The icon and overlay tests decode the real assets and read the real page, so a renamed CSS variable or a broken rasteriser fails the build rather than the stream.

npm run smoke boots the entire real code path, same main process, same preload, same window, and checks that the page loads, that Chromium (not Electron) is presented, that main → page → main round-trips, that the app mounts, that the page reports ytcfg.LOGGED_IN, that the overlay server is serving, that a channel point redemption reaches YouTube Music's own queue, and that closing the window does what the settings say it does, quitting, or going to the tray when Keep playing when the window is closed is on. That last one is asserted both ways on purpose: it used to demand the quit unconditionally, which failed on any machine configured to keep playing and reported a broken release that was working exactly as configured. The redemption check drives the app through a stand-in OBS and reads the page, so the one step that can fail silently, accepted, searched, never played, is asserted on every run rather than only when someone remembers to look. It puts the song request settings back afterwards, whatever happened, and it takes its own track back out of YouTube's queue once the assertion is made, through the app's own route into YouTube, so a check that is run twice does not leave two test songs behind. The app's queue is the real one, on purpose: a request that never reached YouTube would have proved nothing. If the track cannot be reached (a track appended past the rendered rows has no row to click until the queue is scrolled), the check says so in its own output rather than pretending. If the profile is still sitting on Google's consent wall, the checks that need the web app are reported as SKIP rather than FAIL, and the exit code stays 0.

Updating

npm install electron@latest

Use the official electron package only. Never repackage or rename the binary, see the design rules above. Settings → Check for updates shows the current runtime and these instructions.

Two builds, one codebase

The app is one project and two products, and the difference between them is one feature:

ProductContainsInstalls to
Ondographicsno downloader at all. Nothing in it fetches yt-dlp or ffmpeg, and nothing in it offers to.%LOCALAPPDATA%\Programs\Ondographics
Ondographics Localthe downloader: songs can be saved to a folder on this PC, and the hub shows the row that fetches the two programs and the list of what has been saved next to Liked songs.%LOCALAPPDATA%\Programs\Ondographics Local

Both are built by the same npm run build-installer, both install side by side, and each has its own Start menu group, its own Desktop entry, its own Apps & features entry and its own taskbar identity, so installing one cannot overwrite the other's shortcuts. They share one settings folder (%APPDATA%\Ondographics): it is one person on one machine, so the sign-in, the theme, the queue and the song requests carry across, and only one of the two runs at a time.

How the difference is enforced is the point of the split. The public build does not contain the downloader: src/main/local-music.js, scripts/install-downloaders.js and the tests for that module are left out of its payload by the build, and the payload is read back and checked for them (carries no downloader) rather than trusted. The app then requires the module conditionally and answers "this build does not save songs to disk" where the local build would act, and the hub hides the three places it would be offered (the sidebar row, the install block, and the + on the transport). There is no switch to flip back: a build without the feature cannot be talked into it.

Which build a payload is, is written into it as a one-line flavour.json next to package.json. The installer, the shortcut script, the uninstaller and the app all read that same marker, which is why the same scripts install either product with no arguments and nothing to get wrong. A working copy has no marker, and no marker means the local feature set under the public name: npm start runs the thing that has the downloader, wearing the identity the shortcut script writes.

npm run build-installer                      # both products
npm run build-installer -- --flavour=public  # just the public one
npm run build-installer -- --flavour=local   # just the local one
npm run build-installer -- --sfx             # also build the single-file setup program

Installing it on another PC

npm run build-installer

That writes one archive per build into dist/: the public one under the product's own name, and the local one beside it.

FileWhat it is
Ondographics-<version>-portable.zipunzip it anywhere: run.cmd runs the app there, install.cmd installs it
Ondographics-Local-<version>-portable.zipthe same, for the build that keeps the downloader

The archive also runs where it sits. run.cmd starts the app from the unzipped folder: nothing is copied, nothing is installed, and the only thing written outside that folder is what the app keeps wherever it runs — %APPDATA%\Ondographics, the same settings folder an installed copy uses, which is why the sign-in, the theme and the queue carry across. The one part of an install a folder run does not have is a Start menu entry, and with it the taskbar icon, because Windows resolves that identity through a shortcut: run.cmd -Shortcut asks for one (and -Desktop for a Desktop entry too), and run.cmd -Remove takes them away again. A shortcut belongs to the build rather than to the folder, and an identity can only have one holder, so -Shortcut from a folder run replaces an installed copy of the same build's Start menu entry — the two ways of running one build are not meant to be kept side by side, while the other build keeps its own entry and identity. One copy runs at a time whatever folder it came from, since both share the settings folder and the app holds a single-instance lock on it.

That archive is the download, and there is deliberately only one. --sfx will also build a self-extracting Ondographics-Setup-<version>.exe — a single file to double-click, made with the iexpress.exe Windows already has — but nothing here hands one out, because it is an unsigned .exe: expect Windows to ask about it, and Smart App Control refuses it outright on a machine that enforces it. The archive has nothing for either of them to object to, and it installs the identical app, to the identical place, with the identical shortcuts: the stock Electron runtime unpacked next to these application files, then the project's own create-shortcut.ps1 to register the taskbar identity.

The one thing worth knowing about the setup program, if you build one, is that it takes no command-line options. It is an iexpress.exe package, and that wrapper parses the command line before any of this project's code runs: hand it a switch and it answers Command line option syntax error. Type Command /? for Help. and installs nothing, while still exiting 0, so a script that checks the exit code is told the install succeeded when it never started. The options below (-InstallDir, -NoShortcuts, -Quiet) belong to install.ps1, which the archive contains and which takes them properly — through install.cmd, which hands them over.

The build checks itself before it says it is done. An installer is easy to get subtly wrong, a new module left out of the archive, a package built before the last edit, and that kind of mistake only shows up after somebody has installed it and found the feature missing. A list of file names would not catch it either, since a package built from a stale tree has every expected name and the wrong bytes. So the build finishes by reading the application's own files back out of the archive it just made — and out of the payload.zip inside the setup program's cabinet, when --sfx built one — and comparing them with the checkout, name for name and hash for hash. That is half a megabyte of sources and costs a few seconds; the 367 MB runtime is checked for presence instead of hashed. If anything differs, the build fails and names the file, rather than handing you a package that is missing the thing you just wrote. npm run verify-installer runs the same check on its own against whatever is in dist/, and --no-verify skips it deliberately.

If you build a setup program and Smart App Control is enforced on the machine you are installing to, it will not start at all, and Windows says so in its own words, which is worth recognising because it looks like nothing happened:

'Ondographics-Setup-1.0.0.exe' was blocked by your organization's
Device Guard policy. Contact your support person for more info.

That is a policy decision about the wrapper, not a corrupt download, and it is one of the reasons the archive is what this project ships. HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy\VerifiedAndReputablePolicyState is 1 on a machine where this happens.

Nothing is repackaged, renamed or copied out of the runtime, which is the whole reason the app still launches where the previous one stopped. The archive ships no wrapper of its own — no .exe that Windows has to be talked into — so there is no "unrecognised app" dialog to click through and nothing for Smart App Control to refuse. --sign will sign a setup program if you build one, with a certificate the building machine holds; that proves the file has not changed since the build and does not make anyone else's Windows recognise the publisher.

Where it installs, all per-user and with no administrator rights:

%LOCALAPPDATA%\Programs\Ondographics\app\     the app and its runtime
%APPDATA%\Ondographics                        settings, logs, the queue (kept on uninstall)

Those settings are the same folder the checkout uses, so a signed-in session, an overlay style and a request queue carry over to an installed copy with nothing to import.

The Start menu group gets the app and an Uninstall entry; uninstall also appears in Apps & features. The uninstaller removes only shortcuts that actually point into that install and only removes the taskbar identity when it names that install, so a checkout and an installed copy can live on one machine without breaking each other, which is exactly what happened while this was being written, and is why the rule is there.

To try the installer without installing it, note that these are install.ps1's options, not the setup program's, for the reason above:

powershell -File scripts\installer\install.ps1 -InstallDir "$env:TEMP\ytm-test" -NoShortcuts -NoLaunch

Layout

src/
  main/         main process
    index.js        bootstrap, IPC, single instance, power management
    flavour.js      which of the two builds this is, read from the payload's marker
    app-window.js   window, session, navigation policy, recovery
    player.js       player state mirror and command dispatch
    page-scripts.js the pieces that run inside YouTube Music's own page, as source
    music-window.js      the hub window, and the API the hub page talks to
    chooser-window.js    "choose what to play", the first window after a fresh start
    local-music.js       saving a song with yt-dlp and ffmpeg (not in the public build)
    music-source.js      the catalogues: audius.js, jamendo.js, soundcloud.js, youtube.js,
                         archive.js, and the search/browse shape they all answer in
    pet.js               the pixel pet's genome, its animation frames and its messages
    pet-room.js      the room's catalogue, prices and placements
    pet-art.js       the sheets the pet and the room are cut from
    room-drawn.js    the generated table of decorations the app paints for itself
    pet-progress.js  coins, experience, likes, dislikes and the play history
    now-playing.js   what is playing, for the overlay, the pet, the tray and Discord
    now-playing-window.js  the window the pet and the album art can be torn off into
    mixer.js  mixer-window.js  mixer-audio.js  the virtual mixer and its second output
    loopback-levels.js   levels for the loopback page and the visualiser
    stage-window.js      the full-screen visualiser window
    source-window.js     the window a service opens in, with its own profile
    requests-window.js   the song request panel and its IPC
    crash-guard.js       the last-resort handlers that keep a crash reportable
    archive.js  logo-image.js  notify.js  menu.js  tray.js  taskbar.js  media-keys.js
    song-requests.js    the request list: rules, YouTube's queue commands, ordering
    request-sources/twitch-chat.js      IRC over WebSocket, trigger word
    request-sources/twitch-eventsub.js  channel-point redemptions straight from Twitch
    request-sources/twitch-web.js       channel-point redemptions read off Twitch's chat page
                                        (the page reads itself, and pushes: see preload/)
    request-sources/obs-websocket.js    channel-point redemptions out of OBS
    request-sources/redemption.js       the one redemption shape every source agrees on
    request-sources/recent-requests.js  each viewer's last request, for a silent redemption
    twitch-session.js   the Twitch sign-in, encrypted with the OS key ring
    discord-presence.js what is playing, in your Discord profile (named pipe, no dependency)
    requests-window.js  the song request panel and its IPC
    config.js       JSON config with migration-safe merging
    cookie-crypto.js    unlocks a Chromium profile's cookies (v10 / AES-GCM)
    session-import.js   finds the old session, copies client state, installs cookies
    companion-server.js loopback server for the OBS overlay (GET only, no CORS)
    overlay-picker.js   the style picker window and its IPC
    level-pump.js       the overlay visualiser's audio levels, read in the page
    taskbar-identity.js keeps the shell's rival shortcut off our app id
    tray.js  taskbar.js  media-keys.js  notify.js  menu.js
  preload/ytm.js  the player bridge, or the local pages' API
  preload/music.js     the hub page's API: the pet, the room, the library, now playing
  preload/stage.js     the full-screen visualiser window
  preload/loopback.js  the page that draws a second output's levels
  preload/twitch-chat.js  the redemption reader: it runs inside Twitch's chat page
                          and pushes what it reads (see the reader's own section)
  renderer/music.html          the hub: discover, playlists, the pet, the room, the shop
  renderer/overlay.html        the OBS overlay page
  renderer/selector.html       the overlay card's style picker
  renderer/visualiser.html     the full-screen visualiser page, for the window and for OBS
  renderer/visualiser-picker.html  its scene picker, a window of live previews
  renderer/chooser.html        choose what to play, on a fresh start
  renderer/requests.html       the song request panel
  renderer/mixer.html  renderer/mixer-output.html  the virtual mixer and its published output
  renderer/loopback.html       what a second output is playing, drawn for the person watching it
  renderer/now-playing.html    the torn-off now playing window
  renderer/error.html          the in-window error page a failed load lands on, with Retry
  shared/         ipc contract, URL policy, artwork, chat commands, the chooser, close intent
  assets/         generated icons (npm run icons), the pet's sprites, the scene art
  assets/pet-room/  the room's sheet and its CREDITS.md
sheets/         the eight painted sheets the room is cut from, and the plan that names them
scripts/
  launch.js           launches the stock Electron binary
  gen-pet-art.js      paints the pet's sprite sheets from code
  cut-sheets.js       cuts the room's furniture out of sheets/, packs one sheet and writes the
                      crop table beside the app (runs under Electron: it has to read a JPEG)
  lib/png.js          a PNG reader and writer, with no image dependency
  export-session.js   decrypts the old profile's cookies to JSON
  import-session.js   export, then install, what npm run import-session does
  install-shortcut.js + create-shortcut.ps1   Start Menu / Desktop shortcuts
  install-downloaders.js  fetches yt-dlp and ffmpeg (not in the public build)
  build-installer.js  both products: the setup .exe and the portable archive (output in dist/)
  verify-artifacts.js reads the app back out of dist/, matches it against this checkout, and
                      checks that the public payload really carries no downloader
  installer/          what those packages actually run: install.ps1, uninstall.ps1
  powershell.js       finds PowerShell by path for every script that needs it
  check-icon.js       runs the identity and taskbar-button probes below
  generate-icons.js   writes PNG and ICO files with no image dependencies
dev/            diagnostic probes, read-only, not part of the app
  claimants.ps1            every shortcut claiming the app id, with target/args/icon
  taskbar-icon-probe.ps1   captures the live taskbar button and scores its colours
  watch-taskbar-icon.ps1   samples that button across a launch, with the button's name
  window-icon-probe.ps1    reads back the icons Windows holds for the window
  list-shortcuts.ps1       every shortcut in the Start Menu, Desktop and pins
  find-claims.ps1          byte-level scan for a shortcut mentioning the app id
  cdp.mjs                  talks to a running app over the DevTools protocol
  queue-check.mjs          runs a real song request against the live page
  hub-request-check.js     opens the real hub window against a stand-in catalogue and reads a request back out of its own queue, including behind an hour-long mix
  stand-in-obs.js          a fake OBS that answers the vendor check and sends one redemption
  mock-obs.mjs             runs that stand-in on its own, for poking at by hand
  redemption-check.mjs     drives a redemption through that stand-in and asserts YouTube's own queue grew
  obs-probe.mjs            asks a running OBS whether it has a Twitch vendor, and prints its events
  twitch-notice-check.mjs puts one redemption notice on the app's own Twitch page, to prove it reads
test/
  smoke-runner.js     the end-to-end run used by npm run smoke
  *.test.js           the unit suite npm test runs, one file per subject

Pixel art for the pet and the room is a mix of spritesheet packs the project owner supplied from itch.io and sheets painted for this project; see src/assets/pet-room/CREDITS.md for what came from where, and sheets/README.md for the eight painted sheets the room's furniture is cut out of.

Troubleshooting

The overlay shows nothing. It fades out while playback is paused, that is deliberate. Add ?alwaysShow=1 to check the layout. If nothing appears even while playing, see OBS-OVERLAY.md.

A channel point redemption does nothing, but the OBS line says connected. Check that line for an amber note, and logs\main.log for OBS is not offering a Twitch vendor. A connected obs-websocket is not enough: redemptions only arrive through OBS's Twitch integration, which is registered when the account is connected in OBS → Settings → Stream → Twitch → Connect Account, the chat dock does not provide it. Some OBS builds have no such integration to register at all, though, and no sign-in will ever create one; that case and "not connected" answer identically, so the note covers both. If the account is already connected and nothing arrives, stop there and use the direct Twitch source, which needs no OBS. node dev/obs-probe.mjs asks a running OBS the same question directly and prints its answers, plus every event it sends for twelve seconds, which is how the two were told apart.

A channel point redemption still does nothing, on the source that reads Twitch's chat page. Two things to look at, both in logs\main.log. First, a redemption notice on the page reads as {reward, points, user, text}, that line means the page is being read, and it appears even for a notice the app deliberately ignores, so its absence is the finding: the reader is looking at a page that has no notices on it (wrong channel name, or the chat page did not load). Second, a chat line mentioned a redemption but could not be read, Twitch has restyled the notice, the line is printed in full as evidence, and the reader needs that one shape added. node dev/twitch-notice-check.mjs puts a notice in Twitch's real markup on the app's own page and reports what the app did with it, which separates "the player is broken" from "the reader is blind" in about ten seconds.

Discord shows nothing. The log says which of the three states it is in. needs an application id means the four steps in What you are listening to, in Discord have not been done, and the id has to be the Application ID from discord.com/developers/applications; a bot token, a public key or a client secret is not it. no Discord client is listening on this machine means Discord is not running, and the app is waiting for it. Discord closed the connection without answering is what a real Discord client does with an id it does not know. Discord refused the presence update prints Discord's own reason.

The direct Twitch source will not connect. The log names the reason; the common ones are: no Client ID (create one at dev.twitch.tv/console/apps and paste it in), a Client ID that is not a Public client (a confidential app cannot use device sign-in at all), and a sign-in that has been revoked in Twitch's own Settings → Connections (the Twitch sign-in was refused, sign in again). If the panel says signed in as … but the state is not connected, the subscription is what failed and Twitch's message is in the log line Twitch refused the redemption subscription.

A request says it is waiting its turn and does nothing. That is the chain. Play next has one, and so does the hub, which plays every request next whatever the mode says: the request above it is on its way to the surface or sitting behind whatever is playing, and the next one goes when that one is reached, or, if it never is, when the chain lets go after 8 minutes. The panel names the request it is behind, and the log says the same thing as a request is waiting its turn, so it lands behind the one ahead of it. Add to the queue holds nothing back on YouTube Music, so switching mode there releases the line. If a request waits with nothing above it, that is a bug rather than the chain, and the log line song request added to the player queue is what to look for.

A request does not appear on the hub. The log line is a song request was searched for the hub followed by song request added to the player queue; the second one means the app handed it over and the hub took it. From there it is on the hub's own page: the transport row says … asked for … · playing next, or · playing now when what it landed behind was not a song, and the queue pane (the button at the top right of the hub) lists it directly after the song on air. The app's log line names the catalogue that answered and the person who asked, so a request that was searched for somewhere unexpected is visible there rather than guessed at. That path has its own check rather than being trusted: npm run hub-check opens the real hub window against a stand-in catalogue and reads the request back out of the page's own queue.

A redemption plays the wrong song. The viewer's reward message wins, and only an empty one falls back to their last !sr in chat. The log line for each request says which text was used (channel point redemption carries it, and a redemption carried no message, so this viewer's recent chat request is used when it was borrowed).

The window opens on a Google consent page. Normal for a fresh profile. Click through it, or run npm run import-session.

The window is black and stays black, with a log full of the page did not answer a script in time. That was the ad blocker: a webRequest listener that returns its verdict instead of calling the callback leaves the in-flight document request paused for ever on Electron 44, so nothing commits and nothing can be evaluated in the page. A build from before that fix can be recovered by switching the blocker off, edit integrations.adblock.enabled in %APPDATA%\Ondographics\config.json to false while the app is closed, or reinstall. In the log, the tell is ad blocker {enabled:true} followed by no page bridge ready at all, where a healthy start shows it about two seconds in. The current code is verified by the probe (node_modules\electron\dist\electron.exe dev\adblock-probe.js return) and by tests that every request gets an answer, including the ones whose rules throw. There was a second, harmless reason the same line appeared once per start, and it is worth knowing about because it is now fixed: the mixer's settings were being handed to the page before music.youtube.com had committed its first document, so the navigation discarded the script and the only way it could end was on the timeout. Nothing was wrong with the page and nothing was broken by it, the reconcile that runs when the document arrives put it right, but the line looked like a fault, and a warning that is always there is a warning nobody reads. A page script now waits for a load that is in flight, so the page's half of the mixer is handed over as soon as the document commits and a healthy start says nothing about it at all. (The mixer being switched off and nothing asking for a visualiser still means no graph is built, that is by design, and the page reports installed: false rather than an error.)

The music sounds quieter and duller than it should while the mixer is on. A build from before this one carried a double level, and the older the build the worse it is: the mixer's graph set its own master gain to the element's own level, on a belief that the tap sits before the element's volume control. It does not, and copying the level there applied it twice, the music played at the square of what the slider said, so 40% was 16% (-16 dB instead of -8) and 20% was 4%. That is quieter than every other app on the machine by a wide margin, which against a GoXLR channel's own EQ and compression is heard as dull and hollow rather than as a wrong number. The measurement is dev/tap-probe.html: it taps a generated tone, changes the element's volume underneath it and reads the tap, and reports exactly 0.5 for a volume of 0.5, 0.25 for 0.25 and 0 for muted. The graph in this build carries no level of its own. (The same file is worth keeping: createMediaElementSource behaves differently in different Chromium versions, and this is how the shipped one is asked rather than assumed.)

The mixer makes the music sound echoey, phased or doubled. That is the two buses landing on one device. They play the same music, and the stream bus reaches its device through a MediaStream hand-off, a copy taken a few tens of milliseconds late, so on one device the second copy is heard as an echo rather than as a second output. It is refused rather than warned about now: the stream bus is held silent and logs\main.log says the stream bus is aimed at the device you are listening on, with the fix. The settings that cause it are the defaults, the monitor on the system output and the stream device unset, which is the same output, so the first drag of the stream fader would have produced it. A second bus is for a second device: a virtual cable, a GoXLR channel, anything that is not what you are listening on. If the mixer is switched off and there is no visualiser, no graph exists at all and the music takes the same path it always did.

npm run export-session says it could not find a session. The old app has to have been signed in at least once; the check looks for LOGIN_INFO/SAPISID rather than for a file that merely exists. If it is refusing to read the profile at all, close the old app first, a running Chromium holds the database.

Settings are remembered but the account is not, or vice versa. Profile state (localStorage, IndexedDB) and cookies are imported by different steps, and the report says which half worked:

Profile state: Imported Local Storage, Session Storage, IndexedDB
OK: Installed 50/50 cookies; signed in as APISID, HSID, LOGIN_INFO, SAPISID, SSID, __Secure-1PSID, __Secure-3PSID

Why are the cookies in this profile unencrypted? On this machine Chromium's Windows key store is not usable inside Electron: fresh profiles write their first cookie to the plaintext value column, and rows that arrive encrypted are discarded on load (both measured, and the reason the import goes through JSON). It is the same behaviour in other Electron apps installed here, and the profile folder is still protected by file permissions. Fixing encryption means getting Chromium to load the profile key again, which is a job of its own.

The visualiser moves, but not with the music. Three different things looked exactly like this, and all three are measured rather than guessed at. (1) The waveform slid sideways. An analyser hands over a sliding window, so an oscilloscope trace drawn from the start of it begins at an arbitrary point of the wave and moves at a speed that has nothing to do with the sound: measured on a loud track, consecutive frames of the ninety-six points differed by 0.38 of full scale, while the spectrum bands at the same moment moved by 0.02. It is triggered now, the trace starts at the first rising zero crossing, as a scope does, so it holds still and changes only when the sound does. (2) The bars were redrawn rather than moved. Bars rose over 45 ms and fell over 240, and the frames they were fed arrived twenty times a second, so a hit landed halfway down its own decay before the next one; a bar now reaches the height it was read at within a frame and falls in 150 ms. (3) The previews in the style picker were not listening. They are this same page with ?demo=1, and they animated a generated sample, which is a shape, not a reading, and cannot be on the music by construction. A preview now reads the live audio whenever there is any and falls back to the sample only while there is none.

The log says the overlay server could not start. Something else holds port 9863. If you have an older YouTube Music desktop app or a widget bridge still installed, close it, this app is the only thing that needs the port now.

Clicking ✕ does nothing at all. YouTube Music registers a beforeunload handler once it has been playing for a while, and Chromium honours it by cancelling the window close, silently. The app answers that handler in app-window.js and lets the close through, and the smoke test installs the same kind of guard so the behaviour is checked on every run. If it ever regresses, the log records it as page tried to block the window close; allowing it.

The taskbar still shows the Electron atom. Run npm run check-icon first, it lists every shortcut claiming the app's ID and then scores the button itself, so it says whether the button is actually wrong or whether what you are looking at is a cached icon. A claimant that is not one of ours is the cause: run npm run install-shortcut, which deletes rivals and rewrites ours, then close the app completely and reopen it. Windows caches taskbar icons per session, so if the button was already drawn before the fix it can stay wrong until Explorer restarts, or until the iconcache* files in %LOCALAPPDATA%\Microsoft\Windows\Explorer are cleared.

Nothing happens when I click the shortcut. Check whether Windows blocked it:

Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-CodeIntegrity/Operational'} -MaxEvents 20 |
  Where-Object { $_.Id -in 3077,3033,3118 } | Select-Object TimeCreated, Id, Message

If the new app appears there, the executable was moved or replaced, restore it by reinstalling Electron.

npm run smoke never comes back, and leaves the app running. The gate itself could not be loaded: test/smoke-runner.js held a backtick inside a template literal, so the file did not parse, require threw where nothing was watching, and the app, whose entire smoke sequence is that file, carried on playing music instead of reporting. Nothing was said, which is the worst shape of it: no result, no exit code, just an app that never quit. npm test was green throughout, because the only thing that loads that file is the smoke run. There is now a test that compiles every .js file the app ships, so a file that cannot be loaded is a failure rather than a silence; node --check test/smoke-runner.js is the same check by hand.

The build stopped with "An artefact does not match this checkout." That is the check doing its job: something in dist/ is not the code you have. The lines above it name each artefact and the first few files that are missing, changed or unexpected. npm run verify-installer prints the whole list without rebuilding. The usual cause is an edit made after the build, or a file removed from the checkout that a staged copy still carried, build again and it will pass. npm run build-installer -- --no-verify skips the check, deliberately and out loud.

Media keys do nothing. Another app (Spotify, a browser, a keyboard utility) already holds them. The log records exactly which keys were denied: Help → Open log file, or %APPDATA%\Ondographics\logs\main.log.

YouTube Music shows "taking longer than usual to load". That is a YouTube-side embed block, not a bug in this app. Reload (Ctrl+R), or Ctrl+Shift+R to ignore the cache.


Not affiliated with, endorsed by, or connected to Google or YouTube.