1091 lines
40 KiB
TypeScript
1091 lines
40 KiB
TypeScript
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: "channel-password", label: "Channel passwords" },
|
||
{ 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><video></Code> element for local
|
||
files instead of hls.js.
|
||
</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. 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="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="channel-password">
|
||
<H2>Channel passwords</H2>
|
||
<P>
|
||
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.
|
||
</P>
|
||
|
||
<H3>Setting a password</H3>
|
||
<P>
|
||
Enter a password in the <strong className="text-zinc-300">Password</strong>{" "}
|
||
field when creating a channel or editing it in the Dashboard. 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="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>
|
||
);
|
||
}
|