# K-TV Backend The Rust API server for K-TV — a self-hosted linear TV channel orchestration layer for personal media servers. K-TV turns your Jellyfin library (or any compatible media source) into broadcast-style TV channels with wall-clock scheduling. Instead of scrolling for what to watch, you tune into your "90s Sitcom Network" or "Friday Night Horror" channel and let the schedule run. ## Architecture The backend is a Cargo workspace with three crates following Hexagonal (Ports & Adapters) architecture: ``` k-tv-backend/ ├── domain/ # Pure business logic — no I/O, no frameworks ├── infra/ # Adapters: SQLite/Postgres repositories, Jellyfin HTTP client └── api/ # Axum HTTP server — routes, DTOs, startup wiring ``` The domain defines ports (traits). Infra implements them. The API wires everything together. The domain never imports from infra or api. ## Quick Start ```bash cp .env.example .env # Edit .env — minimum required: JELLYFIN_BASE_URL, JELLYFIN_API_KEY, JELLYFIN_USER_ID cargo run ``` The API is available at `http://localhost:3000/api/v1/`. ## Configuration All configuration is via environment variables. See [`.env.example`](.env.example). ### Server | Variable | Default | Description | |----------|---------|-------------| | `HOST` | `127.0.0.1` | Bind address | | `PORT` | `3000` | Listen port | ### Database | Variable | Default | Description | |----------|---------|-------------| | `DATABASE_URL` | `sqlite:data.db?mode=rwc` | SQLite or Postgres connection string | | `DB_MAX_CONNECTIONS` | `5` | Connection pool max size | | `DB_MIN_CONNECTIONS` | `1` | Connection pool min size | ### Authentication | Variable | Default | Description | |----------|---------|-------------| | `JWT_SECRET` | _(insecure dev default)_ | Signing key for JWT tokens (min 32 chars in production) | | `JWT_EXPIRY_HOURS` | `24` | Token lifetime in hours | | `JWT_ISSUER` | — | Optional issuer claim embedded in tokens | | `JWT_AUDIENCE` | — | Optional audience claim embedded in tokens | | `COOKIE_SECRET` | _(insecure dev default)_ | Encrypts OIDC state cookie (min 64 chars in production) | | `SECURE_COOKIE` | `false` | Set `true` when serving over HTTPS | ### OIDC (optional — requires `auth-oidc` feature) | Variable | Description | |----------|-------------| | `OIDC_ISSUER` | Identity provider URL (Keycloak, Auth0, Zitadel, etc.) | | `OIDC_CLIENT_ID` | OIDC client ID | | `OIDC_CLIENT_SECRET` | OIDC client secret | | `OIDC_REDIRECT_URL` | Callback URL — must be `http(s):///api/v1/auth/callback` | | `OIDC_RESOURCE_ID` | Optional audience claim to verify | OIDC state (CSRF token, PKCE verifier, nonce) is stored in a short-lived encrypted cookie — no database session table required. ### Jellyfin | Variable | Description | |----------|-------------| | `JELLYFIN_BASE_URL` | Base URL of your Jellyfin instance, e.g. `http://192.168.1.10:8096` | | `JELLYFIN_API_KEY` | API key — Jellyfin Dashboard → API Keys | | `JELLYFIN_USER_ID` | User ID used for library browsing | If Jellyfin variables are not set, the server starts normally but schedule generation endpoints return an error. Channel CRUD and auth still work. ### CORS & Production | Variable | Default | Description | |----------|---------|-------------| | `CORS_ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated allowed origins | | `PRODUCTION` | `false` | Enforces minimum secret lengths when `true` | ## Feature Flags ```toml # api/Cargo.toml defaults default = ["sqlite", "auth-jwt", "jellyfin"] ``` | Feature | Description | |---------|-------------| | `sqlite` | SQLite database (default) | | `postgres` | PostgreSQL database | | `auth-jwt` | JWT Bearer token authentication | | `auth-oidc` | OpenID Connect integration | | `jellyfin` | Jellyfin media provider adapter | ## API Reference All endpoints are under `/api/v1/`. Endpoints marked **Bearer** require an `Authorization: Bearer ` header. ### Authentication | Method | Path | Auth | Description | |--------|------|------|-------------| | `POST` | `/auth/register` | — | Register with email + password → JWT | | `POST` | `/auth/login` | — | Login with email + password → JWT | | `POST` | `/auth/logout` | — | Returns 200; client discards the token | | `GET` | `/auth/me` | Bearer | Current user info | | `POST` | `/auth/token` | Bearer | Issue a fresh JWT (`auth-jwt`) | | `GET` | `/auth/login/oidc` | — | Start OIDC flow (`auth-oidc`) | | `GET` | `/auth/callback` | — | Complete OIDC flow → JWT (`auth-oidc`) | ### Channels | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/channels` | Bearer | List all channels owned by the current user | | `POST` | `/channels` | Bearer | Create a channel | | `GET` | `/channels/:id` | Bearer | Get a channel | | `PUT` | `/channels/:id` | Bearer | Update a channel — only provided fields change | | `DELETE` | `/channels/:id` | Bearer | Delete a channel and all its schedules | ### Schedule & Broadcast | Method | Path | Auth | Description | |--------|------|------|-------------| | `POST` | `/channels/:id/schedule` | Bearer | Generate a fresh 48-hour schedule (replaces existing) | | `GET` | `/channels/:id/schedule` | Bearer | Get the currently active 48-hour schedule | | `GET` | `/channels/:id/now` | Bearer | What is playing right now — `204` means no-signal (gap between blocks) | | `GET` | `/channels/:id/epg?from=&until=` | Bearer | EPG slots overlapping a time window (RFC3339 datetimes) | | `GET` | `/channels/:id/stream` | Bearer | `307` redirect to the current item's stream URL — `204` if no-signal | ### Other | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/config` | — | Server configuration flags | ## Examples ### Register and get a token ```bash curl -s -X POST http://localhost:3000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "yourpassword"}' | jq . # → {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 86400} TOKEN="eyJ..." ``` ### Create a channel ```bash curl -s -X POST http://localhost:3000/api/v1/channels \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "90s Sitcom Network", "description": "Nothing but classic sitcoms, all day", "timezone": "America/New_York" }' | jq .id ``` ### Design a schedule The `schedule_config` is the shareable programming template. Each block describes a time window and how to fill it. The channel's `timezone` determines when each block starts. ```bash curl -s -X PUT http://localhost:3000/api/v1/channels/ \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "schedule_config": { "blocks": [ { "id": "00000000-0000-0000-0000-000000000001", "name": "Evening Sitcoms", "start_time": "20:00:00", "duration_mins": 120, "content": { "type": "algorithmic", "filter": { "content_type": "episode", "genres": ["Comedy"], "decade": 1990, "min_duration_secs": 1200, "max_duration_secs": 1800, "collections": [], "tags": [] }, "strategy": "random" } }, { "id": "00000000-0000-0000-0000-000000000002", "name": "Late Night Movie", "start_time": "22:00:00", "duration_mins": 150, "content": { "type": "algorithmic", "filter": { "content_type": "movie", "genres": ["Comedy"], "decade": null, "min_duration_secs": null, "max_duration_secs": null, "collections": [], "tags": [] }, "strategy": "best_fit" } } ] } }' | jq . ``` Blocks that don't cover the full 24 hours leave gaps — the client renders those as a no-signal screen. For a manual block, use `"type": "manual"` with an `"items"` array of Jellyfin item IDs: ```json { "type": "manual", "items": ["abc123", "def456", "ghi789"] } ``` ### Generate and tune in ```bash # Generate the 48-hour schedule from now curl -s -X POST http://localhost:3000/api/v1/channels//schedule \ -H "Authorization: Bearer $TOKEN" | jq '{generation: .generation, slots: (.slots | length)}' # What is playing right now? curl -s http://localhost:3000/api/v1/channels//now \ -H "Authorization: Bearer $TOKEN" \ | jq '{title: .slot.item.title, offset_secs: .offset_secs}' # → 204 No Content if the channel is in a gap between blocks # EPG for the next 4 hours (RFC3339 datetimes) FROM=$(date -u +%Y-%m-%dT%H:%M:%SZ) UNTIL=$(date -u -d '+4 hours' +%Y-%m-%dT%H:%M:%SZ) # Linux # UNTIL=$(date -u -v+4H +%Y-%m-%dT%H:%M:%SZ) # macOS curl -s "http://localhost:3000/api/v1/channels//epg?from=$FROM&until=$UNTIL" \ -H "Authorization: Bearer $TOKEN" \ | jq '[.[] | {title: .item.title, start: .start_at, end: .end_at}]' # Get the stream redirect for the current item (feed into your player) curl -s -I http://localhost:3000/api/v1/channels//stream \ -H "Authorization: Bearer $TOKEN" # → HTTP/1.1 307 Temporary Redirect # → Location: http://jellyfin:8096/Videos//stream?static=true&api_key=... ``` ## Key Domain Concepts ### Channel A named broadcast channel owned by a user. Holds a `schedule_config` (the programming template) and a `recycle_policy`. ### ScheduleConfig The shareable programming template: an ordered list of `ProgrammingBlock`s. Channels do not need to cover all 24 hours — gaps are valid and produce a no-signal state. ### ProgrammingBlock Each block occupies a time-of-day window and is either: - **Algorithmic** — the engine selects items from the media provider using a `MediaFilter` and a `FillStrategy` - **Manual** — the user hand-picks specific items by ID in a specific order ### MediaFilter Provider-agnostic filter used by algorithmic blocks: | Field | Description | |-------|-------------| | `content_type` | `"movie"`, `"episode"`, or `"short"` | | `genres` | List of genre strings, e.g. `["Comedy", "Action"]` | | `decade` | Starting year of a decade — `1990` means 1990–1999 | | `tags` | Provider tag strings | | `min_duration_secs` / `max_duration_secs` | Duration bounds for item selection | | `collections` | Abstract groupings (Jellyfin library IDs, Plex sections, folder paths, etc.) | ### FillStrategy How an algorithmic block fills its time budget: | Value | Behaviour | |-------|-----------| | `best_fit` | Greedy bin-packing — picks the longest item that still fits, minimises dead air | | `sequential` | Items in provider order — good for series where episode sequence matters | | `random` | Shuffle pool then fill — good for variety channels | ### RecyclePolicy Controls when previously aired items become eligible again: | Field | Description | |-------|-------------| | `cooldown_days` | Don't replay an item within this many calendar days | | `cooldown_generations` | Don't replay within this many schedule generations | | `min_available_ratio` | Always keep at least this fraction (0.0–1.0) of the matching pool selectable, even if their cooldown hasn't expired. Prevents small libraries from running dry. | ### No-signal state `GET /channels/:id/now` and `GET /channels/:id/stream` return `204 No Content` when the current time falls in a gap between blocks. The frontend should display static / noise in this case — matching the broadcast TV experience. ### GeneratedSchedule A resolved 48-hour program: concrete UTC wall-clock timestamps for every scheduled item. Generated on demand via `POST /channels/:id/schedule`. The generation counter increments on each regeneration and drives the recycle policy. ## Development ### Run tests ```bash cargo test # all crates cargo test -p domain # domain unit tests only ``` ### Run migrations manually ```bash sqlx migrate run --source migrations_sqlite # SQLite sqlx migrate run --source migrations_postgres # PostgreSQL ``` ### Build variants ```bash # Default: SQLite + JWT + Jellyfin cargo build # Add OIDC cargo build -F sqlite,auth-jwt,auth-oidc,jellyfin # PostgreSQL variant cargo build --no-default-features -F postgres,auth-jwt,jellyfin ``` ### Docker ```bash docker compose up ``` See [`compose.yml`](compose.yml) for the configuration. ## Project Structure ``` k-tv-backend/ ├── domain/src/ │ ├── entities.rs # Channel, ProgrammingBlock, GeneratedSchedule, │ │ # ScheduledSlot, MediaItem, PlaybackRecord, User, ... │ ├── value_objects.rs # MediaFilter, FillStrategy, RecyclePolicy, │ │ # MediaItemId, ContentType, Email, ... │ ├── ports.rs # IMediaProvider trait │ ├── repositories.rs # ChannelRepository, ScheduleRepository, UserRepository │ ├── services.rs # ChannelService, ScheduleEngineService, UserService │ └── errors.rs # DomainError │ ├── infra/src/ │ ├── channel_repository.rs # SQLite + Postgres ChannelRepository adapters │ ├── schedule_repository.rs # SQLite + Postgres ScheduleRepository adapters │ ├── user_repository.rs # SQLite + Postgres UserRepository adapters │ ├── jellyfin.rs # Jellyfin IMediaProvider adapter │ ├── auth/ │ │ ├── jwt.rs # JWT create + validate │ │ └── oidc.rs # OIDC flow (stateless cookie state) │ ├── db.rs # Connection pool │ └── factory.rs # Repository builder functions │ ├── api/src/ │ ├── routes/ │ │ ├── auth.rs # /auth/* endpoints │ │ ├── channels.rs # /channels/* endpoints (CRUD, EPG, broadcast) │ │ └── config.rs # /config endpoint │ ├── config.rs # Config::from_env() │ ├── state.rs # AppState │ ├── extractors.rs # CurrentUser (JWT Bearer extractor) │ ├── error.rs # ApiError → HTTP status mapping │ ├── dto.rs # All request + response types │ └── main.rs # Startup wiring │ ├── migrations_sqlite/ ├── migrations_postgres/ ├── .env.example └── compose.yml ``` ## License MIT