Files
k-tv/k-tv-backend/README.md
Gabriel Kaszewski 01108aa23e feat: initialize k-tv-frontend with Next.js and Tailwind CSS
- Added package.json with dependencies and scripts for development, build, and linting.
- Created postcss.config.mjs for Tailwind CSS integration.
- Added SVG assets for UI components including file, globe, next, vercel, and window icons.
- Configured TypeScript with tsconfig.json for strict type checking and module resolution.
2026-03-11 19:13:21 +01:00

397 lines
14 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
└── 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.
### 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
```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 |
## 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
```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`.
### 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.) |
### 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
```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
```
### 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
│ ├── 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