Port collection templates with poster support and async syncs

- Add poster URLs through collection import and template apply flows
- Queue large template bundle syncs and preserve existing collection posters
- Update template bundle assets, docs, and limit handling
This commit is contained in:
Silo Server Migration
2026-05-23 12:31:08 -04:00
parent 4a88c5ac44
commit 3eddacfe88
175 changed files with 867 additions and 145 deletions
@@ -21,7 +21,10 @@ const baseTemplate: CollectionTemplate = {
describe("CollectionTemplateCard", () => {
it("shows the template title, description, source badge, and media kind", () => {
const markup = renderToStaticMarkup(
<CollectionTemplateCard template={baseTemplate} onPick={() => {}} />,
<CollectionTemplateCard
template={{ ...baseTemplate, poster_path: "/images/collection-templates/trending.jpg" }}
onPick={() => {}}
/>,
);
expect(markup).toContain("Trending Movies This Week");
@@ -29,6 +32,7 @@ describe("CollectionTemplateCard", () => {
expect(markup).toContain("TMDB");
expect(markup).toContain("Movies");
expect(markup).toContain("syncs daily");
expect(markup).not.toContain("<img");
});
it("badges templates that require a profile", () => {
@@ -19,34 +19,20 @@ export function CollectionTemplateCard({ template, onPick }: Props) {
<button
type="button"
onClick={() => onPick(template)}
className="border-border hover:border-primary hover:bg-accent group bg-card flex h-full flex-col overflow-hidden rounded-md border text-left transition-colors"
className="border-border hover:border-primary hover:bg-accent bg-card flex h-full flex-col rounded-md border p-4 text-left transition-colors"
>
{template.poster_path ? (
<div className="bg-muted relative aspect-[2/3] w-full overflow-hidden">
<img
src={template.poster_path}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
<div className="absolute top-2 right-2 flex flex-wrap justify-end gap-1">
<TemplateBadges template={template} />
</div>
<div className="flex items-start justify-between gap-3">
<div
className="bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-xl"
aria-hidden
>
{template.icon}
</div>
) : (
<div className="flex items-start justify-between gap-2 p-4 pb-0">
<div
className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-md text-xl"
aria-hidden
>
{template.icon}
</div>
<div className="flex flex-wrap items-center justify-end gap-1">
<TemplateBadges template={template} />
</div>
<div className="flex flex-wrap items-center justify-end gap-1">
<TemplateBadges template={template} />
</div>
)}
<div className="flex flex-1 flex-col gap-3 p-4">
</div>
<div className="mt-3 flex flex-1 flex-col gap-3">
<div>
<p className="text-sm leading-tight font-medium">{template.title}</p>
<p className="text-muted-foreground mt-1 line-clamp-3 text-xs">{template.description}</p>
@@ -30,6 +30,7 @@ import { Switch } from "@/components/ui/switch";
import { SyncScheduleField } from "@/components/collections/SyncScheduleField";
import { MDBListBrowser } from "./MDBListBrowser";
import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField";
interface Props {
template: CollectionTemplate;
@@ -94,6 +95,10 @@ export function CollectionTemplateConfigForm({
const defaultProfileId = template.requires_profile ? (profiles[0]?.id ?? "") : "";
const [profileId, setProfileId] = useState(defaultProfileId);
const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? "");
const [posterMode, setPosterMode] = useState<TemplatePosterMode>(() =>
template.poster_path ? "default" : "custom",
);
const [customPosterUrl, setCustomPosterUrl] = useState("");
// Discover- and Collection-source templates are bundle-only: the spec is
// backend-driven and can't be edited inline. Render a read-only summary so
@@ -135,6 +140,9 @@ export function CollectionTemplateConfigForm({
featured,
sync_schedule: syncSchedule.trim() || undefined,
limit: parsedLimit,
...(posterMode === "custom"
? { poster_source_url: customPosterUrl.trim() || undefined }
: { poster_url: template.poster_path || undefined }),
};
if (template.source === "tmdb" && template.tmdb) {
@@ -284,6 +292,15 @@ export function CollectionTemplateConfigForm({
</div>
) : null}
<TemplatePosterField
template={template}
mode={posterMode}
onModeChange={setPosterMode}
customUrl={customPosterUrl}
onCustomUrlChange={setCustomPosterUrl}
inputId="template-poster-url"
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="template-limit">Max Items</Label>
@@ -291,7 +308,7 @@ export function CollectionTemplateConfigForm({
id="template-limit"
type="number"
min={1}
max={100}
max={200}
step={1}
inputMode="numeric"
value={limit}
@@ -341,6 +341,7 @@ function TemplateBundleApplyView({
const [result, setResult] = useState<ApplyCollectionTemplateBundleResponse | null>(null);
const applyBundle = useApplyCollectionTemplateBundle();
const disabled = libraryIds.length === 0 || applyBundle.isPending;
const queuesInitialSyncs = bundle.id === "all_defaults";
const selectedLibraries = useMemo(
() => libraries.filter((library) => libraryIds.includes(library.id)),
[libraries, libraryIds],
@@ -403,6 +404,12 @@ function TemplateBundleApplyView({
<div className="rounded-md border p-3">
<div className="text-sm font-semibold">{bundle.title}</div>
<p className="text-muted-foreground mt-1 text-xs leading-snug">{bundle.description}</p>
{queuesInitialSyncs ? (
<p className="text-muted-foreground mt-2 text-xs leading-snug">
Collections are created first; initial syncs are queued so this large bundle can finish
without waiting on every source.
</p>
) : null}
</div>
<div className="space-y-2">
@@ -488,7 +495,7 @@ function TemplateBundleApplyView({
Preview
</Button>
<Button type="button" disabled={disabled} onClick={() => run(false)}>
Apply Defaults
{applyBundle.isPending ? "Applying..." : "Apply Defaults"}
</Button>
</div>
@@ -594,8 +601,13 @@ function buildFeaturedBundleRequest(
function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleResponse }) {
const actionLabel = result.dry_run ? "Would create" : "Created";
const deleteActionLabel = result.dry_run ? "Would delete" : "Deleted";
const created = result.created ?? [];
const skipped = result.skipped ?? [];
const failed = result.failed ?? [];
const deleted = result.deleted ?? [];
const deleteFailed = result.delete_failed ?? [];
const deleteSkipped = result.delete_skipped ?? [];
const syncQueued = result.sync_queued ?? [];
const featured = result.featured ?? [];
const featuredFailed = result.featured_failed ?? [];
return (
@@ -603,13 +615,20 @@ function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleRe
<div className="font-medium">
{result.delete_existing ? (
<>
{deleteActionLabel} {result.deleted.length}; delete skipped {deleteSkipped.length};
delete failed {deleteFailed.length};{" "}
{deleteActionLabel} {deleted.length}; delete skipped {deleteSkipped.length}; delete
failed {deleteFailed.length};{" "}
</>
) : null}
{actionLabel} {result.created.length}; skipped {result.skipped.length}; failed{" "}
{result.failed.length}; featured {featured.length}; featured failed {featuredFailed.length}
{actionLabel} {created.length}; skipped {skipped.length}; failed {failed.length}
{syncQueued.length > 0 ? `; sync queued ${syncQueued.length}` : ""}; featured{" "}
{featured.length}; featured failed {featuredFailed.length}
</div>
{syncQueued.length > 0 ? (
<div className="text-muted-foreground text-xs leading-snug">
Initial syncs are running in the background. Collections are available now; items appear
as each sync finishes.
</div>
) : null}
{deleteFailed.length > 0 ? (
<div className="text-destructive space-y-1 text-xs">
{deleteFailed.slice(0, 5).map((entry) => (
@@ -620,9 +639,9 @@ function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleRe
))}
</div>
) : null}
{result.failed.length > 0 ? (
{failed.length > 0 ? (
<div className="text-destructive space-y-1 text-xs">
{result.failed.slice(0, 5).map((entry) => (
{failed.slice(0, 5).map((entry) => (
<div key={`${entry.library_id}:${entry.template_id}`}>
{entry.library_name} / {entry.template_title}: {entry.reason ?? "failed"}
</div>
@@ -0,0 +1,101 @@
import { Check } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { CollectionTemplate } from "@/lib/collectionTemplates";
export type TemplatePosterMode = "default" | "custom";
interface TemplatePosterFieldProps {
template: CollectionTemplate;
mode: TemplatePosterMode;
onModeChange: (mode: TemplatePosterMode) => void;
customUrl: string;
onCustomUrlChange: (value: string) => void;
inputId: string;
}
export function TemplatePosterField({
template,
mode,
onModeChange,
customUrl,
onCustomUrlChange,
inputId,
}: TemplatePosterFieldProps) {
const hasDefaultPoster = Boolean(template.poster_path);
return (
<div className="space-y-2">
<Label>Poster</Label>
<div className="border-border space-y-3 rounded-md border p-3">
{hasDefaultPoster ? (
<div role="radiogroup" aria-label="Poster source" className="grid gap-2 sm:grid-cols-2">
<PosterChoiceButton
label="Server default"
active={mode === "default"}
onClick={() => onModeChange("default")}
/>
<PosterChoiceButton
label="Custom URL"
active={mode === "custom"}
onClick={() => onModeChange("custom")}
/>
</div>
) : null}
{mode === "default" && template.poster_path ? (
<div className="flex items-center gap-3">
<img
src={template.poster_path}
alt=""
loading="lazy"
className="border-border bg-muted h-20 w-14 shrink-0 rounded-md border object-cover"
/>
<div className="min-w-0">
<p className="text-sm font-medium">Server default</p>
<p className="text-muted-foreground truncate text-xs">{template.poster_path}</p>
</div>
</div>
) : (
<div className="space-y-2">
<Input
id={inputId}
type="url"
value={customUrl}
onChange={(event) => onCustomUrlChange(event.target.value)}
placeholder="https://example.com/poster.jpg"
/>
</div>
)}
</div>
</div>
);
}
function PosterChoiceButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="radio"
aria-checked={active}
onClick={onClick}
className={cn(
"border-border hover:bg-accent flex h-9 items-center justify-between rounded-md border px-3 text-sm transition-colors",
active && "border-primary bg-primary/10 text-primary",
)}
>
<span>{label}</span>
{active ? <Check className="h-4 w-4" aria-hidden /> : null}
</button>
);
}
@@ -34,6 +34,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { MDBListBrowser } from "./MDBListBrowser";
import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField";
interface Props {
template: CollectionTemplate;
@@ -89,6 +90,10 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated
const [isShared, setIsShared] = useState(false);
const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? "");
const [libraryIds, setLibraryIds] = useState<number[]>([]);
const [posterMode, setPosterMode] = useState<TemplatePosterMode>(() =>
template.poster_path ? "default" : "custom",
);
const [customPosterUrl, setCustomPosterUrl] = useState("");
const eligibility = libraryEligibilityForMediaKind(template.media_kind);
const pickerLibraries = libraries.map((lib) => ({
@@ -113,6 +118,10 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated
sync_schedule: scheduleChoiceToRequest(schedule),
limit: parsedLimit,
is_shared: isShared,
poster_url:
posterMode === "custom"
? customPosterUrl.trim() || undefined
: template.poster_path || undefined,
library_ids: libraryIds.length > 0 ? libraryIds : undefined,
};
@@ -236,6 +245,15 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated
</p>
) : null}
<TemplatePosterField
template={template}
mode={posterMode}
onModeChange={setPosterMode}
customUrl={customPosterUrl}
onCustomUrlChange={setCustomPosterUrl}
inputId="user-template-poster-url"
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="user-template-limit">Max Items</Label>