feat: add Docs link to navigation in MainLayout
This commit is contained in:
@@ -1,8 +1,883 @@
|
|||||||
export default function DocsPage() {
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Primitive components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Section({ id, children }: { id: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
<section id={id} className="scroll-mt-20">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Docs</h1>
|
{children}
|
||||||
<p className="text-sm text-zinc-500">API reference and usage documentation go here.</p>
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function H2({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 className="mb-4 mt-12 text-xl font-semibold text-zinc-100 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function H3({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h3 className="mb-3 mt-8 text-base font-semibold text-zinc-200">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function P({ children }: { children: ReactNode }) {
|
||||||
|
return <p className="mb-4 leading-relaxed text-zinc-400">{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<code className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-[13px] text-zinc-300">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pre({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<pre className="mb-4 overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 font-mono text-[13px] leading-relaxed text-zinc-300">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Note({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg border border-zinc-700 bg-zinc-800/40 px-4 py-3 text-sm text-zinc-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Warn({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-800/50 bg-amber-950/30 px-4 py-3 text-sm text-amber-300/80">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ul({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ul className="mb-4 ml-5 list-disc space-y-1 text-zinc-400">{children}</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Li({ children }: { children: ReactNode }) {
|
||||||
|
return <li>{children}</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Table({
|
||||||
|
head,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
head: string[];
|
||||||
|
rows: (string | ReactNode)[][];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 overflow-x-auto rounded-lg border border-zinc-800">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-zinc-800/60">
|
||||||
|
<tr>
|
||||||
|
{head.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-4 py-2.5 text-left font-medium text-zinc-300"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-800/20">
|
||||||
|
{row.map((cell, j) => (
|
||||||
|
<td key={j} className="px-4 py-2.5 align-top text-zinc-400">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table of contents
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TOC = [
|
||||||
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "requirements", label: "Requirements" },
|
||||||
|
{ id: "backend-setup", label: "Backend setup" },
|
||||||
|
{ id: "frontend-setup", label: "Frontend setup" },
|
||||||
|
{ id: "jellyfin", label: "Connecting Jellyfin" },
|
||||||
|
{ id: "first-channel", label: "Your first channel" },
|
||||||
|
{ id: "blocks", label: "Programming blocks" },
|
||||||
|
{ id: "filters", label: "Filters reference" },
|
||||||
|
{ id: "strategies", label: "Fill strategies" },
|
||||||
|
{ id: "recycle-policy", label: "Recycle policy" },
|
||||||
|
{ id: "import-export", label: "Import & export" },
|
||||||
|
{ id: "tv-page", label: "Watching TV" },
|
||||||
|
{ id: "troubleshooting", label: "Troubleshooting" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DocsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl gap-12 px-6 py-12">
|
||||||
|
{/* Sidebar TOC */}
|
||||||
|
<aside className="hidden w-52 shrink-0 lg:block">
|
||||||
|
<div className="sticky top-20">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||||
|
On this page
|
||||||
|
</p>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{TOC.map(({ id, label }) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
className="rounded px-2 py-1 text-sm text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<article className="min-w-0 flex-1">
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="overview">
|
||||||
|
<H2>Overview</H2>
|
||||||
|
<P>
|
||||||
|
K-TV turns your self-hosted media library into broadcast-style linear
|
||||||
|
TV channels. You define programming blocks — time slots with filters
|
||||||
|
and fill strategies — and the scheduler automatically picks content
|
||||||
|
from your{" "}
|
||||||
|
<a
|
||||||
|
href="https://jellyfin.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-300 underline underline-offset-2 hover:text-white"
|
||||||
|
>
|
||||||
|
Jellyfin
|
||||||
|
</a>{" "}
|
||||||
|
library to fill them. Viewers open the TV page and watch a live
|
||||||
|
stream with no seeking — just like real TV.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
The project has two parts: a{" "}
|
||||||
|
<strong className="text-zinc-300">backend</strong> (Rust / Axum)
|
||||||
|
that manages channels, generates schedules, and proxies streams from
|
||||||
|
Jellyfin, and a{" "}
|
||||||
|
<strong className="text-zinc-300">frontend</strong> (Next.js) that
|
||||||
|
provides the TV viewer and the channel management dashboard.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="requirements">
|
||||||
|
<H2>Requirements</H2>
|
||||||
|
<Table
|
||||||
|
head={["Dependency", "Version", "Notes"]}
|
||||||
|
rows={[
|
||||||
|
[<Code key="r">Rust</Code>, "1.77+", "Install via rustup"],
|
||||||
|
[<Code key="n">Node.js</Code>, "20+", "Frontend only"],
|
||||||
|
[<Code key="j">Jellyfin</Code>, "10.8+", "Your media server"],
|
||||||
|
[
|
||||||
|
<Code key="db">SQLite or PostgreSQL</Code>,
|
||||||
|
"any",
|
||||||
|
"SQLite is the default — no extra setup needed",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Note>
|
||||||
|
SQLite is the default and requires no additional database setup.
|
||||||
|
PostgreSQL support is available by rebuilding the backend with the{" "}
|
||||||
|
<Code>postgres</Code> Cargo feature.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="backend-setup">
|
||||||
|
<H2>Backend setup</H2>
|
||||||
|
<P>
|
||||||
|
Clone the repository and start the server. All configuration is read
|
||||||
|
from environment variables or a <Code>.env</Code> file in the
|
||||||
|
working directory.
|
||||||
|
</P>
|
||||||
|
<Pre>{`git clone <repo-url> k-tv-backend
|
||||||
|
cd k-tv-backend
|
||||||
|
cargo run`}</Pre>
|
||||||
|
<P>
|
||||||
|
The server starts on <Code>http://127.0.0.1:3000</Code> by default.
|
||||||
|
Database migrations run automatically on startup.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Environment variables</H3>
|
||||||
|
<Table
|
||||||
|
head={["Variable", "Default", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="h">HOST</Code>,
|
||||||
|
<Code key="h2">127.0.0.1</Code>,
|
||||||
|
"Bind address. Use 0.0.0.0 in containers.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="p">PORT</Code>,
|
||||||
|
<Code key="p2">3000</Code>,
|
||||||
|
"HTTP port.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="du">DATABASE_URL</Code>,
|
||||||
|
<Code key="du2">sqlite:data.db?mode=rwc</Code>,
|
||||||
|
"SQLite file path or postgres:// connection string.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="co">CORS_ALLOWED_ORIGINS</Code>,
|
||||||
|
<Code key="co2">http://localhost:5173</Code>,
|
||||||
|
"Comma-separated list of allowed frontend origins.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="jbu">JELLYFIN_BASE_URL</Code>,
|
||||||
|
"—",
|
||||||
|
"Jellyfin server URL, e.g. http://192.168.1.10:8096",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="jak">JELLYFIN_API_KEY</Code>,
|
||||||
|
"—",
|
||||||
|
"Jellyfin API key (see Connecting Jellyfin).",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="jui">JELLYFIN_USER_ID</Code>,
|
||||||
|
"—",
|
||||||
|
"Jellyfin user ID used for library browsing.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="js">JWT_SECRET</Code>,
|
||||||
|
"—",
|
||||||
|
"Secret used to sign login tokens. Generate with: openssl rand -hex 32",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="je">JWT_EXPIRY_HOURS</Code>,
|
||||||
|
<Code key="je2">24</Code>,
|
||||||
|
"How long a login token stays valid.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="cs">COOKIE_SECRET</Code>,
|
||||||
|
"dev default",
|
||||||
|
"Must be at least 64 bytes in production.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="sc">SECURE_COOKIE</Code>,
|
||||||
|
<Code key="sc2">false</Code>,
|
||||||
|
"Set to true when serving over HTTPS.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="dm">DB_MAX_CONNECTIONS</Code>,
|
||||||
|
<Code key="dm2">5</Code>,
|
||||||
|
"Connection pool maximum.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="di">DB_MIN_CONNECTIONS</Code>,
|
||||||
|
<Code key="di2">1</Code>,
|
||||||
|
"Connections kept alive in the pool.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="pr">PRODUCTION</Code>,
|
||||||
|
<Code key="pr2">false</Code>,
|
||||||
|
"Set to true or 1 to enable production mode.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H3>Minimal production .env</H3>
|
||||||
|
<Pre>{`HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=sqlite:/app/data/k-tv.db?mode=rwc
|
||||||
|
CORS_ALLOWED_ORIGINS=https://your-frontend-domain.com
|
||||||
|
JWT_SECRET=<output of: openssl rand -hex 32>
|
||||||
|
COOKIE_SECRET=<64+ character random string>
|
||||||
|
SECURE_COOKIE=true
|
||||||
|
PRODUCTION=true
|
||||||
|
JELLYFIN_BASE_URL=http://jellyfin:8096
|
||||||
|
JELLYFIN_API_KEY=<your jellyfin api key>
|
||||||
|
JELLYFIN_USER_ID=<your jellyfin user id>`}</Pre>
|
||||||
|
|
||||||
|
<Warn>
|
||||||
|
Always set a strong <Code>JWT_SECRET</Code> in production. The
|
||||||
|
default <Code>COOKIE_SECRET</Code> is publicly known and must be
|
||||||
|
replaced before going live.
|
||||||
|
</Warn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="frontend-setup">
|
||||||
|
<H2>Frontend setup</H2>
|
||||||
|
<Pre>{`cd k-tv-frontend
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
# edit .env.local
|
||||||
|
npm install
|
||||||
|
npm run dev`}</Pre>
|
||||||
|
|
||||||
|
<H3>Environment variables</H3>
|
||||||
|
<Table
|
||||||
|
head={["Variable", "Default", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="np">NEXT_PUBLIC_API_URL</Code>,
|
||||||
|
<Code key="np2">http://localhost:3000/api/v1</Code>,
|
||||||
|
"Backend API base URL — sent to the browser.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="au">API_URL</Code>,
|
||||||
|
"Falls back to NEXT_PUBLIC_API_URL",
|
||||||
|
"Server-side API URL used by Next.js API routes. Set this if the frontend container reaches the backend via a private hostname.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The TV page and channel list are fully public — no login required to
|
||||||
|
watch. An account is only needed to create or manage channels from
|
||||||
|
the Dashboard.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="jellyfin">
|
||||||
|
<H2>Connecting Jellyfin</H2>
|
||||||
|
<P>
|
||||||
|
K-TV fetches content metadata and HLS stream URLs from Jellyfin. You
|
||||||
|
need three things: the server URL, an API key, and the user ID K-TV
|
||||||
|
will browse as.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>1. API key</H3>
|
||||||
|
<P>
|
||||||
|
In Jellyfin go to{" "}
|
||||||
|
<strong className="text-zinc-300">
|
||||||
|
Dashboard → API Keys
|
||||||
|
</strong>{" "}
|
||||||
|
and create a new key. Give it a name like <em>K-TV</em>. Copy the
|
||||||
|
value into <Code>JELLYFIN_API_KEY</Code>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>2. User ID</H3>
|
||||||
|
<P>
|
||||||
|
Go to{" "}
|
||||||
|
<strong className="text-zinc-300">Dashboard → Users</strong>, click
|
||||||
|
the user K-TV should browse as (usually your admin account), and
|
||||||
|
copy the user ID from the browser URL:
|
||||||
|
</P>
|
||||||
|
<Pre>{`/web/index.html#!/useredit?userId=<COPY THIS PART>`}</Pre>
|
||||||
|
<P>
|
||||||
|
Paste it into <Code>JELLYFIN_USER_ID</Code>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>3. Library IDs (optional)</H3>
|
||||||
|
<P>
|
||||||
|
Library IDs are used in the <Code>collections</Code> filter field to
|
||||||
|
restrict a block to a specific Jellyfin library or folder. Browse to
|
||||||
|
a library in Jellyfin and copy the <Code>parentId</Code> query
|
||||||
|
parameter from the URL. Leave <Code>collections</Code> empty to
|
||||||
|
search across all libraries.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Stream format</H3>
|
||||||
|
<P>
|
||||||
|
K-TV requests adaptive HLS streams from Jellyfin. Jellyfin
|
||||||
|
transcodes to H.264 / AAC on the fly (or serves a direct stream if
|
||||||
|
the file is already compatible). The frontend player handles
|
||||||
|
bitrate adaptation and seeks to the correct broadcast position
|
||||||
|
automatically so viewers join mid-show at the right point.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Subtitles</H3>
|
||||||
|
<P>
|
||||||
|
External subtitle files (SRT, ASS) attached to a Jellyfin item are
|
||||||
|
automatically converted to WebVTT and embedded in the HLS manifest.
|
||||||
|
A <strong className="text-zinc-300">CC</strong> button appears in
|
||||||
|
the TV player when tracks are available. Image-based subtitles
|
||||||
|
(PGS/VOBSUB from Blu-ray sources) require burn-in transcoding and
|
||||||
|
are not currently supported.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="first-channel">
|
||||||
|
<H2>Your first channel</H2>
|
||||||
|
<P>
|
||||||
|
Log in and open the{" "}
|
||||||
|
<strong className="text-zinc-300">Dashboard</strong>. Click{" "}
|
||||||
|
<strong className="text-zinc-300">New channel</strong> and fill in:
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Field", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
"Name",
|
||||||
|
"Display name shown to viewers in the TV overlay.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Timezone",
|
||||||
|
"IANA timezone (e.g. America/New_York). Block start times are anchored to this zone, including DST changes.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Description",
|
||||||
|
"Optional. Shown only in the Dashboard.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<P>
|
||||||
|
After creating the channel, open the edit sheet (pencil icon). Add
|
||||||
|
programming blocks in the list or draw them directly on the 24-hour
|
||||||
|
timeline. Once the schedule looks right, click{" "}
|
||||||
|
<strong className="text-zinc-300">Generate schedule</strong> on the
|
||||||
|
channel card. K-TV queries Jellyfin, fills each block with matching
|
||||||
|
content, and starts broadcasting immediately.
|
||||||
|
</P>
|
||||||
|
<Note>
|
||||||
|
Schedules are valid for 48 hours. K-TV does not regenerate them
|
||||||
|
automatically — return to the Dashboard and click{" "}
|
||||||
|
<strong className="text-zinc-300">Generate</strong> whenever you
|
||||||
|
want a fresh lineup.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="blocks">
|
||||||
|
<H2>Programming blocks</H2>
|
||||||
|
<P>
|
||||||
|
A programming block is a repeating daily time slot. Every day the
|
||||||
|
block starts at its <Code>start_time</Code> (in the channel
|
||||||
|
timezone) and runs for <Code>duration_mins</Code> minutes. The
|
||||||
|
scheduler fills it with as many items as will fit.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Timeline editor</H3>
|
||||||
|
<Ul>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Draw a block</strong> — click
|
||||||
|
and drag on an empty area of the 24-hour timeline.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Move a block</strong> — drag the
|
||||||
|
block body left or right. Snaps to 15-minute increments.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Resize a block</strong> — drag
|
||||||
|
its right edge.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Select a block</strong> — click
|
||||||
|
it on the timeline to scroll its detail editor into view below.
|
||||||
|
</Li>
|
||||||
|
</Ul>
|
||||||
|
<P>
|
||||||
|
Gaps between blocks are fine — the TV player shows a no-signal
|
||||||
|
screen during those times. You do not need to fill every minute of
|
||||||
|
the day.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Content types</H3>
|
||||||
|
<Table
|
||||||
|
head={["Type", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="a">algorithmic</Code>,
|
||||||
|
"The scheduler picks items from your Jellyfin library based on filters you define. Recommended for most blocks.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="m">manual</Code>,
|
||||||
|
"Plays a fixed, ordered list of Jellyfin item IDs. Useful for a specific playlist or sequential episode run.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="filters">
|
||||||
|
<H2>Filters reference</H2>
|
||||||
|
<P>
|
||||||
|
Filters apply to <Code>algorithmic</Code> blocks. All fields are
|
||||||
|
optional — omit or leave blank to match everything. Multiple values
|
||||||
|
in an array field must <em>all</em> match (AND logic).
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Field", "Type", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="ct">content_type</Code>,
|
||||||
|
<>
|
||||||
|
<Code key="mv">movie</Code> |{" "}
|
||||||
|
<Code key="ep">episode</Code> |{" "}
|
||||||
|
<Code key="sh">short</Code>
|
||||||
|
</>,
|
||||||
|
"Restrict to one media type. Leave empty for any type. Short films are stored as movies in Jellyfin.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="g">genres</Code>,
|
||||||
|
"string[]",
|
||||||
|
"Only include items matching all listed genres. Names are case-sensitive and must match Jellyfin exactly.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="d">decade</Code>,
|
||||||
|
"integer",
|
||||||
|
"Filter by production decade. 1990 matches 1990–1999.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="t">tags</Code>,
|
||||||
|
"string[]",
|
||||||
|
"Only include items that have all listed tags.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="mn">min_duration_secs</Code>,
|
||||||
|
"integer",
|
||||||
|
"Minimum item duration in seconds. 1800 = 30 min, 3600 = 1 hour.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="mx">max_duration_secs</Code>,
|
||||||
|
"integer",
|
||||||
|
"Maximum item duration in seconds.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="cl">collections</Code>,
|
||||||
|
"string[]",
|
||||||
|
"Jellyfin library / folder IDs. Find the ID in the Jellyfin URL when browsing a library. Leave empty to search all libraries.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Note>
|
||||||
|
Genre and tag names come from Jellyfin metadata. If a filter returns
|
||||||
|
no results, check the exact spelling in the Jellyfin library browser
|
||||||
|
filter panel.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="strategies">
|
||||||
|
<H2>Fill strategies</H2>
|
||||||
|
<P>
|
||||||
|
The fill strategy controls how items are ordered and selected from
|
||||||
|
the filtered pool.
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Strategy", "Behaviour", "Best for"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="r">random</Code>,
|
||||||
|
"Shuffles the pool and fills the block in random order. Each schedule generation produces a different lineup.",
|
||||||
|
"Movie channels, variety blocks — anything where you want variety.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="s">sequential</Code>,
|
||||||
|
"Items are played in the order Jellyfin returns them (typically name or episode number).",
|
||||||
|
"Series watched in order, e.g. a block dedicated to one show.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="b">best_fit</Code>,
|
||||||
|
"Greedy bin-packing: repeatedly picks the longest item that still fits in the remaining time, minimising dead air at the end.",
|
||||||
|
"Blocks where you want the slot filled as tightly as possible.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="recycle-policy">
|
||||||
|
<H2>Recycle policy</H2>
|
||||||
|
<P>
|
||||||
|
The recycle policy controls how soon the same item can reappear
|
||||||
|
across schedule generations, preventing a small library from cycling
|
||||||
|
the same content every day.
|
||||||
|
</P>
|
||||||
|
<Table
|
||||||
|
head={["Field", "Default", "Description"]}
|
||||||
|
rows={[
|
||||||
|
[
|
||||||
|
<Code key="cd">cooldown_days</Code>,
|
||||||
|
"null (disabled)",
|
||||||
|
"An item won't be scheduled again until at least this many days have passed since it last aired.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="cg">cooldown_generations</Code>,
|
||||||
|
"null (disabled)",
|
||||||
|
"An item won't be scheduled again until at least this many schedule generations have passed.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
<Code key="mr">min_available_ratio</Code>,
|
||||||
|
"0.1",
|
||||||
|
"Safety valve. Even with cooldowns active, always keep at least this fraction of the pool available. A value of 0.1 means 10% of items are always eligible, preventing the scheduler from running dry on small libraries.",
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<P>
|
||||||
|
Both cooldowns can be combined — an item must satisfy both before
|
||||||
|
becoming eligible. <Code>min_available_ratio</Code> overrides
|
||||||
|
cooldowns when too many items are excluded.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="import-export">
|
||||||
|
<H2>Import & export</H2>
|
||||||
|
<P>
|
||||||
|
Channels can be exported as JSON and shared or reimported. This
|
||||||
|
makes it easy to build configurations with an LLM and paste them
|
||||||
|
directly into K-TV.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Exporting</H3>
|
||||||
|
<P>
|
||||||
|
Click the download icon on any channel card in the Dashboard. A{" "}
|
||||||
|
<Code>.json</Code> file is saved containing the channel name,
|
||||||
|
timezone, all programming blocks, and the recycle policy.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Importing</H3>
|
||||||
|
<P>
|
||||||
|
Click <strong className="text-zinc-300">Import channel</strong> at
|
||||||
|
the top of the Dashboard. You can paste JSON text into the text area
|
||||||
|
or drag and drop a <Code>.json</Code> file. A live preview shows the
|
||||||
|
parsed channel name, timezone, and block list before you confirm.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
The importer is lenient: block IDs are generated automatically if
|
||||||
|
missing, and <Code>start_time</Code> accepts both{" "}
|
||||||
|
<Code>HH:MM</Code> and <Code>HH:MM:SS</Code>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>JSON format</H3>
|
||||||
|
<Pre>{`{
|
||||||
|
"name": "90s Sitcom Network",
|
||||||
|
"description": "Nothing but classic sitcoms.",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"name": "Morning Sitcoms",
|
||||||
|
"start_time": "09:00",
|
||||||
|
"duration_mins": 180,
|
||||||
|
"content": {
|
||||||
|
"type": "algorithmic",
|
||||||
|
"filter": {
|
||||||
|
"content_type": "episode",
|
||||||
|
"genres": ["Comedy"],
|
||||||
|
"decade": 1990,
|
||||||
|
"tags": [],
|
||||||
|
"min_duration_secs": null,
|
||||||
|
"max_duration_secs": 1800,
|
||||||
|
"collections": []
|
||||||
|
},
|
||||||
|
"strategy": "random"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recycle_policy": {
|
||||||
|
"cooldown_days": 7,
|
||||||
|
"cooldown_generations": null,
|
||||||
|
"min_available_ratio": 0.15
|
||||||
|
}
|
||||||
|
}`}</Pre>
|
||||||
|
|
||||||
|
<H3>Generating channels with an LLM</H3>
|
||||||
|
<P>
|
||||||
|
Paste this prompt into any LLM and fill in your preferences:
|
||||||
|
</P>
|
||||||
|
<Pre>{`Generate a K-TV channel JSON for a channel called "[your channel name]".
|
||||||
|
The channel should [describe your theme, e.g. "play 90s action movies in
|
||||||
|
the evening and crime dramas late at night"].
|
||||||
|
Use timezone "[your timezone, e.g. America/Chicago]".
|
||||||
|
Use algorithmic blocks with appropriate genres, content types, and strategies.
|
||||||
|
Output only valid JSON matching this structure:
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"description": string,
|
||||||
|
"timezone": string,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"start_time": "HH:MM",
|
||||||
|
"duration_mins": number,
|
||||||
|
"content": {
|
||||||
|
"type": "algorithmic",
|
||||||
|
"filter": {
|
||||||
|
"content_type": "movie" | "episode" | "short" | null,
|
||||||
|
"genres": string[],
|
||||||
|
"decade": number | null,
|
||||||
|
"tags": string[],
|
||||||
|
"min_duration_secs": number | null,
|
||||||
|
"max_duration_secs": number | null,
|
||||||
|
"collections": []
|
||||||
|
},
|
||||||
|
"strategy": "random" | "sequential" | "best_fit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recycle_policy": {
|
||||||
|
"cooldown_days": number | null,
|
||||||
|
"cooldown_generations": number | null,
|
||||||
|
"min_available_ratio": number
|
||||||
|
}
|
||||||
|
}`}</Pre>
|
||||||
|
<Note>
|
||||||
|
Genre and tag names must exactly match what Jellyfin uses in your
|
||||||
|
library. After importing, verify filter fields against your Jellyfin
|
||||||
|
library before generating a schedule.
|
||||||
|
</Note>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="tv-page">
|
||||||
|
<H2>Watching TV</H2>
|
||||||
|
<P>
|
||||||
|
Open <Code>/tv</Code> to start watching. No login required. The
|
||||||
|
player tunes to the first channel and syncs to the current broadcast
|
||||||
|
position automatically — you join mid-show, just like real TV.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Keyboard shortcuts</H3>
|
||||||
|
<Table
|
||||||
|
head={["Key", "Action"]}
|
||||||
|
rows={[
|
||||||
|
["Arrow Up / Page Up", "Next channel"],
|
||||||
|
["Arrow Down / Page Down", "Previous channel"],
|
||||||
|
["G", "Toggle the program guide"],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H3>Overlays</H3>
|
||||||
|
<P>
|
||||||
|
Move your mouse or press any key to reveal the on-screen overlays.
|
||||||
|
They fade after a few seconds of inactivity.
|
||||||
|
</P>
|
||||||
|
<Ul>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Bottom-left</strong> — channel
|
||||||
|
info: what is playing, episode details, description, genre tags,
|
||||||
|
and a progress bar with start/end times.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Bottom-right</strong> — channel
|
||||||
|
controls (previous / next).
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
<strong className="text-zinc-300">Top-right</strong> — Guide
|
||||||
|
toggle and CC button (when subtitles are available).
|
||||||
|
</Li>
|
||||||
|
</Ul>
|
||||||
|
|
||||||
|
<H3>Program guide</H3>
|
||||||
|
<P>
|
||||||
|
Press <strong className="text-zinc-300">G</strong> or click the
|
||||||
|
Guide button to open the upcoming schedule for the current channel.
|
||||||
|
Colour-coded blocks show each slot; the current item is highlighted.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Subtitles (CC)</H3>
|
||||||
|
<P>
|
||||||
|
When the playing item has subtitle tracks in its HLS stream, a{" "}
|
||||||
|
<strong className="text-zinc-300">CC</strong> button appears in the
|
||||||
|
top-right corner. Click it to pick a language track or turn
|
||||||
|
subtitles off. The button is highlighted when subtitles are active.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Up next banner</H3>
|
||||||
|
<P>
|
||||||
|
When the current item is more than 80% complete, an "Up next" banner
|
||||||
|
appears at the bottom showing the next item's title and start time.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Autoplay after page refresh</H3>
|
||||||
|
<P>
|
||||||
|
Browsers block video autoplay on page refresh until the user
|
||||||
|
interacts with the page. Move your mouse or press any key after
|
||||||
|
refreshing and playback resumes immediately.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
<Section id="troubleshooting">
|
||||||
|
<H2>Troubleshooting</H2>
|
||||||
|
|
||||||
|
<H3>Schedule generation fails</H3>
|
||||||
|
<P>
|
||||||
|
Check that <Code>JELLYFIN_BASE_URL</Code>,{" "}
|
||||||
|
<Code>JELLYFIN_API_KEY</Code>, and <Code>JELLYFIN_USER_ID</Code> are
|
||||||
|
all set. The backend logs a warning on startup when any are missing.
|
||||||
|
Confirm the Jellyfin server is reachable from the machine running
|
||||||
|
the backend.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Video won't play / stream error</H3>
|
||||||
|
<P>
|
||||||
|
Click <strong className="text-zinc-300">Retry</strong> on the error
|
||||||
|
screen. If it keeps failing, check that Jellyfin is online and the
|
||||||
|
API key has not been revoked. For transcoding errors, check the
|
||||||
|
Jellyfin dashboard for active sessions and codec errors in its logs.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Block fills with no items</H3>
|
||||||
|
<P>
|
||||||
|
Your filter is too strict or Jellyfin returned nothing matching.
|
||||||
|
Try:
|
||||||
|
</P>
|
||||||
|
<Ul>
|
||||||
|
<Li>Removing one filter at a time to find the culprit.</Li>
|
||||||
|
<Li>
|
||||||
|
Verifying genre/tag names match Jellyfin exactly — they are
|
||||||
|
case-sensitive.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
Clearing <Code>collections</Code> to search all libraries.
|
||||||
|
</Li>
|
||||||
|
<Li>
|
||||||
|
Lowering <Code>min_available_ratio</Code> if the recycle cooldown
|
||||||
|
is excluding too many items.
|
||||||
|
</Li>
|
||||||
|
</Ul>
|
||||||
|
|
||||||
|
<H3>Channel shows no signal</H3>
|
||||||
|
<P>
|
||||||
|
No signal means there is no scheduled slot at the current time.
|
||||||
|
Either no schedule has been generated yet (click Generate on the
|
||||||
|
Dashboard), or the current time falls in a gap between blocks. Add a
|
||||||
|
block covering the current time and regenerate.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>CORS errors in the browser</H3>
|
||||||
|
<P>
|
||||||
|
Make sure <Code>CORS_ALLOWED_ORIGINS</Code> contains the exact
|
||||||
|
origin of the frontend — scheme, hostname, and port, no trailing
|
||||||
|
slash. Example: <Code>https://ktv.example.com</Code>. Wildcards are
|
||||||
|
not supported.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>Subtitles not showing</H3>
|
||||||
|
<P>
|
||||||
|
The CC button only appears when Jellyfin includes subtitle tracks in
|
||||||
|
the HLS manifest. Verify the media item has external subtitle files
|
||||||
|
(SRT/ASS) associated in Jellyfin. Image-based subtitles (PGS/VOBSUB
|
||||||
|
from Blu-ray sources) are not supported by the HLS path.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NavAuth } from "./components/nav-auth";
|
|||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: "/tv", label: "TV" },
|
{ href: "/tv", label: "TV" },
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
|
{ href: "/docs", label: "Docs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user