frontend: align types to backend, playout HLS, rm Jellyfin proxy (#12)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.0–1.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>
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user