Files

616 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# JAMS — Architecture Design
> **JAMS**: Just A simple intelligent Media Server
> Patterns derived from Jellyfin's architecture, implemented in Rust.
---
## 1. Cargo Workspace Layout
```
jams/ ← workspace root
├── Cargo.toml ← workspace members
├── crates/
│ ├── jams-server/ ← binary entry, Axum router, DI wiring
│ ├── jams-core/ ← shared traits, domain types, error types
│ ├── jams-library/ ← LibraryManager, FileWatcher, MediaResolver
│ ├── jams-metadata/ ← MetadataRouter + provider implementations
│ ├── jams-llm/ ← LlmRouter + LLM provider implementations
│ ├── jams-media/ ← ffmpeg wrapper, MediaProbe, thumbnail extractor
│ ├── jams-stream/ ← StreamBuilder, direct play, HLS handler
│ └── jams-db/ ← SQLite/sqlx, migrations, repositories
├── docs/
└── docker/
├── Dockerfile
└── docker-compose.yml
```
**Dependency direction** (one-way, no cycles):
```
jams-server
├── jams-library → jams-core, jams-db
├── jams-metadata → jams-core, jams-llm
├── jams-llm → jams-core
├── jams-media → jams-core
├── jams-stream → jams-core, jams-media, jams-db
└── jams-db → jams-core
```
`jams-core` has zero internal dependencies. All traits are defined here.
---
## 2. Core Traits (jams-core)
Mirroring Jellyfin's `IMetadataProvider` hierarchy using Rust trait objects.
### 2.1 Metadata Provider
```rust
// crates/jams-core/src/providers/metadata.rs
#[async_trait]
pub trait MetadataProvider: Send + Sync {
fn name(&self) -> &str;
/// Lower number = higher priority (mirrors Jellyfin IHasOrder, default 50)
fn priority(&self) -> u8 { 50 }
fn supports(&self, item_type: ItemType) -> bool;
/// Returns None if this provider has no result for the query
async fn fetch(&self, query: &MetadataQuery) -> Result<Option<MetadataResult>>;
}
pub struct MetadataQuery {
pub title: String,
pub year: Option<u16>,
pub item_type: ItemType,
pub external_ids: HashMap<String, String>, // "tmdb" -> "12345"
}
pub struct MetadataResult {
pub source: String, // "tmdb" | "tvdb" | "llm"
pub external_id: Option<String>,
pub title: String,
pub overview: Option<String>,
pub genres: Vec<String>,
pub year: Option<u16>,
pub rating: Option<f32>,
pub poster_url: Option<String>,
pub backdrop_url: Option<String>,
pub cast: Vec<PersonInfo>,
pub llm_generated: bool, // transparency flag for LLM-generated fields
pub raw_json: Option<String>, // cached raw API response
}
```
### 2.2 LLM Provider
```rust
// crates/jams-core/src/providers/llm.rs
#[async_trait]
pub trait LlmProvider: Send + Sync {
fn name(&self) -> &str;
async fn is_available(&self) -> bool;
async fn complete(&self, prompt: &str, opts: &LlmOptions) -> Result<String>;
}
pub struct LlmOptions {
pub model: Option<String>,
pub max_tokens: u32,
pub temperature: f32,
}
```
### 2.3 Domain Model
Inspired by Jellyfin's `BaseItem`, but using a Rust enum hierarchy instead of class inheritance.
```rust
// crates/jams-core/src/domain/item.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
pub id: Uuid,
pub item_type: ItemType,
pub title: String,
pub sort_title: String,
pub file_path: PathBuf,
pub file_hash: String, // SHA-256, deduplication
pub duration_secs: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ItemType {
Movie,
Series,
Episode { season: u8, episode: u16, series_id: Uuid },
HomeVideo,
}
#[derive(Debug, Clone)]
pub struct ClassificationResult {
pub item_type: ItemType,
pub confidence: f32, // 0.01.0; < 0.85 flags for manual review
pub llm_used: bool,
pub model: Option<String>,
}
```
### 2.4 Client Capabilities (mirrors Jellyfin DeviceProfile)
```rust
pub struct ClientCapabilities {
pub supported_containers: Vec<String>,
pub supported_video_codecs: Vec<String>,
pub supported_audio_codecs: Vec<String>,
pub max_bitrate_bps: u64,
}
```
---
## 3. Processing Pipeline
Inspired by Jellyfin's LibraryManager + TaskManager. Key improvement over Jellyfin: jobs are persisted to SQLite (Jellyfin uses a pure in-memory `ConcurrentQueue`), enabling crash recovery.
```
notify::Watcher (inotify)
│ IngestEvent { path, event_type }
mpsc::channel<IngestEvent>
┌──────────────────────────────────────────┐
│ IngestWorker │
│ 1. Validate file extension │
│ 2. SHA-256 fingerprint → check dup │
│ 3. Insert media_items (status: pending) │
│ 4. Insert processing_jobs record │
└──────────────────────┬───────────────────┘
┌──────────────────────────────────────────┐
│ ClassificationWorker │
│ 1. MediaResolver: parse filename │
│ - Regex match S##E## → Episode │
│ - Directory structure → Series/Movie │
│ 2. confidence >= 0.85 → classify │
│ 3. confidence < 0.85 → LlmRouter │
│ 4. Update media_items.item_type │
└──────────────────────┬───────────────────┘
┌──────────────────────────────────────────┐
│ MetadataWorker │
│ 1. MetadataRouter.fetch() │
│ Iterate providers by priority: │
│ TmdbProvider → TvdbProvider → ... │
│ 2. First Some() wins; stop iteration │
│ 3. All None → LlmFallbackProvider │
│ 4. Write to metadata table │
└──────────────────────┬───────────────────┘
┌──────────────────────────────────────────┐
│ ThumbnailWorker │
│ 1. metadata.poster_url exists → download│
│ 2. No poster → ffmpeg keyframe extract │
│ 3. Save to {config_dir}/thumbs/{id}.jpg │
└──────────────────────┬───────────────────┘
processing_jobs.status = done
```
**Crash recovery**: On startup, scan `processing_jobs` for `status = 'running'`, reset to `pending` and re-enqueue.
---
## 4. MediaResolver — Filename Parsing (ref: Emby.Naming)
```rust
// crates/jams-library/src/resolver.rs
pub struct MediaResolver {
episode_patterns: Vec<Regex>, // compiled once at startup
}
impl MediaResolver {
pub fn resolve(&self, path: &Path) -> ParsedMedia {
// 1. Directory structure analysis (ref: Jellyfin BaseVideoResolver)
if self.has_season_dirs(path) {
return ParsedMedia::Series { ... };
}
// 2. Filename regex match
if let Some(ep) = self.try_parse_episode(path) {
return ParsedMedia::Episode(ep);
}
// 3. Default: Movie
ParsedMedia::Movie { title: self.clean_title(path) }
}
}
// Regex patterns (ref: Jellyfin NamingOptions, 20+ common formats)
const EPISODE_PATTERNS: &[&str] = &[
r"[Ss](?P<s>\d{1,2})[\s._-]*[Ee](?P<e>\d{1,3})", // S01E01
r"Season\s*(?P<s>\d+)\s*Episode\s*(?P<e>\d+)", // Season 1 Episode 2
r"[\s_-](?P<e>\d{2,4})[\s_-]", // Absolute (anime)
r"(?P<y>\d{4})[\.\-](?P<m>\d{2})[\.\-](?P<d>\d{2})", // Date-based
];
```
---
## 5. MetadataRouter — Priority Routing (ref: Jellyfin ProviderManager)
```rust
// crates/jams-metadata/src/router.rs
pub struct MetadataRouter {
providers: Vec<Box<dyn MetadataProvider>>, // sorted by priority()
}
impl MetadataRouter {
pub fn new(mut providers: Vec<Box<dyn MetadataProvider>>) -> Self {
providers.sort_by_key(|p| p.priority());
Self { providers }
}
pub async fn fetch(&self, query: &MetadataQuery) -> Result<Option<MetadataResult>> {
for provider in &self.providers {
if !provider.supports(query.item_type) { continue; }
match provider.fetch(query).await {
Ok(Some(result)) => return Ok(Some(result)), // first hit wins
Ok(None) => continue,
Err(e) => { tracing::warn!("{} failed: {e}", provider.name()); continue; }
}
}
Ok(None)
}
}
```
### Provider Priority Table
| Provider | priority | Item Types |
|---|---|---|
| TmdbProvider | 10 | Movie, Series, Episode |
| TvdbProvider | 20 | Series, Episode |
| AniDbProvider | 30 | Series (anime) |
| LlmFallbackProvider | 90 | All (last resort) |
---
## 6. LlmRouter — Multi-Provider with Fallback
```rust
// crates/jams-llm/src/router.rs
pub struct LlmRouter {
primary: Box<dyn LlmProvider>,
fallback: Option<Box<dyn LlmProvider>>,
}
impl LlmRouter {
pub async fn complete(&self, prompt: &str, opts: &LlmOptions) -> Result<String> {
if self.primary.is_available().await {
return self.primary.complete(prompt, opts).await;
}
if let Some(fb) = &self.fallback {
tracing::warn!("Primary LLM unavailable, falling back to {}", fb.name());
return fb.complete(prompt, opts).await;
}
Err(LlmError::NoAvailableProvider)
}
pub async fn classify(&self, file_name: &str, context: &str) -> Result<ClassificationResult> {
let prompt = prompts::classification(file_name, context);
let raw = self.complete(&prompt, &LlmOptions::default()).await?;
serde_json::from_str(&raw).map_err(LlmError::ParseError)
}
}
// Provider implementations:
// crates/jams-llm/src/providers/
// ollama.rs → GET http://localhost:11434/api/generate
// claude.rs → POST https://api.anthropic.com/v1/messages
// openai.rs → POST https://api.openai.com/v1/chat/completions
```
---
## 7. StreamBuilder — Decision Tree (ref: Jellyfin StreamBuilder.cs)
```rust
// crates/jams-stream/src/builder.rs
pub enum StreamPlan {
DirectPlay { path: PathBuf },
Remux { path: PathBuf, target_container: String },
Transcode { path: PathBuf, video_codec: String, audio_codec: String, bitrate: u64 },
}
impl StreamBuilder {
pub async fn build(item: &MediaItem, probe: &MediaProbe, caps: &ClientCapabilities) -> StreamPlan {
if Self::can_direct_play(probe, caps) {
return StreamPlan::DirectPlay { path: item.file_path.clone() };
}
if Self::can_remux(probe, caps) {
return StreamPlan::Remux { path: item.file_path.clone(), target_container: "mp4".into() };
}
StreamPlan::Transcode {
path: item.file_path.clone(),
video_codec: "h264".into(),
audio_codec: "aac".into(),
bitrate: caps.max_bitrate_bps.min(8_000_000),
}
}
fn can_direct_play(probe: &MediaProbe, caps: &ClientCapabilities) -> bool {
caps.supported_containers.contains(&probe.container)
&& caps.supported_video_codecs.contains(&probe.video_codec)
&& caps.supported_audio_codecs.contains(&probe.audio_codec)
&& probe.bitrate_bps <= caps.max_bitrate_bps
}
fn can_remux(probe: &MediaProbe, caps: &ClientCapabilities) -> bool {
caps.supported_video_codecs.contains(&probe.video_codec)
&& caps.supported_audio_codecs.contains(&probe.audio_codec)
}
}
```
---
## 8. Database Schema (jams-db)
```sql
CREATE TABLE media_items (
id TEXT PRIMARY KEY,
item_type TEXT NOT NULL, -- 'movie'|'series'|'episode'|'home_video'
title TEXT NOT NULL,
sort_title TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
file_hash TEXT NOT NULL, -- SHA-256 deduplication
duration_s INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE episode_info (
item_id TEXT PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE,
series_id TEXT REFERENCES media_items(id),
season_num INTEGER,
episode_num INTEGER NOT NULL
);
CREATE TABLE metadata (
item_id TEXT PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE,
source TEXT NOT NULL, -- 'tmdb'|'tvdb'|'llm'
external_id TEXT,
overview TEXT,
genres TEXT, -- JSON array
cast_crew TEXT, -- JSON array
rating REAL,
poster_url TEXT,
backdrop_url TEXT,
year INTEGER,
llm_generated INTEGER NOT NULL DEFAULT 0, -- transparency flag
raw_json TEXT, -- cached API response
fetched_at TEXT NOT NULL
);
CREATE TABLE tags (
item_id TEXT REFERENCES media_items(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
confidence REAL NOT NULL,
llm_model TEXT NOT NULL,
PRIMARY KEY (item_id, tag)
);
CREATE TABLE processing_jobs (
id TEXT PRIMARY KEY,
item_id TEXT REFERENCES media_items(id) ON DELETE CASCADE,
job_type TEXT NOT NULL, -- 'classify'|'metadata'|'thumbnail'
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_items_type ON media_items(item_type);
CREATE INDEX idx_items_hash ON media_items(file_hash);
CREATE INDEX idx_jobs_status ON processing_jobs(status);
CREATE INDEX idx_episode_series ON episode_info(series_id);
```
**Repository split** (ref: Jellyfin's ongoing service decomposition):
| Service | Responsibility |
|---|---|
| `ItemRepository` | CRUD on media_items |
| `MetadataRepository` | CRUD on metadata + tags |
| `JobRepository` | Job queue, crash recovery queries |
| `SearchService` | Full-text search (SQLite FTS5) |
| `EpisodeRepository` | Series/episode relationship queries |
---
## 9. REST API
All responses `Content-Type: application/json`. Errors:
```json
{ "error": "NOT_FOUND", "message": "Item 123 not found" }
```
```
# Library
GET /api/library list items (paginated, type/genre filter)
GET /api/library/:id item detail with metadata + tags
POST /api/library/scan trigger full rescan
DELETE /api/library/:id remove from library (does not delete file)
# Streaming
GET /api/stream/:id video stream (Range request support)
GET /api/stream/:id/thumbnail thumbnail image (JPEG)
# Search
GET /api/search?q=&type=&genre=&year=
# Jobs
GET /api/jobs list all jobs + status
GET /api/jobs/:id
POST /api/jobs/:id/retry retry failed job
# Classification
POST /api/classify/:id force LLM reclassification
# Config
GET /api/config current config (API keys redacted)
PATCH /api/config partial update
```
---
## 10. Configuration (TOML)
```toml
[server]
host = "0.0.0.0"
port = 3000
[library]
paths = ["/media/movies", "/media/tv"]
scan_interval_secs = 3600
[metadata]
tmdb_api_key = ""
tvdb_api_key = ""
provider_order = ["tmdb", "tvdb", "llm"]
[llm]
default_provider = "ollama"
fallback_provider = "ollama"
[llm.ollama]
base_url = "http://localhost:11434"
model = "llama3.2"
[llm.claude]
api_key = ""
model = "claude-sonnet-4-6"
[llm.openai]
api_key = ""
base_url = "https://api.openai.com/v1"
model = "gpt-4o"
[streaming]
transcode_dir = "/tmp/jams-transcode"
max_concurrent_jobs = 2
[db]
path = "/data/jams.db"
```
---
## 11. Docker Deployment
```yaml
# docker-compose.yml
services:
lms:
build: .
ports:
- "3000:3000"
volumes:
- ./config:/config # config + SQLite DB
- /your/media:/media:ro # media library (read-only)
- /tmp/jams-transcode:/transcode
environment:
- JAMS_DB_PATH=/config/jams.db
- JAMS_CONFIG=/config/jams.toml
restart: unless-stopped
ollama:
image: ollama/ollama
volumes:
- ollama-data:/root/.ollama
ports:
- "11434:11434"
volumes:
ollama-data:
```
```dockerfile
FROM rust:1.82-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release --bin jams-server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/jams-server /usr/local/bin/jams-server
EXPOSE 3000
ENTRYPOINT ["jams-server"]
```
---
## 12. Key Crate Dependencies
```toml
# jams-server
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
# jams-core
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"
thiserror = "1"
# jams-library
notify = "6"
regex = "1"
# jams-db
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio", "migrate", "uuid", "chrono"] }
# jams-llm / jams-metadata
reqwest = { version = "0.12", features = ["json"] }
# jams-media
tokio-process = "1"
```
---
## 13. Key Differences from Jellyfin
| Concern | Jellyfin | This Project |
|---|---|---|
| Language | C# / .NET 9 | Rust |
| Metadata | External providers primary | External providers + **LLM fallback** |
| Job queue | In-memory ConcurrentQueue | **SQLite-persisted**, crash-recoverable |
| Plugin system | Dynamic assembly loading | **Compiled-in**, no dynamic loading in MVP |
| Frontend | Built-in web client | **TBD / separate**, pure REST API |
| Subtitles | Supported | **Out of scope** |
| Config format | JSON | **TOML** |