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
| Crate | Responsibility |
|---|---|
mythos-server | Main binary. Loads config, runs migrations, builds the axum app, embeds and serves the SvelteKit SPA. |
mythos-core | Shared domain types (MediaItem, MediaKind, …) plus the playback::{PlaybackRequest, PlaybackDecision} wire contract that the desktop client (and the future Jellyfin shim) speak. |
mythos-db | SQLite pool + sqlx::migrate! runner. Re-exports SqlitePool. |
mythos-auth | argon2id 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-api | axum 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-scan | Filesystem 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-meta | TMDb 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-stream | Direct-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-core | Host-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_versionfor forced logout.libraries— root paths the scanner walks. Each carries anowner_id(backfilled to the oldest admin) and avisibilityofpublicorrestricted(migration 0023).media_files— one row per file on disk, withffprobecolumns andcolor_primaries/color_transfer/color_spacefor HDR detection (migration 0009), plus adv_profilecolumn 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 —/playauto-downgrades them to a full transcode instead of remuxing.movies— one row per identified movie, pointing at amedia_filesrow.series→seasons→episodes— TV identity. Eachepisodesrow FKs 1:1 to amedia_filesrow, mirroring howmoviesdoes, 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.srtsidecars (asidecarflag on the row marks the last group; added in migration 0012).collections(+ membership) — Plex-style franchise groupings, auto-generated from TMDbbelongs_to_collectionor 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 forrestrictedlibraries (migration 0023).settings— runtime-configurable settings (TMDb key, tonemap pipeline- algorithm, the
server_display_nameclients read over/api/server-info(migration 0024), etc.).
- algorithm, the
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=LaxHttpOnly cookie. The cookie’sSecureflag follows thecookie_secureconfig knob (defaults totruein release builds,falsein 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/streamreturns the file with HTTP byte-range support. The web client wraps a plain<video>element inmedia-chromefor the player UI; HLS is fed byhls.jswhen 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
ffmpegsession via theTranscodeManager, produces segmented HLS, and serves segments on demand. The player callsDELETE /api/movies/:id/hlson teardown soffmpegsubprocesses don’t leak — every new transcode entry point routes throughTranscodeManagerto 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_sourcedrops tiers that would only upscale the source from the master playlist beforeffmpegever runs. The comparison is width-aware — it pitsmax(source_w, source_h)againstmax(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 insidehls::resolve_renditions, so a hand-crafted?v=2160pURL on a 1080p source still can’t steer the encoder into upscaling.- The
scale_filteris fit-in-box, not pin-height. Each tier is treated as a box the output fits inside viascale=w='min(iw,W)':h='min(ih,H)':force_original_aspect_ratio=decrease(with the matchingscale_vaapi/scale_cudaforms 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 nominalRESOLUTION— 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.
Title search
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()andplaybackSession.open()(with a different item) firestopTranscodeSessionsynchronously, 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
$effectcleanup also callsstopTranscodeSessionon unmount — a safety net for paths that bypass the session (beforeunloadon 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 viacargo 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 onAVPlayerwith 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 withares-package.
Why Rust
Three reasons:
- Single binary.
cargo build --releaseproduces one statically linked executable. The SPA is baked in. There is no runtime to install, nonode_modulesto ship. - Predictable performance. No GC pauses while you’re seeking. The transcode supervisor can hold its frame budget without surprises.
- Memory honesty. Long-running media servers accumulate small leaks.
Lifetimes and
Dropmake those visible at compile time.