Mythos is a Cargo workspace. Each crate has one responsibility; the mythos-server binary wires them together with axum. The repo also hosts a Qt 6 + cxx-qt desktop client under apps/mythos-qt/ that reuses the SvelteKit UI as a sibling workspace member, plus a host-agnostic mythos-desktop-core crate that the Qt app and any future desktop shells share. Alongside it under apps/ live three more native clients that build with their own toolchains (not Cargo members): a Kotlin/Jetpack-Compose Android phone + Google TV app (apps/mythos-android), a SwiftUI Apple tvOS app (apps/mythos-tvos), and an LG webOS TV launcher (apps/mythos-webos).

The crates

CrateResponsibility
mythos-serverMain binary. Loads config, runs migrations, builds the axum app, embeds and serves the SvelteKit SPA.
mythos-coreShared domain types (MediaItem, MediaKind, …) plus the playback::{PlaybackRequest, PlaybackDecision} wire contract that the desktop client (and the future Jellyfin shim) speak.
mythos-dbSQLite pool + sqlx::migrate! runner. Re-exports SqlitePool.
mythos-authargon2id password hashing, HS256 JWT issuance, AuthUser / AdminUser extractors. Errors deliberately don’t implement IntoResponse — translation to HTTP lives in mythos-api so the crate stays usable from non-HTTP contexts.
mythos-apiaxum routers and handlers: auth, library, movie, series, episode, scan, play, hls, subtitles, settings, search, collections, credits, shares, and the public, pre-auth server-info.
mythos-scanFilesystem walker (jwalk) and ffprobe driver. Movie and TV branches share the walk; the TV branch parses SxxEyy / 1x01 with a season-dir fallback. Movie identification prefers (YYYY)-bracketed years over bare year tokens so titles like Blade Runner 2049 (2017) parse correctly.
mythos-metaTMDb client with an on-disk poster cache. Handles movies, TV series, seasons, and episode stills. The movie enrichment pass retries without the year hint when the first attempt misses, rescuing year-typo files. MusicBrainz / OpenLibrary land later in Phase 3.
mythos-streamDirect-play byte-range responses, FFmpeg HLS transcoder, ABR ladder, hardware-encoder probe, subtitle burn-in, HDR→SDR tonemapping pipeline. Movies and episodes share the streaming surface via a SessionKey { user_id, item_id, kind } so their transcode sessions can’t collide.
mythos-desktop-coreHost-agnostic desktop primitives — libmpv handle, ServerClient (auth + servers + playback HTTP), keychain, AppError. Reused by apps/mythos-qt and any future desktop shell.

Outside crates/, apps/mythos-qt/ is a Qt 6 + cxx-qt workspace member that reuses the same SvelteKit codebase as the server’s embedded SPA. The SPA is baked into the desktop binary via rust-embed and served to QtWebEngine through a custom mythos:// URL scheme handled by a QWebEngineUrlSchemeHandler. Runtime backend selection ('qt' in window && 'webChannelTransport' in window.qt) picks libmpv-via-QWebChannel vs. <video> + hls.js at startup.

Workspace dependencies live at the root Cargo.toml under [workspace.dependencies]; member crates pull them with dep.workspace = true.

The runtime

A single Tokio runtime hosts everything. The HTTP server, the scanner workers, and any active transcoding sessions share the same scheduler.

The SPA gets into the binary at build time: crates/mythos-server/build.rs runs pnpm install && pnpm build in web/, producing web/build/. rust-embed bakes that directory in. The fallback handler in crates/mythos-server/src/web.rs serves embedded assets for known paths and falls back to index.html for everything else, so client-side routing works.

In debug builds, rust-embed reads from disk at runtime — SPA changes show up after pnpm build without a Rust rebuild.

State

SQLite is the only backend. The schema is migration-managed (migrations/) and small enough to inspect by hand. Posters, transcode segments, and extracted subtitles live on disk under data_dir.

The current schema (migrations 0001–0025):

  • users — argon2id hashes, token_version for forced logout.
  • libraries — root paths the scanner walks. Each carries an owner_id (backfilled to the oldest admin) and a visibility of public or restricted (migration 0023).
  • media_files — one row per file on disk, with ffprobe columns and color_primaries / color_transfer / color_space for HDR detection (migration 0009), plus a dv_profile column carrying the Dolby Vision profile parsed from ffprobe’s DOVI side data (migration 0025).
  • media_backdrops — per-item backdrop images proxied from TMDb so the UI’s Plex-style ambient gradient and featured-backdrop hero never hit TMDb directly (migration 0010).
  • media_file_keyframes — per-file keyframe index so remux-mode HLS aligns its segment boundaries to real IDR frames instead of best-guess timestamps (migration 0011). Only true IDR slice NALs are recorded (type 5 for H.264, types 19/20 for HEVC); CRA and BLA frames are excluded because their RASL leading pictures reference frames from before the boundary and would stutter when the player starts playback there. Open-GOP HEVC files (most modern release groups) therefore end up with empty indexes — /play auto-downgrades them to a full transcode instead of remuxing.
  • movies — one row per identified movie, pointing at a media_files row.
  • seriesseasonsepisodes — TV identity. Each episodes row FKs 1:1 to a media_files row, mirroring how movies does, so subtitles, byte-range streaming, and HLS transcoding work for episodes without any branch in the streaming code.
  • movie_progress / episode_progress — debounced watch position per user, per kind.
  • media_subtitles — extracted text subs, image-sub render artifacts, and discovered .srt sidecars (a sidecar flag on the row marks the last group; added in migration 0012).
  • collections (+ membership) — Plex-style franchise groupings, auto-generated from TMDb belongs_to_collection or hand-curated, library-scoped (migration 0015).
  • people / media_credits — cast & crew enriched from TMDb (top-billed cast plus directors / writers / producers), with cached headshots (migrations 0018–0020).
  • library_shares — explicit per-user grants for restricted libraries (migration 0023).
  • settings — runtime-configurable settings (TMDb key, tonemap pipeline
    • algorithm, the server_display_name clients read over /api/server-info (migration 0024), etc.).

Tables for tracks, albums, artists, photos, and books don’t exist yet — they ship later in Phase 3 alongside their scanners.

IDs are UUID v7, stored as TEXT. Timestamps are ISO-8601 UTC strings.

Auth

Passwords are hashed with argon2id. On login the server issues an HS256 JWT, which it delivers two ways:

  • Web clients get a SameSite=Lax HttpOnly cookie. The cookie’s Secure flag follows the cookie_secure config knob (defaults to true in release builds, false in debug).
  • API clients can present the same token as Authorization: Bearer ….

The signing key resolves in this order: MYTHOS_JWT_SECRET (base64, ≥32 bytes) → {data_dir}/jwt.secret → a freshly generated 32-byte key, atomically written to disk. Tokens carry a per-user token_version so revoking sessions is one bump.

Multi-server & sharing

A Mythos server stays single-tenant — multi-server pairing is a client concern. The server doesn’t know about other servers and runs no mDNS advertiser. Each client (web, Qt, Android, tvOS) keeps its own list of paired servers and fans browse, search, and continue-watching out across all of them; real pairing is just a URL plus a login. The only server-side support is a public, pre-auth GET /api/server-info{ display_name, version } that a client reads to label a freshly-paired server (the display name is the server_display_name setting, seeded by migration 0024, editable in Settings; it falls back to "Mythos").

On the web client the registry lives in web/src/lib/servers/registry.svelte.ts: a synthetic same-origin entry (cookie auth) is rebuilt every boot from /api/server-info, while cross-origin pairings (bearer JWT) persist in localStorage. Routes are namespaced under /s/<serverId>/…, and API calls split into an active-server family (apiGet / authedFetch, used by login / setup / admin) and a server-scoped family (serverGet / serverFetch(server, …)) that carries the bearer token, times out, and flips the server to offline on failure.

Library visibility & sharing is the one place a single server is multi-user. A library’s visibility is public (every authenticated user) or restricted (owner + admins + rows in library_shares); new libraries default to restricted. Content endpoints gate through require_*_view / require_*_manage helpers and return 404, not 403, on an invisible item so existence isn’t leaked. The sharing API lives under /api/libraries/{id}/shares.

The streaming pipeline

Two paths converge at the player:

  • Direct play. GET /api/movies/:id/stream returns the file with HTTP byte-range support. The web client wraps a plain <video> element in media-chrome for the player UI; HLS is fed by hls.js when the transcode path kicks in. Watch progress is debounced and persisted server-side so resume works across devices.
  • HLS transcode. If the client’s declared profile says the file is unplayable, Mythos spawns an ffmpeg session via the TranscodeManager, produces segmented HLS, and serves segments on demand. The player calls DELETE /api/movies/:id/hls on teardown so ffmpeg subprocesses don’t leak — every new transcode entry point routes through TranscodeManager to keep the lifecycle centralized.

The HLS pipeline supports multi-rendition ABR. Hardware encoders are probed and smoke-tested at startup — a build with NVENC compiled in but no working driver falls back cleanly. Priority order is NVENC → QSV → VAAPI → VideoToolbox → libx264. The NVENC path stays on the GPU end-to-end via NVDEC + scale_cuda so the frame never round-trips through system RAM.

ABR ladder

The ladder runs from 360p through 2160p (4K). The 2160p rung carries H.264 Level 5.1 codec hints so clients don’t reject it on profile-level grounds. Alongside the AVC ladder, Mythos offers an HEVC ladder (720p / 1080p / 2160p) for clients that advertise HEVC support, so a 4K HDR source can stay HEVC end-to-end instead of being forced down to H.264.

Two passes guard against upscaling, both inside mythos-stream:

  • prune_for_source drops tiers that would only upscale the source from the master playlist before ffmpeg ever runs. The comparison is width-aware — it pits max(source_w, source_h) against max(tier_w, tier_h) — so a cinematic 4K master at 3840×1606 (≈ 2.39:1 letterbox) keeps the 2160p tier even though its scanline count sits below 2160. Pruning happens on /play, on /playback, and inside hls::resolve_renditions, so a hand-crafted ?v=2160p URL on a 1080p source still can’t steer the encoder into upscaling.
  • The scale_filter is fit-in-box, not pin-height. Each tier is treated as a box the output fits inside via scale=w='min(iw,W)':h='min(ih,H)':force_original_aspect_ratio=decrease (with the matching scale_vaapi / scale_cuda forms on the hardware paths). Picking the 2160p tier on a 3840×1606 source produces 3840×1606, not an upscaled 5165×2160. The HLS master still advertises the tier’s nominal RESOLUTION — that attribute is a hint to clients, not a contract on encoded dimensions.

The client’s max_height is intentionally not applied to the ladder. Instead the SPA exposes a manual quality picker in the player chrome — Auto plus every source-feasible tier — so the viewer can pin a rung above their screen size when they want to; hls.js’s bandwidth-driven auto-selection handles the rest in Auto mode. The contract lives in PlaybackBackend.setQualityLevel(idx) (web/src/lib/player/backend.ts) with -1 meaning “hand back to ABR auto”; the web backend mirrors onto hls.currentLevel and listens for LEVEL_SWITCHED so the UI can surface what ABR actually picked. The mpv backend stubs to an empty levels list (mpv’s HLS demuxer doesn’t expose ABR variants over IPC, and the desktop client typically direct-plays anyway).

HDR → SDR tonemapping

For HDR sources, Mythos applies an explicit tonemap filter in the transcode graph before encoding. The choice of filter pipeline (software / Tonemapx / VAAPI / OpenCL / CUDA) and algorithm (Hable / Mobius / Reinhard / BT.2390) is admin-configurable from the settings UI and persisted in the settings table. Mythos probes ffmpeg at startup for the tonemap filters it actually has compiled in, and pipelines whose filter isn’t present silently fall back to software so a missing build feature can’t break playback.

Tonemapx is jellyfin-ffmpeg’s SIMD-optimised CPU tonemap kernel — much faster than the stock tonemap filter on the CPU path, but only available when ffmpeg is jellyfin-ffmpeg. The Docker image already points MYTHOS_FFMPEG_BIN / MYTHOS_FFPROBE_BIN at it; on bare-metal installs set those env vars to enable the option. It’s also the practical answer on Intel Gen 12+ (Iris Xe and newer), where the opencl pipeline is broken at the NEO driver level — NEO no longer advertises cl_intel_va_api_media_sharing, so the hwmap step returns ENOSYS. The Docker image deliberately omits intel-opencl-icd for the same reason.

Source HDR detection uses the color_primaries / color_transfer / color_space columns on media_files; if those are still NULL (a library scanned before migration 0009), the first HDR play self-heals them by ffprobing on demand.

Dolby Vision

Dolby Vision sources always transcode until a client declares supports_dolby_vision. The scanner reads the DV profile from ffprobe’s DOVI configuration record and persists it on media_files.dv_profile (migration 0025); the playback decision then forces a video transcode whenever a file has a DV profile and the client hasn’t opted in. The reason is that naïve HEVC decoders — every Android phone’s ExoPlayer, hls.js + browser <video>, libmpv without DV wired through — demux the base layer but choke on the DV NALs, surfacing as a black picture with working audio. Transcoding decodes the base layer into HDR10 frames, drops the DV NALs on the encode side, and the existing tonemap chain handles HDR→SDR from there. No Mythos client declares DV support today.

GET /api/search?q=… returns a flat, ranked list of movies and series that match the query. The current implementation is a case-insensitive LIKE against sort_title via mythos_db::SearchRepo — it’ll graduate to SQLite FTS5 once libraries get big enough to chug. The web client binds the endpoint to a single search box with keyboard navigation (/ walks results, Enter opens).

Player overlay lifecycle

The video player is hoisted into the root layout (+layout.svelte) and managed by a singleton playbackSession (web/src/lib/player/session.svelte.ts). Pages don’t mount the player themselves — they call playbackSession.open(...) and a single overlay component decides whether to render as a fullscreen modal or as an 88 px mini-bar pinned to the bottom of the page.

The overlay toggles between modes via a data-mode attribute; the same <Player> instance is rendered in both modes so the <video> element, the media-controller, the playback backend, and the active HLS session are not torn down on a mode swap. Modal-only chrome (top overlay, scrubber and buttons bars, subs menu, up-next countdown, info strip) is {#if mode === 'modal'}-gated; the mini chrome is a .mini-row sibling of <media-controller> driven by the same backendPaused / backendPositionMs mirrors the modal uses, so it works identically in browser and Qt/mpv modes.

Clicking the small video in the mini-bar calls playbackSession.expand() to restore the fullscreen modal. Esc on the modal minimizes rather than closes — the explicit X is the only true “close” action.

playbackSession.minimize() also calls document.exitFullscreen() before flipping data-mode to 'mini'. HTML5 fullscreen pins <media-controller> as the fullscreen element and a pure-CSS layout swap doesn’t dislodge it; without the explicit exit, the mini-bar never gets a chance to render and the user sees a “controls vanished” fullscreen page.

HLS session teardown

HLS sessions are torn down via two paths that both call DELETE /api/{movies,episodes}/:id/hls:

  • playbackSession.close() and playbackSession.open() (with a different item) fire stopTranscodeSession synchronously, so the DELETE goes out the instant the user closes or swaps the player rather than waiting on the overlay’s fade transition.
  • The Player’s $effect cleanup also calls stopTranscodeSession on unmount — a safety net for paths that bypass the session (beforeunload on page reload, manual route navigation outside the SPA router).

Every new transcode entry point routes through TranscodeManager so the lifecycle stays centralized and ffmpeg subprocesses don’t leak.

Desktop client (Qt 6 + libmpv)

apps/mythos-qt/ is a Qt 6 + cxx-qt shell around the same SvelteKit codebase as the server’s embedded SPA. The same routes, the same components, the same Player.svelte — but at runtime the playback layer dispatches to libmpv via QWebChannel instead of <video> + hls.js. Mpv’s position / paused state is mirrored back into the same Svelte stores the browser backend writes to, so Player.svelte doesn’t branch on backend.

The SvelteKit chrome runs in a QtWebEngineView. The SPA is baked into the desktop binary via rust-embed and served through a custom mythos:// URL scheme handled by a QWebEngineUrlSchemeHandler. The custom scheme also opts the SPA out of Chromium’s secure-context rules, so the bundled UI can fetch plain-http Mythos servers on a LAN — a SecureScheme flag was deliberately not registered for the same reason.

IPC runs over QWebChannel. Rust exposes QObject bridges (auth_bridge, servers_bridge, playback_bridge) via cxx-qt; the SPA talks to them through the standard qt.webChannelTransport surface. Runtime backend detection — 'qt' in window && 'webChannelTransport' in window.qt — picks the libmpv path; otherwise the SPA stays on <video> + hls.js.

Mpv runs as Arc<Mpv> in the host process; its event loop lives on a separate thread with its own EventContext::new(mpv.ctx) (the libmpv2 test pattern, because Mpv::event_context_mut would need &mut Mpv which is incompatible with the Arc<Mpv> shared by bridge commands).

Video composes via the video-region pattern — sidestepping transparent-WebEngineView overlays, which are unreliable on NVIDIA + Wayland-XWayland + Chromium’s Vulkan fallback. A MpvVideo QQuickFramebufferObject renders mpv frames into a Qt OpenGL FBO via mpv’s render API; the SPA reports the desired rect to Rust as setVideoRegion(x, y, w, h) and a QML layer binds MpvVideo’s geometry to those properties. A native QML chrome layer (top overlay + scrubber) renders on top of the FBO in the strips around the video, with click-pass-through blocked so chrome hits don’t reach the mpv surface. Idle-auto-hide on the QML chrome, f-key fullscreen routed through Qt directly (rather than the Chromium key handler), and <media-controller>-via-QWebChannel state mirroring keep the modal feel identical to the browser SPA.

Qt 6 RHI requires mpv_render_context_render to be bracketed with QQuickWindow::beginExternalCommands() / endExternalCommands() — without it RHI keeps sampling a stale FBO and mpv eventually logs “render() not being called or stuck” after the first frame. The pure-QtQuick risk-gate spike doesn’t need this; QtWebEngine in production does.

Subtitles split by format, not by source. mpv’s sid property uses a per-type 1-based track id — sid=1 is the first subtitle track in the container, regardless of where it sits in the overall stream order — not the ffprobe stream index. All text subs, sidecar .srt and embedded (subrip / ass / mov_text / …) alike, therefore route through Mpv::set_external_sub, which fires sub-add <vtt_url> select against the server’s /api/{movies,episodes}/{id}/subtitles/{sub_id}/vtt extractor. The endpoint covers both sources — sidecars are served verbatim; embedded text streams are extracted on demand via -map 0:N. sub-add requires a loaded file, so the SPA defers text picks to after Mpv::load() resolves; the bearer header set during load() carries over to the VTT URL. Image subs (PGS / VOBSUB) can’t be extracted as WebVTT and stay on the sid path — they work in practice because mpv’s auto-selection on file load usually lands the right track already.

Limits today, both planned follow-ups:

  • 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, so this matters less in practice than it reads.
  • Bundled binary distribution (installers / cargo dist) is pending; the desktop client is built from source today via cargo run -p mythos-qt.

Mobile & TV clients

Three more native clients live under apps/ and build with their own toolchains, not as Cargo workspace members. Unlike the Qt app — which reuses the SvelteKit SPA — these are written natively against the same REST API, and each pairs with multiple servers at once:

  • apps/mythos-android — Kotlin + Jetpack Compose, two apps from one Gradle build: a phone/foldable app (Material 3 Adaptive + foldable postures) and a D-pad-native Google/Android TV app (Compose for TV). Playback runs on Media3/ExoPlayer with the same HLS-vs-transcode decisioning and audio passthrough, gated by a runtime sink probe.
  • apps/mythos-tvos — native SwiftUI on AVPlayer with a custom chrome suite; an Xcode project, built with Xcode.
  • apps/mythos-webos — a thin LG webOS launcher (.ipk) that loads the server’s SPA over the LAN; packaged with ares-package.

Why Rust

Three reasons:

  1. Single binary. cargo build --release produces one statically linked executable. The SPA is baked in. There is no runtime to install, no node_modules to ship.
  2. Predictable performance. No GC pauses while you’re seeking. The transcode supervisor can hold its frame budget without surprises.
  3. Memory honesty. Long-running media servers accumulate small leaks. Lifetimes and Drop make those visible at compile time.