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({
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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.0–1.0 · Fraction of the pool kept selectable even if cooldown is active"
|
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
|
<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>
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user