Files
k-tv/k-tv-frontend/app/(main)/docs/page.tsx

1245 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from "react";
// ---------------------------------------------------------------------------
// Primitive components
// ---------------------------------------------------------------------------
function Section({ id, children }: { id: string; children: ReactNode }) {
return (
<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: "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: "access-control", label: "Access control" },
{ id: "channel-logo", label: "Channel logo" },
{ id: "webhooks", label: "Webhooks" },
{ id: "admin", label: "Admin panel" },
{ 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="docker">
<H2>Docker deployment</H2>
<P>
The recommended way to run K-TV in production is with Docker Compose.
The repository ships a <Code>compose.yml</Code> that runs the backend
and frontend as separate containers, and an optional{" "}
<Code>compose.traefik.yml</Code> overlay for HTTPS via Traefik.
</P>
<H3>Minimal compose.yml</H3>
<Pre>{`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: <openssl rand -hex 32>
COOKIE_SECRET: <64+ char random string>
SECURE_COOKIE: "true"
PRODUCTION: "true"
JELLYFIN_BASE_URL: http://jellyfin:8096
JELLYFIN_API_KEY: <key>
JELLYFIN_USER_ID: <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"`}</Pre>
<H3>Build-time vs runtime env vars</H3>
<P>
<Code>NEXT_PUBLIC_API_URL</Code> is embedded into the frontend bundle
at build time. It must be passed as a{" "}
<Code>--build-arg</Code> when building the image:
</P>
<Pre>{`docker build \\
--build-arg NEXT_PUBLIC_API_URL=https://tv-api.example.com/api/v1 \\
-t registry.example.com/k-tv-frontend:latest .`}</Pre>
<P>
If you use the provided <Code>compose.yml</Code>, set{" "}
<Code>NEXT_PUBLIC_API_URL</Code> under <Code>build.args</Code> so it
is picked up automatically on every build.
</P>
<P>
<Code>API_URL</Code> (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:{" "}
<Code>http://backend:3000/api/v1</Code>. It is never baked into the
image.
</P>
<H3>HTTPS with Traefik</H3>
<P>
Merge <Code>compose.traefik.yml</Code> over the base file to add
Traefik labels for automatic TLS certificates and routing:
</P>
<Pre>{`docker compose -f compose.yml -f compose.traefik.yml up -d`}</Pre>
<Note>
Set <Code>SECURE_COOKIE=true</Code> and{" "}
<Code>PRODUCTION=true</Code> whenever the backend is behind HTTPS.
The default cookie secret is publicly known always replace it
before going live.
</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="local-files">
<H2>Local files provider</H2>
<P>
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.
</P>
<H3>Enabling local files</H3>
<P>
Build the backend with the <Code>local-files</Code> Cargo feature
and set the <Code>LOCAL_FILES_DIR</Code> environment variable to the
root of your video library:
</P>
<Pre>{`cargo run --features local-files
# .env
LOCAL_FILES_DIR=/media/videos`}</Pre>
<P>
On startup the backend indexes all video files under{" "}
<Code>LOCAL_FILES_DIR</Code>. Duration is detected via{" "}
<strong className="text-zinc-300">ffprobe</strong> (must be
installed and on <Code>PATH</Code>). Tags are derived from ancestor
directory names; the top-level subdirectory acts as the collection
ID.
</P>
<H3>Rescanning</H3>
<P>
When you add or remove files, trigger a rescan from the Dashboard
(the <strong className="text-zinc-300">Rescan library</strong>{" "}
button appears when the local files provider is active) or call the
API directly:
</P>
<Pre>{`POST /api/v1/files/rescan
Authorization: Bearer <token>
# Response
{ "items_found": 142 }`}</Pre>
<H3>Streaming</H3>
<P>
Local file streams are served by{" "}
<Code>GET /api/v1/files/stream/:id</Code>. This endpoint is{" "}
<strong className="text-zinc-300">public</strong> (no auth required)
and supports <Code>Range</Code> headers for seeking. The frontend
player uses the native <Code>&lt;video&gt;</Code> element for local
files instead of hls.js.
</P>
<H3>Transcode settings</H3>
<P>
When transcoding is available (<Code>TRANSCODE_DIR</Code> is set),
a gear icon appears in the Dashboard header. Click it to open the{" "}
<strong className="text-zinc-300">Transcode Settings</strong>{" "}
dialog, where you can adjust the cache cleanup TTL how long
transcoded segment files are kept before the hourly cleanup removes
them.
</P>
<H3>Filter support</H3>
<Note>
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.
</Note>
</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. If the channel&apos;s{" "}
<strong className="text-zinc-300">Auto-schedule</strong> toggle is
enabled (in the edit sheet), the server regenerates the schedule
automatically when it expires. Otherwise, return to the Dashboard
and click <strong className="text-zinc-300">Generate</strong>{" "}
whenever you want a fresh lineup.
</Note>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="blocks">
<H2>Programming blocks</H2>
<P>
A programming block is a repeating daily time slot. Every day the
block starts at its <Code>start_time</Code> (in the channel
timezone) and runs for <Code>duration_mins</Code> minutes. The
scheduler fills it with as many items as will fit.
</P>
<H3>Timeline editor</H3>
<Ul>
<Li>
<strong className="text-zinc-300">Draw a block</strong> click
and drag on an empty area of the 24-hour timeline.
</Li>
<Li>
<strong className="text-zinc-300">Move a block</strong> drag the
block body left or right. Snaps to 15-minute increments.
</Li>
<Li>
<strong className="text-zinc-300">Resize a block</strong> drag
its right edge.
</Li>
<Li>
<strong className="text-zinc-300">Select a block</strong> click
it on the timeline to scroll its detail editor into view below.
</Li>
</Ul>
<P>
Gaps between blocks are fine the TV player shows a no-signal
screen during those times. You do not need to fill every minute of
the day.
</P>
<H3>Content types</H3>
<Table
head={["Type", "Description"]}
rows={[
[
<Code key="a">algorithmic</Code>,
"The scheduler picks items from your Jellyfin library based on filters you define. Recommended for most blocks.",
],
[
<Code key="m">manual</Code>,
"Plays a fixed, ordered list of Jellyfin item IDs. Useful for a specific playlist or sequential episode run.",
],
]}
/>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="filters">
<H2>Filters reference</H2>
<P>
Filters apply to <Code>algorithmic</Code> blocks. All fields are
optional omit or leave blank to match everything. Multiple values
in an array field must <em>all</em> match (AND logic).
</P>
<Table
head={["Field", "Type", "Description"]}
rows={[
[
<Code key="ct">content_type</Code>,
<>
<Code key="mv">movie</Code> |{" "}
<Code key="ep">episode</Code> |{" "}
<Code key="sh">short</Code>
</>,
"Restrict to one media type. Leave empty for any type. Short films are stored as movies in Jellyfin.",
],
[
<Code key="g">genres</Code>,
"string[]",
"Only include items matching all listed genres. Names are case-sensitive and must match Jellyfin exactly.",
],
[
<Code key="d">decade</Code>,
"integer",
"Filter by production decade. 1990 matches 19901999.",
],
[
<Code key="t">tags</Code>,
"string[]",
"Only include items that have all listed tags.",
],
[
<Code key="mn">min_duration_secs</Code>,
"integer",
"Minimum item duration in seconds. 1800 = 30 min, 3600 = 1 hour.",
],
[
<Code key="mx">max_duration_secs</Code>,
"integer",
"Maximum item duration in seconds.",
],
[
<Code key="cl">collections</Code>,
"string[]",
"Jellyfin library / folder IDs. Find the ID in the Jellyfin URL when browsing a library. Leave empty to search all libraries.",
],
[
<Code key="sn">series_names</Code>,
"string[]",
"Only include episodes from the listed TV series (OR-combined). Jellyfin only.",
],
[
<Code key="st">search_term</Code>,
"string",
"Free-text search passed to the provider.",
],
]}
/>
<Note>
Genre and tag names come from Jellyfin metadata. If a filter returns
no results, check the exact spelling in the Jellyfin library browser
filter panel.
</Note>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="strategies">
<H2>Fill strategies</H2>
<P>
The fill strategy controls how items are ordered and selected from
the filtered pool.
</P>
<Table
head={["Strategy", "Behaviour", "Best for"]}
rows={[
[
<Code key="r">random</Code>,
"Shuffles the pool and fills the block in random order. Each schedule generation produces a different lineup.",
"Movie channels, variety blocks — anything where you want variety.",
],
[
<Code key="s">sequential</Code>,
"Items are played in the order Jellyfin returns them (typically name or episode number).",
"Series watched in order, e.g. a block dedicated to one show.",
],
[
<Code key="b">best_fit</Code>,
"Greedy bin-packing: repeatedly picks the longest item that still fits in the remaining time, minimising dead air at the end.",
"Blocks where you want the slot filled as tightly as possible.",
],
]}
/>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="recycle-policy">
<H2>Recycle policy</H2>
<P>
The recycle policy controls how soon the same item can reappear
across schedule generations, preventing a small library from cycling
the same content every day.
</P>
<Table
head={["Field", "Default", "Description"]}
rows={[
[
<Code key="cd">cooldown_days</Code>,
"null (disabled)",
"An item won't be scheduled again until at least this many days have passed since it last aired.",
],
[
<Code key="cg">cooldown_generations</Code>,
"null (disabled)",
"An item won't be scheduled again until at least this many schedule generations have passed.",
],
[
<Code key="mr">min_available_ratio</Code>,
"0.1",
"Safety valve. Even with cooldowns active, always keep at least this fraction of the pool available. A value of 0.1 means 10% of items are always eligible, preventing the scheduler from running dry on small libraries.",
],
]}
/>
<P>
Both cooldowns can be combined an item must satisfy both before
becoming eligible. <Code>min_available_ratio</Code> overrides
cooldowns when too many items are excluded.
</P>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="import-export">
<H2>Import &amp; export</H2>
<P>
Channels can be exported as JSON and shared or reimported. This
makes it easy to build configurations with an LLM and paste them
directly into K-TV.
</P>
<H3>Exporting</H3>
<P>
Click the download icon on any channel card in the Dashboard. A{" "}
<Code>.json</Code> file is saved containing the channel name,
timezone, all programming blocks, and the recycle policy.
</P>
<H3>Importing</H3>
<P>
Click <strong className="text-zinc-300">Import channel</strong> at
the top of the Dashboard. You can paste JSON text into the text area
or drag and drop a <Code>.json</Code> file. A live preview shows the
parsed channel name, timezone, and block list before you confirm.
</P>
<P>
The importer is lenient: block IDs are generated automatically if
missing, and <Code>start_time</Code> accepts both{" "}
<Code>HH:MM</Code> and <Code>HH:MM:SS</Code>.
</P>
<H3>JSON format</H3>
<Pre>{`{
"name": "90s Sitcom Network",
"description": "Nothing but classic sitcoms.",
"timezone": "America/New_York",
"blocks": [
{
"name": "Morning Sitcoms",
"start_time": "09:00",
"duration_mins": 180,
"content": {
"type": "algorithmic",
"filter": {
"content_type": "episode",
"genres": ["Comedy"],
"decade": 1990,
"tags": [],
"min_duration_secs": null,
"max_duration_secs": 1800,
"collections": []
},
"strategy": "random"
}
}
],
"recycle_policy": {
"cooldown_days": 7,
"cooldown_generations": null,
"min_available_ratio": 0.15
}
}`}</Pre>
<H3>Generating channels with an LLM</H3>
<P>
Paste this prompt into any LLM and fill in your preferences:
</P>
<Pre>{`Generate a K-TV channel JSON for a channel called "[your channel name]".
The channel should [describe your theme, e.g. "play 90s action movies in
the evening and crime dramas late at night"].
Use timezone "[your timezone, e.g. America/Chicago]".
Use algorithmic blocks with appropriate genres, content types, and strategies.
Output only valid JSON matching this structure:
{
"name": string,
"description": string,
"timezone": string,
"blocks": [
{
"name": string,
"start_time": "HH:MM",
"duration_mins": number,
"content": {
"type": "algorithmic",
"filter": {
"content_type": "movie" | "episode" | "short" | null,
"genres": string[],
"decade": number | null,
"tags": string[],
"min_duration_secs": number | null,
"max_duration_secs": number | null,
"collections": []
},
"strategy": "random" | "sequential" | "best_fit"
}
}
],
"recycle_policy": {
"cooldown_days": number | null,
"cooldown_generations": number | null,
"min_available_ratio": number
}
}`}</Pre>
<Note>
Genre and tag names must exactly match what Jellyfin uses in your
library. After importing, verify filter fields against your Jellyfin
library before generating a schedule.
</Note>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="iptv">
<H2>IPTV export</H2>
<P>
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.
</P>
<H3>Getting the URLs</H3>
<P>
Open the Dashboard and click the antenna icon on any channel card to
open the IPTV Export dialog. It shows two URLs:
</P>
<Table
head={["URL", "Format", "Purpose"]}
rows={[
[
<Code key="m3u">/iptv/playlist.m3u?token=</Code>,
"M3U",
"Channel list — paste this into your IPTV client as the playlist source.",
],
[
<Code key="xml">/iptv/epg.xml?token=</Code>,
"XMLTV",
"Electronic program guide — paste this as the EPG / guide data source.",
],
]}
/>
<H3>Adding to an IPTV client</H3>
<P>
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.
</P>
<Warn>
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.
</Warn>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="access-control">
<H2>Access control</H2>
<P>
Each channel has an <Code>access_mode</Code> field that controls who
can watch it. Set it in the edit sheet.
</P>
<Table
head={["Mode", "Description"]}
rows={[
[
<Code key="pub">public</Code>,
"Anyone can watch. This is the default.",
],
[
<Code key="pp">password_protected</Code>,
"Viewers must enter a password before the stream plays.",
],
[
<Code key="ar">account_required</Code>,
"Viewers must be logged in to any K-TV account.",
],
[
<Code key="oo">owner_only</Code>,
"Only the channel owner can watch.",
],
]}
/>
<H3>Setting a password</H3>
<P>
When <Code>access_mode</Code> is{" "}
<Code>password_protected</Code>, enter a value in the{" "}
<strong className="text-zinc-300">Password</strong> field in the
edit sheet. Leave the field blank to remove an existing password.
</P>
<Warn>
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.
</Warn>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="channel-logo">
<H2>Channel logo</H2>
<P>
A logo can be shown as a watermark overlay in the TV player. Set
these fields in the channel edit sheet:
</P>
<Table
head={["Field", "Description"]}
rows={[
[
"Logo",
"URL or inline SVG markup. The image is rendered as a semi-transparent overlay on the video.",
],
[
"Logo position",
<>
Corner where the logo appears.{" "}
<Code>top_right</Code> (default) /{" "}
<Code>top_left</Code> /{" "}
<Code>bottom_left</Code> /{" "}
<Code>bottom_right</Code>.
</>,
],
[
"Logo opacity",
"A value from 0.0 (invisible) to 1.0 (fully opaque). Controls how prominent the watermark is.",
],
]}
/>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="webhooks">
<H2>Webhooks</H2>
<P>
Channels can fire an HTTP POST to a URL of your choice when domain
events occur (e.g. a schedule is generated). Configure webhooks in
the edit sheet under the{" "}
<strong className="text-zinc-300">Webhook</strong> section.
</P>
<H3>Preset formats</H3>
<Table
head={["Preset", "Description"]}
rows={[
["Default", "Simple JSON with event name, timestamp, and data."],
["Discord", "Formatted Discord embed message via a webhook URL."],
["Slack", "Slack Block Kit message via an incoming webhook URL."],
["Custom", "Write your own Handlebars template (see below)."],
]}
/>
<H3>Template variables</H3>
<P>
Custom templates use Handlebars syntax. Available variables:
</P>
<Table
head={["Variable", "Description"]}
rows={[
[<Code key="ev">{"{{event}}"}</Code>, "Event name, e.g. schedule_generated."],
[<Code key="ts">{"{{timestamp}}"}</Code>, "ISO 8601 timestamp of the event."],
[<Code key="ti">{"{{data.item.title}}"}</Code>, "Title of the affected media item (where applicable)."],
]}
/>
<H3>Extra headers</H3>
<P>
Use <Code>webhook_headers</Code> to add custom HTTP headers to
every delivery for example{" "}
<Code>Authorization: Bearer </Code> for endpoints that require
authentication.
</P>
<H3>Poll interval</H3>
<P>
<Code>webhook_poll_interval_secs</Code> controls how often the
backend checks for pending webhook deliveries. Lower values mean
faster delivery but more database reads.
</P>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="tv-page">
<H2>Watching TV</H2>
<P>
Open <Code>/tv</Code> to start watching. No login required. The
player tunes to the first channel and syncs to the current broadcast
position automatically you join mid-show, just like real TV.
</P>
<H3>Keyboard shortcuts</H3>
<Table
head={["Key", "Action"]}
rows={[
["Arrow Up / Page Up", "Next channel"],
["Arrow Down / Page Down", "Previous channel"],
["09", "Type a channel number and jump to it after 1.5 s (e.g. press 1 then 4 → channel 14)"],
["G", "Toggle the program guide"],
["M", "Mute / unmute"],
["F", "Toggle fullscreen"],
]}
/>
<H3>Overlays</H3>
<P>
Move your mouse or press any key to reveal the on-screen overlays.
They fade after a few seconds of inactivity.
</P>
<Ul>
<Li>
<strong className="text-zinc-300">Bottom-left</strong> channel
info: what is playing, episode details, description, genre tags,
and a progress bar with start/end times.
</Li>
<Li>
<strong className="text-zinc-300">Bottom-right</strong> channel
controls (previous / next).
</Li>
<Li>
<strong className="text-zinc-300">Top-right</strong> Guide
toggle and CC button (when subtitles are available).
</Li>
</Ul>
<H3>Program guide</H3>
<P>
Press <strong className="text-zinc-300">G</strong> or click the
Guide button to open the upcoming schedule for the current channel.
Colour-coded blocks show each slot; the current item is highlighted.
</P>
<H3>Subtitles (CC)</H3>
<P>
When the playing item has subtitle tracks in its HLS stream, a{" "}
<strong className="text-zinc-300">CC</strong> button appears in the
top-right corner. Click it to pick a language track or turn
subtitles off. The button is highlighted when subtitles are active.
</P>
<H3>Up next banner</H3>
<P>
When the current item is more than 80% complete, an "Up next" banner
appears at the bottom showing the next item's title and start time.
</P>
<H3>Autoplay after page refresh</H3>
<P>
Browsers block video autoplay on page refresh until the user
interacts with the page. Move your mouse or press any key after
refreshing and playback resumes immediately.
</P>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="admin">
<H2>Admin panel</H2>
<P>
The <Code>/admin</Code> route is available to any logged-in user.
It provides two live views into the running server:
</P>
<Table
head={["Tab", "Description"]}
rows={[
[
"Server logs",
"Live stream of backend log lines via SSE. Each entry shows the log level, target module, message, and timestamp.",
],
[
"Activity log",
"Last 50 in-app events such as schedule generations, channel creates/updates, and other domain actions.",
],
]}
/>
<Note>
Access requires a valid login session. Unauthenticated visitors are
redirected to the login page.
</Note>
</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>
);
}