feat: implement multi-provider support in media library

- Introduced IProviderRegistry to manage multiple media providers.
- Updated AppState to use provider_registry instead of a single media_provider.
- Refactored library routes to support provider-specific queries for collections, series, genres, and items.
- Enhanced ProgrammingBlock to include provider_id for algorithmic and manual content types.
- Modified frontend components to allow selection of providers and updated API calls to include provider parameters.
- Adjusted hooks and types to accommodate provider-specific functionality.
This commit is contained in:
2026-03-14 23:59:21 +01:00
parent c53892159a
commit ead65e6be2
21 changed files with 468 additions and 150 deletions

View File

@@ -19,7 +19,7 @@ import type {
FillStrategy,
ContentType,
MediaFilter,
ProviderCapabilities,
ProviderInfo,
RecyclePolicy,
} from "@/lib/types";
@@ -57,10 +57,12 @@ const blockSchema = z.object({
type: z.literal("algorithmic"),
filter: mediaFilterSchema,
strategy: z.enum(["best_fit", "sequential", "random"]),
provider_id: z.string().optional(),
}),
z.object({
type: z.literal("manual"),
items: z.array(z.string()),
provider_id: z.string().optional(),
}),
]),
loop_on_finish: z.boolean().optional(),
@@ -239,7 +241,8 @@ interface AlgorithmicFilterEditorProps {
errors: FieldErrors;
setFilter: (patch: Partial<MediaFilter>) => void;
setStrategy: (strategy: FillStrategy) => void;
capabilities?: ProviderCapabilities;
setProviderId: (id: string) => void;
providers: ProviderInfo[];
}
function AlgorithmicFilterEditor({
@@ -248,16 +251,23 @@ function AlgorithmicFilterEditor({
errors,
setFilter,
setStrategy,
capabilities,
setProviderId,
providers,
}: AlgorithmicFilterEditorProps) {
const [showGenres, setShowGenres] = useState(false);
const { data: collections, isLoading: loadingCollections } = useCollections();
const providerId = content.provider_id ?? "";
const capabilities = providers.find((p) => p.id === providerId)?.capabilities
?? providers[0]?.capabilities;
const { data: collections, isLoading: loadingCollections } = useCollections(providerId || undefined);
const { data: series, isLoading: loadingSeries } = useSeries(undefined, {
enabled: capabilities?.series !== false,
provider: providerId || undefined,
});
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, {
enabled: capabilities?.genres !== false,
provider: providerId || undefined,
});
const isEpisode = content.filter.content_type === "episode";
@@ -270,6 +280,16 @@ function AlgorithmicFilterEditor({
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
{providers.length > 1 && (
<Field label="Provider">
<NativeSelect value={providerId} onChange={(v) => setProviderId(v)}>
{providers.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</NativeSelect>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Media type">
<NativeSelect
@@ -430,7 +450,7 @@ function AlgorithmicFilterEditor({
</div>
{/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
<FilterPreview filter={content.filter} strategy={content.strategy} />
<FilterPreview filter={content.filter} strategy={content.strategy} provider={providerId || undefined} />
</div>
);
}
@@ -448,10 +468,10 @@ interface BlockEditorProps {
onChange: (block: ProgrammingBlock) => void;
onRemove: () => void;
onSelect: () => void;
capabilities?: ProviderCapabilities;
providers: ProviderInfo[];
}
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, capabilities }: BlockEditorProps) {
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, providers }: BlockEditorProps) {
const [expanded, setExpanded] = useState(isSelected);
const elRef = useRef<HTMLDivElement>(null);
@@ -470,12 +490,13 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
const pfx = `blocks.${index}`;
const setContentType = (type: "algorithmic" | "manual") => {
const pid = content.provider_id ?? "";
onChange({
...block,
content:
type === "algorithmic"
? { type: "algorithmic", filter: defaultFilter(), strategy: "random" }
: { type: "manual", items: [] },
? { type: "algorithmic", filter: defaultFilter(), strategy: "random", provider_id: pid }
: { type: "manual", items: [], provider_id: pid },
});
};
@@ -489,6 +510,10 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
onChange({ ...block, content: { ...content, strategy } });
};
const setProviderId = (id: string) => {
onChange({ ...block, content: { ...content, provider_id: id } });
};
return (
<div
ref={elRef}
@@ -569,7 +594,8 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
errors={errors}
setFilter={setFilter}
setStrategy={setStrategy}
capabilities={capabilities}
setProviderId={setProviderId}
providers={providers}
/>
{content.strategy === "sequential" && (
@@ -734,7 +760,7 @@ interface EditChannelSheetProps {
) => void;
isPending: boolean;
error?: string | null;
capabilities?: ProviderCapabilities;
providers?: ProviderInfo[];
}
export function EditChannelSheet({
@@ -744,7 +770,7 @@ export function EditChannelSheet({
onSubmit,
isPending,
error,
capabilities,
providers = [],
}: EditChannelSheetProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -1044,7 +1070,7 @@ export function EditChannelSheet({
onChange={(b) => updateBlock(idx, b)}
onRemove={() => removeBlock(idx)}
onSelect={() => setSelectedBlockId(block.id)}
capabilities={capabilities}
providers={providers}
/>
))}
</div>

View File

@@ -8,6 +8,7 @@ import type { MediaFilter, LibraryItemResponse } from "@/lib/types";
interface FilterPreviewProps {
filter: MediaFilter;
strategy?: string;
provider?: string;
}
function fmtDuration(secs: number): string {
@@ -32,10 +33,10 @@ function ItemRow({ item }: { item: LibraryItemResponse }) {
);
}
type Snapshot = { filter: MediaFilter; strategy?: string };
type Snapshot = { filter: MediaFilter; strategy?: string; provider?: string };
export function FilterPreview({ filter, strategy }: FilterPreviewProps) {
// Capture both filter and strategy at click time so edits don't silently
export function FilterPreview({ filter, strategy, provider }: FilterPreviewProps) {
// Capture filter, strategy, and provider 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);
@@ -43,14 +44,16 @@ export function FilterPreview({ filter, strategy }: FilterPreviewProps) {
snapshot?.filter ?? null,
!!snapshot,
snapshot?.strategy,
snapshot?.provider,
);
const handlePreview = () => setSnapshot({ filter: { ...filter }, strategy });
const handlePreview = () => setSnapshot({ filter: { ...filter }, strategy, provider });
const filterChanged =
snapshot !== null &&
(JSON.stringify(snapshot.filter) !== JSON.stringify(filter) ||
snapshot.strategy !== strategy);
snapshot.strategy !== strategy ||
snapshot.provider !== provider);
return (
<div className="space-y-1">