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>

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ScheduleSlot } from "@/app/(main)/tv/components";
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
// ---------------------------------------------------------------------------
/**
* Resolves the live stream URL for a channel, starting at the correct
* broadcast offset so refresh doesn't replay from the beginning.
*
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to
* the Jellyfin stream URL. Since browsers can't read redirect Location headers
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that
* runs server-side) and return the final URL as JSON.
*
* 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,
});
const PLAYOUT_URL =
process.env.NEXT_PUBLIC_PLAYOUT_URL ?? "http://localhost:9090";
export function useStreamUrl(channelId: string | undefined) {
if (!channelId) return { data: null, isLoading: false, error: null };
return {
data: `${PLAYOUT_URL}/playout/${channelId}/playlist.m3u8`,
isLoading: false,
error: null,
};
}

View File

@@ -6,7 +6,7 @@ export function exportChannel(channel: ChannelResponse): void {
description: channel.description ?? undefined,
timezone: channel.timezone,
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)], {
type: "application/json",

View File

@@ -26,9 +26,7 @@ export const mediaFilterSchema = z.object({
export const accessModeSchema = z.enum([
"public",
"password_protected",
"account_required",
"owner_only",
"private",
]);
export const blockSchema = z.object({
@@ -40,7 +38,7 @@ export const blockSchema = z.object({
z.object({
type: z.literal("algorithmic"),
filter: mediaFilterSchema,
strategy: z.enum(["best_fit", "sequential", "random"]),
strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]),
provider_id: z.string().optional(),
}),
z.object({
@@ -50,7 +48,7 @@ export const blockSchema = z.object({
}),
]),
loop_on_finish: z.boolean().optional(),
ignore_recycle_policy: z.boolean().optional(),
ignore_rotation_policy: z.boolean().optional(),
access_mode: accessModeSchema.optional(),
access_password: z.string().optional(),
});
@@ -63,7 +61,7 @@ export const channelFormSchema = z.object({
.default(() =>
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_generations: z.number().int().min(0).nullable().optional(),
min_available_ratio: z

View File

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