Files

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, local files
└── 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.

Local Files (optional — requires local-files feature)

Variable Default Description
LOCAL_FILES_DIR Absolute path to local video library root. Enables the local-files provider when set.
TRANSCODE_DIR Directory for FFmpeg HLS transcode cache. Enables transcoding when set.
TRANSCODE_CLEANUP_TTL_HOURS 24 Hours after last access before a transcode cache entry is deleted.

CORS & Production

Variable Default Description
CORS_ALLOWED_ORIGINS http://localhost:5173 Comma-separated allowed origins
BASE_URL http://localhost:3000 Public base URL used to build stream URLs for local files
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
local-files Local filesystem media provider with optional FFmpeg transcoding

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

Library

All endpoints require Bearer auth and return 501 Not Implemented if the active provider lacks the relevant capability.

Method Path Auth Description
GET /library/collections Bearer List media collections/libraries
GET /library/series Bearer List TV series (supports ?collection=, ?provider=)
GET /library/genres Bearer List genres (supports ?type=, ?provider=)
GET /library/items Bearer Search/filter media items (supports ?q=, ?type=, ?series[]=, ?collection=, ?limit=, ?strategy=, ?provider=)

Files (local-files feature only)

Method Path Auth Description
GET /files/stream/:id Range-header video streaming for local files
POST /files/rescan Bearer Trigger library rescan, returns { items_found }
GET /files/transcode/:id/playlist.m3u8 HLS transcode playlist
GET /files/transcode/:id/:segment HLS transcode segment
GET /files/transcode-settings Bearer Get transcode settings (cleanup_ttl_hours)
PUT /files/transcode-settings Bearer Update transcode settings
GET /files/transcode-stats Bearer Cache stats { cache_size_bytes, item_count }
DELETE /files/transcode-cache Bearer Clear the transcode cache

IPTV

Method Path Auth Description
GET /iptv/playlist.m3u ?token= M3U playlist of all channels
GET /iptv/epg.xml ?token= XMLTV EPG for all channels

Admin

Method Path Auth Description
GET /admin/logs ?token= SSE stream of live server log lines ({ level, target, message, timestamp })
GET /admin/activity Bearer Recent 50 in-app activity events

Config

Method Path Auth Description
GET /config Server configuration flags and provider capabilities

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.

Channel fields:

Field Description
access_mode public / password_protected / account_required / owner_only
access_password Hashed password when access_mode is password_protected
logo URL or inline SVG for the watermark overlay
logo_position top_right (default) / top_left / bottom_left / bottom_right
logo_opacity 0.01.0, default 1.0
auto_schedule When true, the server auto-regenerates the schedule when it expires
webhook_url HTTP endpoint called on domain events
webhook_poll_interval_secs Polling interval for webhook delivery
webhook_body_template Handlebars template for the webhook POST body
webhook_headers JSON object of extra HTTP headers sent with webhooks

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.)
series_names List of TV series names (OR-combined)
search_term Free-text search term for library browsing

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.

ProviderCapabilities

GET /config returns providers[] with per-provider capabilities. Library endpoints return 501 if the active provider lacks the relevant capability.

Capability Description
collections Provider can list/filter by collections
series Provider exposes TV series groupings
genres Provider exposes genre metadata
tags Provider supports tag filtering
decade Provider supports decade filtering
search Provider supports free-text search
streaming_protocol hls or direct_file
rescan Provider supports triggering a library rescan
transcode FFmpeg transcoding is available (local-files only)

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

# With local files + transcoding
cargo build -F sqlite,auth-jwt,jellyfin,local-files

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, ProviderCapabilities
│   ├── events.rs             # Domain event types
│   ├── 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
│   ├── activity_log_repository/ # Activity log persistence
│   ├── jellyfin.rs              # Jellyfin IMediaProvider adapter
│   ├── local_files/             # Local filesystem provider + FFmpeg transcoder
│   ├── 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/            # /channels/* endpoints (CRUD, EPG, broadcast)
│   │   ├── admin.rs             # /admin/logs (SSE), /admin/activity
│   │   ├── config.rs            # /config endpoint
│   │   ├── files.rs             # /files/* endpoints (local-files feature)
│   │   ├── iptv.rs              # /iptv/playlist.m3u, /iptv/epg.xml
│   │   └── library.rs           # /library/* endpoints
│   ├── 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
│   ├── events.rs                # SSE event broadcasting
│   ├── log_layer.rs             # Tracing layer → SSE log stream
│   ├── poller.rs                # Webhook polling task
│   ├── scheduler.rs             # Auto-schedule renewal task
│   ├── webhook.rs               # Webhook delivery
│   └── main.rs                  # Startup wiring
│
├── migrations_sqlite/
├── migrations_postgres/
├── .env.example
└── compose.yml

License

MIT