diff --git a/k-tv-backend/Cargo.lock b/k-tv-backend/Cargo.lock index d1e45b3..9608959 100644 --- a/k-tv-backend/Cargo.lock +++ b/k-tv-backend/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "dotenvy", "infra", "k-core", + "rand 0.8.5", "serde", "serde_json", "serde_qs", diff --git a/k-tv-backend/api/Cargo.toml b/k-tv-backend/api/Cargo.toml index 52f8595..c517d5d 100644 --- a/k-tv-backend/api/Cargo.toml +++ b/k-tv-backend/api/Cargo.toml @@ -35,6 +35,7 @@ tokio = { version = "1.48.0", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0" serde_qs = "0.13" +rand = "0.8" # Error handling thiserror = "2.0.17" diff --git a/k-tv-backend/api/src/routes/library.rs b/k-tv-backend/api/src/routes/library.rs index fa0ce69..1563aa7 100644 --- a/k-tv-backend/api/src/routes/library.rs +++ b/k-tv-backend/api/src/routes/library.rs @@ -114,13 +114,17 @@ struct ItemsQuery { #[serde(rename = "type")] content_type: Option, /// Filter episodes by series name. Repeat the param for multiple series: - /// `?series=iCarly&series=Victorious` + /// `?series[]=iCarly&series[]=Victorious` #[serde(default)] series: Vec, /// Scope to a provider collection ID. collection: Option, /// Maximum number of results (default: 50, max: 200). limit: Option, + /// Fill strategy to simulate: "random" | "sequential" | "best_fit". + /// Applies the same ordering the schedule engine would use so the preview + /// reflects what will actually be scheduled. + strategy: Option, } // ============================================================================ @@ -187,7 +191,21 @@ async fn search_items( ..Default::default() }; - let items = state.media_provider.fetch_items(&filter).await?; + let mut items = state.media_provider.fetch_items(&filter).await?; + + // Apply the same ordering the schedule engine uses so the preview reflects + // what will actually be scheduled rather than raw provider order. + match params.strategy.as_deref() { + Some("random") => { + use rand::seq::SliceRandom; + items.shuffle(&mut rand::thread_rng()); + } + Some("best_fit") => { + // Mirror the greedy bin-packing: longest items first. + items.sort_by(|a, b| b.duration_secs.cmp(&a.duration_secs)); + } + _ => {} // "sequential" / unset: keep provider order (episode order per series) + } let response: Vec = items .into_iter() diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index 84fbcaa..a2e30b0 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -402,8 +402,8 @@ function AlgorithmicFilterEditor({ - {/* Preview — snapshot of current filter, only fetches on explicit click */} - + {/* Preview — snapshot of current filter+strategy, only fetches on explicit click */} + ); } diff --git a/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx b/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx index 874f937..6b21cf4 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx @@ -7,6 +7,7 @@ import type { MediaFilter, LibraryItemResponse } from "@/lib/types"; interface FilterPreviewProps { filter: MediaFilter; + strategy?: string; } function fmtDuration(secs: number): string { @@ -31,17 +32,25 @@ function ItemRow({ item }: { item: LibraryItemResponse }) { ); } -export function FilterPreview({ filter }: FilterPreviewProps) { - // Snapshot of filter at the moment "Preview" was clicked, so edits to the - // filter don't silently re-fetch while the user is still configuring. - const [snapshot, setSnapshot] = useState(null); +type Snapshot = { filter: MediaFilter; strategy?: string }; - const { data: items, isFetching, isError } = useLibraryItems(snapshot, !!snapshot); +export function FilterPreview({ filter, strategy }: FilterPreviewProps) { + // Capture both filter and strategy at click time so edits don't silently + // re-fetch while the user is still configuring the block. + const [snapshot, setSnapshot] = useState(null); - const handlePreview = () => setSnapshot({ ...filter }); + const { data: items, isFetching, isError } = useLibraryItems( + snapshot?.filter ?? null, + !!snapshot, + snapshot?.strategy, + ); + + const handlePreview = () => setSnapshot({ filter: { ...filter }, strategy }); const filterChanged = - snapshot !== null && JSON.stringify(snapshot) !== JSON.stringify(filter); + snapshot !== null && + (JSON.stringify(snapshot.filter) !== JSON.stringify(filter) || + snapshot.strategy !== strategy); return (
@@ -67,6 +76,7 @@ export function FilterPreview({ filter }: FilterPreviewProps) {

{items.length === 30 ? "First 30 matches" : `${items.length} match${items.length !== 1 ? "es" : ""}`} + {strategy === "random" ? " · shuffled" : strategy === "best_fit" ? " · longest first" : ""}

{items.length === 0 ? (

No items match this filter.

diff --git a/k-tv-frontend/hooks/use-library.ts b/k-tv-frontend/hooks/use-library.ts index b9bf860..db5dbed 100644 --- a/k-tv-frontend/hooks/use-library.ts +++ b/k-tv-frontend/hooks/use-library.ts @@ -51,11 +51,12 @@ export function useGenres(contentType?: string) { export function useLibraryItems( filter: Pick | null, enabled: boolean, + strategy?: string, ) { const { token } = useAuthContext(); return useQuery({ - queryKey: ["library", "items", filter], - queryFn: () => api.library.items(token!, filter!, 30), + queryKey: ["library", "items", filter, strategy ?? null], + queryFn: () => api.library.items(token!, filter!, 30, strategy), enabled: !!token && enabled && !!filter, staleTime: 2 * 60 * 1000, }); diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts index edf5316..080e9d9 100644 --- a/k-tv-frontend/lib/api.ts +++ b/k-tv-frontend/lib/api.ts @@ -129,6 +129,7 @@ export const api = { token: string, filter: Pick, limit = 50, + strategy?: string, ) => { const params = new URLSearchParams(); if (filter.search_term) params.set("q", filter.search_term); @@ -136,6 +137,7 @@ export const api = { filter.series_names?.forEach((name) => params.append("series[]", name)); if (filter.collections?.[0]) params.set("collection", filter.collections[0]); params.set("limit", String(limit)); + if (strategy) params.set("strategy", strategy); return request(`/library/items?${params}`, { token }); }, },