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
widparent) 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 aGtkGLAreaunderlay on Linux, but cross-platform parity was a tunnel of platform-specific GL contexts andraw-window-handlequirks. - Wayland sessions reporting
wl_surfacehandles weren’t supported by libmpv’swidpath at all, and theforce-window=yesfallback 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:
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).spike-mpv— proved mpv’s render API would composite into aQQuickFramebufferObjectwithout RHI sampling stale frames. It turned out yes — if the render call is bracketed withQQuickWindow::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.spike-webchannel— proved QWebChannel + cxx-qt could do an IPC roundtrip from QML / JS into a RustQObjectand 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.fflips betweenFullScreenandWindowedon 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 --releasefrom 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=recentquery parameter (added on/api/libraries/{id}/{movies,series}) that orders bycreated_at DESC, id DESC— using UUID v7 as a same-batch tiebreaker. Thesort=titledefault 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.
beforeNavigatenow snapshotsscrollYper pathname; on a popstate-with-saved-offset,disableScrollHandlingkeeps SvelteKit’s clamp out of the way andafterNavigaterAF-pollsdocument.scrollHeightuntil 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 .