import type { ReactNode } from "react"; // --------------------------------------------------------------------------- // Primitive components // --------------------------------------------------------------------------- function Section({ id, children }: { id: string; children: ReactNode }) { return (
{children}
); } function H2({ children }: { children: ReactNode }) { return (

{children}

); } function H3({ children }: { children: ReactNode }) { return (

{children}

); } function P({ children }: { children: ReactNode }) { return

{children}

; } function Code({ children }: { children: ReactNode }) { return ( {children} ); } function Pre({ children }: { children: ReactNode }) { return (
      {children}
    
); } function Note({ children }: { children: ReactNode }) { return (
{children}
); } function Warn({ children }: { children: ReactNode }) { return (
{children}
); } function Ul({ children }: { children: ReactNode }) { return ( ); } function Li({ children }: { children: ReactNode }) { return
  • {children}
  • ; } function Table({ head, rows, }: { head: string[]; rows: (string | ReactNode)[][]; }) { return (
    {head.map((h) => ( ))} {rows.map((row, i) => ( {row.map((cell, j) => ( ))} ))}
    {h}
    {cell}
    ); } // --------------------------------------------------------------------------- // 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: "docker", label: "Docker deployment" }, { id: "jellyfin", label: "Connecting Jellyfin" }, { id: "local-files", label: "Local files" }, { 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: "iptv", label: "IPTV export" }, { id: "channel-password", label: "Channel passwords" }, { id: "tv-page", label: "Watching TV" }, { id: "troubleshooting", label: "Troubleshooting" }, ]; // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- export default function DocsPage() { return (
    {/* Sidebar TOC */} {/* Main content */}
    {/* ---------------------------------------------------------------- */}

    Overview

    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{" "} Jellyfin {" "} library to fill them. Viewers open the TV page and watch a live stream with no seeking — just like real TV.

    The project has two parts: a{" "} backend (Rust / Axum) that manages channels, generates schedules, and proxies streams from Jellyfin, and a{" "} frontend (Next.js) that provides the TV viewer and the channel management dashboard.

    {/* ---------------------------------------------------------------- */}

    Requirements

    Rust, "1.77+", "Install via rustup"], [Node.js, "20+", "Frontend only"], [Jellyfin, "10.8+", "Your media server"], [ SQLite or PostgreSQL, "any", "SQLite is the default — no extra setup needed", ], ]} /> SQLite is the default and requires no additional database setup. PostgreSQL support is available by rebuilding the backend with the{" "} postgres Cargo feature. {/* ---------------------------------------------------------------- */}

    Backend setup

    Clone the repository and start the server. All configuration is read from environment variables or a .env file in the working directory.

    {`git clone  k-tv-backend
    cd k-tv-backend
    cargo run`}

    The server starts on http://127.0.0.1:3000 by default. Database migrations run automatically on startup.

    Environment variables

    HOST, 127.0.0.1, "Bind address. Use 0.0.0.0 in containers.", ], [ PORT, 3000, "HTTP port.", ], [ DATABASE_URL, sqlite:data.db?mode=rwc, "SQLite file path or postgres:// connection string.", ], [ CORS_ALLOWED_ORIGINS, http://localhost:5173, "Comma-separated list of allowed frontend origins.", ], [ JELLYFIN_BASE_URL, "—", "Jellyfin server URL, e.g. http://192.168.1.10:8096", ], [ JELLYFIN_API_KEY, "—", "Jellyfin API key (see Connecting Jellyfin).", ], [ JELLYFIN_USER_ID, "—", "Jellyfin user ID used for library browsing.", ], [ JWT_SECRET, "—", "Secret used to sign login tokens. Generate with: openssl rand -hex 32", ], [ JWT_EXPIRY_HOURS, 24, "How long a login token stays valid.", ], [ COOKIE_SECRET, "dev default", "Must be at least 64 bytes in production.", ], [ SECURE_COOKIE, false, "Set to true when serving over HTTPS.", ], [ DB_MAX_CONNECTIONS, 5, "Connection pool maximum.", ], [ DB_MIN_CONNECTIONS, 1, "Connections kept alive in the pool.", ], [ PRODUCTION, false, "Set to true or 1 to enable production mode.", ], ]} />

    Minimal production .env

    {`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=
    COOKIE_SECRET=<64+ character random string>
    SECURE_COOKIE=true
    PRODUCTION=true
    JELLYFIN_BASE_URL=http://jellyfin:8096
    JELLYFIN_API_KEY=
    JELLYFIN_USER_ID=`}
    Always set a strong JWT_SECRET in production. The default COOKIE_SECRET is publicly known and must be replaced before going live. {/* ---------------------------------------------------------------- */}

    Frontend setup

    {`cd k-tv-frontend
    cp .env.local.example .env.local
    # edit .env.local
    npm install
    npm run dev`}

    Environment variables

    NEXT_PUBLIC_API_URL, http://localhost:3000/api/v1, "Backend API base URL — sent to the browser.", ], [ API_URL, "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.", ], ]} /> 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. {/* ---------------------------------------------------------------- */}

    Docker deployment

    The recommended way to run K-TV in production is with Docker Compose. The repository ships a compose.yml that runs the backend and frontend as separate containers, and an optional{" "} compose.traefik.yml overlay for HTTPS via Traefik.

    Minimal compose.yml

    {`services:
      backend:
        image: registry.example.com/k-tv-backend:latest
        environment:
          HOST: 0.0.0.0
          DATABASE_URL: sqlite:/app/data/k-tv.db?mode=rwc
          CORS_ALLOWED_ORIGINS: https://tv.example.com
          JWT_SECRET: 
          COOKIE_SECRET: <64+ char random string>
          SECURE_COOKIE: "true"
          PRODUCTION: "true"
          JELLYFIN_BASE_URL: http://jellyfin:8096
          JELLYFIN_API_KEY: 
          JELLYFIN_USER_ID: 
        volumes:
          - ./data:/app/data
    
      frontend:
        image: registry.example.com/k-tv-frontend:latest
        environment:
          API_URL: http://backend:3000/api/v1
        ports:
          - "3001:3000"`}

    Build-time vs runtime env vars

    NEXT_PUBLIC_API_URL is embedded into the frontend bundle at build time. It must be passed as a{" "} --build-arg when building the image:

    {`docker build \\
      --build-arg NEXT_PUBLIC_API_URL=https://tv-api.example.com/api/v1 \\
      -t registry.example.com/k-tv-frontend:latest .`}

    If you use the provided compose.yml, set{" "} NEXT_PUBLIC_API_URL under build.args so it is picked up automatically on every build.

    API_URL (server-side only — used by Next.js API routes) is set at runtime via the container environment and can reference the backend by its internal Docker hostname:{" "} http://backend:3000/api/v1. It is never baked into the image.

    HTTPS with Traefik

    Merge compose.traefik.yml over the base file to add Traefik labels for automatic TLS certificates and routing:

    {`docker compose -f compose.yml -f compose.traefik.yml up -d`}
    Set SECURE_COOKIE=true and{" "} PRODUCTION=true whenever the backend is behind HTTPS. The default cookie secret is publicly known — always replace it before going live.
    {/* ---------------------------------------------------------------- */}

    Connecting Jellyfin

    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.

    1. API key

    In Jellyfin go to{" "} Dashboard → API Keys {" "} and create a new key. Give it a name like K-TV. Copy the value into JELLYFIN_API_KEY.

    2. User ID

    Go to{" "} Dashboard → Users, click the user K-TV should browse as (usually your admin account), and copy the user ID from the browser URL:

    {`/web/index.html#!/useredit?userId=`}

    Paste it into JELLYFIN_USER_ID.

    3. Library IDs (optional)

    Library IDs are used in the collections filter field to restrict a block to a specific Jellyfin library or folder. Browse to a library in Jellyfin and copy the parentId query parameter from the URL. Leave collections empty to search across all libraries.

    Stream format

    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.

    Subtitles

    External subtitle files (SRT, ASS) attached to a Jellyfin item are automatically converted to WebVTT and embedded in the HLS manifest. A CC 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.

    {/* ---------------------------------------------------------------- */}

    Local files provider

    In addition to Jellyfin, K-TV can serve content directly from a local directory. This is useful when you want to schedule video files without running a separate media server.

    Enabling local files

    Build the backend with the local-files Cargo feature and set the LOCAL_FILES_DIR environment variable to the root of your video library:

    {`cargo run --features local-files
    
    # .env
    LOCAL_FILES_DIR=/media/videos`}

    On startup the backend indexes all video files under{" "} LOCAL_FILES_DIR. Duration is detected via{" "} ffprobe (must be installed and on PATH). Tags are derived from ancestor directory names; the top-level subdirectory acts as the collection ID.

    Rescanning

    When you add or remove files, trigger a rescan from the Dashboard (the Rescan library{" "} button appears when the local files provider is active) or call the API directly:

    {`POST /api/v1/files/rescan
    Authorization: Bearer 
    
    # Response
    { "items_found": 142 }`}

    Streaming

    Local file streams are served by{" "} GET /api/v1/files/stream/:id. This endpoint is{" "} public (no auth required) and supports Range headers for seeking. The frontend player uses the native <video> element for local files instead of hls.js.

    Filter support

    When the local files provider is active, the series picker and genre filter are hidden in the block editor — those fields are only supported by Jellyfin. Tags, decade, duration limits, and collection filters work normally.
    {/* ---------------------------------------------------------------- */}

    Your first channel

    Log in and open the{" "} Dashboard. Click{" "} New channel and fill in:

    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{" "} Generate schedule on the channel card. K-TV queries Jellyfin, fills each block with matching content, and starts broadcasting immediately.

    Schedules are valid for 48 hours. K-TV does not regenerate them automatically — return to the Dashboard and click{" "} Generate whenever you want a fresh lineup. {/* ---------------------------------------------------------------- */}

    Programming blocks

    A programming block is a repeating daily time slot. Every day the block starts at its start_time (in the channel timezone) and runs for duration_mins minutes. The scheduler fills it with as many items as will fit.

    Timeline editor

    • Draw a block — click and drag on an empty area of the 24-hour timeline.
    • Move a block — drag the block body left or right. Snaps to 15-minute increments.
    • Resize a block — drag its right edge.
    • Select a block — click it on the timeline to scroll its detail editor into view below.

    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.

    Content types

    algorithmic, "The scheduler picks items from your Jellyfin library based on filters you define. Recommended for most blocks.", ], [ manual, "Plays a fixed, ordered list of Jellyfin item IDs. Useful for a specific playlist or sequential episode run.", ], ]} /> {/* ---------------------------------------------------------------- */}

    Filters reference

    Filters apply to algorithmic blocks. All fields are optional — omit or leave blank to match everything. Multiple values in an array field must all match (AND logic).

    content_type, <> movie |{" "} episode |{" "} short , "Restrict to one media type. Leave empty for any type. Short films are stored as movies in Jellyfin.", ], [ genres, "string[]", "Only include items matching all listed genres. Names are case-sensitive and must match Jellyfin exactly.", ], [ decade, "integer", "Filter by production decade. 1990 matches 1990–1999.", ], [ tags, "string[]", "Only include items that have all listed tags.", ], [ min_duration_secs, "integer", "Minimum item duration in seconds. 1800 = 30 min, 3600 = 1 hour.", ], [ max_duration_secs, "integer", "Maximum item duration in seconds.", ], [ collections, "string[]", "Jellyfin library / folder IDs. Find the ID in the Jellyfin URL when browsing a library. Leave empty to search all libraries.", ], ]} /> 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. {/* ---------------------------------------------------------------- */}

    Fill strategies

    The fill strategy controls how items are ordered and selected from the filtered pool.

    random, "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.", ], [ sequential, "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.", ], [ best_fit, "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.", ], ]} /> {/* ---------------------------------------------------------------- */}

    Recycle policy

    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.

    cooldown_days, "null (disabled)", "An item won't be scheduled again until at least this many days have passed since it last aired.", ], [ cooldown_generations, "null (disabled)", "An item won't be scheduled again until at least this many schedule generations have passed.", ], [ min_available_ratio, "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.", ], ]} />

    Both cooldowns can be combined — an item must satisfy both before becoming eligible. min_available_ratio overrides cooldowns when too many items are excluded.

    {/* ---------------------------------------------------------------- */}

    Import & export

    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.

    Exporting

    Click the download icon on any channel card in the Dashboard. A{" "} .json file is saved containing the channel name, timezone, all programming blocks, and the recycle policy.

    Importing

    Click Import channel at the top of the Dashboard. You can paste JSON text into the text area or drag and drop a .json file. A live preview shows the parsed channel name, timezone, and block list before you confirm.

    The importer is lenient: block IDs are generated automatically if missing, and start_time accepts both{" "} HH:MM and HH:MM:SS.

    JSON format

    {`{
      "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
      }
    }`}

    Generating channels with an LLM

    Paste this prompt into any LLM and fill in your preferences:

    {`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
      }
    }`}
    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.
    {/* ---------------------------------------------------------------- */}

    IPTV export

    K-TV can export your channels as a standard IPTV playlist so you can watch in any IPTV client — TiviMate, VLC, Infuse, Jellyfin, and others.

    Getting the URLs

    Open the Dashboard and click the antenna icon on any channel card to open the IPTV Export dialog. It shows two URLs:

    /iptv/playlist.m3u?token=…, "M3U", "Channel list — paste this into your IPTV client as the playlist source.", ], [ /iptv/epg.xml?token=…, "XMLTV", "Electronic program guide — paste this as the EPG / guide data source.", ], ]} />

    Adding to an IPTV client

    Copy the M3U URL and add it as a new playlist in your client. If the client supports XMLTV, also add the EPG URL so programme titles and descriptions appear in the guide.

    Both URLs contain your session JWT as a query parameter. Anyone with the URL can access your channels — treat it like a password and do not share it publicly. Rotating your session (logging out and back in) invalidates the old URLs. {/* ---------------------------------------------------------------- */}

    Channel passwords

    Individual channels can be protected with an optional password. When set, TV viewers are prompted to enter the password before the stream plays. Channels without a password are always public.

    Setting a password

    Enter a password in the Password{" "} field when creating a channel or editing it in the Dashboard. Leave the field blank to remove an existing password.

    Channel passwords are not end-to-end encrypted. They prevent casual access — someone who can intercept network traffic or extract the JWT from an IPTV URL can still reach the stream. Do not use channel passwords as the sole protection for sensitive content.
    {/* ---------------------------------------------------------------- */}

    Watching TV

    Open /tv 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.

    Keyboard shortcuts

    Overlays

    Move your mouse or press any key to reveal the on-screen overlays. They fade after a few seconds of inactivity.

    • Bottom-left — channel info: what is playing, episode details, description, genre tags, and a progress bar with start/end times.
    • Bottom-right — channel controls (previous / next).
    • Top-right — Guide toggle and CC button (when subtitles are available).

    Program guide

    Press G 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.

    Subtitles (CC)

    When the playing item has subtitle tracks in its HLS stream, a{" "} CC 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.

    Up next banner

    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.

    Autoplay after page refresh

    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.

    {/* ---------------------------------------------------------------- */}

    Troubleshooting

    Schedule generation fails

    Check that JELLYFIN_BASE_URL,{" "} JELLYFIN_API_KEY, and JELLYFIN_USER_ID 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.

    Video won't play / stream error

    Click Retry 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.

    Block fills with no items

    Your filter is too strict or Jellyfin returned nothing matching. Try:

    • Removing one filter at a time to find the culprit.
    • Verifying genre/tag names match Jellyfin exactly — they are case-sensitive.
    • Clearing collections to search all libraries.
    • Lowering min_available_ratio if the recycle cooldown is excluding too many items.

    Channel shows no signal

    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.

    CORS errors in the browser

    Make sure CORS_ALLOWED_ORIGINS contains the exact origin of the frontend — scheme, hostname, and port, no trailing slash. Example: https://ktv.example.com. Wildcards are not supported.

    Subtitles not showing

    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.

    ); }