Channel management and user settings go here.
);
}
diff --git a/k-tv-frontend/app/(main)/layout.tsx b/k-tv-frontend/app/(main)/layout.tsx
index 31afa60..8047e75 100644
--- a/k-tv-frontend/app/(main)/layout.tsx
+++ b/k-tv-frontend/app/(main)/layout.tsx
@@ -1,10 +1,10 @@
import Link from "next/link";
import { type ReactNode } from "react";
+import { NavAuth } from "./components/nav-auth";
const NAV_LINKS = [
{ href: "/tv", label: "TV" },
{ href: "/dashboard", label: "Dashboard" },
- { href: "/docs", label: "Docs" },
];
export default function MainLayout({ children }: { children: ReactNode }) {
@@ -12,21 +12,29 @@ export default function MainLayout({ children }: { children: ReactNode }) {
-
+
K-TV
-
- {NAV_LINKS.map(({ href, label }) => (
-
-
- {label}
-
-
- ))}
-
+
+
+ {NAV_LINKS.map(({ href, label }) => (
+
+
+ {label}
+
+
+ ))}
+
+
+
+
+
{children}
diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx
index cc50bfb..85ea91e 100644
--- a/k-tv-frontend/app/(main)/tv/page.tsx
+++ b/k-tv-frontend/app/(main)/tv/page.tsx
@@ -9,109 +9,80 @@ import {
UpNextBanner,
NoSignal,
} from "./components";
-import type { ScheduleSlot } from "./components";
+import { useAuthContext } from "@/context/auth-context";
+import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
+import {
+ useStreamUrl,
+ fmtTime,
+ calcProgress,
+ minutesUntil,
+ toScheduleSlots,
+ findNextSlot,
+} from "@/hooks/use-tv";
// ---------------------------------------------------------------------------
-// Mock data — replace with TanStack Query hooks once the API is ready
-// ---------------------------------------------------------------------------
-
-interface MockChannel {
- number: number;
- name: string;
- src?: string;
- schedule: ScheduleSlot[];
- current: {
- title: string;
- startTime: string;
- endTime: string;
- progress: number;
- description?: string;
- };
- next: {
- title: string;
- startTime: string;
- minutesUntil: number;
- };
-}
-
-const MOCK_CHANNELS: MockChannel[] = [
- {
- number: 1,
- name: "Cinema Classic",
- schedule: [
- { id: "c1-1", title: "The Maltese Falcon", startTime: "17:00", endTime: "18:45" },
- { id: "c1-2", title: "Casablanca", startTime: "18:45", endTime: "20:30", isCurrent: true },
- { id: "c1-3", title: "Sunset Boulevard", startTime: "20:30", endTime: "22:15" },
- { id: "c1-4", title: "Rear Window", startTime: "22:15", endTime: "00:00" },
- ],
- current: {
- title: "Casablanca",
- startTime: "18:45",
- endTime: "20:30",
- progress: 72,
- description:
- "A cynical American expatriate struggles to decide whether or not he should help his former lover and her fugitive husband escape French Morocco.",
- },
- next: { title: "Sunset Boulevard", startTime: "20:30", minutesUntil: 23 },
- },
- {
- number: 2,
- name: "Nature & Wild",
- schedule: [
- { id: "c2-1", title: "Planet Earth II", startTime: "19:00", endTime: "20:00", isCurrent: true },
- { id: "c2-2", title: "Blue Planet", startTime: "20:00", endTime: "21:00" },
- { id: "c2-3", title: "Africa", startTime: "21:00", endTime: "22:00" },
- ],
- current: {
- title: "Planet Earth II",
- startTime: "19:00",
- endTime: "20:00",
- progress: 85,
- description:
- "David Attenborough explores the world's most iconic landscapes and the remarkable animals that inhabit them.",
- },
- next: { title: "Blue Planet", startTime: "20:00", minutesUntil: 9 },
- },
- {
- number: 3,
- name: "Sci-Fi Zone",
- schedule: [
- { id: "c3-1", title: "2001: A Space Odyssey", startTime: "19:30", endTime: "22:10", isCurrent: true },
- { id: "c3-2", title: "Blade Runner", startTime: "22:10", endTime: "00:17" },
- ],
- current: {
- title: "2001: A Space Odyssey",
- startTime: "19:30",
- endTime: "22:10",
- progress: 40,
- description:
- "After discovering a mysterious artifact, mankind sets off on a quest to find its origins with help from intelligent supercomputer H.A.L. 9000.",
- },
- next: { title: "Blade Runner", startTime: "22:10", minutesUntil: 96 },
- },
-];
-
+// Constants
// ---------------------------------------------------------------------------
const IDLE_TIMEOUT_MS = 3500;
-const BANNER_THRESHOLD = 80; // show "up next" banner when progress ≥ this
+const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
export default function TvPage() {
+ const { token } = useAuthContext();
+
+ // Channel list
+ const { data: channels, isLoading: isLoadingChannels } = useChannels();
+
+ // Channel navigation
const [channelIdx, setChannelIdx] = useState(0);
+ const channel = channels?.[channelIdx];
+
+ // Overlay / idle state
const [showOverlays, setShowOverlays] = useState(true);
const [showSchedule, setShowSchedule] = useState(false);
const idleTimer = useRef
| null>(null);
- const channel = MOCK_CHANNELS[channelIdx];
- const showBanner = channel.current.progress >= BANNER_THRESHOLD;
+ // Tick for live progress calculation (every 30 s is fine for the progress bar)
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ const id = setInterval(() => setTick((n) => n + 1), 30_000);
+ return () => clearInterval(id);
+ }, []);
+
+ // Per-channel data
+ const { data: broadcast, isLoading: isLoadingBroadcast } =
+ useCurrentBroadcast(channel?.id ?? "");
+ const { data: epgSlots } = useEpg(channel?.id ?? "");
+ const { data: streamUrl } = useStreamUrl(channel?.id, token);
// ------------------------------------------------------------------
- // Idle detection — hide overlays after inactivity
+ // Derived display values
// ------------------------------------------------------------------
+
+ const hasBroadcast = !!broadcast;
+ const progress = hasBroadcast
+ ? calcProgress(broadcast.slot.start_at, broadcast.slot.item.duration_secs)
+ : 0;
+
+ const scheduleSlots = toScheduleSlots(epgSlots ?? [], broadcast?.slot.id);
+ const nextSlot = findNextSlot(epgSlots ?? [], broadcast?.slot.id);
+ const showBanner = hasBroadcast && progress >= BANNER_THRESHOLD && !!nextSlot;
+
+ // ------------------------------------------------------------------
+ // Idle detection
+ // ------------------------------------------------------------------
+
const resetIdle = useCallback(() => {
setShowOverlays(true);
if (idleTimer.current) clearTimeout(idleTimer.current);
- idleTimer.current = setTimeout(() => setShowOverlays(false), IDLE_TIMEOUT_MS);
+ idleTimer.current = setTimeout(
+ () => setShowOverlays(false),
+ IDLE_TIMEOUT_MS,
+ );
}, []);
useEffect(() => {
@@ -124,15 +95,18 @@ export default function TvPage() {
// ------------------------------------------------------------------
// Channel switching
// ------------------------------------------------------------------
+
+ const channelCount = channels?.length ?? 0;
+
const prevChannel = useCallback(() => {
- setChannelIdx((i) => (i - 1 + MOCK_CHANNELS.length) % MOCK_CHANNELS.length);
+ setChannelIdx((i) => (i - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1));
resetIdle();
- }, [resetIdle]);
+ }, [channelCount, resetIdle]);
const nextChannel = useCallback(() => {
- setChannelIdx((i) => (i + 1) % MOCK_CHANNELS.length);
+ setChannelIdx((i) => (i + 1) % Math.max(channelCount, 1));
resetIdle();
- }, [resetIdle]);
+ }, [channelCount, resetIdle]);
const toggleSchedule = useCallback(() => {
setShowSchedule((s) => !s);
@@ -142,10 +116,14 @@ export default function TvPage() {
// ------------------------------------------------------------------
// Keyboard shortcuts
// ------------------------------------------------------------------
+
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
- // Don't steal input from focused form elements
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ )
+ return;
switch (e.key) {
case "ArrowUp":
@@ -169,9 +147,46 @@ export default function TvPage() {
return () => window.removeEventListener("keydown", handleKey);
}, [nextChannel, prevChannel, toggleSchedule]);
+ // ------------------------------------------------------------------
+ // Render helpers
+ // ------------------------------------------------------------------
+
+ const renderBase = () => {
+ if (isLoadingChannels) {
+ return ;
+ }
+ if (!channels || channels.length === 0) {
+ return (
+
+ );
+ }
+ if (isLoadingBroadcast) {
+ return ;
+ }
+ if (!hasBroadcast) {
+ return (
+
+ );
+ }
+ if (streamUrl) {
+ return (
+
+ );
+ }
+ // Broadcast exists but stream URL resolving — show no-signal until ready
+ return ;
+ };
+
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
+
return (
{/* ── Base layer ─────────────────────────────────────────────── */}
- {channel.src ? (
-
- ) : (
-
-
-
- )}
+
{renderBase()}
- {/* ── Overlays ───────────────────────────────────────────────── */}
-
- {/* Top-right: guide toggle */}
-
-
+
- {showSchedule ? "Hide guide" : "Guide [G]"}
-
-
+ {/* Top-right: guide toggle */}
+
+
+ {showSchedule ? "Hide guide" : "Guide [G]"}
+
+
- {/* Bottom: banner + info row */}
-
- {showBanner && (
-
- )}
+ {/* Bottom: banner + info row */}
+
+ {showBanner && nextSlot && (
+
+ )}
-
-
-
-
+
+ {hasBroadcast ? (
+
+ ) : (
+ /* Minimal channel badge when no broadcast */
+
+
+
+ {channelIdx + 1}
+
+
+ {channel.name}
+
+
+
+ )}
+
+
+
+
+
-
-
- {/* Schedule overlay — outside the fading div so it has its own visibility */}
- {showOverlays && showSchedule && (
-
-
-
+ {/* Schedule overlay — outside the fading div so it has its own visibility */}
+ {showOverlays && showSchedule && (
+
+
+
+ )}
+ >
)}
);
diff --git a/k-tv-frontend/app/api/stream/[channelId]/route.ts b/k-tv-frontend/app/api/stream/[channelId]/route.ts
new file mode 100644
index 0000000..dcf2c7d
--- /dev/null
+++ b/k-tv-frontend/app/api/stream/[channelId]/route.ts
@@ -0,0 +1,56 @@
+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=
+ *
+ * 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
+ * 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");
+
+ if (!token) {
+ return new Response(null, { status: 401 });
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(`${API_URL}/channels/${channelId}/stream`, {
+ headers: { Authorization: `Bearer ${token}` },
+ redirect: "manual",
+ });
+ } catch {
+ return new Response(null, { status: 502 });
+ }
+
+ if (res.status === 204) {
+ return new Response(null, { status: 204 });
+ }
+
+ 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 });
+}
diff --git a/k-tv-frontend/app/layout.tsx b/k-tv-frontend/app/layout.tsx
index 6ce4e25..c3ceb7a 100644
--- a/k-tv-frontend/app/layout.tsx
+++ b/k-tv-frontend/app/layout.tsx
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "K-TV",
+ description: "Self-hosted linear TV channel orchestration for your media library",
};
export default function RootLayout({
diff --git a/k-tv-frontend/app/providers.tsx b/k-tv-frontend/app/providers.tsx
index e4e8596..7f7aa53 100644
--- a/k-tv-frontend/app/providers.tsx
+++ b/k-tv-frontend/app/providers.tsx
@@ -3,6 +3,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
+import { AuthProvider } from "@/context/auth-context";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -13,13 +14,15 @@ export function Providers({ children }: { children: React.ReactNode }) {
staleTime: 60 * 1000,
},
},
- })
+ }),
);
return (
-
- {children}
-
-
+
+
+ {children}
+
+
+
);
}
diff --git a/k-tv-frontend/context/auth-context.tsx b/k-tv-frontend/context/auth-context.tsx
new file mode 100644
index 0000000..fc27b4f
--- /dev/null
+++ b/k-tv-frontend/context/auth-context.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ type ReactNode,
+} from "react";
+
+const TOKEN_KEY = "k-tv-token";
+
+interface AuthContextValue {
+ token: string | null;
+ /** True once the initial localStorage read has completed */
+ isLoaded: boolean;
+ setToken: (token: string | null) => void;
+}
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [token, setTokenState] = useState(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ useEffect(() => {
+ const stored = localStorage.getItem(TOKEN_KEY);
+ if (stored) setTokenState(stored);
+ setIsLoaded(true);
+ }, []);
+
+ const setToken = (t: string | null) => {
+ setTokenState(t);
+ if (t) {
+ localStorage.setItem(TOKEN_KEY, t);
+ } else {
+ localStorage.removeItem(TOKEN_KEY);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuthContext() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuthContext must be used within AuthProvider");
+ return ctx;
+}
diff --git a/k-tv-frontend/hooks/use-auth.ts b/k-tv-frontend/hooks/use-auth.ts
new file mode 100644
index 0000000..a310a10
--- /dev/null
+++ b/k-tv-frontend/hooks/use-auth.ts
@@ -0,0 +1,60 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { api } from "@/lib/api";
+import { useAuthContext } from "@/context/auth-context";
+
+export function useCurrentUser() {
+ const { token } = useAuthContext();
+ return useQuery({
+ queryKey: ["me"],
+ queryFn: () => api.auth.me(token!),
+ enabled: !!token,
+ retry: false,
+ });
+}
+
+export function useLogin() {
+ const { setToken } = useAuthContext();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ email, password }: { email: string; password: string }) =>
+ api.auth.login(email, password),
+ onSuccess: (data) => {
+ setToken(data.access_token);
+ queryClient.invalidateQueries({ queryKey: ["me"] });
+ router.push("/dashboard");
+ },
+ });
+}
+
+export function useRegister() {
+ const { setToken } = useAuthContext();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ email, password }: { email: string; password: string }) =>
+ api.auth.register(email, password),
+ onSuccess: (data) => {
+ setToken(data.access_token);
+ queryClient.invalidateQueries({ queryKey: ["me"] });
+ router.push("/dashboard");
+ },
+ });
+}
+
+export function useLogout() {
+ const { token, setToken } = useAuthContext();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: () => (token ? api.auth.logout(token) : Promise.resolve()),
+ onSettled: () => {
+ setToken(null);
+ queryClient.clear();
+ router.push("/login");
+ },
+ });
+}
diff --git a/k-tv-frontend/hooks/use-channels.ts b/k-tv-frontend/hooks/use-channels.ts
new file mode 100644
index 0000000..b18759e
--- /dev/null
+++ b/k-tv-frontend/hooks/use-channels.ts
@@ -0,0 +1,102 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import { useAuthContext } from "@/context/auth-context";
+import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types";
+
+export function useChannels() {
+ const { token } = useAuthContext();
+ return useQuery({
+ queryKey: ["channels"],
+ queryFn: () => api.channels.list(token!),
+ enabled: !!token,
+ });
+}
+
+export function useChannel(id: string) {
+ const { token } = useAuthContext();
+ return useQuery({
+ queryKey: ["channel", id],
+ queryFn: () => api.channels.get(id, token!),
+ enabled: !!token && !!id,
+ });
+}
+
+export function useCreateChannel() {
+ const { token } = useAuthContext();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateChannelRequest) =>
+ api.channels.create(data, token!),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["channels"] });
+ },
+ });
+}
+
+export function useUpdateChannel() {
+ const { token } = useAuthContext();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, data }: { id: string; data: UpdateChannelRequest }) =>
+ api.channels.update(id, data, token!),
+ onSuccess: (updated) => {
+ queryClient.invalidateQueries({ queryKey: ["channels"] });
+ queryClient.invalidateQueries({ queryKey: ["channel", updated.id] });
+ },
+ });
+}
+
+export function useDeleteChannel() {
+ const { token } = useAuthContext();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => api.channels.delete(id, token!),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["channels"] });
+ },
+ });
+}
+
+export function useGenerateSchedule() {
+ const { token } = useAuthContext();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (channelId: string) =>
+ api.schedule.generate(channelId, token!),
+ onSuccess: (_, channelId) => {
+ queryClient.invalidateQueries({ queryKey: ["schedule", channelId] });
+ },
+ });
+}
+
+export function useActiveSchedule(channelId: string) {
+ const { token } = useAuthContext();
+ return useQuery({
+ queryKey: ["schedule", channelId],
+ queryFn: () => api.schedule.getActive(channelId, token!),
+ enabled: !!token && !!channelId,
+ retry: false,
+ });
+}
+
+export function useCurrentBroadcast(channelId: string) {
+ const { token } = useAuthContext();
+ return useQuery({
+ queryKey: ["broadcast", channelId],
+ queryFn: () => api.schedule.getCurrentBroadcast(channelId, token!),
+ enabled: !!token && !!channelId,
+ refetchInterval: 30_000,
+ retry: false,
+ });
+}
+
+export function useEpg(channelId: string, from?: string, until?: string) {
+ const { token } = useAuthContext();
+ return useQuery({
+ queryKey: ["epg", channelId, from, until],
+ queryFn: () => api.schedule.getEpg(channelId, token!, from, until),
+ enabled: !!token && !!channelId,
+ });
+}
diff --git a/k-tv-frontend/hooks/use-tv.ts b/k-tv-frontend/hooks/use-tv.ts
new file mode 100644
index 0000000..9bfca69
--- /dev/null
+++ b/k-tv-frontend/hooks/use-tv.ts
@@ -0,0 +1,94 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import type { ScheduleSlot } from "@/app/(main)/tv/components";
+import type { ScheduledSlotResponse } from "@/lib/types";
+
+// ---------------------------------------------------------------------------
+// Pure transformation utilities
+// ---------------------------------------------------------------------------
+
+/** Format an ISO-8601 string to "HH:MM" in the user's local timezone. */
+export function fmtTime(iso: string): string {
+ return new Date(iso).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+}
+
+/** Progress percentage through a slot based on wall-clock time. */
+export function calcProgress(startAt: string, durationSecs: number): number {
+ if (durationSecs <= 0) return 0;
+ const elapsedSecs = (Date.now() - new Date(startAt).getTime()) / 1000;
+ return Math.min(100, Math.max(0, Math.round((elapsedSecs / durationSecs) * 100)));
+}
+
+/** Minutes until a future timestamp (rounded, minimum 0). */
+export function minutesUntil(iso: string): number {
+ return Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
+}
+
+/**
+ * Map EPG slots to the shape expected by ScheduleOverlay.
+ * Marks the slot matching currentSlotId as current.
+ */
+export function toScheduleSlots(
+ slots: ScheduledSlotResponse[],
+ currentSlotId?: string,
+): ScheduleSlot[] {
+ return slots.map((slot) => ({
+ id: slot.id,
+ title: slot.item.title,
+ startTime: fmtTime(slot.start_at),
+ endTime: fmtTime(slot.end_at),
+ isCurrent: slot.id === currentSlotId,
+ }));
+}
+
+/**
+ * Find the slot immediately after the current one.
+ * Returns null if current slot is last or not found.
+ */
+export function findNextSlot(
+ slots: ScheduledSlotResponse[],
+ currentSlotId?: string,
+): ScheduledSlotResponse | null {
+ if (!currentSlotId || slots.length === 0) return null;
+ const idx = slots.findIndex((s) => s.id === currentSlotId);
+ if (idx === -1 || idx === slots.length - 1) return null;
+ return slots[idx + 1];
+}
+
+// ---------------------------------------------------------------------------
+// useStreamUrl — resolves the 307 stream redirect via a Next.js API route
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolves the live stream URL for a channel.
+ *
+ * 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.
+ *
+ * Returns null when the channel is in a gap (no-signal / 204).
+ */
+export function useStreamUrl(channelId: string | undefined, token: string | null) {
+ return useQuery({
+ queryKey: ["stream-url", channelId],
+ queryFn: async (): Promise => {
+ const res = await fetch(
+ `/api/stream/${channelId}?token=${encodeURIComponent(token!)}`,
+ { cache: "no-store" },
+ );
+ if (res.status === 204) return null;
+ if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`);
+ const { url } = await res.json();
+ return url as string;
+ },
+ enabled: !!channelId && !!token,
+ refetchInterval: 30_000,
+ retry: false,
+ });
+}
diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts
new file mode 100644
index 0000000..021effe
--- /dev/null
+++ b/k-tv-frontend/lib/api.ts
@@ -0,0 +1,132 @@
+import type {
+ TokenResponse,
+ UserResponse,
+ ChannelResponse,
+ CreateChannelRequest,
+ UpdateChannelRequest,
+ ScheduleResponse,
+ ScheduledSlotResponse,
+ CurrentBroadcastResponse,
+} from "@/lib/types";
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1";
+
+export class ApiRequestError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "ApiRequestError";
+ }
+}
+
+async function request(
+ path: string,
+ options: RequestInit & { token?: string } = {},
+): Promise {
+ const { token, ...init } = options;
+ const headers = new Headers(init.headers);
+
+ if (token) {
+ headers.set("Authorization", `Bearer ${token}`);
+ }
+ if (init.body && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
+
+ if (!res.ok) {
+ let message = res.statusText;
+ try {
+ const body = await res.json();
+ message = body.message ?? body.error ?? message;
+ } catch {
+ // ignore parse error, use statusText
+ }
+ throw new ApiRequestError(res.status, message);
+ }
+
+ if (res.status === 204) return null as T;
+ return res.json() as Promise;
+}
+
+export const api = {
+ auth: {
+ register: (email: string, password: string) =>
+ request("/auth/register", {
+ method: "POST",
+ body: JSON.stringify({ email, password }),
+ }),
+
+ login: (email: string, password: string) =>
+ request("/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ email, password }),
+ }),
+
+ logout: (token: string) =>
+ request("/auth/logout", { method: "POST", token }),
+
+ me: (token: string) => request("/auth/me", { token }),
+ },
+
+ channels: {
+ list: (token: string) =>
+ request("/channels", { token }),
+
+ get: (id: string, token: string) =>
+ request(`/channels/${id}`, { token }),
+
+ create: (data: CreateChannelRequest, token: string) =>
+ request("/channels", {
+ method: "POST",
+ body: JSON.stringify(data),
+ token,
+ }),
+
+ update: (id: string, data: UpdateChannelRequest, token: string) =>
+ request(`/channels/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ token,
+ }),
+
+ delete: (id: string, token: string) =>
+ request(`/channels/${id}`, { method: "DELETE", token }),
+ },
+
+ schedule: {
+ generate: (channelId: string, token: string) =>
+ request(`/channels/${channelId}/schedule`, {
+ method: "POST",
+ token,
+ }),
+
+ getActive: (channelId: string, token: string) =>
+ request(`/channels/${channelId}/schedule`, { token }),
+
+ getCurrentBroadcast: (channelId: string, token: string) =>
+ request(`/channels/${channelId}/now`, {
+ token,
+ }),
+
+ getEpg: (
+ channelId: string,
+ token: string,
+ from?: string,
+ until?: string,
+ ) => {
+ const params = new URLSearchParams();
+ if (from) params.set("from", from);
+ if (until) params.set("until", until);
+ const qs = params.toString();
+ return request(
+ `/channels/${channelId}/epg${qs ? `?${qs}` : ""}`,
+ { token },
+ );
+ },
+ },
+};
diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts
new file mode 100644
index 0000000..7871f32
--- /dev/null
+++ b/k-tv-frontend/lib/types.ts
@@ -0,0 +1,118 @@
+// API response and request types matching the backend DTOs
+
+export type ContentType = "movie" | "episode" | "short";
+
+export type FillStrategy = "best_fit" | "sequential" | "random";
+
+export interface MediaFilter {
+ content_type?: ContentType | null;
+ genres: string[];
+ decade?: number | null;
+ tags: string[];
+ min_duration_secs?: number | null;
+ max_duration_secs?: number | null;
+ collections: string[];
+}
+
+export interface RecyclePolicy {
+ cooldown_days?: number | null;
+ cooldown_generations?: number | null;
+ min_available_ratio: number;
+}
+
+export type BlockContent =
+ | { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy }
+ | { type: "manual"; items: string[] };
+
+export interface ProgrammingBlock {
+ id: string;
+ name: string;
+ /** "HH:MM:SS" */
+ start_time: string;
+ duration_mins: number;
+ content: BlockContent;
+}
+
+export interface ScheduleConfig {
+ blocks: ProgrammingBlock[];
+}
+
+// Auth
+
+export interface TokenResponse {
+ access_token: string;
+ token_type: string;
+ expires_in: number;
+}
+
+export interface UserResponse {
+ id: string;
+ email: string;
+ created_at: string;
+}
+
+// Channels
+
+export interface ChannelResponse {
+ id: string;
+ owner_id: string;
+ name: string;
+ description?: string | null;
+ timezone: string;
+ schedule_config: ScheduleConfig;
+ recycle_policy: RecyclePolicy;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateChannelRequest {
+ name: string;
+ timezone: string;
+ description?: string;
+}
+
+export interface UpdateChannelRequest {
+ name?: string;
+ description?: string;
+ timezone?: string;
+ schedule_config?: ScheduleConfig;
+ recycle_policy?: RecyclePolicy;
+}
+
+// Media & Schedule
+
+export interface MediaItemResponse {
+ id: string;
+ title: string;
+ content_type: ContentType;
+ duration_secs: number;
+ description?: string | null;
+ genres: string[];
+ tags: string[];
+ year?: number | null;
+}
+
+export interface ScheduledSlotResponse {
+ id: string;
+ block_id: string;
+ item: MediaItemResponse;
+ /** RFC3339 */
+ start_at: string;
+ /** RFC3339 */
+ end_at: string;
+}
+
+export interface ScheduleResponse {
+ id: string;
+ channel_id: string;
+ generation: number;
+ generated_at: string;
+ valid_from: string;
+ valid_until: string;
+ slots: ScheduledSlotResponse[];
+}
+
+export interface CurrentBroadcastResponse {
+ slot: ScheduledSlotResponse;
+ offset_secs: number;
+}