frontend: align types to backend, playout HLS, rm Jellyfin proxy (#12)

This commit is contained in:
2026-07-12 15:11:50 +02:00
parent 711f0e4411
commit 5bc1e5e44b
19 changed files with 128 additions and 334 deletions

View File

@@ -12,12 +12,8 @@ interface AccessSettingsEditorProps {
export function AccessSettingsEditor({
accessMode,
accessPassword,
onAccessModeChange,
onAccessPasswordChange,
label = "Access",
passwordLabel = "Password",
passwordHint = "Leave blank to keep existing password",
}: AccessSettingsEditorProps) {
return (
<div className="space-y-2">
@@ -25,33 +21,13 @@ export function AccessSettingsEditor({
<label className="block text-xs font-medium text-zinc-400">{label}</label>
<select
value={accessMode}
onChange={(e) => {
onAccessModeChange(e.target.value as AccessMode);
onAccessPasswordChange("");
}}
onChange={(e) => onAccessModeChange(e.target.value as AccessMode)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
<option value="private">Private</option>
</select>
</div>
{accessMode === "password_protected" && (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">
{passwordLabel}
</label>
<input
type="password"
placeholder={passwordHint}
value={accessPassword}
onChange={(e) => onAccessPasswordChange(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</div>
)}
</div>
);
}

View File

@@ -176,6 +176,9 @@ export function AlgorithmicFilterEditor({
<option value="random">Random</option>
<option value="best_fit">Best fit</option>
<option value="sequential">Sequential</option>
<option value="alternating">Alternating</option>
<option value="weighted">Weighted</option>
<option value="marathon">Marathon</option>
</NativeSelect>
</Field>
</div>

View File

@@ -19,7 +19,6 @@ interface CreateChannelDialogProps {
timezone: string;
description: string;
access_mode?: AccessMode;
access_password?: string;
}) => void;
isPending: boolean;
error?: string | null;
@@ -36,7 +35,6 @@ export function CreateChannelDialog({
const [timezone, setTimezone] = useState("UTC");
const [description, setDescription] = useState("");
const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -45,7 +43,6 @@ export function CreateChannelDialog({
timezone,
description,
access_mode: accessMode !== "public" ? accessMode : undefined,
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
});
};
@@ -57,7 +54,6 @@ export function CreateChannelDialog({
setTimezone("UTC");
setDescription("");
setAccessMode("public");
setAccessPassword("");
}
}
};
@@ -120,25 +116,10 @@ export function CreateChannelDialog({
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
<option value="private">Private</option>
</select>
</div>
{accessMode === "password_protected" && (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">Password</label>
<input
type="password"
value={accessPassword}
onChange={(e) => setAccessPassword(e.target.value)}
placeholder="Channel password"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</div>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
<DialogFooter>
@@ -151,7 +132,7 @@ export function CreateChannelDialog({
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Creating" : "Create channel"}
{isPending ? "Creating..." : "Create channel"}
</Button>
</DialogFooter>
</form>

View File

@@ -11,7 +11,7 @@ import {
import { Button } from "@/components/ui/button";
import { BlockTimeline, BLOCK_COLORS } from "./block-timeline";
import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
import { RecyclePolicyEditor } from "./recycle-policy-editor";
import { RotationPolicyEditor } from "./rotation-policy-editor";
import { WebhookEditor } from "./webhook-editor";
import { AccessSettingsEditor } from "./access-settings-editor";
import { LogoEditor } from "./logo-editor";
@@ -27,7 +27,7 @@ import type {
FillStrategy,
MediaFilter,
ProviderInfo,
RecyclePolicy,
RotationPolicy,
Weekday,
} from "@/lib/types";
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
@@ -261,9 +261,9 @@ function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorP
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={block.ignore_recycle_policy ?? false}
checked={block.ignore_rotation_policy ?? false}
onChange={(e) =>
onChange({ ...block, ignore_recycle_policy: e.target.checked })
onChange({ ...block, ignore_rotation_policy: e.target.checked })
}
className="accent-zinc-400"
/>
@@ -301,7 +301,7 @@ function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorP
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
<p className="text-[11px] text-zinc-600">
One Jellyfin item ID per line, played in order.
One item ID per line, played in order.
</p>
</div>
)}
@@ -339,12 +339,11 @@ interface EditChannelSheetProps {
description: string;
timezone: string;
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
recycle_policy: RecyclePolicy;
rotation_policy: RotationPolicy;
auto_schedule: boolean;
access_mode?: AccessMode;
access_password?: string;
access_mode?: string;
logo?: string | null;
logo_position?: LogoPosition;
logo_position?: string;
logo_opacity?: number;
webhook_url?: string | null;
webhook_poll_interval_secs?: number;
@@ -401,7 +400,7 @@ export function EditChannelSheet({
description: form.description,
timezone: form.timezone,
day_blocks: form.dayBlocks,
recycle_policy: form.recyclePolicy,
rotation_policy: form.rotationPolicy,
auto_schedule: form.autoSchedule,
access_mode: form.accessMode,
access_password: form.accessPassword,
@@ -418,10 +417,9 @@ export function EditChannelSheet({
description: form.description,
timezone: form.timezone,
schedule_config: { day_blocks: form.dayBlocks },
recycle_policy: form.recyclePolicy,
rotation_policy: form.rotationPolicy,
auto_schedule: form.autoSchedule,
access_mode: form.accessMode !== "public" ? form.accessMode : "public",
access_password: form.accessPassword || "",
access_mode: form.accessMode,
logo: form.logo,
logo_position: form.logoPosition,
logo_opacity: form.logoOpacity / 100,
@@ -540,12 +538,12 @@ export function EditChannelSheet({
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Recycle policy
Rotation policy
</h3>
<RecyclePolicyEditor
policy={form.recyclePolicy}
<RotationPolicyEditor
policy={form.rotationPolicy}
errors={fieldErrors}
onChange={form.setRecyclePolicy}
onChange={form.setRotationPolicy}
/>
</section>

View File

@@ -11,7 +11,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { ProgrammingBlock, RecyclePolicy } from "@/lib/types";
import type { ProgrammingBlock, RotationPolicy } from "@/lib/types";
// ---------------------------------------------------------------------------
// Import schema — lenient so LLM output and community exports both work
@@ -37,7 +37,7 @@ const importBlockSchema = z.object({
max_duration_secs: z.number().nullable().optional(),
collections: z.array(z.string()).default([]),
}),
strategy: z.enum(["best_fit", "sequential", "random"]).default("random"),
strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]).default("random"),
}),
z.object({
type: z.literal("manual"),
@@ -46,7 +46,7 @@ const importBlockSchema = z.object({
]),
});
const recyclePolicySchema = z
const rotationPolicySchema = z
.object({
cooldown_days: z.number().int().min(0).nullable().optional(),
cooldown_generations: z.number().int().min(0).nullable().optional(),
@@ -62,7 +62,7 @@ const importSchema = z
timezone: z.string().default("UTC"),
blocks: z.array(importBlockSchema).optional(),
schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(),
recycle_policy: recyclePolicySchema,
rotation_policy: rotationPolicySchema,
})
.transform((d) => ({
name: d.name,
@@ -72,7 +72,7 @@ const importSchema = z
...b,
id: b.id ?? crypto.randomUUID(),
})) as ProgrammingBlock[],
recycle_policy: d.recycle_policy as RecyclePolicy,
rotation_policy: d.rotation_policy as RotationPolicy,
}));
export type ChannelImportData = z.output<typeof importSchema>;

View File

@@ -1,4 +1,4 @@
import type { RecyclePolicy } from "@/lib/types";
import type { RotationPolicy } from "@/lib/types";
import type { FieldErrors } from "@/lib/schemas";
function NumberInput({
@@ -58,17 +58,17 @@ function Field({
);
}
interface RecyclePolicyEditorProps {
policy: RecyclePolicy;
interface RotationPolicyEditorProps {
policy: RotationPolicy;
errors: FieldErrors;
onChange: (policy: RecyclePolicy) => void;
onChange: (policy: RotationPolicy) => void;
}
export function RecyclePolicyEditor({
export function RotationPolicyEditor({
policy,
errors,
onChange,
}: RecyclePolicyEditorProps) {
}: RotationPolicyEditorProps) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
@@ -96,7 +96,7 @@ export function RecyclePolicyEditor({
<Field
label="Min available ratio"
hint="0.01.0 · Fraction of the pool kept selectable even if cooldown is active"
error={errors["recycle_policy.min_available_ratio"]}
error={errors["rotation_policy.min_available_ratio"]}
>
<NumberInput
value={policy.min_available_ratio}
@@ -107,7 +107,7 @@ export function RecyclePolicyEditor({
max={1}
step={0.01}
placeholder="0.1"
error={!!errors["recycle_policy.min_available_ratio"]}
error={!!errors["rotation_policy.min_available_ratio"]}
/>
</Field>
</div>

View File

@@ -6,12 +6,12 @@ import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
import { BLOCK_COLORS } from "./block-timeline";
// Stable color per block_id within a schedule
// Stable color per source_block_id
function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
const seen = new Map<string, string>();
slots.forEach((slot) => {
if (!seen.has(slot.block_id)) {
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
if (!seen.has(slot.source_block_id)) {
seen.set(slot.source_block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
}
});
return seen;
@@ -66,7 +66,7 @@ function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100;
const color = colorMap.get(slot.block_id) ?? "#6b7280";
const color = colorMap.get(slot.source_block_id) ?? "#6b7280";
const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
@@ -201,7 +201,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3>
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
{schedule.slots.map((slot) => {
const color = colorMap.get(slot.block_id) ?? "#6b7280";
const color = colorMap.get(slot.source_block_id) ?? "#6b7280";
const start = new Date(slot.start_at).toLocaleString(undefined, {
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
});

View File

@@ -32,7 +32,7 @@ import { ScheduleHistoryDialog } from "./components/schedule-history-dialog";
import type {
ChannelResponse,
ProgrammingBlock,
RecyclePolicy,
RotationPolicy,
Weekday,
} from "@/lib/types";
@@ -67,7 +67,6 @@ export default function DashboardPage() {
timezone: string;
description: string;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
}) => {
createChannel.mutate(
{
@@ -75,7 +74,6 @@ export default function DashboardPage() {
timezone: data.timezone,
description: data.description || undefined,
access_mode: data.access_mode,
access_password: data.access_password,
},
{ onSuccess: () => setCreateOpen(false) },
);
@@ -88,12 +86,11 @@ export default function DashboardPage() {
description: string;
timezone: string;
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
recycle_policy: RecyclePolicy;
rotation_policy: RotationPolicy;
auto_schedule: boolean;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
access_mode?: string;
logo?: string | null;
logo_position?: import("@/lib/types").LogoPosition;
logo_position?: string;
logo_opacity?: number;
webhook_url?: string | null;
webhook_poll_interval_secs?: number;

View File

@@ -128,7 +128,7 @@ const TOC = [
{ id: "blocks", label: "Programming blocks" },
{ id: "filters", label: "Filters reference" },
{ id: "strategies", label: "Fill strategies" },
{ id: "recycle-policy", label: "Recycle policy" },
{ id: "rotation-policy", label: "Rotation policy" },
{ id: "import-export", label: "Import & export" },
{ id: "iptv", label: "IPTV export" },
{ id: "access-control", label: "Access control" },
@@ -356,6 +356,11 @@ npm run dev`}</Pre>
"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.",
],
[
<Code key="po">NEXT_PUBLIC_PLAYOUT_URL</Code>,
<Code key="po2">http://localhost:9090</Code>,
"Base URL of the Playout Service. The TV page connects directly to this for HLS streams.",
],
]}
/>
@@ -760,10 +765,10 @@ Authorization: Bearer <token>
</Section>
{/* ---------------------------------------------------------------- */}
<Section id="recycle-policy">
<H2>Recycle policy</H2>
<Section id="rotation-policy">
<H2>Rotation policy</H2>
<P>
The recycle policy controls how soon the same item can reappear
The rotation policy controls how soon the same item can reappear
across schedule generations, preventing a small library from cycling
the same content every day.
</P>
@@ -807,7 +812,7 @@ Authorization: Bearer <token>
<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.
timezone, all programming blocks, and the rotation policy.
</P>
<H3>Importing</H3>
@@ -848,7 +853,7 @@ Authorization: Bearer <token>
}
}
],
"recycle_policy": {
"rotation_policy": {
"cooldown_days": 7,
"cooldown_generations": null,
"min_available_ratio": 0.15
@@ -886,11 +891,11 @@ Output only valid JSON matching this structure:
"max_duration_secs": number | null,
"collections": []
},
"strategy": "random" | "sequential" | "best_fit"
"strategy": "random" | "sequential" | "best_fit" | "alternating" | "weighted" | "marathon"
}
}
],
"recycle_policy": {
"rotation_policy": {
"cooldown_days": number | null,
"cooldown_generations": number | null,
"min_available_ratio": number
@@ -963,34 +968,11 @@ Output only valid JSON matching this structure:
"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.",
<Code key="priv">private</Code>,
"Only authenticated users 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>
{/* ---------------------------------------------------------------- */}
@@ -1210,7 +1192,7 @@ Output only valid JSON matching this structure:
Clearing <Code>collections</Code> to search all libraries.
</Li>
<Li>
Lowering <Code>min_available_ratio</Code> if the recycle cooldown
Lowering <Code>min_available_ratio</Code> if the rotation cooldown
is excluding too many items.
</Li>
</Ul>

View File

@@ -1,6 +1,4 @@
import type { LogoPosition } from "@/lib/types";
function logoPositionClass(pos?: LogoPosition) {
function logoPositionClass(pos?: string) {
switch (pos) {
case "top_left":
return "top-0 left-0";
@@ -15,7 +13,7 @@ function logoPositionClass(pos?: LogoPosition) {
interface LogoWatermarkProps {
logo: string;
position?: LogoPosition;
position?: string;
opacity?: number;
}

View File

@@ -89,8 +89,6 @@ function TvPageContent() {
error: broadcastError,
} = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword);
const blockPassword = passwords.getBlockPassword(broadcast?.slot.id);
const { data: epgSlots } = useEpg(
channel?.id ?? "",
undefined,
@@ -102,14 +100,7 @@ function TvPageContent() {
const volume = useVolume(videoRef, isCasting);
const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id);
const { data: streamUrl, error: streamUrlError } = useStreamUrl(
channel?.id,
token,
broadcast?.slot.id,
passwords.channelPassword,
blockPassword,
quality.quality,
);
const { data: streamUrl, error: streamUrlError } = useStreamUrl(channel?.id);
const channelCount = channels?.length ?? 0;
@@ -176,13 +167,6 @@ function TvPageContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastError]);
useEffect(() => {
if ((streamUrlError as Error)?.message === "password_required") {
passwords.setShowBlockPasswordModal(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamUrlError]);
// Clear transient states when slot changes
useEffect(() => {
setStreamError(false);

View File

@@ -1,67 +0,0 @@
import { NextRequest } from "next/server";
// Server-side URL of the K-TV backend (never exposed to the browser).
// Falls back to the public URL if the internal one isn't set.
const API_URL =
process.env.API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"http://localhost:4000/api/v1";
/**
* GET /api/stream/[channelId]?token=<bearer>
*
* Resolves the backend's 307 stream redirect and returns the final
* Jellyfin URL as JSON. Browsers can't read the Location header from a
* redirected fetch, so this server-side route does it for them.
*
* Returns:
* 200 { url: string } — stream URL ready to use as <video src>
* 204 — channel is in a gap (no-signal)
* 401 — missing token
* 502 — backend error
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ channelId: string }> },
) {
const { channelId } = await params;
const token = request.nextUrl.searchParams.get("token");
const channelPassword = request.nextUrl.searchParams.get("channel_password");
const blockPassword = request.nextUrl.searchParams.get("block_password");
const quality = request.nextUrl.searchParams.get("quality");
let res: Response;
try {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
if (blockPassword) headers["X-Block-Password"] = blockPassword;
const backendParams = new URLSearchParams();
if (quality) backendParams.set("quality", quality);
const backendQuery = backendParams.toString() ? `?${backendParams}` : "";
res = await fetch(`${API_URL}/channels/${channelId}/stream${backendQuery}`, {
headers,
redirect: "manual",
});
} catch {
return new Response(null, { status: 502 });
}
if (res.status === 204) {
return new Response(null, { status: 204 });
}
if (res.status === 401 || res.status === 403) {
const body = await res.json().catch(() => ({}));
return Response.json(body, { status: res.status });
}
if (res.status === 307 || res.status === 302 || res.status === 301) {
const location = res.headers.get("Location");
if (location) {
return Response.json({ url: location });
}
}
return new Response(null, { status: 502 });
}

View File

@@ -61,7 +61,7 @@ export default function LandingPage() {
</h3>
<p className="text-sm leading-relaxed text-zinc-400">
Draw time blocks on a 24-hour timeline. Each block has its own
filters, fill strategy, and recycle policy. Schedules are
filters, fill strategy, and rotation policy. Schedules are
generated on demand and valid for 48 hours.
</p>
</div>