Files
k-tv/k-tv-backend/README.md

493 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 `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 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
```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