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

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

View File

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

View File

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