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">

View File

@@ -367,7 +367,7 @@ export default function DashboardPage() {
onSubmit={handleEdit}
isPending={updateChannel.isPending}
error={updateChannel.error?.message}
capabilities={capabilities}
providers={config?.providers ?? []}
/>
<ScheduleSheet

View File

@@ -7,38 +7,38 @@ import type { MediaFilter } from "@/lib/types";
const STALE = 10 * 60 * 1000; // 10 min — library metadata rarely changes in a session
/** List top-level collections (Jellyfin libraries, Plex sections, etc.) */
export function useCollections() {
/** List top-level collections for a provider (empty = primary). */
export function useCollections(provider?: string) {
const { token } = useAuthContext();
return useQuery({
queryKey: ["library", "collections"],
queryFn: () => api.library.collections(token!),
queryKey: ["library", "collections", provider ?? null],
queryFn: () => api.library.collections(token!, provider),
enabled: !!token,
staleTime: STALE,
});
}
/**
* List TV series, optionally scoped to a collection.
* List TV series, optionally scoped to a collection and provider.
* All series are loaded upfront so the series picker can filter client-side
* without a request per keystroke.
*/
export function useSeries(collectionId?: string, opts?: { enabled?: boolean }) {
export function useSeries(collectionId?: string, opts?: { enabled?: boolean; provider?: string }) {
const { token } = useAuthContext();
return useQuery({
queryKey: ["library", "series", collectionId ?? null],
queryFn: () => api.library.series(token!, collectionId),
queryKey: ["library", "series", collectionId ?? null, opts?.provider ?? null],
queryFn: () => api.library.series(token!, collectionId, opts?.provider),
enabled: !!token && (opts?.enabled ?? true),
staleTime: STALE,
});
}
/** List available genres, optionally scoped to a content type. */
export function useGenres(contentType?: string, opts?: { enabled?: boolean }) {
/** List available genres, optionally scoped to a content type and provider. */
export function useGenres(contentType?: string, opts?: { enabled?: boolean; provider?: string }) {
const { token } = useAuthContext();
return useQuery({
queryKey: ["library", "genres", contentType ?? null],
queryFn: () => api.library.genres(token!, contentType),
queryKey: ["library", "genres", contentType ?? null, opts?.provider ?? null],
queryFn: () => api.library.genres(token!, contentType, opts?.provider),
enabled: !!token && (opts?.enabled ?? true),
staleTime: STALE,
});
@@ -64,11 +64,12 @@ export function useLibraryItems(
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres"> | null,
enabled: boolean,
strategy?: string,
provider?: string,
) {
const { token } = useAuthContext();
return useQuery({
queryKey: ["library", "items", filter, strategy ?? null],
queryFn: () => api.library.items(token!, filter!, 30, strategy),
queryKey: ["library", "items", filter, strategy ?? null, provider ?? null],
queryFn: () => api.library.items(token!, filter!, 30, strategy, provider),
enabled: !!token && enabled && !!filter,
staleTime: 2 * 60 * 1000,
});

View File

@@ -108,19 +108,25 @@ export const api = {
},
library: {
collections: (token: string) =>
request<CollectionResponse[]>("/library/collections", { token }),
collections: (token: string, provider?: string) => {
const params = new URLSearchParams();
if (provider) params.set("provider", provider);
const qs = params.toString();
return request<CollectionResponse[]>(`/library/collections${qs ? `?${qs}` : ""}`, { token });
},
series: (token: string, collectionId?: string) => {
series: (token: string, collectionId?: string, provider?: string) => {
const params = new URLSearchParams();
if (collectionId) params.set("collection", collectionId);
if (provider) params.set("provider", provider);
const qs = params.toString();
return request<SeriesResponse[]>(`/library/series${qs ? `?${qs}` : ""}`, { token });
},
genres: (token: string, contentType?: string) => {
genres: (token: string, contentType?: string, provider?: string) => {
const params = new URLSearchParams();
if (contentType) params.set("type", contentType);
if (provider) params.set("provider", provider);
const qs = params.toString();
return request<string[]>(`/library/genres${qs ? `?${qs}` : ""}`, { token });
},
@@ -130,6 +136,7 @@ export const api = {
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">,
limit = 50,
strategy?: string,
provider?: string,
) => {
const params = new URLSearchParams();
if (filter.search_term) params.set("q", filter.search_term);
@@ -138,6 +145,7 @@ export const api = {
if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
params.set("limit", String(limit));
if (strategy) params.set("strategy", strategy);
if (provider) params.set("provider", provider);
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
},
},

View File

@@ -57,8 +57,8 @@ export interface RecyclePolicy {
}
export type BlockContent =
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy }
| { type: "manual"; items: string[] };
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string }
| { type: "manual"; items: string[]; provider_id?: string };
export interface ProgrammingBlock {
id: string;
@@ -95,8 +95,16 @@ export interface ProviderCapabilities {
rescan: boolean;
}
export interface ProviderInfo {
id: string;
capabilities: ProviderCapabilities;
}
export interface ConfigResponse {
allow_registration: boolean;
/** All registered providers. Added in multi-provider update. */
providers: ProviderInfo[];
/** Primary provider capabilities — kept for backward compat. */
provider_capabilities: ProviderCapabilities;
}