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,
});