blog
rusttauriaudio

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

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.

RepoSimoHypers/limusic (GPL-3.0)
First commit3 July 2026
Latest releasev0.3.4, 29 July 2026
Shipped31 releases, 97 commits
PlatformsLinux (self-updating AppImage, rpm), Windows (self-updating setup.exe, msi)
Downloads845 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.

  1. 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.
  2. Maintainable stream extraction. The signature cipher, the n-transform and the PoToken layers break whenever YouTube ships a new player.js or 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#

workspace layout
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:

ui/src/lib/api.ts
// 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.

AreaLines
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 primitives5,856
TypeScript in ui/src2,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:

crates/innertube/clients.json
{
  "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:

crates/innertube/src/transport.rs
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:

RuleWhat it prevents
Metadata always from the main clientThe VR clients return playable URLs and thin metadata
WEB_REMIX skips HEAD validationIts authenticated URL answers 403 to a HEAD, then streams on GET
The last client is accepted unvalidatedRejecting it leaves nothing to fall through to
Self-heal runs off the hot pathA config refresh would stall the loop while someone waits for sound
PoToken and cipher failures degrade, never abortBoth layers are optional for clients that need neither

The loop itself is one range, and the -1 is the trick:

src-tauri/src/orchestrator.rs
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:

src-tauri/src/cipher/extractor.rs
/// 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:

TierMechanismBreaks how
1Config table keyed by player.js hash, refreshable remotelyA break is a config push, not a release
2Regex patterns over the sourceSilently, on the next obfuscation change
3Brute force inside the webviewOnly 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:

discovery harness
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:

potoken harness
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:

src-tauri/src/cipher/mod.rs
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:

src-tauri/src/webview.rs
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.

src-tauri/tauri.conf.json
"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:

crates/player/src/lib.rs
// 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:

crates/player/src/lib.rs
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.

ProcessPSS at v0.3.2
WebKitWebProcess (UI content)451 MiB
limusic-app (Rust plus UI process)211 MiB
WebKitNetworkProcess36 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 use M_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 on WEBKIT_CACHE_MODEL_WEB_BROWSER, sized against total system RAM. The on-disk WebKitCache was 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:

TheoryResult
Smaller thumbnail source sizes400px: 117 MiB. 192px: 119 MiB. No difference, WebKit discards decoded data for offscreen images
content-visibility: auto on card trees119/135 MiB without, 129/128 MiB with. Nothing
Dropping backdrop-filter, hover transforms, shadow transitions208/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:

blank300 cards
GPU compositing97 MiB135 MiB
software rendering62 MiB245 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:

crates/listen-protocol/src/lib.rs
/// 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
.AppImage156.8 MB
.msi55.2 MB
-setup.exe40.4 MB
.rpm12.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-libs installed. 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.js or 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