Ferrex is a Rust workspace where server, player, and shared core evolve in lockstep. One language, one build system, shared types across the entire stack.
System overview #
graph LR
subgraph player[ferrex-player]
domains[10 Domains
DDD]
shaders[GPU Shaders
posters, bg]
subwave[Subwave
video]
end
subgraph server[ferrex-server]
handlers[Handlers
REST/WS]
orchestration[Orchestration
scan jobs]
auth[Auth
sessions]
end
subgraph core[ferrex-core]
queue[Queue Service
lease-based]
events[Event Bus
broadcast]
dispatcher[Job Dispatcher
5 job types]
end
model[(ferrex-model
DTOs & Entities)]
contracts[(ferrex-contracts
Trait Definitions)]
postgres[(Postgres)]
redis[(Redis)]
gstreamer{{GStreamer}}
player <-.->|REST / WebSocket| server
player --> core
player --> model
player --> contracts
server --> core
server --> model
core --> model
core --> contracts
orchestration -->|SQLx| postgres
handlers -->|rate limits| redis
subwave -->|decode pipeline| gstreamer
Crate responsibilities #
| Crate | Lines | Purpose |
|---|---|---|
| ferrex-player | ~75k | Desktop UI with GPU-accelerated poster grids, 10-domain architecture, keyboard navigation |
| ferrex-server | ~21k | HTTP/WS API, library scanning, metadata enrichment, auth, image caching |
| ferrex-core | ~56k | Orchestration runtime, queue service, job dispatcher, shared domain logic |
| ferrex-model | ~5k | DTOs: Media, Movie, Series, Season, Episode, watch status, filter types |
| ferrex-contracts | ~2k | Trait definitions: MediaLike, MediaIDLike, MediaOps, Browsable, Playable |
| ferrex-config | ~7.5k | Configuration loading and environment handling |
What are traits? Traits are Rust’s way of defining shared behavior—like interfaces in other languages.
MediaLikesays “anything implementing me has a title, year, etc.” This lets the same code work with Movies, Series, and Episodes. Learn more about traits
Player architecture #
The player uses domain-driven design with 10 specialized domains, each owning its state and message handling.
What’s DDD? Domain-Driven Design organizes code around business concepts (domains) rather than technical layers. Each domain owns its state and logic—AuthDomain handles authentication, MediaDomain handles media. Messages route to the right domain automatically. Learn more about DDD
graph LR msg[/DomainMessage/] -->|routes| registry[DomainRegistry] registry -->|broadcasts| event[/CrossDomainEvent/] registry --> auth[AuthDomain] registry --> library[LibraryDomain] registry --> media[MediaDomain] registry --> metadata[MetadataDomain] registry --> playerdomain[PlayerDomain] registry --> streaming[StreamingDomain] registry --> settings[SettingsDomain] registry --> ui[UIDomain] registry --> usermgmt[UserMgmtDomain] registry --> search[SearchDomain]
Message routing #
Every user action becomes a DomainMessage that routes to exactly one domain. When a domain needs to notify others (e.g., authentication changed), it emits a CrossDomainEvent that broadcasts to all domains.
enum DomainMessage {
Auth(AuthMessage),
Library(LibraryMessage),
Player(PlayerMessage),
Ui(UiMessage),
Event(CrossDomainEvent), // broadcast
// ...
}Zero-copy data #
What’s rkyv? Think of it as “memory-mapped JSON”—your data sits in a byte buffer and you read it directly without copying. Perfect for 30k+ item libraries where deserialization would tank performance! rkyv docs
What’s Yoke? It’s a Rust trick that lets you hold a reference into owned data without lifetime gymnastics. The
MovieYoketype means “I own the archived bytes, and I can hand out references to the movie inside.” yoke docs
Media data uses rkyv for zero-copy deserialization. The MediaRepo holds archived bytes, and domains access data through Yoke references without copying:
type MovieYoke = Yoke<&'static ArchivedMovieReference, Arc<AlignedVec>>;This keeps 30k+ library items in memory without deserialization overhead.
Poster rendering pipeline #
The poster grid renders 150+ visible items at 120fps using GPU shader widgets and demand-based loading.
graph LR
scroll([User scrolls]) --> carousel[VirtualCarousel]
carousel --> demand[Demand Plan]
demand --> cache[ImageService]
cache --> hit{Hit}
cache --> miss{Miss}
hit -->|ready| shader[ShaderWidget]
miss --> fetch[API Request]
fetch --> upload[GPU Upload]
upload --> shader
Key optimizations #
- Virtual carousel: Only renders visible rows, calculates demand for prefetch
- Batched GPU uploads: Groups texture uploads to avoid pipeline stalls
- Frame-latency throttling: Monitors frame time, backs off uploads if approaching budget
- Zero-copy cache: rkyv-archived image metadata, yoke references into cache
Orchestration runtime #
Library scanning uses a lease-based job queue with fair scheduling across libraries.
graph LR
subgraph orchestrator[ScanOrchestrator]
scheduler[WeightedFairScheduler
Fair scheduling per-library]
budget[WorkloadBudget
Token-based concurrency]
dispatcher[JobDispatcher
Routes to actor pipelines]
events[InProcEventBus
tokio::broadcast]
end
queue[(Job Queue
Postgres)]
jobs[5 Job Types
FolderScan • MediaAnalyze
MetadataEnrich • IndexUpsert
ImageFetch]
lifecycle[Job Lifecycle
Enqueue - Ready - Leased
Complete • Fail • DeadLetter]
scheduler -->|dequeue eligible| queue
queue --> dispatcher
dispatcher --> jobs
budget -->|grant tokens| dispatcher
events -->|emit events| lifecycle
Lease system #
Jobs are claimed with a 30-second TTL lease. Workers renew at 50% elapsed or when <2 seconds remain. If a worker crashes, housekeeping resurrects expired leases back to ready state.
Fair scheduling #
WeightedFairScheduler ensures no single library starves others. Priority bands (P0-P3) handle urgency:
- P0: Hot filesystem changes, watcher overflow
- P1: User-requested scans, bulk seed
- P2: Maintenance sweeps
Authentication & device trust #
Ferrex uses device-based authentication where trusted devices can authenticate with a PIN for convenience, while new devices require full password authentication.
What’s Argon2id? It’s a memory-hard password hashing algorithm—the current gold standard for protecting passwords. Memory-hard means attackers can’t just throw GPUs at it. Learn more
graph TB
User["User"] -->|username + password| AuthSvc["AuthenticationService"]
User -->|PIN on trusted device| AuthSvc
AuthSvc -->|verify| UserAuth["UserAuthentication
Aggregate"]
AuthSvc -->|manage| DeviceSess["DeviceSession
Aggregate"]
AuthSvc -->|issue| Tokens["SessionToken
RefreshToken"]
DeviceTrust["DeviceTrustService"] -->|register/revoke| DeviceSess
PinMgmt["PinManagementService"] -->|set/rotate| UserAuth
Tokens -->|store hashed| SessionStore["AuthSessionRepository"]
DeviceSess -->|persist| DeviceRepo["DeviceSessionRepository"]
RBAC["Role-Based Access Control"] -->|permissions| UserAuth
Authentication flows #
| Flow | When Used | Token Scope | Lifetime |
|---|---|---|---|
| Password auth | New device, first login | Full | 24h session, 30d refresh |
| PIN auth | Trusted device | Playback | 24h session, 30d refresh |
| Token refresh | Session expired | Inherits | Rotation on each use |
Device lifecycle #
- Pending — Device registered, awaiting PIN setup
- Trusted — PIN configured, can authenticate with PIN
- Revoked — Access revoked, all sessions invalidated
Session security #
- Tokens are 256-bit cryptographically random
- Stored as HMAC-SHA256 hashes (never plain-text)
- Refresh tokens use family+generation tracking to detect reuse attacks
- 3 failed PIN attempts locks device; 5 failed passwords locks account
RBAC permissions #
Categories: users, libraries, media, server, sync
Roles grant permissions; per-user overrides allow exceptions (GRANT/DENY).
Query system #
Media queries use a fluent builder API with intelligent execution strategy selection.
What’s a fluent builder? It’s a pattern where each method returns
self, letting you chain calls like.movies_only().genre("Action").limit(50).build(). Clean and readable!
graph LR
Builder["MediaQueryBuilder"] -->|build| Query["MediaQuery"]
Query -->|validate| Guard["ComplexityGuard"]
Guard -->|analyze| Decision["DecisionEngine"]
Decision -->|choose| Strategy["ExecutionStrategy"]
Strategy -->|ClientOnly| Client["Local filtering"]
Strategy -->|ServerOnly| Server["API request"]
Strategy -->|ParallelRace| Both["Execute both"]
Building queries #
let query = MediaQueryBuilder::new()
.for_user(user_id)
.movies_only()
.genre("Action")
.year_range(2020, 2024)
.rating_range(7.0, 10.0)
.sort_by(SortBy::ReleaseDate, SortOrder::Descending)
.limit(50)
.build();Complexity guard #
Prevents resource-intensive queries from overwhelming the system:
| Factor | Cost | Mitigation |
|---|---|---|
| Fuzzy search | +10 | Min 3 chars required |
| Watch status filter | +5 | Requires expensive joins |
| Multiple genres | +2 each | Logarithmic cost |
| Large offset | +1 per 1000 | Use cursor pagination |
Default limit: 15 points (10 during peak hours).
Decision engine #
Chooses execution strategy based on:
- Data completeness — How much is cached locally?
- Network quality — Latency, bandwidth, packet loss
- Query complexity — Simple sorts vs. multi-field search
When costs are within 500ms, executes ParallelRace (both client and server, returns faster).
Settings architecture #
The player settings are organized into 9 domain-isolated sections, each with its own state, messages, and update handlers.
User sections #
| Section | Purpose |
|---|---|
| Profile | Display name, email, avatar |
| Playback | Auto-play, resume behavior, seeking, subtitles, skip durations |
| Display | Theme, grid size, poster dimensions, spacing, animations |
| Performance | Scroll physics, texture uploads, prefetch windows, carousel tuning |
| Security | Password change, PIN setup/rotation |
| Devices | Trusted device list, revoke access |
Admin sections (permission-gated) #
| Section | Purpose |
|---|---|
| Libraries | Add/edit/delete libraries, trigger scans |
| Users | Create/edit/delete users, manage roles |
| Server | Session policies, device trust policies, password requirements |
Performance tuning #
The Performance section exposes runtime-configurable knobs:
- Scroll physics: decay tau, velocity ramp, boost multiplier
- Texture uploads: max per frame (budget-aware)
- Prefetch windows: rows above/below viewport
- Carousel: snap durations, anchor settle time
Keyboard navigation & animations #
The player is keyboard-first with smooth animations at compositor framerate.
graph LR input([Keyboard Input]) --> focus[FocusManager] focus --> uidomain[UIDomain] uidomain --> motion[MotionController] motion --> render([GPU @ 120fps])
Animation types #
- Grid navigation: Smooth scroll to keep selection centered, scale animation on focus
- Carousel: Momentum-based scrolling, snap-to-item
- Detail panel: Slide-in/out transitions
- Poster flip: 3D rotation on right-click to reveal action menu
- View transitions: Crossfade between library/detail/player views
Poster flip physics #
Right-clicking a poster triggers a spring-damped 3D flip to reveal an action menu.
graph LR
Click([Right-click]) --> Rise[Rise phase]
Rise --> Emerge[Emerge phase]
Emerge --> Flip[Flip rotation]
Flip --> Settle[Spring settle]
Settle --> Menu([Menu visible])
Spring-damper constants:
- Spring stiffness (K): 80.0
- Damping (B): 20.0 (flip), 6.0 (settle)
- Snap strength: 30.0
- Max velocity: π × 10 rad/s
The physics simulation runs at compositor framerate, ensuring smooth 120fps animation with no frame drops during the flip sequence.
Data flow: library scan #
- User adds library path via player UI
- Server receives
POST /librariesand persists library config - ScanOrchestrator creates
FolderScanjob for root path - Worker leases job, walks filesystem, emits child jobs for discovered media
- MediaAnalyze jobs extract duration, resolution, codecs via ffprobe
- MetadataEnrich jobs fetch TMDB data (title, year, genres, cast)
- ImageFetch jobs download posters/backdrops, cache variants
- IndexUpsert jobs write to Postgres
- Player receives SSE event, refreshes library view
Data flow: HDR playback (Wayland) #
- User selects media in player grid
- PlayerDomain requests stream info from server
- subwave initializes GStreamer pipeline with HDR-aware elements
- Wayland subsurface created — video renders on dedicated compositor plane
- HDR metadata preserved end-to-end, no tone mapping required
- Watch progress synced to server via WebSocket heartbeat
Data flow: poster load #
Cache hit:
- VirtualCarousel calculates visible range
- ImageService finds texture in GPU cache
- ShaderWidget renders immediately
Cache miss:
- VirtualCarousel adds to demand plan
- MetadataDomain queues fetch request
- API returns image bytes
- GPU upload batched with other pending textures
- ShaderWidget renders on next frame
Why this stack #
| Component | Choice | Rationale |
|---|---|---|
| Server framework | Axum | Type-safe extractors, Tower middleware, async-native |
| Database | Postgres + SQLx | Compile-time query checking, JSONB flexibility, real scale potential |
| Job queue | Postgres + custom runtime | Durable leases, fair scheduling, avoids external broker complexity |
| Cache | Redis | Rate limiting, session cache, simple and battle-tested |
| Player UI | Iced (forked) | Full compositor control for subsurface HDR, primitive batching |
| Video backend | subwave + GStreamer | Zero-copy HDR path, hardware decode, subtitle support |
| Serialization | rkyv | Zero-copy deserialization for 30k+ item libraries |
Key technical decisions #
Forked Iced over upstream #
Context: Zero-copy HDR requires Wayland subsurfaces. Upstream Iced doesn’t support this.
Decision: Maintain a fork (iced-ferrex) with subsurface support and primitive batching for poster grids.
Trade-off: Merge burden when upstream updates, but necessary for HDR quality and grid performance.
Custom job queue over message broker #
Context: Kafka/RabbitMQ add operational complexity for a desktop-focused media server.
Decision: Postgres-backed queue with lease-based claiming, TTL renewal, and fair scheduling.
Trade-off: Less battle-tested, but fits the single-server deployment model and keeps dependencies minimal.
SQLx over ORM #
Context: ORMs hide query structure; compile-time checking catches type errors before runtime.
Decision: Raw SQL with SQLx macros. Queries are explicit and optimizable.
Trade-off: More verbose, but no magic—what you write is what runs.
Zero-copy with rkyv #
Context: Large libraries (30k+ items) need to stay responsive. Traditional serde deserializes everything.
Decision: Archive media data with rkyv, access via yoke references without copying.
Trade-off: More complex type signatures, but eliminates deserialization as a bottleneck.
For implementation details and code references, contact me for private access to the repo (or a curated code sample).