Skip to main content

Ferrex architecture

·1780 words·9 mins
Grayson Hieb
Author
Grayson Hieb
Creator of Ferrex.

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. MediaLike says “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 MovieYoke type 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
#

  1. Pending — Device registered, awaiting PIN setup
  2. Trusted — PIN configured, can authenticate with PIN
  3. 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
#

  1. User adds library path via player UI
  2. Server receives POST /libraries and persists library config
  3. ScanOrchestrator creates FolderScan job for root path
  4. Worker leases job, walks filesystem, emits child jobs for discovered media
  5. MediaAnalyze jobs extract duration, resolution, codecs via ffprobe
  6. MetadataEnrich jobs fetch TMDB data (title, year, genres, cast)
  7. ImageFetch jobs download posters/backdrops, cache variants
  8. IndexUpsert jobs write to Postgres
  9. Player receives SSE event, refreshes library view

Data flow: HDR playback (Wayland)
#

  1. User selects media in player grid
  2. PlayerDomain requests stream info from server
  3. subwave initializes GStreamer pipeline with HDR-aware elements
  4. Wayland subsurface created — video renders on dedicated compositor plane
  5. HDR metadata preserved end-to-end, no tone mapping required
  6. Watch progress synced to server via WebSocket heartbeat

Data flow: poster load
#

Cache hit:

  1. VirtualCarousel calculates visible range
  2. ImageService finds texture in GPU cache
  3. ShaderWidget renders immediately

Cache miss:

  1. VirtualCarousel adds to demand plan
  2. MetadataDomain queues fetch request
  3. API returns image bytes
  4. GPU upload batched with other pending textures
  5. 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).