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