The previous post ended with a Tauri 2 desktop scaffold standing on top of an overlay-mpv embed. That scaffold is gone. The production desktop client is now Qt 6 + cxx-qt at apps/mythos-qt/, with libmpv rendered into a QQuickFramebufferObject and IPC over QWebChannel. While that was happening, the library-admin surface picked up a stack of features that make running a Mythos library on changing filesystems feel much less like restarting a daemon.

Why retire Tauri

The Tauri client got far enough to play a movie, and the previous post’s “two known limits” each turned into a deeper problem on contact:

  • The overlay embed (Tauri main window as mpv’s wid parent) needed a transparent webview above an opaque mpv surface to compose chrome over video. WebKitGTK transparency on Linux is honored unevenly, and the Chromium-based webview Tauri uses on platforms where WebKitGTK isn’t available (or that fall back through the Vulkan backend on NVIDIA + Wayland-XWayland) silently dropped the alpha channel. We shipped a working spike with a GtkGLArea underlay on Linux, but cross-platform parity was a tunnel of platform-specific GL contexts and raw-window-handle quirks.
  • Wayland sessions reporting wl_surface handles weren’t supported by libmpv’s wid path at all, and the force-window=yes fallback popping into a second window was never the right shape for a desktop product.

Both problems share a root cause: trying to make a webview transparent and compose a separate video surface behind it. Qt’s QtQuick scene graph composes the other way — the FBO is a node in the scene, the chrome is QML rendered on top of it, and the webview is just another QML item — so we don’t need a transparent webview at all.

The Qt 6 + cxx-qt port

Three risk-gate spikes preceded the production code, each kept as a discoverable [[bin]] in apps/mythos-qt/Cargo.toml for reference:

  1. spike-qt-cxx — proved cxx-qt + Qt 6.5+ + cargo could build a working executable on the target platforms (the build-chain is the reason most Rust+Qt projects don’t exist).
  2. spike-mpv — proved mpv’s render API would composite into a QQuickFramebufferObject without RHI sampling stale frames. It turned out yes — if the render call is bracketed with QQuickWindow::beginExternalCommands() / endExternalCommands(). Without those, RHI keeps sampling the FBO it cached on the previous frame; mpv eventually logs “render() not being called or stuck” after the first frame. The pure-QtQuick spike doesn’t need this; QtWebEngine in production does.
  3. spike-webchannel — proved QWebChannel + cxx-qt could do an IPC roundtrip from QML / JS into a Rust QObject and back.

With all three green, the port pulled the old Tauri-specific Rust into a new mythos-desktop-core crate (libmpv handle, ServerClient, keychain, AppError) so any future desktop shell can reuse it without copying. The Qt crate consumes that core and wires it to QObject bridges over QWebChannel: auth_bridge, servers_bridge, playback_bridge.

The video-region pattern

The biggest architectural difference from the Tauri client is how chrome composes onto video. Instead of trying to lay a transparent webview over mpv, the SPA reports a target rect via setVideoRegion(x, y, w, h); QML binds MpvVideo’s geometry to those properties; and a native QML chrome layer (top overlay + scrubber + mini-row) renders in the strips around the video rect. The webview content (the SPA chrome that isn’t related to the player) stays in a sibling QtWebEngineView; modal mode reserves the strips for QML chrome, mini mode shrinks the rect to the bar at the bottom of the page.

Three things fall out of this naturally:

  • Click-pass-through to the video is blocked. The QML chrome consumes clicks on the strips; mpv only gets pointer events that hit the video rect itself. Worth pointing out because the obvious alternative — let click events propagate everywhere and rely on z-order — leaks player interactions into a webview that has no idea the player is open.
  • Fullscreen toggles Window.visibility, not Chromium fullscreen. f flips between FullScreen and Windowed on the Qt side rather than going through the Chromium key handler — which means the SPA doesn’t get to fight us about whether the page or the controls own the fullscreen API surface.
  • The QML chrome auto-hides on idle. mpv’s OSD is silenced because the QML scrubber would otherwise duplicate it.

Bundling the SPA into the desktop binary

The Tauri client ran pnpm dev against a separate dev server; the Qt client takes the same web/build/ the embedded server SPA uses and bakes it into the desktop binary via rust-embed. QtWebEngine reaches it through a custom mythos:// URL scheme handled by a QWebEngineUrlSchemeHandler. mime_guess derives Content-Type from the requested path so QtWebEngine treats .css as a stylesheet and .wasm as wasm.

The scheme registration matters for one other reason: the SPA needs to fetch plain-http Mythos servers on a LAN, and a https-or-localhost secure-context rule would block that. The custom scheme is registered without the SecureScheme flag, so Chromium doesn’t apply that gate to the bundled UI. (Earlier the scheme was registered as secure; it broke every server fetch with https:// in the address bar but fetch('http://mythos.lan:8080/...') blocked by mixed-content rules.)

What still doesn’t work

  • ABR-variant selection from mpv’s HLS demuxer isn’t exposed over IPC. The player’s quality picker is a no-op against the desktop player. Mpv typically direct-plays anyway, so this matters less in practice than it reads — but it’s the one capability gap that still distinguishes the browser and desktop backends.
  • No bundled installers. Today you cargo run -p mythos-qt --release from source; a packaging story (cargo dist, Linux AppImage, macOS .app) is on the list, not in this batch.

Library-admin actions

Running a Mythos library against a moving filesystem used to mean “trigger a full library rescan, wait, hope”. Three new admin actions make targeted operations possible:

POST /api/libraries/{id}/refresh-art

Re-downloads posters, backdrops, season posters, and episode stills over what’s already on disk for every enriched item in the library — without re-walking the filesystem. Useful after a TMDb update lands better art, or after a corrupt poster cache. Per-item progress logs land in the scan tracker the same way library scans do.

DELETE /api/libraries/{id}/scan

Cancels an in-flight library scan or art refresh. Internally it calls AbortHandle::abort on the spawned task and flips the per-library tracker to Completed { cancelled: true }. The same tracker hosts both operation kinds via a ScanKind tag (Scan / RefreshArt), so they share a slot — a library can’t run both concurrently, but the UI uses the kind to swap labels (“Scanning…” vs “Refreshing art…”, “Cancelled · Ns elapsed” vs “Completed Ns elapsed”). A task that finishes between abort() and its next await point can’t clobber the cancelled record because tracker.complete no-ops when the cancelled flag is already set.

POST /api/series/{id}/rescan and POST /api/series/{id}/rematch

Per-series surgery. Rescan runs a sort_title-scoped filesystem walk that upserts new episode files and prunes vanished ones, without rerunning the whole library. Rematch clears the existing tmdb_id and child art / overview, then re-searches against TMDb — for the case where the first match was wrong and the file’s been on disk too long for the original heuristic to catch the right answer on its own.

There’s also an auto-rematch on directory rename: during a scan, if a series row’s prior tmdb_id was non-null but every media_file we saw this pass was newly inserted, that’s the signature of a renamed directory. The match gets cleared and a fresh search spawns into the same series_search_tasks JoinSet. So mv "Severance (2022)" "Severance" no longer needs an operator to know they should hit Rematch.

Per-season posters

TMDb’s per-season poster URLs are now downloaded the same way the series-level posters are — gated on season.poster_url.is_none() so rescans don’t re-hit them. The browse UI was already wired for them; the data side just needed to land.

Home + nav polish

Smaller stuff, but each one was the kind of friction that adds up:

  • “Recently added” rails on the home page. The home page’s per-library rails used to show the first N items by title — useful on a fresh library, but on a library you’ve been adding to for months the first N items are the least relevant. The rails now call the library endpoint with a sort=recent query parameter (added on /api/libraries/{id}/{movies,series}) that orders by created_at DESC, id DESC — using UUID v7 as a same-batch tiebreaker. The sort=title default keeps library browse alphabetical.
  • Continue-watching tiles got independently-linkable title, season, and episode. The whole tile used to navigate to the episode; now the title routes to the series page, “S02E03” routes to the season, and the episode label stays on the episode page — so you can pop out one level without backing into history.
  • Hover-to-open Libraries + Settings dropdowns. The nav used to flatten every library and every settings page into the top bar; it now collapses them into two dropdowns that open on hover (gated on (hover: hover) and (pointer: fine) so touch devices don’t get hover-to-open behavior they can’t dismiss). The Home link was dropped — clicking the wordmark already goes home.
  • Back/forward scroll restoration. SvelteKit’s default is to clamp scrollY to 0 on navigation; that means hitting back from a detail page lands you at the top of the library you were scrolled halfway through. beforeNavigate now snapshots scrollY per pathname; on a popstate-with-saved-offset, disableScrollHandling keeps SvelteKit’s clamp out of the way and afterNavigate rAF-polls document.scrollHeight until the page has grown tall enough to honor the prior offset — capped at 2 s so a navigation to a page that genuinely is shorter doesn’t hang the scroll.

Scanner + HLS fixes

Two not-flashy fixes worth calling out because each shipped after a visible regression and the why is non-obvious.

walk_series_dir past Season XX wrappers

Layouts like Show - Foo/Season 03/Show Foo S03E01 - Title/episode.mkv used to mint a second series row at the Season 03 level. The fix lives in walk_series_dir: when peeling the per-episode dir name to derive the series sort key, the walker now climbs past all skippable / season-suffixed ancestors before falling back. And rescan_series now calls series_repo.prune_empty_for_library so any duplicate row left over from the previous behavior — whose episodes just migrated to the canonical row via the (library_id, path) UNIQUE upsert — gets swept the same way the full scan does it.

Frame-aligned EXTINF in transcode-mode HLS

Hls.js was occasionally jumping small SourceBuffer holes (1–2 s visible skips) during transcode-mode playback. The cause: ffmpeg’s -force_key_frames expr:gte(t,n_forced*6.0) puts the cut on the first frame at-or-after the 6 s mark — so for 23.976 / 29.97 / 59.94 fps sources a “6 s” segment is actually ~6.006 s. The synthetic playlist was hard-coding 6.000, accumulating drift against MSE’s real PTS, and hls.js eventually treated the drift as a gap.

mythos_stream::build_variant_playlist now takes an Option<(num, den)> fps hint and uses segment_boundaries to emit frame-exact EXTINF lines; the API handler probes via mythos_scan::probe_video_fps and threads the rational through. Remux-mode playlists were already deriving EXTINF from the keyframe index, so they were never affected — just the transcode-mode path.

What’s next

Still Phase 3 sub-phases: music, photos, books. Phase 6 (the Jellyfin-API compatibility shim) waits for those to land. The Qt client’s bundled-installer and ABR-variant-selection gaps stay on the parallel follow-up track.

Source on GitLab .