Files
k-tv/k-tv-backend

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

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.

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)://<host>/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

# 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 <token> 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

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

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.

curl -s -X PUT http://localhost:3000/api/v1/channels/<id> \
  -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:

{
  "type": "manual",
  "items": ["abc123", "def456", "ghi789"]
}

Generate and tune in

# Generate the 48-hour schedule from now
curl -s -X POST http://localhost:3000/api/v1/channels/<id>/schedule \
  -H "Authorization: Bearer $TOKEN" | jq '{generation: .generation, slots: (.slots | length)}'

# What is playing right now?
curl -s http://localhost:3000/api/v1/channels/<id>/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/<id>/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/<id>/stream \
  -H "Authorization: Bearer $TOKEN"
# → HTTP/1.1 307 Temporary Redirect
# → Location: http://jellyfin:8096/Videos/<id>/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 ProgrammingBlocks. 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 19901999
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.01.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

cargo test            # all crates
cargo test -p domain  # domain unit tests only

Run migrations manually

sqlx migrate run --source migrations_sqlite    # SQLite
sqlx migrate run --source migrations_postgres  # PostgreSQL

Build variants

# 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

docker compose up

See 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