feat(library): add strategy parameter for item fetching and update filter preview
This commit is contained in:
1
k-tv-backend/Cargo.lock
generated
1
k-tv-backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user