Compare commits

..

10 Commits

38 changed files with 1897 additions and 154 deletions

61
.env.example Normal file
View 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
View 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
View 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:

View File

@@ -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,

View File

@@ -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,
} }
} }
} }

View File

@@ -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

View File

@@ -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,
}) })
} }

View File

@@ -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:

View File

@@ -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.

View File

@@ -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);

View File

@@ -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,
}) })
} }

View File

@@ -0,0 +1,4 @@
.next
node_modules
.env*
*.md

46
k-tv-frontend/Dockerfile Normal file
View 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"]

View File

@@ -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>
<p className="text-center text-xs text-zinc-500"> {config?.allow_registration !== false && (
No account?{" "} <p className="text-center text-xs text-zinc-500">
<Link href="/register" className="text-zinc-300 hover:text-white"> No account?{" "}
Create one <Link href="/register" className="text-zinc-300 hover:text-white">
</Link> Create one
</p> </Link>
</p>
)}
</div> </div>
); );
} }

View File

@@ -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();

View File

@@ -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"}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 19901999.",
],
[
<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 &amp; 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"],
["09", "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>
); );
} }

View File

@@ -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 }) {

View File

@@ -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, 0100 */ /** Progress through the current show, 0100 */
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 */}
<p className="text-base font-semibold leading-tight text-white"> <div className="space-y-0.5">
{showTitle} <p className="text-base font-semibold leading-tight text-white">
</p> {headline}
</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 */}

View File

@@ -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>

View File

@@ -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>
); );
}, },

View File

@@ -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">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -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>

View File

@@ -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}`),
}); });
} }

View File

@@ -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) => {
id: slot.id, const item = slot.item;
title: slot.item.title, const isEpisode = item.content_type === "episode";
startTime: fmtTime(slot.start_at),
endTime: fmtTime(slot.end_at), // Headline: series name for episodes (fall back to episode title), film title otherwise
isCurrent: slot.id === currentSlotId, 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,
title,
subtitle,
durationMins,
startTime: fmtTime(slot.start_at),
endTime: fmtTime(slot.end_at),
isCurrent: slot.id === currentSlotId,
};
});
} }
/** /**

View File

@@ -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", {

View File

@@ -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 {

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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