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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -367,7 +367,7 @@ export default function DashboardPage() {
|
||||
onSubmit={handleEdit}
|
||||
isPending={updateChannel.isPending}
|
||||
error={updateChannel.error?.message}
|
||||
capabilities={capabilities}
|
||||
providers={config?.providers ?? []}
|
||||
/>
|
||||
|
||||
<ScheduleSheet
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user