493 lines
20 KiB
Markdown
493 lines
20 KiB
Markdown
# 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
|
||
|
||
```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)://<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
|
||
|
||
```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 |
|
||
| `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
|
||
|
||
```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/<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:
|
||
|
||
```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/<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 `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.) |
|
||
| `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
|
||
|
||
```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
|
||
|
||
# With local files + transcoding
|
||
cargo build -F sqlite,auth-jwt,jellyfin,local-files
|
||
```
|
||
|
||
### 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, 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
|