- provider_configs: add id TEXT PK; migrate existing rows (provider_type becomes id)
- local_files_index: add provider_id column + index; scope all queries per instance
- ProviderConfigRow: add id field; add get_by_id to trait
- LocalIndex:🆕 add provider_id param; all SQL scoped by provider_id
- factory: thread provider_id through build_local_files_bundle
- AppState.local_index: Option<Arc<LocalIndex>> → HashMap<String, Arc<LocalIndex>>
- admin_providers: restructured routes (POST /admin/providers create, PUT/DELETE /{id}, POST /test)
- admin_providers: use row.id as registry key for jellyfin and local_files
- files.rescan: optional ?provider=<id> query param
- frontend: add id to ProviderConfig, update api/hooks, new multi-instance panel UX
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.0–1.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
MediaFilterand aFillStrategy - 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.) |
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.0–1.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