feat(library): add strategy parameter for item fetching and update filter preview

This commit is contained in:
2026-03-12 03:24:32 +01:00
parent 6d1bed2ecb
commit e5a9b99b14
7 changed files with 46 additions and 13 deletions

View File

@@ -80,6 +80,7 @@ dependencies = [
"dotenvy", "dotenvy",
"infra", "infra",
"k-core", "k-core",
"rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"serde_qs", "serde_qs",

View File

@@ -35,6 +35,7 @@ tokio = { version = "1.48.0", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_qs = "0.13" serde_qs = "0.13"
rand = "0.8"
# Error handling # Error handling
thiserror = "2.0.17" thiserror = "2.0.17"

View File

@@ -114,13 +114,17 @@ struct ItemsQuery {
#[serde(rename = "type")] #[serde(rename = "type")]
content_type: Option<String>, content_type: Option<String>,
/// Filter episodes by series name. Repeat the param for multiple series: /// Filter episodes by series name. Repeat the param for multiple series:
/// `?series=iCarly&series=Victorious` /// `?series[]=iCarly&series[]=Victorious`
#[serde(default)] #[serde(default)]
series: Vec<String>, series: Vec<String>,
/// Scope to a provider collection ID. /// Scope to a provider collection ID.
collection: Option<String>, collection: Option<String>,
/// Maximum number of results (default: 50, max: 200). /// Maximum number of results (default: 50, max: 200).
limit: Option<usize>, limit: Option<usize>,
/// 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<String>,
} }
// ============================================================================ // ============================================================================
@@ -187,7 +191,21 @@ async fn search_items(
..Default::default() ..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<LibraryItemResponse> = items let response: Vec<LibraryItemResponse> = items
.into_iter() .into_iter()

View File

@@ -402,8 +402,8 @@ function AlgorithmicFilterEditor({
</Field> </Field>
</div> </div>
{/* Preview — snapshot of current filter, only fetches on explicit click */} {/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
<FilterPreview filter={content.filter} /> <FilterPreview filter={content.filter} strategy={content.strategy} />
</div> </div>
); );
} }

View File

@@ -7,6 +7,7 @@ import type { MediaFilter, LibraryItemResponse } from "@/lib/types";
interface FilterPreviewProps { interface FilterPreviewProps {
filter: MediaFilter; filter: MediaFilter;
strategy?: string;
} }
function fmtDuration(secs: number): string { function fmtDuration(secs: number): string {
@@ -31,17 +32,25 @@ function ItemRow({ item }: { item: LibraryItemResponse }) {
); );
} }
export function FilterPreview({ filter }: FilterPreviewProps) { type Snapshot = { filter: MediaFilter; strategy?: string };
// 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<MediaFilter | null>(null);
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<Snapshot | null>(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 = const filterChanged =
snapshot !== null && JSON.stringify(snapshot) !== JSON.stringify(filter); snapshot !== null &&
(JSON.stringify(snapshot.filter) !== JSON.stringify(filter) ||
snapshot.strategy !== strategy);
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@@ -67,6 +76,7 @@ export function FilterPreview({ filter }: FilterPreviewProps) {
<div className="rounded-md border border-zinc-700/60 bg-zinc-800/30 px-3 py-1"> <div className="rounded-md border border-zinc-700/60 bg-zinc-800/30 px-3 py-1">
<p className="pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wider text-zinc-600"> <p className="pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wider text-zinc-600">
{items.length === 30 ? "First 30 matches" : `${items.length} match${items.length !== 1 ? "es" : ""}`} {items.length === 30 ? "First 30 matches" : `${items.length} match${items.length !== 1 ? "es" : ""}`}
{strategy === "random" ? " · shuffled" : strategy === "best_fit" ? " · longest first" : ""}
</p> </p>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="py-1.5 text-xs text-zinc-600">No items match this filter.</p> <p className="py-1.5 text-xs text-zinc-600">No items match this filter.</p>

View File

@@ -51,11 +51,12 @@ export function useGenres(contentType?: string) {
export function useLibraryItems( export function useLibraryItems(
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres"> | null, filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres"> | null,
enabled: boolean, enabled: boolean,
strategy?: string,
) { ) {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
queryKey: ["library", "items", filter], queryKey: ["library", "items", filter, strategy ?? null],
queryFn: () => api.library.items(token!, filter!, 30), queryFn: () => api.library.items(token!, filter!, 30, strategy),
enabled: !!token && enabled && !!filter, enabled: !!token && enabled && !!filter,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
}); });

View File

@@ -129,6 +129,7 @@ export const api = {
token: string, token: string,
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">, filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">,
limit = 50, limit = 50,
strategy?: string,
) => { ) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filter.search_term) params.set("q", filter.search_term); 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)); filter.series_names?.forEach((name) => params.append("series[]", name));
if (filter.collections?.[0]) params.set("collection", filter.collections[0]); if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
params.set("limit", String(limit)); params.set("limit", String(limit));
if (strategy) params.set("strategy", strategy);
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token }); return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
}, },
}, },