Playing YouTube Music without a browser
Limusic is a native desktop client in Rust and Tauri. It speaks YouTube's private API, runs YouTube's own obfuscated JavaScript in a hidden webview, and hands the stream to libmpv. The fallback path turned out to be the product.
contents
- Two rules, written before any code
- The shape of it
- One video id, from click to sound
- Speaking as YouTube's own apps
- The waterfall
- Validation that has to be skipped
- Metadata always comes from the main client
- Self-heal runs off the hot path
- Running YouTube's obfuscated JavaScript on purpose
- BotGuard in a 1x1 webview
- The fallback path is the product
- Where the platform disagrees with the documentation
- Four bugs in 369 lines of libmpv wrapper
- Where the 700 MiB went
- Listen Together
- Small decisions that took longer than they look
- What it costs
- What it does not do
I wanted a YouTube Music client on Fedora that did not cost me half a gigabyte of RAM to leave open all day. Every option was a browser wearing a hat.
Limusic is what I built instead. It talks directly to YouTube's private InnerTube API, resolves the protected stream URL itself, and plays it through libmpv. No Electron, no bundled Chromium, no backend server of mine anywhere in the path. There is also no ad blocking, because ads live in YouTube's player UI and never touch the audio streams. If you never load the player, there is nothing to block.
The playback engine design is not mine. It is a port of the one behind Metrolist, an Android YouTube Music client written in Kotlin, rebuilt from scratch in Rust and Tauri 2 with a SvelteKit front end. What is mine is the port, everything desktop-shaped that came with it, and the parts below where the platform disagreed with the documentation.
| Repo | SimoHypers/limusic (GPL-3.0) |
| First commit | 3 July 2026 |
| Latest release | v0.3.4, 29 July 2026 |
| Shipped | 31 releases, 97 commits |
| Platforms | Linux (self-updating AppImage, rpm), Windows (self-updating setup.exe, msi) |
| Downloads | 845 release assets, 17 stars |
Two rules, written before any code#
I wrote both of these down before scaffolding anything, and everything strange in the design traces back to one of them.
- Memory efficiency. Electron was banned up front. Tauri uses the OS webview, so a renderer process is unavoidable, but nothing else was allowed to be heavy: extraction in Rust, audio in a native library, no JVM, no bundled Chromium, no Python in the default build.
- Maintainable stream extraction. The signature cipher, the
n-transform and the PoToken layers break whenever YouTube ships a newplayer.jsor bumps a client version, which happens several times a year. Breakage had to be recoverable through config plus self-heal, with a second, independently maintained extractor always available underneath.
The shape of it#
crates/innertube/ Pure Rust. HTTP transport, client identities, models, endpoints,
rustypipe fallback. No Tauri, no webview, no mpv. Unit-testable
against JSON fixtures.
crates/player/ libmpv wrapper. Takes a URL plus headers. Knows nothing about YouTube.
src-tauri/ The app: orchestrator (the brain), cipher/ and potoken/ (hidden
webviews), session/ (auth), media/ (souvlaki), db (SQLite),
commands (the UI's only API).
ui/ SvelteKit SPA. Talks to Rust via Tauri commands and events only.One rule holds that together, inherited from Metrolist: the transport layer knows nothing about
Tauri, webviews, mpv or the OS, and the UI never contacts YouTube, never interprets a stream URL and
never runs extraction JS. A change that leaks YouTube logic into the front end, or platform code into
crates/innertube, is wrong even if it compiles.
The front end states the same contract from its side, in the header of the only file allowed through:
// The UI's only door to Rust: commands in, events out. The UI never touches YouTube;
// everything here is a Tauri command or event payload.
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';62 commands cross that door. Nothing else does.
| Area | Lines |
|---|---|
src-tauri/ (orchestrator, cipher, potoken, state, commands) | 9,440 |
crates/innertube (pure protocol) | 3,740 |
crates/sync-server (Listen Together relay) | 818 |
crates/player (libmpv) | 369 |
crates/listen-protocol (shared wire types) | 260 |
| Svelte, excluding vendored shadcn primitives | 5,856 |
TypeScript in ui/src | 2,034 |
About 125 test functions across the workspace, and 639 entries in the Cargo dependency graph.
Why SvelteKit in SPA mode
There is no Node server in a Tauri app, so SSR is not an option: adapter-static with SSR off.
shadcn-svelte fits the same constraint, because it copies component source into the repo instead
of shipping a runtime dependency that fetches anything.
One video id, from click to sound#
You click a track. What has to happen before you hear it is most of this project.
Speaking as YouTube's own apps#
InnerTube is the private API YouTube's own clients use. Every request carries a context object plus
headers describing which official client is speaking, and different clients get treated differently:
some hand back direct stream URLs, some hand back ciphered ones, some are login only, some are
age-gate exempt.
Those identities are config, not code. They live in crates/innertube/clients.json, and an override
file in the app data directory can replace the table at runtime, so a version rotation is a JSON edit
rather than a release:
{
"WEB_REMIX": {
"clientName": "WEB_REMIX",
"clientVersion": "1.20260213.01.00",
"clientId": "67",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
"loginSupported": true,
"useSignatureTimestamp": true,
"useWebPoTokens": true
},
"VISIONOS": {
"clientName": "VISIONOS",
"clientVersion": "0.1",
"clientId": "101",
"osName": "visionOS",
"osVersion": "1.3.21O771",
"deviceMake": "Apple",
"deviceModel": "RealityDevice14,1"
}
}One of the fallback identities is an Apple Vision Pro. Another is a Meta Quest 3 running the YouTube VR app. They are in the list because those clients return unciphered stream URLs, which makes them the cheapest way to get audio when the hard path fails.
Two protocol details from crates/innertube/src/transport.rs that cost me time. The client name
header takes the numeric id, not the name:
set(&mut h, "x-youtube-client-name", &client.client_id);
set(&mut h, "x-youtube-client-version", &client.client_version);And signed-in requests use Google's cookie auth rather than OAuth, which is a SHA-1 over a space-joined triple:
/// `Authorization: SAPISIDHASH <epoch>_<sha1(epoch SAPISID origin)>`
pub fn sapisid_hash(sapisid: &str, origin: &str) -> String {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("SAPISIDHASH {epoch}_{}", sha1_hex(&format!("{epoch} {sapisid} {origin}")))
}It is unit tested against a known SHA-1 vector, so a refactor cannot quietly break sign-in.
The waterfall#
src-tauri/src/orchestrator.rs is 393 lines and it is the piece everything else exists to serve. In:
a video id. Out: a URL mpv can play, plus headers, loudness and metadata. In between, a prioritised
list of client identities where each one can fail in its own way.
WEB_REMIX is the primary client, carrying the signature timestamp, the PoToken and the cipher work.
Behind it come the direct-URL clients, VISIONOS then ANDROID_VR then IOS, and behind those
rustypipe as a last-ditch net maintained by someone other than me.
A handful of rules have to survive every change to that loop. Each one looks like a bug on its own:
| Rule | What it prevents |
|---|---|
| Metadata always from the main client | The VR clients return playable URLs and thin metadata |
| WEB_REMIX skips HEAD validation | Its authenticated URL answers 403 to a HEAD, then streams on GET |
| The last client is accepted unvalidated | Rejecting it leaves nothing to fall through to |
| Self-heal runs off the hot path | A config refresh would stall the loop while someone waits for sound |
| PoToken and cipher failures degrade, never abort | Both layers are optional for clients that need neither |
The loop itself is one range, and the -1 is the trick:
for idx in -1..=last_idx {
let (key, resp): (String, PlayerResponse) = if idx == -1 {
if !main_ok || disabled.contains(MAIN_CLIENT) {
continue;
}
(MAIN_CLIENT.to_owned(), main_resp.clone().unwrap())
} else {
let key = STREAM_FALLBACK_ORDER[idx as usize];-1 means "reuse the response from the main client that we already have", and 0.. are the
fallbacks. In the common case where the primary stream is usable, that saves a duplicate request for
data already in memory.
Validation that has to be skipped#
Every candidate stream normally gets a HEAD request before it goes anywhere near mpv. WEB_REMIX is the exception: its authenticated URL answers a HEAD with 403 and then streams fine on a GET. So it is accepted unvalidated, and if it fails later during real playback the video id is remembered:
// WEB_REMIX skips HEAD (its authed URL 403s on HEAD, streams on GET) unless it already
// failed for this id.
if idx == -1
&& key == MAIN_CLIENT
&& !self.web_remix_failed.lock().await.contains(video_id)
{
return Ok(self.build(video_id, format, url, expires, &key, audio_config_loudness, &main_resp));
}Metadata always comes from the main client#
The VR and visionOS clients return playable URLs and thin metadata. So the WEB_REMIX response is kept
alive purely as the metadata source, and build() reads title, artist, duration, thumbnail and the
watch-history tracking URL out of it no matter which client actually won the audio.
Self-heal runs off the hot path#
When a ciphered client fails validation, the likely cause is a stale player.js config. Refreshing it
inline would stall the fallback loop at the exact moment someone is waiting for sound, so it is
spawned and forgotten while the loop keeps falling through:
} else if needs_n {
// A cipher client that fails validation may have a stale config, so self-heal off
// the hot path and never block falling through.
let cipher = self.cipher.clone();
let failed = self.web_remix_failed.clone();
tauri::async_runtime::spawn(async move {
if cipher.on_stream_rejected().await {
failed.lock().await.clear();
}
});
}Running YouTube's obfuscated JavaScript on purpose#
YouTube protects stream URLs two ways. A signature token has to be descrambled and appended to the
URL. And the n parameter has to be passed through a transform function, or the server throttles the
download below playback speed. Both functions live inside player.js, currently around 2.8 MB of
obfuscated code that changes without notice.
Reimplementing them in Rust is a treadmill, and every project that tries ends up maintaining a
deobfuscator. Limusic fetches YouTube's own player.js and runs it, in a hidden 1x1 webview, and
only needs to know the names of the two entry functions. The injection appends exports inside the
file's closing IIFE so the exported functions keep their original scope:
/// The marker that closes player.js's IIFE, our export-injection point.
pub const IIFE_TAIL: &str = "})(_yt_player);";
pub fn build_injection(player_js: &str, sig_fn: Option<&str>, n_fn: Option<&str>) -> String {
let mut exports = String::from(";");
if let Some(sig) = sig_fn {
exports.push_str(&format!(
"try{{window._cipherSigFunc=function(s){{return {sig}(s);}};}}catch(e){{}}"
));
}
if let Some(n) = n_fn {
exports.push_str(&format!(
"try{{window._nTransformFunc=function(n){{return {n}(n);}};}}catch(e){{}}"
));
}
exports.push_str(&format!("{IIFE_TAIL}"));
// Replace only the final IIFE close so the exports live inside the player closure's scope.
match player_js.rfind(IIFE_TAIL) {Finding the names has three tiers, in descending order of how much I trust them:
| Tier | Mechanism | Breaks how |
|---|---|---|
| 1 | Config table keyed by player.js hash, refreshable remotely | A break is a config push, not a release |
| 2 | Regex patterns over the source | Silently, on the next obfuscation change |
| 3 | Brute force inside the webview | Only if nothing on window fits the shape |
Tier 3 is crude and I like it anyway. Walk every property on window, call anything that takes one
argument, keep whatever maps a probe string to a different valid string:
var t="grut12Abc_-";
function ok(s){return typeof s==='string'&&/^[A-Za-z0-9_-]+$/.test(s)&&s!==t;}
if(typeof window._nTransformFunc==='function'&&ok(window._nTransformFunc(t))){window.__n_ok=true;}
else{for(var k in window){try{var f=window[k];
if(typeof f==='function'&&f.length===1){var r=f(t);
if(ok(r)){window._nTransformFunc=f;window.__n_ok=true;break;}}}catch(e){}}}BotGuard in a 1x1 webview#
YouTube also runs BotGuard, an attestation system whose JavaScript deliberately probes for a real
browser environment. Native Rust does all the HTTP (/Create, /GenerateIT); the webview does
nothing but run Google's code.
That split is not a preference, it is forced. The JS hands back a live function
(webPoSignalOutput) which cannot cross into Rust, so it gets parked in a JS global between calls:
window.__lm={
wpso:null,
async run(cd){var r=await runBotGuard(cd);window.__lm.wpso=r.webPoSignalOutput;return r.botguardResponse;},
async mintInit(intTok){await createPoTokenMinter(window.__lm.wpso,new Uint8Array(intTok));return true;},
async mint(ident){var t=await obtainPoToken(new Uint8Array(ident));return Array.from(t);}
};"Does BotGuard run in a Tauri webview on WebKitGTK?" was the single biggest risk in the whole design,
so I answered it with a throwaway spike before building anything on top of the answer. It
passed, and it mints real tokens with a 12-hour TTL. Every call is still wrapped in a timeout that
returns None, and a webview that reports an uncaught JS error latches permanently bad, because the
orchestrator can always fall through to clients that need no token.
The fallback path is the product#
Here is the part that matters, and it is not a success story.
The cipher path does not work against the current player.js. The 2025-era player is Q-array obfuscated: there is no standalone signature function to name,
the operations are dispatched through obfuscated globals, and the n-transform is not a function on
window at all. It is a class method reached as (new g.of(M,!0)).get("n"), and it rewrites a path
segment rather than a query parameter. The brute force finds nothing because there is nothing of that
shape to find. Deciphering gets skipped.
Playback is fine. In live testing every track resolved through VISIONOS at itag 251, which is Opus
at roughly 160 kbps, the same tier WEB_REMIX would have produced. The graceful degradation path that
was built for a hypothetical failure is the load-bearing one, and has been the whole time.
That is the lesson I actually took from this project. I did not design a happy path with error handling bolted on; I designed a loop where every step is allowed to fail and the next one gets a turn. The step I expected to carry the app is the one that broke, and users did not notice.
There is one piece of hygiene in how that failure is handled. Once discovery proves a player undecipherable, the webview is destroyed, but the analysis is kept, because the signature timestamp extracted from the same file is still needed on every request:
if keep_bridge(sig_available, n_available) {
inner.bridge = Some(bridge);
} else {
tracing::info!("cipher: discovery found no usable sig/n on this player, dropping the webview");
let _ = bridge.destroy();
inner.bridge = None;
}That webview is about 142 MiB. Keeping it resident to hold zero usable functions would have been the easy mistake.
Where the platform disagrees with the documentation#
src-tauri/src/webview.rs is 276 lines of shared plumbing for both hidden webviews, and its header
comment reads like a field report. Three findings earned their place in it.
WebKitGTK does not await promises. run_javascript returns the value of a synchronous
expression, full stop. So synchronous work (the cipher functions) round-trips in one shot through
eval_with_callback, and asynchronous work (BotGuard) has to store its result into a JS global that
Rust then polls. Hence two call paths, eval_json and call_async, which look redundant until you
know why.
WebView2 often never fires the load event for a data: URL. The harness is loaded via
NavigateToString, whose NavigationCompleted frequently never arrives, so gating readiness on that
event meant the hidden webviews simply never became ready on Windows. The event is now an accelerator
and the real gate is a JS probe, chosen so it can only be true when the right document is live:
let probe = bridge
.eval_json("location.protocol==='data:'".into(), Duration::from_millis(800))
.await;
if matches!(probe, Ok(Value::Bool(true))) {
return Ok(bridge);
}A plain 1+1 would pass on the about:blank a fresh WebView2 sits on. location.protocol==='data:'
proves both that the round-trip works and that the harness document loaded.
The app CSP has to stay null
Tauri injects the configured CSP as a <meta> tag into any data: URL webview, and a data:
document has an opaque origin. So a policy as ordinary as default-src 'self' blocks every inline
script in the harness. The page still reports as loaded, the eval probe still round-trips, and it
fails at mint time with Can't find variable: runBotGuard. Hardening the main window costs the
entire extraction stack, and the failure tells you nothing about why.
"csp": null,Four bugs in 369 lines of libmpv wrapper#
crates/player is the smallest crate in the workspace and four of its lines are scar tissue.
mpv needs LC_NUMERIC=C, and GTK takes it away. Tauri and GTK initialise the process locale from
the system locale, and after that mpv_create() returns null:
// libmpv requires LC_NUMERIC=="C" to parse internal option values; Tauri/GTK's init
// resets the process locale from the system locale first, which makes mpv_create()
// return null.
unsafe {
libc::setlocale(libc::LC_NUMERIC, c"C".as_ptr());
}pause is a trap for detecting playback. It starts false, and loadfile does not touch it, so
starting a track sets false to false and fires no property event at all. Anything reading playback
state off pause alone never learns that a track began, and only recovers when the user pauses
manually. The state has to come from pause and idle-active together:
let now = !paused && !idle;
if now != playing {
playing = now;
if tx.send(PlayerEvent::Playing(now)).is_err() { break; }
}Never poll mpv from the async event pump. mpv_get_property answers synchronously on mpv's core
lock, so asking from the app's event pump can stall that pump exactly when mpv is busiest, which is
during a gapless transition while it opens the next stream. A stalled pump stops draining mpv's
events, so track-end never gets handled and playback wedges. Everything is event driven instead.
Any local file path containing a space failed to load. libmpv2's command joins its arguments
into one string and hands that to mpv_command_string, which splits it back apart on whitespace. So
loadfile /music/My music/a, b.mp3 replace arrives as six arguments and fails with
INVALID_PARAMETER. Two characters need escaping, the fix is one function, and the test names the bug
it exists for:
fn quoted(arg: &str) -> String {
format!("\"{}\"", arg.replace('\\', "\\\\").replace('"', "\\\""))
}
#[test]
fn paths_survive_mpvs_command_parser() {
// The bug this exists for: a space used to end the argument.
assert_eq!(quoted("/music/My music/a, b.mp3"), "\"/music/My music/a, b.mp3\"");
assert_eq!(quoted(r"C:\Music\x.mp3"), r#""C:\\Music\\x.mp3""#);
}One more, with wider application than this project: mpv's volume property is not perceptual. It applies gain = (v/100)³, so a ten-step drag near the bottom of the slider moves about 18 dB while the same drag near the top moves about 3 dB. The slider maps percent linearly onto a 40 dB range instead, so every step sounds the same size:
/// mpv applies gain = (v/100)^3, i.e. 60*log10(v/100) dB, so v = 100*10^((s-100)/150)
/// yields a perceived level of 0.4*(s-100) dB. 0 stays a hard mute.
fn perceptual_to_mpv(percent: i64) -> f64 {
if percent <= 0 {
return 0.0;
}
100.0 * 10f64.powf((percent.min(100) as f64 - 100.0) / 150.0)
}The test asserts the ratio between consecutive steps stays constant, which is the property that makes a slider feel linear.
Where the 700 MiB went#
The app idled around 500 MiB and reached 700 to 800 in use, which is exactly the number I built it to avoid. Three of the four plausible fixes measured as nothing.
Method first, because it changes the numbers: everything is PSS from /proc/PID/smaps_rollup, not
RSS. RSS double-counts shared library pages across the three processes and inflates the total by about
150 MiB. Two throwaway harnesses loaded N real thumbnails or N card-shaped DOM trees, scrolled, and
sampled the web process. The harness is noisy: one run reported 206 MiB and three later runs of the
identical config reported 119, 135 and 128. Every number below is a repeat.
| Process | PSS at v0.3.2 |
|---|---|
WebKitWebProcess (UI content) | 451 MiB |
limusic-app (Rust plus UI process) | 211 MiB |
WebKitNetworkProcess | 36 MiB |
| Total | ~700 MiB |
What held up:
- 51 MiB of free-but-unreturned glibc heap in the Rust process. Proven rather than inferred:
gdb -p <pid> -batch -ex 'call (int)malloc_trim(0)'dropped PSS from 211 MiB to 160 MiB and it did not re-grow. glibc gives each thread its own arena and never returns those pages on free, and this process runs 45 threads across tokio, GTK, mpv and souvlaki. The slack comes back at roughly 15 MiB per 15 minutes, so the fix is a periodic trim. I deliberately did not useM_ARENA_MAX: it caps the sprawl at the source but serialises allocation across all those threads, for a win the trim already gets. - WebKit was left on full-browser cache defaults. wry never calls
set_cache_model, so WebKitGTK stays onWEBKIT_CACHE_MODEL_WEB_BROWSER, sized against total system RAM. The on-diskWebKitCachewas 627 MiB, roughly 22,000 thumbnail files. - Blurred artwork was decoding at absurd sizes. The home hero requested a 1200px source, about 5.7 MiB decoded, and re-decoded it on every track change, all to sit under a 40px blur. 96px is indistinguishable there.
And the dead ends, each measured on the same machine:
| Theory | Result |
|---|---|
| Smaller thumbnail source sizes | 400px: 117 MiB. 192px: 119 MiB. No difference, WebKit discards decoded data for offscreen images |
content-visibility: auto on card trees | 119/135 MiB without, 129/128 MiB with. Nothing |
Dropping backdrop-filter, hover transforms, shadow transitions | 208/207 MiB with, 207/207 without. Flat |
The thumbnail one has a twist: a smaller size that 404s on Google's CDN costs more memory, because the retry chain falls back to the original URL, typically 544px.
Then the finding that dwarfed the rest:
| blank | 300 cards | |
|---|---|---|
| GPU compositing | 97 MiB | 135 MiB |
| software rendering | 62 MiB | 245 MiB |
Software rendering starts lower, because no GPU driver libraries are mapped, then costs about 5x for content, because every composited layer becomes a CPU-side backing store. The gap grows with content.
The app forces software rendering, to work around a GBM buffer allocation failure that produced a
solid white window for AppImage users on NVIDIA. Twelve days after that workaround was written, an
unrelated fix stopped the AppDir shadowing the host's libwayland-client, which had been breaking
Mesa's EGL vendor loading: same class of failure, same mechanism. So the workaround is probably now
pure cost, worth 200 to 300 MiB for every NVIDIA AppImage user, and there is a LIMUSIC_FORCE_GPU
escape hatch to settle it.
This one is still open. Getting it wrong ships a white window to the exact users the workaround was written for, so it is gated on a test I have to run on that hardware, not on a hunch.
The moral, since the investigation earned one: the fix that mattered was invisible to every intuition about what makes a UI heavy, and three intuitive fixes measured as noise. My own earlier assumption about thumbnail sizing was one of the three, and the numbers are what disproved it.
Listen Together#
Synced listening rooms over a small self-hosted relay. Everyone streams their own audio from YouTube; the room only relays play, pause, seek, track change and the queue. Rooms have join codes, and the host approves every join and every track suggestion.
The design is Metrolist's, host-authoritative and push-based, with two deliberate changes: JSON instead of protobuf, and the server hands the host role off on disconnect instead of freezing the room for 15 minutes.
Position is never polled. The server stamps a wall-clock time next to the last known position and clients extrapolate:
/// Extrapolated live position at `now_ms`: if playing, last reported position plus elapsed
/// wall-clock since it was set.
pub fn live_position_ms(&self, now_ms: i64) -> i64 {
let mut p = self.position_ms;
if self.is_playing && self.last_update_ms > 0 {
let elapsed = now_ms - self.last_update_ms;
if elapsed > 0 { p += elapsed; }
}
p.max(0)
}Small decisions that took longer than they look#
- A 5,000-track playlist used to wait on about 50 sequential round trips before the first note. Continuation tokens chain, so they cannot be fetched in parallel. The queue is now seeded from the tracks the page already loaded, and the rest is walked in the background and mixed through the unplayed tail, so shuffle stays a real shuffle over the whole playlist.
- An unresolvable upcoming track is dropped during the current track, not at the boundary. Deferring it meant the whole client waterfall failed again on the event pump, which paused playback and used to wedge until a manual skip. Capped at three removals per prime, so a network outage cannot eat the queue.
- Lyrics match on exact track length. Popular songs exist as several cuts and the wrong one drifts a few seconds out. LRCLIB first, then YouTube Music's own timed lyrics, then plain text.
- Last.fm scrobbles at the halfway point or four minutes, whichever comes first, which is
Last.fm's own rule rather than a heuristic I invented. Credentials live in a gitignored file read by
build.rs, never in the public repo. - The content playback nonce is 16 URL-safe characters from a time-plus-counter xorshift, not a crypto RNG, with a comment explaining that a nonce needs to be unique per playback and not unpredictable. It keeps a dependency out of the tree.
- Cookie-paste sign-in shipped, then got deleted. Non-technical users read "open DevTools and copy the Cookie header" as an instruction they were meant to follow, sitting directly under a one-click Google sign-in that already worked. Removing a working feature was the fix.
- A queue slot's attributes were leaking into the song itself. The play record stored the whole song object as it sat in the queue, including "added by" and "autoplay" flags, and the On Repeat playlist rebuilt its rows out of that JSON, so a track added during a Listen Together session kept a guest's badge forever. Fixed on read, which also repaired the rows already written that way.
What it costs#
| Asset (v0.3.4) | Size |
|---|---|
.AppImage | 156.8 MB |
.msi | 55.2 MB |
-setup.exe | 40.4 MB |
.rpm | 12.0 MB |
The rpm is 12 MB because it links the system libmpv and the system WebKitGTK. The AppImage is 156 MB because it bundles both. Worth being precise about: "no Electron" buys you resident memory and a process model, not a small download.
What it does not do#
- The signature cipher path does not work against the current
player.js. Playback works because the fallback clients need no cipher. - The memory work is only partly landed. The trim, the WebKit cache tuning and the artwork sizing are on an unmerged branch, and the software-rendering finding is still open.
- macOS is not shipped. It builds from source, and I have not validated it.
- The rpm cannot self-update (a Tauri limitation) and needs
mpv-libsinstalled. Only the AppImage and the Windows setup.exe update themselves. - This violates YouTube's Terms of Service, and it will break when YouTube changes
player.jsor rotates its client versions. The repo carries a disclaimer: no affiliation with YouTube or Google, and every trademark belongs to its owner. It is not a sanctioned integration and it was never going to be.
97 commits and 31 releases in 26 days, 845 downloads, 17 stars, and a client identity table that will need editing again the next time YouTube ships a player.
Limusic on GitHub · the landing page · Metrolist, which it came from
Rust · Tauri 2 · SvelteKit · libmpv · GPL-3.0