Compare commits
10 Commits
d1122656f3
...
4e1de172f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e1de172f7 | |||
| dc29976c1f | |||
| cb49c3e50a | |||
| 20aed753d8 | |||
| ee64fc0b8a | |||
| 0f1b9c11fe | |||
| 62549faffa | |||
| 2caad1670d | |||
| b2f40054fc | |||
| f6ff65094b |
61
.env.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Copy this file to .env and fill in the values before running `docker compose up`.
|
||||||
|
|
||||||
|
# ── Ports (optional, defaults shown) ─────────────────────────────────────────
|
||||||
|
BACKEND_PORT=3000
|
||||||
|
FRONTEND_PORT=3001
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
|
JWT_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||||
|
|
||||||
|
# Generate: openssl rand -base64 64
|
||||||
|
COOKIE_SECRET=change-me-must-be-at-least-64-characters-long-for-production!!
|
||||||
|
|
||||||
|
JWT_EXPIRY_HOURS=24
|
||||||
|
|
||||||
|
# Set to false to disable new user registration (existing users can still log in)
|
||||||
|
ALLOW_REGISTRATION=true
|
||||||
|
|
||||||
|
# Set to true when serving over HTTPS
|
||||||
|
SECURE_COOKIE=false
|
||||||
|
PRODUCTION=false
|
||||||
|
|
||||||
|
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Origin(s) from which the browser will hit the backend, comma-separated.
|
||||||
|
# Must match what users type in their browser for the frontend.
|
||||||
|
# Example (local): http://localhost:3001
|
||||||
|
# Example (remote): https://tv.example.com
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3001
|
||||||
|
|
||||||
|
# ── Frontend / API URL ────────────────────────────────────────────────────────
|
||||||
|
# Public URL of the BACKEND, as seen from the user's browser.
|
||||||
|
# This is baked into the Next.js client bundle at build time.
|
||||||
|
# Example (local): http://localhost:3000/api/v1
|
||||||
|
# Example (remote): https://api.example.com/api/v1
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api/v1
|
||||||
|
|
||||||
|
# ── Jellyfin ──────────────────────────────────────────────────────────────────
|
||||||
|
JELLYFIN_BASE_URL=http://jellyfin:8096
|
||||||
|
JELLYFIN_API_KEY=your-jellyfin-api-key-here
|
||||||
|
JELLYFIN_USER_ID=your-jellyfin-user-id-here
|
||||||
|
|
||||||
|
# ── Database pool (optional) ──────────────────────────────────────────────────
|
||||||
|
DB_MAX_CONNECTIONS=5
|
||||||
|
DB_MIN_CONNECTIONS=1
|
||||||
|
|
||||||
|
# ── PostgreSQL (optional, uncomment db service in compose.yml first) ──────────
|
||||||
|
# POSTGRES_PASSWORD=change-me
|
||||||
|
|
||||||
|
# ── Traefik (only needed with compose.traefik.yml) ────────────────────────────
|
||||||
|
# External Docker network Traefik is attached to
|
||||||
|
TRAEFIK_NETWORK=traefik_proxy
|
||||||
|
# Traefik entrypoint (usually websecure for HTTPS, web for HTTP)
|
||||||
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
# Cert resolver defined in your Traefik static config
|
||||||
|
TRAEFIK_CERT_RESOLVER=letsencrypt
|
||||||
|
# Public hostnames routed by Traefik
|
||||||
|
FRONTEND_HOST=tv.example.com
|
||||||
|
BACKEND_HOST=tv-api.example.com
|
||||||
|
# When using Traefik, update these to the public URLs:
|
||||||
|
# NEXT_PUBLIC_API_URL=https://tv-api.example.com/api/v1
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://tv.example.com
|
||||||
49
compose.traefik.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Traefik integration overlay.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f compose.yml -f compose.traefik.yml up -d --build
|
||||||
|
#
|
||||||
|
# Assumes Traefik is already running on your host with an external Docker
|
||||||
|
# network. Add these variables to your .env (see .env.example):
|
||||||
|
#
|
||||||
|
# TRAEFIK_NETWORK name of the external Traefik network (default: traefik_proxy)
|
||||||
|
# TRAEFIK_ENTRYPOINT Traefik entrypoint name (default: websecure)
|
||||||
|
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
|
||||||
|
# FRONTEND_HOST public hostname for the frontend e.g. tv.example.com
|
||||||
|
# BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com
|
||||||
|
#
|
||||||
|
# Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL,
|
||||||
|
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports: [] # Traefik handles ingress; no direct port exposure needed
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
|
||||||
|
- "traefik.http.routers.ktv-backend.rule=Host(`${BACKEND_HOST}`)"
|
||||||
|
- "traefik.http.routers.ktv-backend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||||
|
- "traefik.http.routers.ktv-backend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||||
|
- "traefik.http.services.ktv-backend.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports: []
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
|
||||||
|
- "traefik.http.routers.ktv-frontend.rule=Host(`${FRONTEND_HOST}`)"
|
||||||
|
- "traefik.http.routers.ktv-frontend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||||
|
- "traefik.http.routers.ktv-frontend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||||
|
- "traefik.http.services.ktv-frontend.loadbalancer.server.port=3001"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
name: ${TRAEFIK_NETWORK:-traefik_proxy}
|
||||||
74
compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# ── Backend (Rust / Axum) ──────────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
build: ./k-tv-backend
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
|
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
|
||||||
|
# Allow requests from the browser (the user-facing frontend URL)
|
||||||
|
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
|
||||||
|
# Auth — generate with: openssl rand -hex 32
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
# Cookie secret — generate with: openssl rand -base64 64
|
||||||
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
|
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
|
||||||
|
- SECURE_COOKIE=${SECURE_COOKIE:-false}
|
||||||
|
- PRODUCTION=${PRODUCTION:-false}
|
||||||
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||||
|
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
|
||||||
|
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
|
||||||
|
# Jellyfin — all three required for schedule generation
|
||||||
|
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
|
||||||
|
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
||||||
|
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/v1/config || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ── Frontend (Next.js) ────────────────────────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./k-tv-frontend
|
||||||
|
args:
|
||||||
|
# Browser-visible backend URL — must be reachable from the user's browser.
|
||||||
|
# If running on a server: http://your-server-ip:3000/api/v1
|
||||||
|
# Baked into the client bundle at build time; rebuild after changing.
|
||||||
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000/api/v1}
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3001}:3001"
|
||||||
|
environment:
|
||||||
|
# Server-side API URL — uses Docker's internal network, never exposed.
|
||||||
|
# Next.js API routes (e.g. /api/stream/[channelId]) use this.
|
||||||
|
API_URL: http://backend:3000/api/v1
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_data:
|
||||||
|
|
||||||
|
# ── Optional: PostgreSQL ───────────────────────────────────────────────────
|
||||||
|
# Uncomment the db service and set DATABASE_URL in backend's environment:
|
||||||
|
# DATABASE_URL: postgres://ktv:${POSTGRES_PASSWORD}@db:5432/ktv
|
||||||
|
#
|
||||||
|
# db:
|
||||||
|
# image: postgres:16-alpine
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_USER: ktv
|
||||||
|
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
# POSTGRES_DB: ktv
|
||||||
|
# volumes:
|
||||||
|
# - db_data:/var/lib/postgresql/data
|
||||||
|
# restart: unless-stopped
|
||||||
|
#
|
||||||
|
# db_data:
|
||||||
@@ -32,6 +32,9 @@ pub struct Config {
|
|||||||
/// Whether the application is running in production mode
|
/// Whether the application is running in production mode
|
||||||
pub is_production: bool,
|
pub is_production: bool,
|
||||||
|
|
||||||
|
/// Whether new user registration is open. Set ALLOW_REGISTRATION=false to lock down.
|
||||||
|
pub allow_registration: bool,
|
||||||
|
|
||||||
// Jellyfin media provider
|
// Jellyfin media provider
|
||||||
pub jellyfin_base_url: Option<String>,
|
pub jellyfin_base_url: Option<String>,
|
||||||
pub jellyfin_api_key: Option<String>,
|
pub jellyfin_api_key: Option<String>,
|
||||||
@@ -100,6 +103,10 @@ impl Config {
|
|||||||
.map(|v| v.to_lowercase() == "production" || v == "1" || v == "true")
|
.map(|v| v.to_lowercase() == "production" || v == "1" || v == "true")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let allow_registration = env::var("ALLOW_REGISTRATION")
|
||||||
|
.map(|v| !(v == "false" || v == "0"))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
let jellyfin_base_url = env::var("JELLYFIN_BASE_URL").ok();
|
let jellyfin_base_url = env::var("JELLYFIN_BASE_URL").ok();
|
||||||
let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok();
|
let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok();
|
||||||
let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok();
|
let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok();
|
||||||
@@ -123,6 +130,7 @@ impl Config {
|
|||||||
jwt_audience,
|
jwt_audience,
|
||||||
jwt_expiry_hours,
|
jwt_expiry_hours,
|
||||||
is_production,
|
is_production,
|
||||||
|
allow_registration,
|
||||||
jellyfin_base_url,
|
jellyfin_base_url,
|
||||||
jellyfin_api_key,
|
jellyfin_api_key,
|
||||||
jellyfin_user_id,
|
jellyfin_user_id,
|
||||||
|
|||||||
@@ -110,9 +110,13 @@ pub struct MediaItemResponse {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub content_type: domain::ContentType,
|
pub content_type: domain::ContentType,
|
||||||
pub duration_secs: u32,
|
pub duration_secs: u32,
|
||||||
|
pub description: Option<String>,
|
||||||
pub genres: Vec<String>,
|
pub genres: Vec<String>,
|
||||||
pub year: Option<u16>,
|
pub year: Option<u16>,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
pub season_number: Option<u32>,
|
||||||
|
pub episode_number: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<domain::MediaItem> for MediaItemResponse {
|
impl From<domain::MediaItem> for MediaItemResponse {
|
||||||
@@ -122,9 +126,13 @@ impl From<domain::MediaItem> for MediaItemResponse {
|
|||||||
title: i.title,
|
title: i.title,
|
||||||
content_type: i.content_type,
|
content_type: i.content_type,
|
||||||
duration_secs: i.duration_secs,
|
duration_secs: i.duration_secs,
|
||||||
|
description: i.description,
|
||||||
genres: i.genres,
|
genres: i.genres,
|
||||||
year: i.year,
|
year: i.year,
|
||||||
tags: i.tags,
|
tags: i.tags,
|
||||||
|
series_name: i.series_name,
|
||||||
|
season_number: i.season_number,
|
||||||
|
episode_number: i.episode_number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ async fn register(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<RegisterRequest>,
|
Json(payload): Json<RegisterRequest>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
if !state.config.allow_registration {
|
||||||
|
return Err(ApiError::Forbidden("Registration is disabled".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let password_hash = infra::auth::hash_password(payload.password.as_ref());
|
let password_hash = infra::auth::hash_password(payload.password.as_ref());
|
||||||
|
|
||||||
let user = state
|
let user = state
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use axum::{Json, Router, routing::get};
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::config::Config;
|
||||||
use crate::dto::ConfigResponse;
|
use crate::dto::ConfigResponse;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -6,8 +8,8 @@ pub fn router() -> Router<AppState> {
|
|||||||
Router::new().route("/", get(get_config))
|
Router::new().route("/", get(get_config))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config() -> Json<ConfigResponse> {
|
async fn get_config(State(config): State<Arc<Config>>) -> Json<ConfigResponse> {
|
||||||
Json(ConfigResponse {
|
Json(ConfigResponse {
|
||||||
allow_registration: true, // Default to true for template
|
allow_registration: config.allow_registration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,86 +4,44 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- SESSION_SECRET=dev_secret_key_12345
|
# Server
|
||||||
- DATABASE_URL=sqlite:///app/data/notes.db
|
|
||||||
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
|
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
# Database — SQLite by default; swap for a postgres:// URL to use PostgreSQL
|
||||||
|
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
|
||||||
|
# CORS — set to your frontend origin(s), comma-separated
|
||||||
|
- CORS_ALLOWED_ORIGINS=http://localhost:3001
|
||||||
|
# Auth — CHANGE BOTH before going to production
|
||||||
|
# Generate JWT_SECRET with: openssl rand -hex 32
|
||||||
|
# Generate COOKIE_SECRET with: openssl rand -base64 64
|
||||||
|
- JWT_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||||
|
- COOKIE_SECRET=change-me-must-be-at-least-64-characters-long-for-production!!
|
||||||
|
- JWT_EXPIRY_HOURS=24
|
||||||
|
- SECURE_COOKIE=false # set to true when serving over HTTPS
|
||||||
|
- PRODUCTION=false
|
||||||
|
- ALLOW_REGISTRATION=true # set to false to disable new user registration
|
||||||
|
# Database pool
|
||||||
- DB_MAX_CONNECTIONS=5
|
- DB_MAX_CONNECTIONS=5
|
||||||
- DB_MIN_CONNECTIONS=1
|
- DB_MIN_CONNECTIONS=1
|
||||||
- SECURE_COOKIE=true
|
# Jellyfin media provider — all three are required to enable schedule generation
|
||||||
|
- JELLYFIN_BASE_URL=http://jellyfin:8096
|
||||||
|
- JELLYFIN_API_KEY=your-jellyfin-api-key-here
|
||||||
|
- JELLYFIN_USER_ID=your-jellyfin-user-id-here
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data # SQLite database + any other persistent files
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# nats:
|
# ── Optional: PostgreSQL ────────────────────────────────────────────────────
|
||||||
# image: nats:alpine
|
# Uncomment and set DATABASE_URL=postgres://ktv:password@db:5432/ktv above.
|
||||||
|
#
|
||||||
|
# db:
|
||||||
|
# image: postgres:16-alpine
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_USER: ktv
|
||||||
|
# POSTGRES_PASSWORD: password
|
||||||
|
# POSTGRES_DB: ktv
|
||||||
# ports:
|
# ports:
|
||||||
# - "4222:4222"
|
# - "5432:5432"
|
||||||
# - "6222:6222"
|
# volumes:
|
||||||
# - "8222:8222"
|
# - db_data:/var/lib/postgresql/data
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: user
|
|
||||||
POSTGRES_PASSWORD: password
|
|
||||||
POSTGRES_DB: k_template_db
|
|
||||||
ports:
|
|
||||||
- "5439:5432"
|
|
||||||
volumes:
|
|
||||||
- db_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
zitadel-db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: zitadel_db
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: zitadel
|
|
||||||
POSTGRES_PASSWORD: zitadel_password
|
|
||||||
POSTGRES_DB: zitadel
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
|
||||||
- zitadel_db_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
zitadel:
|
|
||||||
image: ghcr.io/zitadel/zitadel:latest
|
|
||||||
container_name: zitadel_local
|
|
||||||
depends_on:
|
|
||||||
zitadel-db:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "8086:8080"
|
|
||||||
# USE start-from-init (Fixes the "relation does not exist" bug)
|
|
||||||
command: 'start-from-init --masterkey "MasterkeyNeedsToBeExactly32Bytes"'
|
|
||||||
environment:
|
|
||||||
# Database Connection
|
|
||||||
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
|
|
||||||
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
|
||||||
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
|
|
||||||
|
|
||||||
# APPLICATION USER (Zitadel uses this to run)
|
|
||||||
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
|
|
||||||
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password
|
|
||||||
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
|
|
||||||
|
|
||||||
# ADMIN USER (Zitadel uses this to create tables/migrations)
|
|
||||||
# We use 'zitadel' because it is the owner of the DB in your postgres container.
|
|
||||||
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
|
|
||||||
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel_password
|
|
||||||
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
|
|
||||||
|
|
||||||
# General Config
|
|
||||||
ZITADEL_EXTERNALDOMAIN: localhost
|
|
||||||
ZITADEL_EXTERNALPORT: 8086
|
|
||||||
ZITADEL_EXTERNALSECURE: "false"
|
|
||||||
ZITADEL_TLS_ENABLED: "false"
|
|
||||||
|
|
||||||
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
zitadel_db_data:
|
|
||||||
@@ -226,9 +226,16 @@ pub struct MediaItem {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub content_type: ContentType,
|
pub content_type: ContentType,
|
||||||
pub duration_secs: u32,
|
pub duration_secs: u32,
|
||||||
|
pub description: Option<String>,
|
||||||
pub genres: Vec<String>,
|
pub genres: Vec<String>,
|
||||||
pub year: Option<u16>,
|
pub year: Option<u16>,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
/// For episodes: the parent TV show name.
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
/// For episodes: season number (1-based).
|
||||||
|
pub season_number: Option<u32>,
|
||||||
|
/// For episodes: episode number within the season (1-based).
|
||||||
|
pub episode_number: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully resolved 48-hour broadcast program for one channel.
|
/// A fully resolved 48-hour broadcast program for one channel.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Services contain the business logic of the application.
|
//! Services contain the business logic of the application.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||||
@@ -185,7 +185,9 @@ impl ScheduleEngineService {
|
|||||||
/// 2. For each `ProgrammingBlock`, compute its UTC wall-clock interval for that day.
|
/// 2. For each `ProgrammingBlock`, compute its UTC wall-clock interval for that day.
|
||||||
/// 3. Clip the interval to `[from, from + 48h)`.
|
/// 3. Clip the interval to `[from, from + 48h)`.
|
||||||
/// 4. Resolve the block content via the media provider, applying the recycle policy.
|
/// 4. Resolve the block content via the media provider, applying the recycle policy.
|
||||||
/// 5. Record every played item in the playback history.
|
/// 5. For `Sequential` blocks, resume from where the previous generation left off
|
||||||
|
/// (series continuity — see `fill_sequential`).
|
||||||
|
/// 6. Record every played item in the playback history.
|
||||||
///
|
///
|
||||||
/// Gaps between blocks are left empty — clients render them as a no-signal state.
|
/// Gaps between blocks are left empty — clients render them as a no-signal state.
|
||||||
pub async fn generate_schedule(
|
pub async fn generate_schedule(
|
||||||
@@ -209,13 +211,29 @@ impl ScheduleEngineService {
|
|||||||
.find_playback_history(channel_id)
|
.find_playback_history(channel_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let generation = self
|
// Load the most recent schedule for two purposes:
|
||||||
.schedule_repo
|
// 1. Derive the next generation number.
|
||||||
.find_latest(channel_id)
|
// 2. Know where each Sequential block left off (series continuity).
|
||||||
.await?
|
let latest_schedule = self.schedule_repo.find_latest(channel_id).await?;
|
||||||
|
|
||||||
|
let generation = latest_schedule
|
||||||
|
.as_ref()
|
||||||
.map(|s| s.generation + 1)
|
.map(|s| s.generation + 1)
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
// Build the initial per-block continuity map from the previous generation's
|
||||||
|
// last slot per block. The map is updated as each block occurrence is resolved
|
||||||
|
// within this generation so that the second day of a 48h schedule continues
|
||||||
|
// from where the first day ended.
|
||||||
|
let mut block_continuity: HashMap<BlockId, MediaItemId> = latest_schedule
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| &s.slots)
|
||||||
|
.fold(HashMap::new(), |mut map, slot| {
|
||||||
|
// keep only the *last* slot per block (slots are sorted ascending)
|
||||||
|
map.insert(slot.source_block_id, slot.item.id.clone());
|
||||||
|
map
|
||||||
|
});
|
||||||
|
|
||||||
let valid_from = from;
|
let valid_from = from;
|
||||||
let valid_until = from + Duration::hours(48);
|
let valid_until = from + Duration::hours(48);
|
||||||
|
|
||||||
@@ -247,6 +265,9 @@ impl ScheduleEngineService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Sequential blocks: resume from the last item aired in this block.
|
||||||
|
let last_item_id = block_continuity.get(&block.id);
|
||||||
|
|
||||||
let mut block_slots = self
|
let mut block_slots = self
|
||||||
.resolve_block(
|
.resolve_block(
|
||||||
block,
|
block,
|
||||||
@@ -255,9 +276,16 @@ impl ScheduleEngineService {
|
|||||||
&history,
|
&history,
|
||||||
&channel.recycle_policy,
|
&channel.recycle_policy,
|
||||||
generation,
|
generation,
|
||||||
|
last_item_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Update continuity so the next occurrence of this block (same
|
||||||
|
// generation, next calendar day) continues from here.
|
||||||
|
if let Some(last_slot) = block_slots.last() {
|
||||||
|
block_continuity.insert(block.id, last_slot.item.id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
slots.append(&mut block_slots);
|
slots.append(&mut block_slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +375,7 @@ impl ScheduleEngineService {
|
|||||||
history: &[PlaybackRecord],
|
history: &[PlaybackRecord],
|
||||||
policy: &RecyclePolicy,
|
policy: &RecyclePolicy,
|
||||||
generation: u32,
|
generation: u32,
|
||||||
|
last_item_id: Option<&MediaItemId>,
|
||||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||||
match &block.content {
|
match &block.content {
|
||||||
BlockContent::Manual { items } => {
|
BlockContent::Manual { items } => {
|
||||||
@@ -354,7 +383,8 @@ impl ScheduleEngineService {
|
|||||||
}
|
}
|
||||||
BlockContent::Algorithmic { filter, strategy } => {
|
BlockContent::Algorithmic { filter, strategy } => {
|
||||||
self.resolve_algorithmic(
|
self.resolve_algorithmic(
|
||||||
filter, strategy, start, end, history, policy, generation, block.id,
|
filter, strategy, start, end, history, policy, generation,
|
||||||
|
block.id, last_item_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -397,6 +427,9 @@ impl ScheduleEngineService {
|
|||||||
|
|
||||||
/// Resolve an algorithmic block: fetch candidates, apply recycle policy,
|
/// Resolve an algorithmic block: fetch candidates, apply recycle policy,
|
||||||
/// run the fill strategy, and build slots.
|
/// run the fill strategy, and build slots.
|
||||||
|
///
|
||||||
|
/// `last_item_id` is the ID of the last item scheduled in this block in the
|
||||||
|
/// previous generation. Used only by `Sequential` for series continuity.
|
||||||
async fn resolve_algorithmic(
|
async fn resolve_algorithmic(
|
||||||
&self,
|
&self,
|
||||||
filter: &MediaFilter,
|
filter: &MediaFilter,
|
||||||
@@ -407,16 +440,20 @@ impl ScheduleEngineService {
|
|||||||
policy: &RecyclePolicy,
|
policy: &RecyclePolicy,
|
||||||
generation: u32,
|
generation: u32,
|
||||||
block_id: BlockId,
|
block_id: BlockId,
|
||||||
|
last_item_id: Option<&MediaItemId>,
|
||||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||||
|
// `candidates` — all items matching the filter, in provider order.
|
||||||
|
// Kept separate from `pool` so Sequential can rotate through the full
|
||||||
|
// ordered list while still honouring cooldowns.
|
||||||
let candidates = self.media_provider.fetch_items(filter).await?;
|
let candidates = self.media_provider.fetch_items(filter).await?;
|
||||||
|
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = Self::apply_recycle_policy(candidates, history, policy, generation);
|
let pool = Self::apply_recycle_policy(&candidates, history, policy, generation);
|
||||||
let target_secs = (end - start).num_seconds() as u32;
|
let target_secs = (end - start).num_seconds() as u32;
|
||||||
let selected = Self::fill_block(&pool, target_secs, strategy);
|
let selected = Self::fill_block(&candidates, &pool, target_secs, strategy, last_item_id);
|
||||||
|
|
||||||
let mut slots = Vec::new();
|
let mut slots = Vec::new();
|
||||||
let mut cursor = start;
|
let mut cursor = start;
|
||||||
@@ -451,7 +488,7 @@ impl ScheduleEngineService {
|
|||||||
/// than `policy.min_available_ratio` of the total, all cooldowns are waived
|
/// than `policy.min_available_ratio` of the total, all cooldowns are waived
|
||||||
/// and the full pool is returned (prevents small libraries from stalling).
|
/// and the full pool is returned (prevents small libraries from stalling).
|
||||||
fn apply_recycle_policy(
|
fn apply_recycle_policy(
|
||||||
candidates: Vec<MediaItem>,
|
candidates: &[MediaItem],
|
||||||
history: &[PlaybackRecord],
|
history: &[PlaybackRecord],
|
||||||
policy: &RecyclePolicy,
|
policy: &RecyclePolicy,
|
||||||
current_generation: u32,
|
current_generation: u32,
|
||||||
@@ -489,7 +526,7 @@ impl ScheduleEngineService {
|
|||||||
|
|
||||||
if available.len() < min_count {
|
if available.len() < min_count {
|
||||||
// Pool too small after applying cooldowns — recycle everything.
|
// Pool too small after applying cooldowns — recycle everything.
|
||||||
candidates
|
candidates.to_vec()
|
||||||
} else {
|
} else {
|
||||||
available
|
available
|
||||||
}
|
}
|
||||||
@@ -500,13 +537,17 @@ impl ScheduleEngineService {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
fn fill_block<'a>(
|
fn fill_block<'a>(
|
||||||
|
candidates: &'a [MediaItem],
|
||||||
pool: &'a [MediaItem],
|
pool: &'a [MediaItem],
|
||||||
target_secs: u32,
|
target_secs: u32,
|
||||||
strategy: &FillStrategy,
|
strategy: &FillStrategy,
|
||||||
|
last_item_id: Option<&MediaItemId>,
|
||||||
) -> Vec<&'a MediaItem> {
|
) -> Vec<&'a MediaItem> {
|
||||||
match strategy {
|
match strategy {
|
||||||
FillStrategy::BestFit => Self::fill_best_fit(pool, target_secs),
|
FillStrategy::BestFit => Self::fill_best_fit(pool, target_secs),
|
||||||
FillStrategy::Sequential => Self::fill_sequential(pool, target_secs),
|
FillStrategy::Sequential => {
|
||||||
|
Self::fill_sequential(candidates, pool, target_secs, last_item_id)
|
||||||
|
}
|
||||||
FillStrategy::Random => {
|
FillStrategy::Random => {
|
||||||
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
||||||
indices.shuffle(&mut rand::thread_rng());
|
indices.shuffle(&mut rand::thread_rng());
|
||||||
@@ -553,12 +594,55 @@ impl ScheduleEngineService {
|
|||||||
selected
|
selected
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sequential: iterate the pool in order, picking items that fit within
|
/// Sequential fill with cross-generation series continuity.
|
||||||
/// the remaining budget. Good for series where episode order matters.
|
///
|
||||||
fn fill_sequential(pool: &[MediaItem], target_secs: u32) -> Vec<&MediaItem> {
|
/// `candidates` — all items matching the filter, in Jellyfin's natural order
|
||||||
|
/// (typically by season + episode number for TV shows).
|
||||||
|
/// `pool` — candidates filtered by the recycle policy (eligible to air).
|
||||||
|
/// `last_item_id` — the last item scheduled in this block in the previous
|
||||||
|
/// generation or in an earlier occurrence of this block within
|
||||||
|
/// the current generation. Used to resume the series from the
|
||||||
|
/// next episode rather than restarting from episode 1.
|
||||||
|
///
|
||||||
|
/// Algorithm:
|
||||||
|
/// 1. Find `last_item_id`'s position in `candidates` and start from the next index.
|
||||||
|
/// 2. Walk the full `candidates` list in order (wrapping around at the end),
|
||||||
|
/// but only pick items that are in `pool` (i.e. not on cooldown).
|
||||||
|
/// 3. Greedily fill the time budget with items in that order.
|
||||||
|
///
|
||||||
|
/// This ensures episodes always air in series order, the series wraps correctly
|
||||||
|
/// when the last episode has been reached, and cooldowns are still respected.
|
||||||
|
fn fill_sequential<'a>(
|
||||||
|
candidates: &'a [MediaItem],
|
||||||
|
pool: &'a [MediaItem],
|
||||||
|
target_secs: u32,
|
||||||
|
last_item_id: Option<&MediaItemId>,
|
||||||
|
) -> Vec<&'a MediaItem> {
|
||||||
|
if pool.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set of item IDs currently eligible to air.
|
||||||
|
let available: HashSet<&MediaItemId> = pool.iter().map(|i| &i.id).collect();
|
||||||
|
|
||||||
|
// Find where in the full ordered list to resume.
|
||||||
|
// Falls back to index 0 if last_item_id is absent or was removed from the library.
|
||||||
|
let start_idx = last_item_id
|
||||||
|
.and_then(|id| candidates.iter().position(|c| &c.id == id))
|
||||||
|
.map(|pos| (pos + 1) % candidates.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Walk candidates in order from start_idx, wrapping around once,
|
||||||
|
// skipping any that are on cooldown (not in `available`).
|
||||||
|
let ordered: Vec<&MediaItem> = (0..candidates.len())
|
||||||
|
.map(|i| &candidates[(start_idx + i) % candidates.len()])
|
||||||
|
.filter(|item| available.contains(&item.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Greedily fill the block's time budget in episode order.
|
||||||
let mut remaining = target_secs;
|
let mut remaining = target_secs;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for item in pool {
|
for item in ordered {
|
||||||
if item.duration_secs <= remaining {
|
if item.duration_secs <= remaining {
|
||||||
remaining -= item.duration_secs;
|
remaining -= item.duration_secs;
|
||||||
result.push(item);
|
result.push(item);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ impl IMediaProvider for JellyfinMediaProvider {
|
|||||||
|
|
||||||
let mut params: Vec<(&str, String)> = vec![
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
("Recursive", "true".into()),
|
("Recursive", "true".into()),
|
||||||
("Fields", "Genres,Tags,RunTimeTicks,ProductionYear".into()),
|
("Fields", "Genres,Tags,RunTimeTicks,ProductionYear,Overview".into()),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(ct) = &filter.content_type {
|
if let Some(ct) = &filter.content_type {
|
||||||
@@ -198,12 +198,23 @@ struct JellyfinItem {
|
|||||||
item_type: String,
|
item_type: String,
|
||||||
#[serde(rename = "RunTimeTicks")]
|
#[serde(rename = "RunTimeTicks")]
|
||||||
run_time_ticks: Option<i64>,
|
run_time_ticks: Option<i64>,
|
||||||
|
#[serde(rename = "Overview")]
|
||||||
|
overview: Option<String>,
|
||||||
#[serde(rename = "Genres")]
|
#[serde(rename = "Genres")]
|
||||||
genres: Option<Vec<String>>,
|
genres: Option<Vec<String>>,
|
||||||
#[serde(rename = "ProductionYear")]
|
#[serde(rename = "ProductionYear")]
|
||||||
production_year: Option<u16>,
|
production_year: Option<u16>,
|
||||||
#[serde(rename = "Tags")]
|
#[serde(rename = "Tags")]
|
||||||
tags: Option<Vec<String>>,
|
tags: Option<Vec<String>>,
|
||||||
|
/// TV show name (episodes only)
|
||||||
|
#[serde(rename = "SeriesName")]
|
||||||
|
series_name: Option<String>,
|
||||||
|
/// Season number (episodes only)
|
||||||
|
#[serde(rename = "ParentIndexNumber")]
|
||||||
|
parent_index_number: Option<u32>,
|
||||||
|
/// Episode number within the season (episodes only)
|
||||||
|
#[serde(rename = "IndexNumber")]
|
||||||
|
index_number: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -238,8 +249,12 @@ fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
|||||||
title: item.name,
|
title: item.name,
|
||||||
content_type,
|
content_type,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
|
description: item.overview,
|
||||||
genres: item.genres.unwrap_or_default(),
|
genres: item.genres.unwrap_or_default(),
|
||||||
year: item.production_year,
|
year: item.production_year,
|
||||||
tags: item.tags.unwrap_or_default(),
|
tags: item.tags.unwrap_or_default(),
|
||||||
|
series_name: item.series_name,
|
||||||
|
season_number: item.parent_index_number,
|
||||||
|
episode_number: item.index_number,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
4
k-tv-frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.env*
|
||||||
|
*.md
|
||||||
46
k-tv-frontend/Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# ── Stage 1: Install dependencies ────────────────────────────────────────────
|
||||||
|
FROM oven/bun:1 AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# ── Stage 2: Build ────────────────────────────────────────────────────────────
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_* vars are baked into the client bundle at build time.
|
||||||
|
# Pass the public backend URL via --build-arg (see compose.yml).
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:3000/api/v1
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# ── Stage 3: Production runner ────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# standalone output + static assets
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV PORT=3001
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLogin } from "@/hooks/use-auth";
|
import { useLogin } from "@/hooks/use-auth";
|
||||||
|
import { useConfig } from "@/hooks/use-channels";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const { mutate: login, isPending, error } = useLogin();
|
const { mutate: login, isPending, error } = useLogin();
|
||||||
|
const { data: config } = useConfig();
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -63,12 +65,14 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{config?.allow_registration !== false && (
|
||||||
<p className="text-center text-xs text-zinc-500">
|
<p className="text-center text-xs text-zinc-500">
|
||||||
No account?{" "}
|
No account?{" "}
|
||||||
<Link href="/register" className="text-zinc-300 hover:text-white">
|
<Link href="/register" className="text-zinc-300 hover:text-white">
|
||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,27 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRegister } from "@/hooks/use-auth";
|
import { useRegister } from "@/hooks/use-auth";
|
||||||
|
import { useConfig } from "@/hooks/use-channels";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const { mutate: register, isPending, error } = useRegister();
|
const { mutate: register, isPending, error } = useRegister();
|
||||||
|
const { data: config } = useConfig();
|
||||||
|
|
||||||
|
if (config && !config.allow_registration) {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-sm space-y-4 text-center">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">Registration disabled</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
The administrator has disabled new account registration.
|
||||||
|
</p>
|
||||||
|
<Link href="/login" className="inline-block text-sm text-zinc-300 hover:text-white">
|
||||||
|
Sign in instead
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,28 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download } from "lucide-react";
|
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useActiveSchedule } from "@/hooks/use-channels";
|
||||||
import type { ChannelResponse } from "@/lib/types";
|
import type { ChannelResponse } from "@/lib/types";
|
||||||
|
|
||||||
interface ChannelCardProps {
|
interface ChannelCardProps {
|
||||||
channel: ChannelResponse;
|
channel: ChannelResponse;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onGenerateSchedule: () => void;
|
onGenerateSchedule: () => void;
|
||||||
onViewSchedule: () => void;
|
onViewSchedule: () => void;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScheduleStatus(channelId: string) {
|
||||||
|
const { data: schedule } = useActiveSchedule(channelId);
|
||||||
|
if (!schedule) return { status: "none" as const, label: null };
|
||||||
|
|
||||||
|
const expiresAt = new Date(schedule.valid_until);
|
||||||
|
const hoursLeft = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (hoursLeft < 0) {
|
||||||
|
return { status: "expired" as const, label: "Schedule expired" };
|
||||||
|
}
|
||||||
|
if (hoursLeft < 6) {
|
||||||
|
const h = Math.ceil(hoursLeft);
|
||||||
|
return { status: "expiring" as const, label: `Expires in ${h}h` };
|
||||||
|
}
|
||||||
|
const fmt = expiresAt.toLocaleDateString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
return { status: "ok" as const, label: `Until ${fmt}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelCard({
|
export function ChannelCard({
|
||||||
channel,
|
channel,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onGenerateSchedule,
|
onGenerateSchedule,
|
||||||
onViewSchedule,
|
onViewSchedule,
|
||||||
onExport,
|
onExport,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
}: ChannelCardProps) {
|
}: ChannelCardProps) {
|
||||||
const blockCount = channel.schedule_config.blocks.length;
|
const blockCount = channel.schedule_config.blocks.length;
|
||||||
|
const { status, label } = useScheduleStatus(channel.id);
|
||||||
|
|
||||||
|
const scheduleColor =
|
||||||
|
status === "expired" ? "text-red-400" :
|
||||||
|
status === "expiring" ? "text-amber-400" :
|
||||||
|
status === "ok" ? "text-zinc-500" :
|
||||||
|
"text-zinc-600";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 transition-colors hover:border-zinc-700">
|
<div className="flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 transition-colors hover:border-zinc-700">
|
||||||
@@ -40,6 +76,26 @@ export function ChannelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{/* Order controls */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={onMoveUp}
|
||||||
|
disabled={isFirst}
|
||||||
|
title="Move up"
|
||||||
|
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onMoveDown}
|
||||||
|
disabled={isLast}
|
||||||
|
title="Move down"
|
||||||
|
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -71,12 +127,13 @@ export function ChannelCard({
|
|||||||
|
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500">
|
||||||
<span>
|
|
||||||
<span className="text-zinc-400">{channel.timezone}</span>
|
<span className="text-zinc-400">{channel.timezone}</span>
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
{blockCount} {blockCount === 1 ? "block" : "blocks"}
|
{blockCount} {blockCount === 1 ? "block" : "blocks"}
|
||||||
</span>
|
</span>
|
||||||
|
{label && (
|
||||||
|
<span className={scheduleColor}>{label}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -85,7 +142,7 @@ export function ChannelCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onGenerateSchedule}
|
onClick={onGenerateSchedule}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="flex-1"
|
className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} />
|
<RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} />
|
||||||
{isGenerating ? "Generating…" : "Generate schedule"}
|
{isGenerating ? "Generating…" : "Generate schedule"}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { useActiveSchedule } from "@/hooks/use-channels";
|
import { useActiveSchedule } from "@/hooks/use-channels";
|
||||||
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
|
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
|
||||||
@@ -21,11 +22,13 @@ interface DayRowProps {
|
|||||||
dayStart: Date;
|
dayStart: Date;
|
||||||
slots: ScheduledSlotResponse[];
|
slots: ScheduledSlotResponse[];
|
||||||
colorMap: Map<string, string>;
|
colorMap: Map<string, string>;
|
||||||
|
now: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
|
function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
|
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
|
||||||
|
const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100;
|
||||||
|
|
||||||
// Only include slots that overlap this day
|
// Only include slots that overlap this day
|
||||||
const daySlots = slots.filter((s) => {
|
const daySlots = slots.filter((s) => {
|
||||||
@@ -46,6 +49,15 @@ function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
|
|||||||
style={{ left: `${(i / 24) * 100}%` }}
|
style={{ left: `${(i / 24) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Current time marker */}
|
||||||
|
{nowPct >= 0 && nowPct <= 100 && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 z-10 w-0.5 bg-red-500"
|
||||||
|
style={{ left: `${nowPct}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-red-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{daySlots.map((slot) => {
|
{daySlots.map((slot) => {
|
||||||
const slotStart = new Date(slot.start_at);
|
const slotStart = new Date(slot.start_at);
|
||||||
const slotEnd = new Date(slot.end_at);
|
const slotEnd = new Date(slot.end_at);
|
||||||
@@ -102,6 +114,13 @@ interface ScheduleSheetProps {
|
|||||||
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
|
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
|
||||||
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
|
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
|
||||||
|
|
||||||
|
// Live clock for the current-time marker — updates every minute
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
|
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
|
||||||
|
|
||||||
// Build day rows from valid_from to valid_until
|
// Build day rows from valid_from to valid_until
|
||||||
@@ -172,6 +191,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
|
|||||||
dayStart={dayStart}
|
dayStart={dayStart}
|
||||||
slots={schedule.slots}
|
slots={schedule.slots}
|
||||||
colorMap={colorMap}
|
colorMap={colorMap}
|
||||||
|
now={now}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Plus, Upload } from "lucide-react";
|
import { Plus, Upload, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
useChannels,
|
useChannels,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/hooks/use-channels";
|
} from "@/hooks/use-channels";
|
||||||
import { useAuthContext } from "@/context/auth-context";
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChannelCard } from "./components/channel-card";
|
import { ChannelCard } from "./components/channel-card";
|
||||||
import { CreateChannelDialog } from "./components/create-channel-dialog";
|
import { CreateChannelDialog } from "./components/create-channel-dialog";
|
||||||
@@ -31,6 +32,69 @@ export default function DashboardPage() {
|
|||||||
const deleteChannel = useDeleteChannel();
|
const deleteChannel = useDeleteChannel();
|
||||||
const generateSchedule = useGenerateSchedule();
|
const generateSchedule = useGenerateSchedule();
|
||||||
|
|
||||||
|
// Channel ordering — persisted to localStorage
|
||||||
|
const [channelOrder, setChannelOrder] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("k-tv-channel-order");
|
||||||
|
if (stored) setChannelOrder(JSON.parse(stored));
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveOrder = (order: string[]) => {
|
||||||
|
setChannelOrder(order);
|
||||||
|
try { localStorage.setItem("k-tv-channel-order", JSON.stringify(order)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort channels by stored order; new channels appear at the end
|
||||||
|
const sortedChannels = channels
|
||||||
|
? [...channels].sort((a, b) => {
|
||||||
|
const ai = channelOrder.indexOf(a.id);
|
||||||
|
const bi = channelOrder.indexOf(b.id);
|
||||||
|
if (ai === -1 && bi === -1) return 0;
|
||||||
|
if (ai === -1) return 1;
|
||||||
|
if (bi === -1) return -1;
|
||||||
|
return ai - bi;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleMoveUp = (channelId: string) => {
|
||||||
|
const ids = sortedChannels.map((c) => c.id);
|
||||||
|
const idx = ids.indexOf(channelId);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const next = [...ids];
|
||||||
|
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||||
|
saveOrder(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveDown = (channelId: string) => {
|
||||||
|
const ids = sortedChannels.map((c) => c.id);
|
||||||
|
const idx = ids.indexOf(channelId);
|
||||||
|
if (idx === -1 || idx >= ids.length - 1) return;
|
||||||
|
const next = [...ids];
|
||||||
|
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||||
|
saveOrder(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Regenerate all channels
|
||||||
|
const [isRegeneratingAll, setIsRegeneratingAll] = useState(false);
|
||||||
|
const handleRegenerateAll = async () => {
|
||||||
|
if (!token || !channels || channels.length === 0) return;
|
||||||
|
setIsRegeneratingAll(true);
|
||||||
|
let failed = 0;
|
||||||
|
for (const ch of channels) {
|
||||||
|
try {
|
||||||
|
await api.schedule.generate(ch.id, token);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["schedule", ch.id] });
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsRegeneratingAll(false);
|
||||||
|
if (failed === 0) toast.success(`All ${channels.length} schedules regenerated`);
|
||||||
|
else toast.error(`${failed} schedule(s) failed to generate`);
|
||||||
|
};
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importPending, setImportPending] = useState(false);
|
const [importPending, setImportPending] = useState(false);
|
||||||
@@ -124,6 +188,18 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{channels && channels.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRegenerateAll}
|
||||||
|
disabled={isRegeneratingAll}
|
||||||
|
title="Regenerate schedules for all channels"
|
||||||
|
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${isRegeneratingAll ? "animate-spin" : ""}`} />
|
||||||
|
Regenerate all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={() => setImportOpen(true)} className="border-zinc-700 text-zinc-300 hover:text-zinc-100">
|
<Button variant="outline" onClick={() => setImportOpen(true)} className="border-zinc-700 text-zinc-300 hover:text-zinc-100">
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
Import
|
Import
|
||||||
@@ -148,7 +224,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{channels && channels.length === 0 && (
|
{!isLoading && channels && channels.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-zinc-800 py-20 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-zinc-800 py-20 text-center">
|
||||||
<p className="text-sm text-zinc-500">No channels yet</p>
|
<p className="text-sm text-zinc-500">No channels yet</p>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||||
@@ -158,9 +234,9 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{channels && channels.length > 0 && (
|
{sortedChannels.length > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map((channel) => (
|
{sortedChannels.map((channel, idx) => (
|
||||||
<ChannelCard
|
<ChannelCard
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
@@ -168,11 +244,15 @@ export default function DashboardPage() {
|
|||||||
generateSchedule.isPending &&
|
generateSchedule.isPending &&
|
||||||
generateSchedule.variables === channel.id
|
generateSchedule.variables === channel.id
|
||||||
}
|
}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
isLast={idx === sortedChannels.length - 1}
|
||||||
onEdit={() => setEditChannel(channel)}
|
onEdit={() => setEditChannel(channel)}
|
||||||
onDelete={() => setDeleteTarget(channel)}
|
onDelete={() => setDeleteTarget(channel)}
|
||||||
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
|
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
|
||||||
onViewSchedule={() => setScheduleChannel(channel)}
|
onViewSchedule={() => setScheduleChannel(channel)}
|
||||||
onExport={() => handleExport(channel)}
|
onExport={() => handleExport(channel)}
|
||||||
|
onMoveUp={() => handleMoveUp(channel.id)}
|
||||||
|
onMoveDown={() => handleMoveDown(channel.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,886 @@
|
|||||||
export default function DocsPage() {
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Primitive components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Section({ id, children }: { id: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
<section id={id} className="scroll-mt-20">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Docs</h1>
|
{children}
|
||||||
<p className="text-sm text-zinc-500">API reference and usage documentation go here.</p>
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function H2({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 className="mb-4 mt-12 text-xl font-semibold text-zinc-100 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function H3({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h3 className="mb-3 mt-8 text-base font-semibold text-zinc-200">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function P({ children }: { children: ReactNode }) {
|
||||||
|
return <p className="mb-4 leading-relaxed text-zinc-400">{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<code className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-[13px] text-zinc-300">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pre({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<pre className="mb-4 overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 font-mono text-[13px] leading-relaxed text-zinc-300">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Note({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg border border-zinc-700 bg-zinc-800/40 px-4 py-3 text-sm text-zinc-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Warn({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-800/50 bg-amber-950/30 px-4 py-3 text-sm text-amber-300/80">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ul({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ul className="mb-4 ml-5 list-disc space-y-1 text-zinc-400">{children}</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Li({ children }: { children: ReactNode }) {
|
||||||
|
return <li>{children}</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Table({
|
||||||
|
head,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
head: string[];
|
||||||
|
rows: (string | ReactNode)[][];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 overflow-x-auto rounded-lg border border-zinc-800">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-zinc-800/60">
|
||||||
|
<tr>
|
||||||
|
{head.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-4 py-2.5 text-left font-medium text-zinc-300"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-800/20">
|
||||||
|
{row.map((cell, j) => (
|
||||||
|
<td key={j} className="px-4 py-2.5 align-top text-zinc-400">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table of contents
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TOC = [
|
||||||
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "requirements", label: "Requirements" },
|
||||||
|
{ id: "backend-setup", label: "Backend setup" },
|
||||||
|
{ id: "frontend-setup", label: "Frontend setup" },
|
||||||
|
{ id: "jellyfin", label: "Connecting Jellyfin" },
|
||||||
|
{ id: "first-channel", label: "Your first channel" },
|
||||||
|
{ id: "blocks", label: "Programming blocks" },
|
||||||
|
{ id: "filters", label: "Filters reference" },
|
||||||
|
{ id: "strategies", label: "Fill strategies" },
|
||||||
|
{ id: "recycle-policy", label: "Recycle policy" },
|
||||||
|
{ id: "import-export", label: "Import & export" },
|
||||||
|
{ id: "tv-page", label: "Watching TV" },
|
||||||
|
{ id: "troubleshooting", label: "Troubleshooting" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DocsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl gap-12 px-6 py-12">
|
||||||
|
{/* Sidebar TOC */}
|
||||||
|
<aside className="hidden w-52 shrink-0 lg:block">
|
||||||
|
<div className="sticky top-20">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||||
|
On this page
|
||||||
|
</p>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{TOC.map(({ id, label }) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
className="rounded px-2 py-1 text-sm text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<article className="min-w-0 flex-1">
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="overview">
|
||||||
|
<H2>Overview</H2>
|
||||||
|
<P>
|
||||||
|
K-TV turns your self-hosted media library into broadcast-style linear
|
||||||
|
TV channels. You define programming blocks — time slots with filters
|
||||||
|
and fill strategies — and the scheduler automatically picks content
|
||||||
|
from your{" "}
|
||||||
|
<a
|
||||||
|
href="https://jellyfin.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-300 underline underline-offset-2 hover:text-white"
|
||||||
|
>
|
||||||
|
Jellyfin
|
||||||
|
</a>{" "}
|
||||||
|
library to fill them. Viewers open the TV page and watch a live
|
||||||
|
stream with no seeking — just like real TV.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
The project has two parts: a{" "}
|
||||||
|
<strong className="text-zinc-300">backend</strong> (Rust / Axum)
|
||||||
|
that manages channels, generates schedules, and proxies streams from
|
||||||
|
Jellyfin, and a{" "}
|
||||||
|
<strong className="text-zinc-300">frontend</strong> (Next.js) that
|
||||||
|
provides the TV viewer and the channel management dashboard.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="requirements">
|
||||||
|
<H2>Requirements</H2>
|
||||||
|
<Table
|
||||||
|
head={["Dependency", "Version", "Notes"]}
|
||||||
|
rows={[
|
||||||
|
[<Code key="r">Rust</Code>, "1.77+", "Install via rustup"],
|
||||||
|
[<Code key="n">Node.js</Code>, "20+", "Frontend only"],
|
||||||
|
[<Code key="j">Jellyfin</Code>, "10.8+", "Your media server"],
|
||||||
|
[
|
||||||
|
<Code key="db">SQLite or PostgreSQL</Code>,
|
||||||
|
"any",
|
||||||
|
"SQLite is the default — no extra setup needed",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Note>
|
||||||
|
SQLite is the default and requires no additional database setup.
|
||||||
|
PostgreSQL support is available by rebuilding the backend with the{" "}
|
||||||
|
<Code>postgres</Code> Cargo feature.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="backend-setup">
|
||||||
|
<H2>Backend setup</H2>
|
||||||
|
<P>
|
||||||
|
Clone the repository and start the server. All configuration is read
|
||||||
|
from environment variables or a <Code>.env</Code> file in the
|
||||||
|
working directory.
|
||||||
|
</P>
|
||||||
|
<Pre>{`git clone <repo-url> k-tv-backend
|
||||||
|
cd k-tv-backend
|
||||||
|
cargo run`}</Pre>
|
||||||
|
<P>
|
||||||
|
The server starts on <Code>http://127.0.0.1:3000</Code> by default.
|
||||||
|
Database migrations run automatically on startup.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Environment variables</H3>
|
||||||
|
<Table
|
||||||
|
head={["Variable", "Default", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="h">HOST</Code>,
|
||||||
|
<Code key="h2">127.0.0.1</Code>,
|
||||||
|
"Bind address. Use 0.0.0.0 in containers.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="p">PORT</Code>,
|
||||||
|
<Code key="p2">3000</Code>,
|
||||||
|
"HTTP port.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="du">DATABASE_URL</Code>,
|
||||||
|
<Code key="du2">sqlite:data.db?mode=rwc</Code>,
|
||||||
|
"SQLite file path or postgres:// connection string.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="co">CORS_ALLOWED_ORIGINS</Code>,
|
||||||
|
<Code key="co2">http://localhost:5173</Code>,
|
||||||
|
"Comma-separated list of allowed frontend origins.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="jbu">JELLYFIN_BASE_URL</Code>,
|
||||||
|
"—",
|
||||||
|
"Jellyfin server URL, e.g. http://192.168.1.10:8096",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="jak">JELLYFIN_API_KEY</Code>,
|
||||||
|
"—",
|
||||||
|
"Jellyfin API key (see Connecting Jellyfin).",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="jui">JELLYFIN_USER_ID</Code>,
|
||||||
|
"—",
|
||||||
|
"Jellyfin user ID used for library browsing.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="js">JWT_SECRET</Code>,
|
||||||
|
"—",
|
||||||
|
"Secret used to sign login tokens. Generate with: openssl rand -hex 32",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="je">JWT_EXPIRY_HOURS</Code>,
|
||||||
|
<Code key="je2">24</Code>,
|
||||||
|
"How long a login token stays valid.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="cs">COOKIE_SECRET</Code>,
|
||||||
|
"dev default",
|
||||||
|
"Must be at least 64 bytes in production.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="sc">SECURE_COOKIE</Code>,
|
||||||
|
<Code key="sc2">false</Code>,
|
||||||
|
"Set to true when serving over HTTPS.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="dm">DB_MAX_CONNECTIONS</Code>,
|
||||||
|
<Code key="dm2">5</Code>,
|
||||||
|
"Connection pool maximum.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="di">DB_MIN_CONNECTIONS</Code>,
|
||||||
|
<Code key="di2">1</Code>,
|
||||||
|
"Connections kept alive in the pool.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="pr">PRODUCTION</Code>,
|
||||||
|
<Code key="pr2">false</Code>,
|
||||||
|
"Set to true or 1 to enable production mode.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H3>Minimal production .env</H3>
|
||||||
|
<Pre>{`HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=sqlite:/app/data/k-tv.db?mode=rwc
|
||||||
|
CORS_ALLOWED_ORIGINS=https://your-frontend-domain.com
|
||||||
|
JWT_SECRET=<output of: openssl rand -hex 32>
|
||||||
|
COOKIE_SECRET=<64+ character random string>
|
||||||
|
SECURE_COOKIE=true
|
||||||
|
PRODUCTION=true
|
||||||
|
JELLYFIN_BASE_URL=http://jellyfin:8096
|
||||||
|
JELLYFIN_API_KEY=<your jellyfin api key>
|
||||||
|
JELLYFIN_USER_ID=<your jellyfin user id>`}</Pre>
|
||||||
|
|
||||||
|
<Warn>
|
||||||
|
Always set a strong <Code>JWT_SECRET</Code> in production. The
|
||||||
|
default <Code>COOKIE_SECRET</Code> is publicly known and must be
|
||||||
|
replaced before going live.
|
||||||
|
</Warn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="frontend-setup">
|
||||||
|
<H2>Frontend setup</H2>
|
||||||
|
<Pre>{`cd k-tv-frontend
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
# edit .env.local
|
||||||
|
npm install
|
||||||
|
npm run dev`}</Pre>
|
||||||
|
|
||||||
|
<H3>Environment variables</H3>
|
||||||
|
<Table
|
||||||
|
head={["Variable", "Default", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="np">NEXT_PUBLIC_API_URL</Code>,
|
||||||
|
<Code key="np2">http://localhost:3000/api/v1</Code>,
|
||||||
|
"Backend API base URL — sent to the browser.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="au">API_URL</Code>,
|
||||||
|
"Falls back to NEXT_PUBLIC_API_URL",
|
||||||
|
"Server-side API URL used by Next.js API routes. Set this if the frontend container reaches the backend via a private hostname.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The TV page and channel list are fully public — no login required to
|
||||||
|
watch. An account is only needed to create or manage channels from
|
||||||
|
the Dashboard.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="jellyfin">
|
||||||
|
<H2>Connecting Jellyfin</H2>
|
||||||
|
<P>
|
||||||
|
K-TV fetches content metadata and HLS stream URLs from Jellyfin. You
|
||||||
|
need three things: the server URL, an API key, and the user ID K-TV
|
||||||
|
will browse as.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>1. API key</H3>
|
||||||
|
<P>
|
||||||
|
In Jellyfin go to{" "}
|
||||||
|
<strong className="text-zinc-300">
|
||||||
|
Dashboard → API Keys
|
||||||
|
</strong>{" "}
|
||||||
|
and create a new key. Give it a name like <em>K-TV</em>. Copy the
|
||||||
|
value into <Code>JELLYFIN_API_KEY</Code>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>2. User ID</H3>
|
||||||
|
<P>
|
||||||
|
Go to{" "}
|
||||||
|
<strong className="text-zinc-300">Dashboard → Users</strong>, click
|
||||||
|
the user K-TV should browse as (usually your admin account), and
|
||||||
|
copy the user ID from the browser URL:
|
||||||
|
</P>
|
||||||
|
<Pre>{`/web/index.html#!/useredit?userId=<COPY THIS PART>`}</Pre>
|
||||||
|
<P>
|
||||||
|
Paste it into <Code>JELLYFIN_USER_ID</Code>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>3. Library IDs (optional)</H3>
|
||||||
|
<P>
|
||||||
|
Library IDs are used in the <Code>collections</Code> filter field to
|
||||||
|
restrict a block to a specific Jellyfin library or folder. Browse to
|
||||||
|
a library in Jellyfin and copy the <Code>parentId</Code> query
|
||||||
|
parameter from the URL. Leave <Code>collections</Code> empty to
|
||||||
|
search across all libraries.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Stream format</H3>
|
||||||
|
<P>
|
||||||
|
K-TV requests adaptive HLS streams from Jellyfin. Jellyfin
|
||||||
|
transcodes to H.264 / AAC on the fly (or serves a direct stream if
|
||||||
|
the file is already compatible). The frontend player handles
|
||||||
|
bitrate adaptation and seeks to the correct broadcast position
|
||||||
|
automatically so viewers join mid-show at the right point.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Subtitles</H3>
|
||||||
|
<P>
|
||||||
|
External subtitle files (SRT, ASS) attached to a Jellyfin item are
|
||||||
|
automatically converted to WebVTT and embedded in the HLS manifest.
|
||||||
|
A <strong className="text-zinc-300">CC</strong> button appears in
|
||||||
|
the TV player when tracks are available. Image-based subtitles
|
||||||
|
(PGS/VOBSUB from Blu-ray sources) require burn-in transcoding and
|
||||||
|
are not currently supported.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="first-channel">
|
||||||
|
<H2>Your first channel</H2>
|
||||||
|
<P>
|
||||||
|
Log in and open the{" "}
|
||||||
|
<strong className="text-zinc-300">Dashboard</strong>. Click{" "}
|
||||||
|
<strong className="text-zinc-300">New channel</strong> and fill in:
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Field", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
"Name",
|
||||||
|
"Display name shown to viewers in the TV overlay.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Timezone",
|
||||||
|
"IANA timezone (e.g. America/New_York). Block start times are anchored to this zone, including DST changes.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Description",
|
||||||
|
"Optional. Shown only in the Dashboard.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<P>
|
||||||
|
After creating the channel, open the edit sheet (pencil icon). Add
|
||||||
|
programming blocks in the list or draw them directly on the 24-hour
|
||||||
|
timeline. Once the schedule looks right, click{" "}
|
||||||
|
<strong className="text-zinc-300">Generate schedule</strong> on the
|
||||||
|
channel card. K-TV queries Jellyfin, fills each block with matching
|
||||||
|
content, and starts broadcasting immediately.
|
||||||
|
</P>
|
||||||
|
<Note>
|
||||||
|
Schedules are valid for 48 hours. K-TV does not regenerate them
|
||||||
|
automatically — return to the Dashboard and click{" "}
|
||||||
|
<strong className="text-zinc-300">Generate</strong> whenever you
|
||||||
|
want a fresh lineup.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="blocks">
|
||||||
|
<H2>Programming blocks</H2>
|
||||||
|
<P>
|
||||||
|
A programming block is a repeating daily time slot. Every day the
|
||||||
|
block starts at its <Code>start_time</Code> (in the channel
|
||||||
|
timezone) and runs for <Code>duration_mins</Code> minutes. The
|
||||||
|
scheduler fills it with as many items as will fit.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Timeline editor</H3>
|
||||||
|
<Ul>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Draw a block</strong> — click
|
||||||
|
and drag on an empty area of the 24-hour timeline.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Move a block</strong> — drag the
|
||||||
|
block body left or right. Snaps to 15-minute increments.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Resize a block</strong> — drag
|
||||||
|
its right edge.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Select a block</strong> — click
|
||||||
|
it on the timeline to scroll its detail editor into view below.
|
||||||
|
</Li>
|
||||||
|
</Ul>
|
||||||
|
<P>
|
||||||
|
Gaps between blocks are fine — the TV player shows a no-signal
|
||||||
|
screen during those times. You do not need to fill every minute of
|
||||||
|
the day.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Content types</H3>
|
||||||
|
<Table
|
||||||
|
head={["Type", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="a">algorithmic</Code>,
|
||||||
|
"The scheduler picks items from your Jellyfin library based on filters you define. Recommended for most blocks.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="m">manual</Code>,
|
||||||
|
"Plays a fixed, ordered list of Jellyfin item IDs. Useful for a specific playlist or sequential episode run.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="filters">
|
||||||
|
<H2>Filters reference</H2>
|
||||||
|
<P>
|
||||||
|
Filters apply to <Code>algorithmic</Code> blocks. All fields are
|
||||||
|
optional — omit or leave blank to match everything. Multiple values
|
||||||
|
in an array field must <em>all</em> match (AND logic).
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Field", "Type", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="ct">content_type</Code>,
|
||||||
|
<>
|
||||||
|
<Code key="mv">movie</Code> |{" "}
|
||||||
|
<Code key="ep">episode</Code> |{" "}
|
||||||
|
<Code key="sh">short</Code>
|
||||||
|
</>,
|
||||||
|
"Restrict to one media type. Leave empty for any type. Short films are stored as movies in Jellyfin.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="g">genres</Code>,
|
||||||
|
"string[]",
|
||||||
|
"Only include items matching all listed genres. Names are case-sensitive and must match Jellyfin exactly.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="d">decade</Code>,
|
||||||
|
"integer",
|
||||||
|
"Filter by production decade. 1990 matches 1990–1999.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="t">tags</Code>,
|
||||||
|
"string[]",
|
||||||
|
"Only include items that have all listed tags.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="mn">min_duration_secs</Code>,
|
||||||
|
"integer",
|
||||||
|
"Minimum item duration in seconds. 1800 = 30 min, 3600 = 1 hour.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="mx">max_duration_secs</Code>,
|
||||||
|
"integer",
|
||||||
|
"Maximum item duration in seconds.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="cl">collections</Code>,
|
||||||
|
"string[]",
|
||||||
|
"Jellyfin library / folder IDs. Find the ID in the Jellyfin URL when browsing a library. Leave empty to search all libraries.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Note>
|
||||||
|
Genre and tag names come from Jellyfin metadata. If a filter returns
|
||||||
|
no results, check the exact spelling in the Jellyfin library browser
|
||||||
|
filter panel.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="strategies">
|
||||||
|
<H2>Fill strategies</H2>
|
||||||
|
<P>
|
||||||
|
The fill strategy controls how items are ordered and selected from
|
||||||
|
the filtered pool.
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Strategy", "Behaviour", "Best for"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="r">random</Code>,
|
||||||
|
"Shuffles the pool and fills the block in random order. Each schedule generation produces a different lineup.",
|
||||||
|
"Movie channels, variety blocks — anything where you want variety.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="s">sequential</Code>,
|
||||||
|
"Items are played in the order Jellyfin returns them (typically name or episode number).",
|
||||||
|
"Series watched in order, e.g. a block dedicated to one show.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="b">best_fit</Code>,
|
||||||
|
"Greedy bin-packing: repeatedly picks the longest item that still fits in the remaining time, minimising dead air at the end.",
|
||||||
|
"Blocks where you want the slot filled as tightly as possible.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="recycle-policy">
|
||||||
|
<H2>Recycle policy</H2>
|
||||||
|
<P>
|
||||||
|
The recycle policy controls how soon the same item can reappear
|
||||||
|
across schedule generations, preventing a small library from cycling
|
||||||
|
the same content every day.
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Field", "Default", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="cd">cooldown_days</Code>,
|
||||||
|
"null (disabled)",
|
||||||
|
"An item won't be scheduled again until at least this many days have passed since it last aired.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="cg">cooldown_generations</Code>,
|
||||||
|
"null (disabled)",
|
||||||
|
"An item won't be scheduled again until at least this many schedule generations have passed.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="mr">min_available_ratio</Code>,
|
||||||
|
"0.1",
|
||||||
|
"Safety valve. Even with cooldowns active, always keep at least this fraction of the pool available. A value of 0.1 means 10% of items are always eligible, preventing the scheduler from running dry on small libraries.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<P>
|
||||||
|
Both cooldowns can be combined — an item must satisfy both before
|
||||||
|
becoming eligible. <Code>min_available_ratio</Code> overrides
|
||||||
|
cooldowns when too many items are excluded.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="import-export">
|
||||||
|
<H2>Import & export</H2>
|
||||||
|
<P>
|
||||||
|
Channels can be exported as JSON and shared or reimported. This
|
||||||
|
makes it easy to build configurations with an LLM and paste them
|
||||||
|
directly into K-TV.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Exporting</H3>
|
||||||
|
<P>
|
||||||
|
Click the download icon on any channel card in the Dashboard. A{" "}
|
||||||
|
<Code>.json</Code> file is saved containing the channel name,
|
||||||
|
timezone, all programming blocks, and the recycle policy.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Importing</H3>
|
||||||
|
<P>
|
||||||
|
Click <strong className="text-zinc-300">Import channel</strong> at
|
||||||
|
the top of the Dashboard. You can paste JSON text into the text area
|
||||||
|
or drag and drop a <Code>.json</Code> file. A live preview shows the
|
||||||
|
parsed channel name, timezone, and block list before you confirm.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
The importer is lenient: block IDs are generated automatically if
|
||||||
|
missing, and <Code>start_time</Code> accepts both{" "}
|
||||||
|
<Code>HH:MM</Code> and <Code>HH:MM:SS</Code>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>JSON format</H3>
|
||||||
|
<Pre>{`{
|
||||||
|
"name": "90s Sitcom Network",
|
||||||
|
"description": "Nothing but classic sitcoms.",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"name": "Morning Sitcoms",
|
||||||
|
"start_time": "09:00",
|
||||||
|
"duration_mins": 180,
|
||||||
|
"content": {
|
||||||
|
"type": "algorithmic",
|
||||||
|
"filter": {
|
||||||
|
"content_type": "episode",
|
||||||
|
"genres": ["Comedy"],
|
||||||
|
"decade": 1990,
|
||||||
|
"tags": [],
|
||||||
|
"min_duration_secs": null,
|
||||||
|
"max_duration_secs": 1800,
|
||||||
|
"collections": []
|
||||||
|
},
|
||||||
|
"strategy": "random"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recycle_policy": {
|
||||||
|
"cooldown_days": 7,
|
||||||
|
"cooldown_generations": null,
|
||||||
|
"min_available_ratio": 0.15
|
||||||
|
}
|
||||||
|
}`}</Pre>
|
||||||
|
|
||||||
|
<H3>Generating channels with an LLM</H3>
|
||||||
|
<P>
|
||||||
|
Paste this prompt into any LLM and fill in your preferences:
|
||||||
|
</P>
|
||||||
|
<Pre>{`Generate a K-TV channel JSON for a channel called "[your channel name]".
|
||||||
|
The channel should [describe your theme, e.g. "play 90s action movies in
|
||||||
|
the evening and crime dramas late at night"].
|
||||||
|
Use timezone "[your timezone, e.g. America/Chicago]".
|
||||||
|
Use algorithmic blocks with appropriate genres, content types, and strategies.
|
||||||
|
Output only valid JSON matching this structure:
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"description": string,
|
||||||
|
"timezone": string,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"start_time": "HH:MM",
|
||||||
|
"duration_mins": number,
|
||||||
|
"content": {
|
||||||
|
"type": "algorithmic",
|
||||||
|
"filter": {
|
||||||
|
"content_type": "movie" | "episode" | "short" | null,
|
||||||
|
"genres": string[],
|
||||||
|
"decade": number | null,
|
||||||
|
"tags": string[],
|
||||||
|
"min_duration_secs": number | null,
|
||||||
|
"max_duration_secs": number | null,
|
||||||
|
"collections": []
|
||||||
|
},
|
||||||
|
"strategy": "random" | "sequential" | "best_fit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recycle_policy": {
|
||||||
|
"cooldown_days": number | null,
|
||||||
|
"cooldown_generations": number | null,
|
||||||
|
"min_available_ratio": number
|
||||||
|
}
|
||||||
|
}`}</Pre>
|
||||||
|
<Note>
|
||||||
|
Genre and tag names must exactly match what Jellyfin uses in your
|
||||||
|
library. After importing, verify filter fields against your Jellyfin
|
||||||
|
library before generating a schedule.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="tv-page">
|
||||||
|
<H2>Watching TV</H2>
|
||||||
|
<P>
|
||||||
|
Open <Code>/tv</Code> to start watching. No login required. The
|
||||||
|
player tunes to the first channel and syncs to the current broadcast
|
||||||
|
position automatically — you join mid-show, just like real TV.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Keyboard shortcuts</H3>
|
||||||
|
<Table
|
||||||
|
head={["Key", "Action"]}
|
||||||
|
rows={[
|
||||||
|
["Arrow Up / Page Up", "Next channel"],
|
||||||
|
["Arrow Down / Page Down", "Previous channel"],
|
||||||
|
["0–9", "Type a channel number and jump to it after 1.5 s (e.g. press 1 then 4 → channel 14)"],
|
||||||
|
["G", "Toggle the program guide"],
|
||||||
|
["M", "Mute / unmute"],
|
||||||
|
["F", "Toggle fullscreen"],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H3>Overlays</H3>
|
||||||
|
<P>
|
||||||
|
Move your mouse or press any key to reveal the on-screen overlays.
|
||||||
|
They fade after a few seconds of inactivity.
|
||||||
|
</P>
|
||||||
|
<Ul>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Bottom-left</strong> — channel
|
||||||
|
info: what is playing, episode details, description, genre tags,
|
||||||
|
and a progress bar with start/end times.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Bottom-right</strong> — channel
|
||||||
|
controls (previous / next).
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Top-right</strong> — Guide
|
||||||
|
toggle and CC button (when subtitles are available).
|
||||||
|
</Li>
|
||||||
|
</Ul>
|
||||||
|
|
||||||
|
<H3>Program guide</H3>
|
||||||
|
<P>
|
||||||
|
Press <strong className="text-zinc-300">G</strong> or click the
|
||||||
|
Guide button to open the upcoming schedule for the current channel.
|
||||||
|
Colour-coded blocks show each slot; the current item is highlighted.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Subtitles (CC)</H3>
|
||||||
|
<P>
|
||||||
|
When the playing item has subtitle tracks in its HLS stream, a{" "}
|
||||||
|
<strong className="text-zinc-300">CC</strong> button appears in the
|
||||||
|
top-right corner. Click it to pick a language track or turn
|
||||||
|
subtitles off. The button is highlighted when subtitles are active.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Up next banner</H3>
|
||||||
|
<P>
|
||||||
|
When the current item is more than 80% complete, an "Up next" banner
|
||||||
|
appears at the bottom showing the next item's title and start time.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Autoplay after page refresh</H3>
|
||||||
|
<P>
|
||||||
|
Browsers block video autoplay on page refresh until the user
|
||||||
|
interacts with the page. Move your mouse or press any key after
|
||||||
|
refreshing and playback resumes immediately.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="troubleshooting">
|
||||||
|
<H2>Troubleshooting</H2>
|
||||||
|
|
||||||
|
<H3>Schedule generation fails</H3>
|
||||||
|
<P>
|
||||||
|
Check that <Code>JELLYFIN_BASE_URL</Code>,{" "}
|
||||||
|
<Code>JELLYFIN_API_KEY</Code>, and <Code>JELLYFIN_USER_ID</Code> are
|
||||||
|
all set. The backend logs a warning on startup when any are missing.
|
||||||
|
Confirm the Jellyfin server is reachable from the machine running
|
||||||
|
the backend.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Video won't play / stream error</H3>
|
||||||
|
<P>
|
||||||
|
Click <strong className="text-zinc-300">Retry</strong> on the error
|
||||||
|
screen. If it keeps failing, check that Jellyfin is online and the
|
||||||
|
API key has not been revoked. For transcoding errors, check the
|
||||||
|
Jellyfin dashboard for active sessions and codec errors in its logs.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Block fills with no items</H3>
|
||||||
|
<P>
|
||||||
|
Your filter is too strict or Jellyfin returned nothing matching.
|
||||||
|
Try:
|
||||||
|
</P>
|
||||||
|
<Ul>
|
||||||
|
<Li>Removing one filter at a time to find the culprit.</Li>
|
||||||
|
<Li>
|
||||||
|
Verifying genre/tag names match Jellyfin exactly — they are
|
||||||
|
case-sensitive.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
Clearing <Code>collections</Code> to search all libraries.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
Lowering <Code>min_available_ratio</Code> if the recycle cooldown
|
||||||
|
is excluding too many items.
|
||||||
|
</Li>
|
||||||
|
</Ul>
|
||||||
|
|
||||||
|
<H3>Channel shows no signal</H3>
|
||||||
|
<P>
|
||||||
|
No signal means there is no scheduled slot at the current time.
|
||||||
|
Either no schedule has been generated yet (click Generate on the
|
||||||
|
Dashboard), or the current time falls in a gap between blocks. Add a
|
||||||
|
block covering the current time and regenerate.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>CORS errors in the browser</H3>
|
||||||
|
<P>
|
||||||
|
Make sure <Code>CORS_ALLOWED_ORIGINS</Code> contains the exact
|
||||||
|
origin of the frontend — scheme, hostname, and port, no trailing
|
||||||
|
slash. Example: <Code>https://ktv.example.com</Code>. Wildcards are
|
||||||
|
not supported.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Subtitles not showing</H3>
|
||||||
|
<P>
|
||||||
|
The CC button only appears when Jellyfin includes subtitle tracks in
|
||||||
|
the HLS manifest. Verify the media item has external subtitle files
|
||||||
|
(SRT/ASS) associated in Jellyfin. Image-based subtitles (PGS/VOBSUB
|
||||||
|
from Blu-ray sources) are not supported by the HLS path.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NavAuth } from "./components/nav-auth";
|
|||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: "/tv", label: "TV" },
|
{ href: "/tv", label: "TV" },
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
|
{ href: "/docs", label: "Docs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -1,25 +1,47 @@
|
|||||||
|
import type { MediaItemResponse } from "@/lib/types";
|
||||||
|
|
||||||
interface ChannelInfoProps {
|
interface ChannelInfoProps {
|
||||||
channelNumber: number;
|
channelNumber: number;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
showTitle: string;
|
item: MediaItemResponse;
|
||||||
showStartTime: string; // "HH:MM"
|
showStartTime: string;
|
||||||
showEndTime: string; // "HH:MM"
|
showEndTime: string;
|
||||||
/** Progress through the current show, 0–100 */
|
/** Progress through the current show, 0–100 */
|
||||||
progress: number;
|
progress: number;
|
||||||
description?: string;
|
}
|
||||||
|
|
||||||
|
function formatEpisodeLabel(item: MediaItemResponse): string | null {
|
||||||
|
if (item.content_type !== "episode") return null;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (item.season_number != null) parts.push(`S${item.season_number}`);
|
||||||
|
if (item.episode_number != null) parts.push(`E${item.episode_number}`);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelInfo({
|
export function ChannelInfo({
|
||||||
channelNumber,
|
channelNumber,
|
||||||
channelName,
|
channelName,
|
||||||
showTitle,
|
item,
|
||||||
showStartTime,
|
showStartTime,
|
||||||
showEndTime,
|
showEndTime,
|
||||||
progress,
|
progress,
|
||||||
description,
|
|
||||||
}: ChannelInfoProps) {
|
}: ChannelInfoProps) {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
|
|
||||||
|
const isEpisode = item.content_type === "episode";
|
||||||
|
const episodeLabel = formatEpisodeLabel(item);
|
||||||
|
// For episodes: series name as headline (fall back to episode title if missing)
|
||||||
|
const headline = isEpisode && item.series_name ? item.series_name : item.title;
|
||||||
|
// Subtitle: only include episode title when series name is the headline (otherwise
|
||||||
|
// the title is already the headline and repeating it would duplicate it)
|
||||||
|
const subtitle = isEpisode
|
||||||
|
? item.series_name
|
||||||
|
? [episodeLabel, item.title].filter(Boolean).join(" · ")
|
||||||
|
: episodeLabel // title is the headline — just show S·E label
|
||||||
|
: item.year
|
||||||
|
? String(item.year)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-lg bg-black/60 p-4 backdrop-blur-md w-80">
|
<div className="flex flex-col gap-2 rounded-lg bg-black/60 p-4 backdrop-blur-md w-80">
|
||||||
{/* Channel badge */}
|
{/* Channel badge */}
|
||||||
@@ -32,14 +54,35 @@ export function ChannelInfo({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show title */}
|
{/* Title block */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
<p className="text-base font-semibold leading-tight text-white">
|
<p className="text-base font-semibold leading-tight text-white">
|
||||||
{showTitle}
|
{headline}
|
||||||
</p>
|
</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-zinc-400">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{description && (
|
{item.description && (
|
||||||
<p className="line-clamp-2 text-xs text-zinc-400">{description}</p>
|
<p className="line-clamp-2 text-xs leading-relaxed text-zinc-500">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{item.genres.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.genres.slice(0, 4).map((g) => (
|
||||||
|
<span
|
||||||
|
key={g}
|
||||||
|
className="rounded bg-zinc-800/80 px-1.5 py-0.5 text-[10px] text-zinc-400"
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
export interface ScheduleSlot {
|
export interface ScheduleSlot {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Headline: series name for episodes, film title for everything else. */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Secondary line: "S1 · E3 · Episode Title" for episodes, year for movies. */
|
||||||
|
subtitle?: string | null;
|
||||||
|
/** Rounded slot duration in minutes. */
|
||||||
|
durationMins: number;
|
||||||
startTime: string; // "HH:MM"
|
startTime: string; // "HH:MM"
|
||||||
endTime: string; // "HH:MM"
|
endTime: string; // "HH:MM"
|
||||||
isCurrent?: boolean;
|
isCurrent?: boolean;
|
||||||
@@ -50,8 +55,17 @@ export function ScheduleOverlay({ channelName, slots }: ScheduleOverlayProps) {
|
|||||||
>
|
>
|
||||||
{slot.title}
|
{slot.title}
|
||||||
</p>
|
</p>
|
||||||
|
{slot.subtitle && (
|
||||||
|
<p className={cn(
|
||||||
|
"truncate text-xs leading-snug",
|
||||||
|
slot.isCurrent ? "text-zinc-400" : "text-zinc-600"
|
||||||
|
)}>
|
||||||
|
{slot.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-0.5 font-mono text-[10px] text-zinc-600">
|
<p className="mt-0.5 font-mono text-[10px] text-zinc-600">
|
||||||
{slot.startTime} – {slot.endTime}
|
{slot.startTime} – {slot.endTime}
|
||||||
|
{" · "}{slot.durationMins}m
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { forwardRef, useEffect, useRef } from "react";
|
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
src?: string;
|
src?: string;
|
||||||
@@ -7,13 +14,28 @@ interface VideoPlayerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Seconds into the current item to seek on load (broadcast sync). */
|
/** Seconds into the current item to seek on load (broadcast sync). */
|
||||||
initialOffset?: number;
|
initialOffset?: number;
|
||||||
|
/** Active subtitle track index, or -1 to disable. */
|
||||||
|
subtitleTrack?: number;
|
||||||
onStreamError?: () => void;
|
onStreamError?: () => void;
|
||||||
|
onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||||
({ src, poster, className, initialOffset = 0, onStreamError }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
className,
|
||||||
|
initialOffset = 0,
|
||||||
|
subtitleTrack = -1,
|
||||||
|
onStreamError,
|
||||||
|
onSubtitleTracksChange,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const internalRef = useRef<HTMLVideoElement | null>(null);
|
const internalRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
|
||||||
const setRef = (el: HTMLVideoElement | null) => {
|
const setRef = (el: HTMLVideoElement | null) => {
|
||||||
internalRef.current = el;
|
internalRef.current = el;
|
||||||
@@ -21,12 +43,21 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
|||||||
else if (ref) ref.current = el;
|
else if (ref) ref.current = el;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Apply subtitle track changes without tearing down the HLS instance
|
||||||
|
useEffect(() => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.subtitleTrack = subtitleTrack;
|
||||||
|
}
|
||||||
|
}, [subtitleTrack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = internalRef.current;
|
const video = internalRef.current;
|
||||||
if (!video || !src) return;
|
if (!video || !src) return;
|
||||||
|
|
||||||
hlsRef.current?.destroy();
|
hlsRef.current?.destroy();
|
||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
|
onSubtitleTracksChange?.([]);
|
||||||
|
setIsBuffering(true);
|
||||||
|
|
||||||
const isHls = src.includes(".m3u8");
|
const isHls = src.includes(".m3u8");
|
||||||
|
|
||||||
@@ -41,6 +72,16 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
|||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
|
||||||
|
onSubtitleTracksChange?.(
|
||||||
|
data.subtitleTracks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
lang: t.lang,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
if (data.fatal) onStreamError?.();
|
if (data.fatal) onStreamError?.();
|
||||||
});
|
});
|
||||||
@@ -79,9 +120,18 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
|||||||
ref={setRef}
|
ref={setRef}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
playsInline
|
playsInline
|
||||||
|
onPlaying={() => setIsBuffering(false)}
|
||||||
|
onWaiting={() => setIsBuffering(true)}
|
||||||
onError={onStreamError}
|
onError={onStreamError}
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Buffering spinner — shown until frames are actually rendering */}
|
||||||
|
{isBuffering && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
UpNextBanner,
|
UpNextBanner,
|
||||||
NoSignal,
|
NoSignal,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import type { SubtitleTrack } from "./components/video-player";
|
||||||
|
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import { useAuthContext } from "@/context/auth-context";
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +55,46 @@ export default function TvPage() {
|
|||||||
|
|
||||||
// Stream error recovery
|
// Stream error recovery
|
||||||
const [streamError, setStreamError] = useState(false);
|
const [streamError, setStreamError] = useState(false);
|
||||||
|
|
||||||
|
// Subtitles
|
||||||
|
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
|
||||||
|
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
|
||||||
|
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
|
||||||
|
|
||||||
|
// Fullscreen
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener("fullscreenchange", handler);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", handler);
|
||||||
|
}, []);
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Volume control
|
||||||
|
const [volume, setVolume] = useState(1); // 0.0 – 1.0
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
videoRef.current.muted = isMuted;
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
}, [isMuted, volume]);
|
||||||
|
const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
|
||||||
|
const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
|
||||||
|
|
||||||
|
// Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s)
|
||||||
|
const [channelInput, setChannelInput] = useState("");
|
||||||
|
const channelInputTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Touch-swipe state
|
||||||
|
const touchStartY = useRef<number | null>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
||||||
@@ -73,6 +115,13 @@ export default function TvPage() {
|
|||||||
setStreamError(false);
|
setStreamError(false);
|
||||||
}, [broadcast?.slot.id]);
|
}, [broadcast?.slot.id]);
|
||||||
|
|
||||||
|
// Reset subtitle state when channel or slot changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSubtitleTracks([]);
|
||||||
|
setActiveSubtitleTrack(-1);
|
||||||
|
setShowSubtitlePicker(false);
|
||||||
|
}, [channelIdx, broadcast?.slot.id]);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Derived display values
|
// Derived display values
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -93,10 +142,11 @@ export default function TvPage() {
|
|||||||
const resetIdle = useCallback(() => {
|
const resetIdle = useCallback(() => {
|
||||||
setShowOverlays(true);
|
setShowOverlays(true);
|
||||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||||
idleTimer.current = setTimeout(
|
idleTimer.current = setTimeout(() => {
|
||||||
() => setShowOverlays(false),
|
setShowOverlays(false);
|
||||||
IDLE_TIMEOUT_MS,
|
setShowVolumeSlider(false);
|
||||||
);
|
setShowSubtitlePicker(false);
|
||||||
|
}, IDLE_TIMEOUT_MS);
|
||||||
// Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction)
|
// Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction)
|
||||||
videoRef.current?.play().catch(() => {});
|
videoRef.current?.play().catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -156,12 +206,59 @@ export default function TvPage() {
|
|||||||
case "G":
|
case "G":
|
||||||
toggleSchedule();
|
toggleSchedule();
|
||||||
break;
|
break;
|
||||||
|
case "f":
|
||||||
|
case "F":
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
case "M":
|
||||||
|
toggleMute();
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
if (e.key >= "0" && e.key <= "9") {
|
||||||
|
setChannelInput((prev) => {
|
||||||
|
const next = prev + e.key;
|
||||||
|
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||||
|
channelInputTimer.current = setTimeout(() => {
|
||||||
|
const num = parseInt(next, 10);
|
||||||
|
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
||||||
|
setChannelIdx(num - 1);
|
||||||
|
resetIdle();
|
||||||
|
}
|
||||||
|
setChannelInput("");
|
||||||
|
}, 1500);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => {
|
||||||
}, [nextChannel, prevChannel, toggleSchedule]);
|
window.removeEventListener("keydown", handleKey);
|
||||||
|
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||||
|
};
|
||||||
|
}, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, resetIdle]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Touch swipe (swipe up = next channel, swipe down = prev channel)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
resetIdle();
|
||||||
|
}, [resetIdle]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||||
|
if (touchStartY.current === null) return;
|
||||||
|
const dy = touchStartY.current - e.changedTouches[0].clientY;
|
||||||
|
touchStartY.current = null;
|
||||||
|
if (Math.abs(dy) > 60) {
|
||||||
|
if (dy > 0) nextChannel();
|
||||||
|
else prevChannel();
|
||||||
|
}
|
||||||
|
}, [nextChannel, prevChannel]);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Stream error recovery
|
// Stream error recovery
|
||||||
@@ -225,6 +322,8 @@ export default function TvPage() {
|
|||||||
src={streamUrl}
|
src={streamUrl}
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
initialOffset={broadcast ? calcOffsetSecs(broadcast.slot.start_at) : 0}
|
initialOffset={broadcast ? calcOffsetSecs(broadcast.slot.start_at) : 0}
|
||||||
|
subtitleTrack={activeSubtitleTrack}
|
||||||
|
onSubtitleTracksChange={setSubtitleTracks}
|
||||||
onStreamError={handleStreamError}
|
onStreamError={handleStreamError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -243,6 +342,8 @@ export default function TvPage() {
|
|||||||
style={{ cursor: showOverlays ? "default" : "none" }}
|
style={{ cursor: showOverlays ? "default" : "none" }}
|
||||||
onMouseMove={resetIdle}
|
onMouseMove={resetIdle}
|
||||||
onClick={resetIdle}
|
onClick={resetIdle}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{/* ── Base layer ─────────────────────────────────────────────── */}
|
{/* ── Base layer ─────────────────────────────────────────────── */}
|
||||||
<div className="absolute inset-0">{renderBase()}</div>
|
<div className="absolute inset-0">{renderBase()}</div>
|
||||||
@@ -254,8 +355,101 @@ export default function TvPage() {
|
|||||||
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
||||||
style={{ opacity: showOverlays ? 1 : 0 }}
|
style={{ opacity: showOverlays ? 1 : 0 }}
|
||||||
>
|
>
|
||||||
{/* Top-right: guide toggle */}
|
{/* Top-right: subtitle picker + guide toggle */}
|
||||||
<div className="flex justify-end p-4">
|
<div className="flex justify-end gap-2 p-4">
|
||||||
|
{subtitleTracks.length > 0 && (
|
||||||
|
<div className="pointer-events-auto relative">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-black/50 px-3 py-1.5 text-xs backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||||||
|
style={{
|
||||||
|
color: activeSubtitleTrack !== -1 ? "white" : undefined,
|
||||||
|
borderBottom:
|
||||||
|
activeSubtitleTrack !== -1
|
||||||
|
? "2px solid white"
|
||||||
|
: "2px solid transparent",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowSubtitlePicker((s) => !s)}
|
||||||
|
>
|
||||||
|
CC
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showSubtitlePicker && (
|
||||||
|
<div className="absolute right-0 top-9 z-30 min-w-[10rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/95 py-1 shadow-xl backdrop-blur">
|
||||||
|
<button
|
||||||
|
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${activeSubtitleTrack === -1 ? "text-white" : "text-zinc-400"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSubtitleTrack(-1);
|
||||||
|
setShowSubtitlePicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</button>
|
||||||
|
{subtitleTracks.map((track) => (
|
||||||
|
<button
|
||||||
|
key={track.id}
|
||||||
|
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${activeSubtitleTrack === track.id ? "text-white" : "text-zinc-400"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSubtitleTrack(track.id);
|
||||||
|
setShowSubtitlePicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.name || track.lang || `Track ${track.id + 1}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Volume control */}
|
||||||
|
<div className="pointer-events-auto relative">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||||||
|
onClick={() => setShowVolumeSlider((s) => !s)}
|
||||||
|
title="Volume"
|
||||||
|
>
|
||||||
|
<VolumeIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showVolumeSlider && (
|
||||||
|
<div className="absolute right-0 top-9 z-30 w-36 rounded-lg border border-zinc-700 bg-zinc-900/95 p-3 shadow-xl backdrop-blur">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={isMuted ? 0 : Math.round(volume * 100)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value) / 100;
|
||||||
|
setVolume(v);
|
||||||
|
setIsMuted(v === 0);
|
||||||
|
}}
|
||||||
|
className="w-full accent-white"
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
{isMuted ? "Unmute [M]" : "Mute [M]"}
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[10px] text-zinc-500">
|
||||||
|
{isMuted ? "0" : Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
|
||||||
|
>
|
||||||
|
{isFullscreen
|
||||||
|
? <Minimize2 className="h-4 w-4" />
|
||||||
|
: <Maximize2 className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||||||
onClick={toggleSchedule}
|
onClick={toggleSchedule}
|
||||||
@@ -279,11 +473,10 @@ export default function TvPage() {
|
|||||||
<ChannelInfo
|
<ChannelInfo
|
||||||
channelNumber={channelIdx + 1}
|
channelNumber={channelIdx + 1}
|
||||||
channelName={channel.name}
|
channelName={channel.name}
|
||||||
showTitle={broadcast.slot.item.title}
|
item={broadcast.slot.item}
|
||||||
showStartTime={fmtTime(broadcast.slot.start_at)}
|
showStartTime={fmtTime(broadcast.slot.start_at)}
|
||||||
showEndTime={fmtTime(broadcast.slot.end_at)}
|
showEndTime={fmtTime(broadcast.slot.end_at)}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
description={broadcast.slot.item.description ?? undefined}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* Minimal channel badge when no broadcast */
|
/* Minimal channel badge when no broadcast */
|
||||||
@@ -311,6 +504,14 @@ export default function TvPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Channel number input overlay */}
|
||||||
|
{channelInput && (
|
||||||
|
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-black/80 px-8 py-5 text-center backdrop-blur">
|
||||||
|
<p className="mb-1 text-[10px] uppercase tracking-widest text-zinc-500">Channel</p>
|
||||||
|
<p className="font-mono text-5xl font-bold text-white">{channelInput}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Schedule overlay — outside the fading div so it has its own visibility */}
|
{/* Schedule overlay — outside the fading div so it has its own visibility */}
|
||||||
{showOverlays && showSchedule && (
|
{showOverlays && showSchedule && (
|
||||||
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
|
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 9.9 KiB |
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AuthProvider } from "@/context/auth-context";
|
import { AuthProvider } from "@/context/auth-context";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
@@ -21,6 +22,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster position="bottom-right" richColors />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useAuthContext } from "@/context/auth-context";
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types";
|
import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types";
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: () => api.config.get(),
|
||||||
|
staleTime: Infinity, // config doesn't change at runtime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useChannels() {
|
export function useChannels() {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -29,9 +38,11 @@ export function useCreateChannel() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateChannelRequest) =>
|
mutationFn: (data: CreateChannelRequest) =>
|
||||||
api.channels.create(data, token!),
|
api.channels.create(data, token!),
|
||||||
onSuccess: () => {
|
onSuccess: (channel) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
toast.success(`Channel "${channel.name}" created`);
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +55,9 @@ export function useUpdateChannel() {
|
|||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["channel", updated.id] });
|
queryClient.invalidateQueries({ queryKey: ["channel", updated.id] });
|
||||||
|
toast.success(`Channel "${updated.name}" saved`);
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +68,9 @@ export function useDeleteChannel() {
|
|||||||
mutationFn: (id: string) => api.channels.delete(id, token!),
|
mutationFn: (id: string) => api.channels.delete(id, token!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
toast.success("Channel deleted");
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +82,9 @@ export function useGenerateSchedule() {
|
|||||||
api.schedule.generate(channelId, token!),
|
api.schedule.generate(channelId, token!),
|
||||||
onSuccess: (_, channelId) => {
|
onSuccess: (_, channelId) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["schedule", channelId] });
|
queryClient.invalidateQueries({ queryKey: ["schedule", channelId] });
|
||||||
|
toast.success("Schedule generated");
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(`Schedule failed: ${e.message}`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,41 @@ export function toScheduleSlots(
|
|||||||
slots: ScheduledSlotResponse[],
|
slots: ScheduledSlotResponse[],
|
||||||
currentSlotId?: string,
|
currentSlotId?: string,
|
||||||
): ScheduleSlot[] {
|
): ScheduleSlot[] {
|
||||||
return slots.map((slot) => ({
|
return slots.map((slot) => {
|
||||||
|
const item = slot.item;
|
||||||
|
const isEpisode = item.content_type === "episode";
|
||||||
|
|
||||||
|
// Headline: series name for episodes (fall back to episode title), film title otherwise
|
||||||
|
const title = isEpisode && item.series_name ? item.series_name : item.title;
|
||||||
|
|
||||||
|
// Subtitle: episode identifier + title, or year for films
|
||||||
|
let subtitle: string | null = null;
|
||||||
|
if (isEpisode) {
|
||||||
|
const epParts: string[] = [];
|
||||||
|
if (item.season_number != null) epParts.push(`S${item.season_number}`);
|
||||||
|
if (item.episode_number != null) epParts.push(`E${item.episode_number}`);
|
||||||
|
const epLabel = epParts.join(" · ");
|
||||||
|
subtitle = item.series_name
|
||||||
|
? [epLabel, item.title].filter(Boolean).join(" · ")
|
||||||
|
: epLabel || null;
|
||||||
|
} else if (item.year) {
|
||||||
|
subtitle = String(item.year);
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMins = Math.round(
|
||||||
|
(new Date(slot.end_at).getTime() - new Date(slot.start_at).getTime()) / 60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
id: slot.id,
|
id: slot.id,
|
||||||
title: slot.item.title,
|
title,
|
||||||
|
subtitle,
|
||||||
|
durationMins,
|
||||||
startTime: fmtTime(slot.start_at),
|
startTime: fmtTime(slot.start_at),
|
||||||
endTime: fmtTime(slot.end_at),
|
endTime: fmtTime(slot.end_at),
|
||||||
isCurrent: slot.id === currentSlotId,
|
isCurrent: slot.id === currentSlotId,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
|
ConfigResponse,
|
||||||
ChannelResponse,
|
ChannelResponse,
|
||||||
CreateChannelRequest,
|
CreateChannelRequest,
|
||||||
UpdateChannelRequest,
|
UpdateChannelRequest,
|
||||||
@@ -54,6 +55,10 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
config: {
|
||||||
|
get: () => request<ConfigResponse>("/config"),
|
||||||
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
register: (email: string, password: string) =>
|
register: (email: string, password: string) =>
|
||||||
request<TokenResponse>("/auth/register", {
|
request<TokenResponse>("/auth/register", {
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export interface ScheduleConfig {
|
|||||||
blocks: ProgrammingBlock[];
|
blocks: ProgrammingBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config
|
||||||
|
|
||||||
|
export interface ConfigResponse {
|
||||||
|
allow_registration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
@@ -90,6 +96,12 @@ export interface MediaItemResponse {
|
|||||||
genres: string[];
|
genres: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
|
/** Episodes only: the parent TV show name. */
|
||||||
|
series_name?: string | null;
|
||||||
|
/** Episodes only: season number (1-based). */
|
||||||
|
season_number?: number | null;
|
||||||
|
/** Episodes only: episode number within the season (1-based). */
|
||||||
|
episode_number?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduledSlotResponse {
|
export interface ScheduledSlotResponse {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
BIN
k-tv-frontend/public/K-TV-Logo.ico
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
k-tv-frontend/public/K-TV-Logo.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |