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({ export function AccessSettingsEditor({
accessMode, accessMode,
accessPassword,
onAccessModeChange, onAccessModeChange,
onAccessPasswordChange,
label = "Access", label = "Access",
passwordLabel = "Password",
passwordHint = "Leave blank to keep existing password",
}: AccessSettingsEditorProps) { }: AccessSettingsEditorProps) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -25,33 +21,13 @@ export function AccessSettingsEditor({
<label className="block text-xs font-medium text-zinc-400">{label}</label> <label className="block text-xs font-medium text-zinc-400">{label}</label>
<select <select
value={accessMode} value={accessMode}
onChange={(e) => { onChange={(e) => onAccessModeChange(e.target.value as AccessMode)}
onAccessModeChange(e.target.value as AccessMode);
onAccessPasswordChange("");
}}
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" 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="public">Public</option>
<option value="password_protected">Password protected</option> <option value="private">Private</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</select> </select>
</div> </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> </div>
); );
} }

View File

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

View File

@@ -19,7 +19,6 @@ interface CreateChannelDialogProps {
timezone: string; timezone: string;
description: string; description: string;
access_mode?: AccessMode; access_mode?: AccessMode;
access_password?: string;
}) => void; }) => void;
isPending: boolean; isPending: boolean;
error?: string | null; error?: string | null;
@@ -36,7 +35,6 @@ export function CreateChannelDialog({
const [timezone, setTimezone] = useState("UTC"); const [timezone, setTimezone] = useState("UTC");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [accessMode, setAccessMode] = useState<AccessMode>("public"); const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -45,7 +43,6 @@ export function CreateChannelDialog({
timezone, timezone,
description, description,
access_mode: accessMode !== "public" ? accessMode : undefined, access_mode: accessMode !== "public" ? accessMode : undefined,
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
}); });
}; };
@@ -57,7 +54,6 @@ export function CreateChannelDialog({
setTimezone("UTC"); setTimezone("UTC");
setDescription(""); setDescription("");
setAccessMode("public"); 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" 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="public">Public</option>
<option value="password_protected">Password protected</option> <option value="private">Private</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</select> </select>
</div> </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>} {error && <p className="text-xs text-red-400">{error}</p>}
<DialogFooter> <DialogFooter>
@@ -151,7 +132,7 @@ export function CreateChannelDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? "Creating" : "Create channel"} {isPending ? "Creating..." : "Create channel"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

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

View File

@@ -11,7 +11,7 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; 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 // 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(), max_duration_secs: z.number().nullable().optional(),
collections: z.array(z.string()).default([]), 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({ z.object({
type: z.literal("manual"), type: z.literal("manual"),
@@ -46,7 +46,7 @@ const importBlockSchema = z.object({
]), ]),
}); });
const recyclePolicySchema = z const rotationPolicySchema = z
.object({ .object({
cooldown_days: z.number().int().min(0).nullable().optional(), cooldown_days: z.number().int().min(0).nullable().optional(),
cooldown_generations: 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"), timezone: z.string().default("UTC"),
blocks: z.array(importBlockSchema).optional(), blocks: z.array(importBlockSchema).optional(),
schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(), schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(),
recycle_policy: recyclePolicySchema, rotation_policy: rotationPolicySchema,
}) })
.transform((d) => ({ .transform((d) => ({
name: d.name, name: d.name,
@@ -72,7 +72,7 @@ const importSchema = z
...b, ...b,
id: b.id ?? crypto.randomUUID(), id: b.id ?? crypto.randomUUID(),
})) as ProgrammingBlock[], })) as ProgrammingBlock[],
recycle_policy: d.recycle_policy as RecyclePolicy, rotation_policy: d.rotation_policy as RotationPolicy,
})); }));
export type ChannelImportData = z.output<typeof importSchema>; 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"; import type { FieldErrors } from "@/lib/schemas";
function NumberInput({ function NumberInput({
@@ -58,17 +58,17 @@ function Field({
); );
} }
interface RecyclePolicyEditorProps { interface RotationPolicyEditorProps {
policy: RecyclePolicy; policy: RotationPolicy;
errors: FieldErrors; errors: FieldErrors;
onChange: (policy: RecyclePolicy) => void; onChange: (policy: RotationPolicy) => void;
} }
export function RecyclePolicyEditor({ export function RotationPolicyEditor({
policy, policy,
errors, errors,
onChange, onChange,
}: RecyclePolicyEditorProps) { }: RotationPolicyEditorProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -96,7 +96,7 @@ export function RecyclePolicyEditor({
<Field <Field
label="Min available ratio" label="Min available ratio"
hint="0.01.0 · Fraction of the pool kept selectable even if cooldown is active" 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 <NumberInput
value={policy.min_available_ratio} value={policy.min_available_ratio}
@@ -107,7 +107,7 @@ export function RecyclePolicyEditor({
max={1} max={1}
step={0.01} step={0.01}
placeholder="0.1" placeholder="0.1"
error={!!errors["recycle_policy.min_available_ratio"]} error={!!errors["rotation_policy.min_available_ratio"]}
/> />
</Field> </Field>
</div> </div>

View File

@@ -6,12 +6,12 @@ import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types"; import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
import { BLOCK_COLORS } from "./block-timeline"; 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> { function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
const seen = new Map<string, string>(); const seen = new Map<string, string>();
slots.forEach((slot) => { slots.forEach((slot) => {
if (!seen.has(slot.block_id)) { if (!seen.has(slot.source_block_id)) {
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]); seen.set(slot.source_block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
} }
}); });
return seen; return seen;
@@ -66,7 +66,7 @@ function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime()); const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100; const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
const widthPct = ((clampedEnd - clampedStart) / 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 startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const endTime = slotEnd.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> <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"> <div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
{schedule.slots.map((slot) => { {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, { const start = new Date(slot.start_at).toLocaleString(undefined, {
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false, 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 { import type {
ChannelResponse, ChannelResponse,
ProgrammingBlock, ProgrammingBlock,
RecyclePolicy, RotationPolicy,
Weekday, Weekday,
} from "@/lib/types"; } from "@/lib/types";
@@ -67,7 +67,6 @@ export default function DashboardPage() {
timezone: string; timezone: string;
description: string; description: string;
access_mode?: import("@/lib/types").AccessMode; access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
}) => { }) => {
createChannel.mutate( createChannel.mutate(
{ {
@@ -75,7 +74,6 @@ export default function DashboardPage() {
timezone: data.timezone, timezone: data.timezone,
description: data.description || undefined, description: data.description || undefined,
access_mode: data.access_mode, access_mode: data.access_mode,
access_password: data.access_password,
}, },
{ onSuccess: () => setCreateOpen(false) }, { onSuccess: () => setCreateOpen(false) },
); );
@@ -88,12 +86,11 @@ export default function DashboardPage() {
description: string; description: string;
timezone: string; timezone: string;
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> }; schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
recycle_policy: RecyclePolicy; rotation_policy: RotationPolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode?: import("@/lib/types").AccessMode; access_mode?: string;
access_password?: string;
logo?: string | null; logo?: string | null;
logo_position?: import("@/lib/types").LogoPosition; logo_position?: string;
logo_opacity?: number; logo_opacity?: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;

View File

@@ -128,7 +128,7 @@ const TOC = [
{ id: "blocks", label: "Programming blocks" }, { id: "blocks", label: "Programming blocks" },
{ id: "filters", label: "Filters reference" }, { id: "filters", label: "Filters reference" },
{ id: "strategies", label: "Fill strategies" }, { id: "strategies", label: "Fill strategies" },
{ id: "recycle-policy", label: "Recycle policy" }, { id: "rotation-policy", label: "Rotation policy" },
{ id: "import-export", label: "Import & export" }, { id: "import-export", label: "Import & export" },
{ id: "iptv", label: "IPTV export" }, { id: "iptv", label: "IPTV export" },
{ id: "access-control", label: "Access control" }, { id: "access-control", label: "Access control" },
@@ -356,6 +356,11 @@ npm run dev`}</Pre>
"Falls back to NEXT_PUBLIC_API_URL", "Falls back to NEXT_PUBLIC_API_URL",
"Server-side API URL used by Next.js API routes. Set this if the frontend container reaches the backend via a private hostname.", "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>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
<Section id="recycle-policy"> <Section id="rotation-policy">
<H2>Recycle policy</H2> <H2>Rotation policy</H2>
<P> <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 across schedule generations, preventing a small library from cycling
the same content every day. the same content every day.
</P> </P>
@@ -807,7 +812,7 @@ Authorization: Bearer <token>
<P> <P>
Click the download icon on any channel card in the Dashboard. A{" "} Click the download icon on any channel card in the Dashboard. A{" "}
<Code>.json</Code> file is saved containing the channel name, <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> </P>
<H3>Importing</H3> <H3>Importing</H3>
@@ -848,7 +853,7 @@ Authorization: Bearer <token>
} }
} }
], ],
"recycle_policy": { "rotation_policy": {
"cooldown_days": 7, "cooldown_days": 7,
"cooldown_generations": null, "cooldown_generations": null,
"min_available_ratio": 0.15 "min_available_ratio": 0.15
@@ -886,11 +891,11 @@ Output only valid JSON matching this structure:
"max_duration_secs": number | null, "max_duration_secs": number | null,
"collections": [] "collections": []
}, },
"strategy": "random" | "sequential" | "best_fit" "strategy": "random" | "sequential" | "best_fit" | "alternating" | "weighted" | "marathon"
} }
} }
], ],
"recycle_policy": { "rotation_policy": {
"cooldown_days": number | null, "cooldown_days": number | null,
"cooldown_generations": number | null, "cooldown_generations": number | null,
"min_available_ratio": number "min_available_ratio": number
@@ -963,34 +968,11 @@ Output only valid JSON matching this structure:
"Anyone can watch. This is the default.", "Anyone can watch. This is the default.",
], ],
[ [
<Code key="pp">password_protected</Code>, <Code key="priv">private</Code>,
"Viewers must enter a password before the stream plays.", "Only authenticated users can watch.",
],
[
<Code key="ar">account_required</Code>,
"Viewers must be logged in to any K-TV account.",
],
[
<Code key="oo">owner_only</Code>,
"Only the channel owner can watch.",
], ],
]} ]}
/> />
<H3>Setting a password</H3>
<P>
When <Code>access_mode</Code> is{" "}
<Code>password_protected</Code>, enter a value in the{" "}
<strong className="text-zinc-300">Password</strong> field in the
edit sheet. Leave the field blank to remove an existing password.
</P>
<Warn>
Channel passwords are not end-to-end encrypted. They prevent casual
access someone who can intercept network traffic or extract the
JWT from an IPTV URL can still reach the stream. Do not use channel
passwords as the sole protection for sensitive content.
</Warn>
</Section> </Section>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
@@ -1210,7 +1192,7 @@ Output only valid JSON matching this structure:
Clearing <Code>collections</Code> to search all libraries. Clearing <Code>collections</Code> to search all libraries.
</Li> </Li>
<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. is excluding too many items.
</Li> </Li>
</Ul> </Ul>

View File

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

View File

@@ -89,8 +89,6 @@ function TvPageContent() {
error: broadcastError, error: broadcastError,
} = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword); } = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword);
const blockPassword = passwords.getBlockPassword(broadcast?.slot.id);
const { data: epgSlots } = useEpg( const { data: epgSlots } = useEpg(
channel?.id ?? "", channel?.id ?? "",
undefined, undefined,
@@ -102,14 +100,7 @@ function TvPageContent() {
const volume = useVolume(videoRef, isCasting); const volume = useVolume(videoRef, isCasting);
const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id); const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id);
const { data: streamUrl, error: streamUrlError } = useStreamUrl( const { data: streamUrl, error: streamUrlError } = useStreamUrl(channel?.id);
channel?.id,
token,
broadcast?.slot.id,
passwords.channelPassword,
blockPassword,
quality.quality,
);
const channelCount = channels?.length ?? 0; const channelCount = channels?.length ?? 0;
@@ -176,13 +167,6 @@ function TvPageContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastError]); }, [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 // Clear transient states when slot changes
useEffect(() => { useEffect(() => {
setStreamError(false); 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> </h3>
<p className="text-sm leading-relaxed text-zinc-400"> <p className="text-sm leading-relaxed text-zinc-400">
Draw time blocks on a 24-hour timeline. Each block has its own 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. generated on demand and valid for 48 hours.
</p> </p>
</div> </div>

View File

@@ -8,7 +8,7 @@ import type {
LogoPosition, LogoPosition,
ProgrammingBlock, ProgrammingBlock,
MediaFilter, MediaFilter,
RecyclePolicy, RotationPolicy,
Weekday, Weekday,
} from "@/lib/types"; } from "@/lib/types";
import { WEEKDAYS } from "@/lib/types"; import { WEEKDAYS } from "@/lib/types";
@@ -51,7 +51,7 @@ export function defaultBlock(startMins = 20 * 60, durationMins = 60): Programmin
duration_mins: durationMins, duration_mins: durationMins,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_rotation_policy: false,
access_mode: "public", access_mode: "public",
}; };
} }
@@ -67,7 +67,7 @@ export function useChannelForm(channel: ChannelResponse | null) {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [timezone, setTimezone] = useState("UTC"); const [timezone, setTimezone] = useState("UTC");
const [dayBlocks, setDayBlocks] = useState<Record<Weekday, ProgrammingBlock[]>>(emptyDayBlocks); const [dayBlocks, setDayBlocks] = useState<Record<Weekday, ProgrammingBlock[]>>(emptyDayBlocks);
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({ const [rotationPolicy, setRotationPolicy] = useState<RotationPolicy>({
cooldown_days: null, cooldown_days: null,
cooldown_generations: null, cooldown_generations: null,
min_available_ratio: 0.1, min_available_ratio: 0.1,
@@ -96,12 +96,12 @@ export function useChannelForm(channel: ChannelResponse | null) {
...emptyDayBlocks(), ...emptyDayBlocks(),
...channel.schedule_config.day_blocks, ...channel.schedule_config.day_blocks,
}); });
setRecyclePolicy(channel.recycle_policy); setRotationPolicy(channel.rotation_policy);
setAutoSchedule(channel.auto_schedule); setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public"); setAccessMode((channel.access_mode as AccessMode) ?? "public");
setAccessPassword(""); setAccessPassword("");
setLogo(channel.logo ?? null); setLogo(channel.logo ?? null);
setLogoPosition(channel.logo_position ?? "top_right"); setLogoPosition((channel.logo_position as LogoPosition) ?? "top_right");
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100)); setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
setWebhookUrl(channel.webhook_url ?? ""); setWebhookUrl(channel.webhook_url ?? "");
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5); setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
@@ -164,7 +164,7 @@ export function useChannelForm(channel: ChannelResponse | null) {
// Blocks (day-keyed) // Blocks (day-keyed)
dayBlocks, setDayBlocks, dayBlocks, setDayBlocks,
selectedBlockId, setSelectedBlockId, selectedBlockId, setSelectedBlockId,
recyclePolicy, setRecyclePolicy, rotationPolicy, setRotationPolicy,
addBlock, addBlock,
updateBlock, updateBlock,
removeBlock, removeBlock,

View File

@@ -33,7 +33,7 @@ export function useImportChannel(token: string | null) {
WEEKDAYS.map(d => [d, d === 'monday' ? data.blocks : []]) WEEKDAYS.map(d => [d, d === 'monday' ? data.blocks : []])
) as Record<Weekday, typeof data.blocks>, ) as Record<Weekday, typeof data.blocks>,
}, },
recycle_policy: data.recycle_policy, rotation_policy: data.rotation_policy,
}, },
token, token,
); );

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import type { ScheduleSlot } from "@/app/(main)/tv/components"; import type { ScheduleSlot } from "@/app/(main)/tv/components";
import type { ScheduledSlotResponse } from "@/lib/types"; import type { ScheduledSlotResponse } from "@/lib/types";
@@ -99,63 +98,17 @@ export function findNextSlot(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// useStreamUrl — resolves the 307 stream redirect via a Next.js API route // useStreamUrl — HLS playlist from the Playout Service
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** const PLAYOUT_URL =
* Resolves the live stream URL for a channel, starting at the correct process.env.NEXT_PUBLIC_PLAYOUT_URL ?? "http://localhost:9090";
* broadcast offset so refresh doesn't replay from the beginning.
* export function useStreamUrl(channelId: string | undefined) {
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to if (!channelId) return { data: null, isLoading: false, error: null };
* the Jellyfin stream URL. Since browsers can't read redirect Location headers return {
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that data: `${PLAYOUT_URL}/playout/${channelId}/playlist.m3u8`,
* runs server-side) and return the final URL as JSON. isLoading: false,
* error: null,
* slotId is included in the query key so the URL is re-fetched automatically };
* when the current item changes (the next scheduled item starts playing).
* Within the same slot, the URL stays stable — no mid-item restarts.
*
* Returns null when the channel is in a gap (no-signal / 204).
*/
/**
* Resolves the stream URL for the current slot, with StartTimeTicks set so
* Jellyfin begins transcoding at the correct broadcast offset.
*
* slotId is in the query key: the URL refetches when the item changes (new
* slot), but stays stable while the same slot is playing — no mid-item
* restarts. offsetSecs is captured once when the query first runs for a
* given slot, so 30-second broadcast refetches don't disturb playback.
*/
export function useStreamUrl(
channelId: string | undefined,
token: string | null,
slotId: string | undefined,
channelPassword?: string,
blockPassword?: string,
bitrateBps?: number,
) {
return useQuery({
queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword, bitrateBps],
queryFn: async (): Promise<string | null> => {
const params = new URLSearchParams();
if (token) params.set("token", token);
if (channelPassword) params.set("channel_password", channelPassword);
if (blockPassword) params.set("block_password", blockPassword);
if (quality) params.set("quality", quality);
const res = await fetch(`/api/stream/${channelId}?${params}`, {
cache: "no-store",
});
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error ?? `Stream resolve failed: ${res.status}`;
throw new Error(msg);
}
const { url } = (await res.json()) as { url: string };
return bitrateBps ? `${url}&VideoBitRate=${bitrateBps}` : url;
},
enabled: !!channelId && !!slotId,
staleTime: Infinity,
retry: false,
});
} }

View File

@@ -6,7 +6,7 @@ export function exportChannel(channel: ChannelResponse): void {
description: channel.description ?? undefined, description: channel.description ?? undefined,
timezone: channel.timezone, timezone: channel.timezone,
day_blocks: channel.schedule_config.day_blocks, day_blocks: channel.schedule_config.day_blocks,
recycle_policy: channel.recycle_policy, rotation_policy: channel.rotation_policy,
}; };
const blob = new Blob([JSON.stringify(payload, null, 2)], { const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json", type: "application/json",

View File

@@ -26,9 +26,7 @@ export const mediaFilterSchema = z.object({
export const accessModeSchema = z.enum([ export const accessModeSchema = z.enum([
"public", "public",
"password_protected", "private",
"account_required",
"owner_only",
]); ]);
export const blockSchema = z.object({ export const blockSchema = z.object({
@@ -40,7 +38,7 @@ export const blockSchema = z.object({
z.object({ z.object({
type: z.literal("algorithmic"), type: z.literal("algorithmic"),
filter: mediaFilterSchema, filter: mediaFilterSchema,
strategy: z.enum(["best_fit", "sequential", "random"]), strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]),
provider_id: z.string().optional(), provider_id: z.string().optional(),
}), }),
z.object({ z.object({
@@ -50,7 +48,7 @@ export const blockSchema = z.object({
}), }),
]), ]),
loop_on_finish: z.boolean().optional(), loop_on_finish: z.boolean().optional(),
ignore_recycle_policy: z.boolean().optional(), ignore_rotation_policy: z.boolean().optional(),
access_mode: accessModeSchema.optional(), access_mode: accessModeSchema.optional(),
access_password: z.string().optional(), access_password: z.string().optional(),
}); });
@@ -63,7 +61,7 @@ export const channelFormSchema = z.object({
.default(() => .default(() =>
Object.fromEntries(WEEKDAYS.map(d => [d, []])) as unknown as Record<Weekday, z.infer<typeof blockSchema>[]> Object.fromEntries(WEEKDAYS.map(d => [d, []])) as unknown as Record<Weekday, z.infer<typeof blockSchema>[]>
), ),
recycle_policy: z.object({ rotation_policy: z.object({
cooldown_days: z.number().int().min(0).nullable().optional(), cooldown_days: z.number().int().min(0).nullable().optional(),
cooldown_generations: z.number().int().min(0).nullable().optional(), cooldown_generations: z.number().int().min(0).nullable().optional(),
min_available_ratio: z min_available_ratio: z

View File

@@ -1,5 +1,3 @@
// API response and request types matching the backend DTOs
export interface ActivityEvent { export interface ActivityEvent {
id: string; id: string;
timestamp: string; timestamp: string;
@@ -17,11 +15,11 @@ export interface LogLine {
export type ContentType = "movie" | "episode" | "short"; export type ContentType = "movie" | "episode" | "short";
export type AccessMode = "public" | "password_protected" | "account_required" | "owner_only"; export type AccessMode = "public" | "private";
export type LogoPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right"; export type LogoPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right";
export type FillStrategy = "best_fit" | "sequential" | "random"; export type FillStrategy = "best_fit" | "sequential" | "random" | "alternating" | "weighted" | "marathon";
export interface MediaFilter { export interface MediaFilter {
content_type?: ContentType | null; content_type?: ContentType | null;
@@ -31,9 +29,7 @@ export interface MediaFilter {
min_duration_secs?: number | null; min_duration_secs?: number | null;
max_duration_secs?: number | null; max_duration_secs?: number | null;
collections: string[]; collections: string[];
/** Filter to one or more TV series by name. OR-combined: any listed show is eligible. */
series_names?: string[]; series_names?: string[];
/** Free-text search, used for library browsing only. */
search_term?: string | null; search_term?: string | null;
} }
@@ -55,22 +51,43 @@ export interface SeriesResponse {
export interface LibraryItemResponse { export interface LibraryItemResponse {
id: string; id: string;
provider_id: string;
external_id: string;
title: string; title: string;
content_type: ContentType; content_type: string;
duration_secs: number; duration_secs: number;
series_name?: string | null; series_name?: string | null;
season_number?: number | null; season_number?: number | null;
episode_number?: number | null; episode_number?: number | null;
year?: number | null; year?: number | null;
genres: string[]; genres: string[];
tags: string[];
collection_id?: string | null;
collection_name?: string | null;
collection_type?: string | null;
thumbnail_url?: string | null;
synced_at?: string | null;
} }
export interface RecyclePolicy { export interface RotationPolicy {
cooldown_days?: number | null; cooldown_days?: number | null;
cooldown_generations?: number | null; cooldown_generations?: number | null;
min_available_ratio: number; min_available_ratio: number;
} }
export interface InterstitialRule {
pool_filter: MediaFilter;
strategy: FillStrategy;
min_gap_secs: number;
}
export interface MidRollRule {
prefer_chapters: boolean;
fallback_interval_mins: number;
break_duration_secs: number;
pool_filter: MediaFilter;
}
export type BlockContent = export type BlockContent =
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string } | { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string }
| { type: "manual"; items: string[]; provider_id?: string }; | { type: "manual"; items: string[]; provider_id?: string };
@@ -78,16 +95,14 @@ export type BlockContent =
export interface ProgrammingBlock { export interface ProgrammingBlock {
id: string; id: string;
name: string; name: string;
/** "HH:MM:SS" */
start_time: string; start_time: string;
duration_mins: number; duration_mins: number;
content: BlockContent; content: BlockContent;
/** Sequential only: loop back to episode 1 after the last episode. Default true on backend. */
loop_on_finish?: boolean; loop_on_finish?: boolean;
/** When true, skip the channel-level recycle policy for this block. Default false on backend. */ ignore_rotation_policy?: boolean;
ignore_recycle_policy?: boolean; interstitial_rule?: InterstitialRule | null;
mid_roll_rule?: MidRollRule | null;
access_mode?: AccessMode; access_mode?: AccessMode;
/** Plain-text password sent to API; hashed server-side. Only set on write operations. */
access_password?: string; access_password?: string;
} }
@@ -154,9 +169,7 @@ export interface ProviderInfo {
export interface ConfigResponse { export interface ConfigResponse {
allow_registration: boolean; allow_registration: boolean;
/** All registered providers. Added in multi-provider update. */
providers: ProviderInfo[]; providers: ProviderInfo[];
/** Primary provider capabilities — kept for backward compat. */
provider_capabilities: ProviderCapabilities; provider_capabilities: ProviderCapabilities;
available_provider_types: string[]; available_provider_types: string[];
} }
@@ -198,14 +211,14 @@ export interface ChannelResponse {
description?: string | null; description?: string | null;
timezone: string; timezone: string;
schedule_config: ScheduleConfig; schedule_config: ScheduleConfig;
recycle_policy: RecyclePolicy; rotation_policy: RotationPolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode: AccessMode; access_mode: string;
logo?: string | null; logo?: string | null;
logo_position: LogoPosition; logo_position: string;
logo_opacity: number; logo_opacity: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs: number;
webhook_body_template?: string | null; webhook_body_template?: string | null;
webhook_headers?: string | null; webhook_headers?: string | null;
created_at: string; created_at: string;
@@ -229,21 +242,15 @@ export interface UpdateChannelRequest {
description?: string; description?: string;
timezone?: string; timezone?: string;
schedule_config?: ScheduleConfig; schedule_config?: ScheduleConfig;
recycle_policy?: RecyclePolicy; rotation_policy?: RotationPolicy;
auto_schedule?: boolean; auto_schedule?: boolean;
access_mode?: AccessMode; access_mode?: string;
/** Empty string clears the password. */
access_password?: string;
/** null = clear logo */
logo?: string | null; logo?: string | null;
logo_position?: LogoPosition; logo_position?: string;
logo_opacity?: number; logo_opacity?: number;
/** null = clear webhook */
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
/** null = clear template */
webhook_body_template?: string | null; webhook_body_template?: string | null;
/** null = clear headers */
webhook_headers?: string | null; webhook_headers?: string | null;
} }
@@ -252,56 +259,40 @@ export interface UpdateChannelRequest {
export interface MediaItemResponse { export interface MediaItemResponse {
id: string; id: string;
title: string; title: string;
content_type: ContentType; content_type: string;
duration_secs: number; duration_secs: number;
description?: string | null; description?: string | null;
genres: string[]; genres: string[];
tags: string[]; tags: string[];
year?: number | null; year?: number | null;
/** Episodes only: the parent TV show name. */
series_name?: string | null; series_name?: string | null;
/** Episodes only: season number (1-based). */
season_number?: number | null; season_number?: number | null;
/** Episodes only: episode number within the season (1-based). */
episode_number?: number | null; episode_number?: number | null;
} }
export interface ScheduledSlotResponse { export interface ScheduledSlotResponse {
id: string; id: string;
block_id: string;
item: MediaItemResponse;
/** RFC3339 */
start_at: string; start_at: string;
/** RFC3339 */
end_at: string; end_at: string;
block_access_mode: AccessMode; item: MediaItemResponse;
source_block_id: string;
} }
export interface ScheduleResponse { export interface ScheduleResponse {
id: string; id: string;
channel_id: string; channel_id: string;
generation: number;
generated_at: string;
valid_from: string; valid_from: string;
valid_until: string; valid_until: string;
generation: number;
slots: ScheduledSlotResponse[]; slots: ScheduledSlotResponse[];
} }
export interface CurrentBroadcastResponse { export interface CurrentBroadcastResponse {
slot: ScheduledSlotResponse; slot: ScheduledSlotResponse;
offset_secs: number; offset_secs: number;
block_access_mode: AccessMode;
} }
// Library management export type LibraryItemFull = LibraryItemResponse;
// Note: LibraryItemResponse is already defined in this file (search for it above).
// LibraryItemFull extends it with the extra fields returned by the DB-backed endpoint.
export interface LibraryItemFull extends LibraryItemResponse {
thumbnail_url?: string | null;
collection_id?: string | null;
collection_name?: string | null;
}
export interface ShowSummary { export interface ShowSummary {
series_name: string; series_name: string;