feat: add local files provider with indexing and rescan functionality

- Implemented LocalFilesProvider to manage local video files.
- Added LocalIndex for in-memory and SQLite-backed indexing of video files.
- Introduced scanning functionality to detect video files and extract metadata.
- Added API endpoints for listing collections, genres, and series based on provider capabilities.
- Enhanced existing routes to check for provider capabilities before processing requests.
- Updated frontend to utilize provider capabilities for conditional rendering of UI elements.
- Implemented rescan functionality to refresh the local files index.
- Added database migration for local files index schema.
This commit is contained in:
2026-03-14 03:44:32 +01:00
parent 9b6bcfc566
commit 8f42164bce
30 changed files with 1033 additions and 59 deletions

View File

@@ -19,6 +19,7 @@ import type {
FillStrategy,
ContentType,
MediaFilter,
ProviderCapabilities,
RecyclePolicy,
} from "@/lib/types";
@@ -238,6 +239,7 @@ interface AlgorithmicFilterEditorProps {
errors: FieldErrors;
setFilter: (patch: Partial<MediaFilter>) => void;
setStrategy: (strategy: FillStrategy) => void;
capabilities?: ProviderCapabilities;
}
function AlgorithmicFilterEditor({
@@ -246,14 +248,23 @@ function AlgorithmicFilterEditor({
errors,
setFilter,
setStrategy,
capabilities,
}: AlgorithmicFilterEditorProps) {
const [showGenres, setShowGenres] = useState(false);
const { data: collections, isLoading: loadingCollections } = useCollections();
const { data: series, isLoading: loadingSeries } = useSeries();
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined);
const { data: series, isLoading: loadingSeries } = useSeries(undefined, {
enabled: capabilities?.series !== false,
});
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, {
enabled: capabilities?.genres !== false,
});
const isEpisode = content.filter.content_type === "episode";
const collectionLabel =
capabilities?.collections && !capabilities?.series && !capabilities?.genres
? "Directory"
: "Library";
return (
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
@@ -289,8 +300,8 @@ function AlgorithmicFilterEditor({
</Field>
</div>
{/* Series — only meaningful for episodes */}
{isEpisode && (
{/* Series — only meaningful for episodes when provider supports it */}
{isEpisode && capabilities?.series !== false && (
<Field
label="Series"
hint={
@@ -308,15 +319,15 @@ function AlgorithmicFilterEditor({
</Field>
)}
{/* Library — real collection names when the provider supports it */}
{/* Library/Directory — real collection names when the provider supports it */}
<Field
label="Library"
label={collectionLabel}
hint={
loadingCollections
? "Loading libraries…"
? `Loading ${collectionLabel.toLowerCase()}s…`
: collections
? "Scope this block to one library"
: "Enter a provider library ID"
? `Scope this block to one ${collectionLabel.toLowerCase()}`
: `Enter a provider ${collectionLabel.toLowerCase()} ID`
}
>
{collections && collections.length > 0 ? (
@@ -341,7 +352,8 @@ function AlgorithmicFilterEditor({
)}
</Field>
{/* Genres with browse-from-library shortcut */}
{/* Genres — only shown when provider supports it */}
{capabilities?.genres !== false && (
<Field label="Genres" hint="Press Enter or comma to add">
<TagInput
values={content.filter.genres}
@@ -376,6 +388,7 @@ function AlgorithmicFilterEditor({
</div>
)}
</Field>
)}
<Field label="Tags" hint="Press Enter or comma to add">
<TagInput
@@ -435,9 +448,10 @@ interface BlockEditorProps {
onChange: (block: ProgrammingBlock) => void;
onRemove: () => void;
onSelect: () => void;
capabilities?: ProviderCapabilities;
}
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect }: BlockEditorProps) {
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, capabilities }: BlockEditorProps) {
const [expanded, setExpanded] = useState(isSelected);
const elRef = useRef<HTMLDivElement>(null);
@@ -555,6 +569,7 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
errors={errors}
setFilter={setFilter}
setStrategy={setStrategy}
capabilities={capabilities}
/>
{content.strategy === "sequential" && (
@@ -719,6 +734,7 @@ interface EditChannelSheetProps {
) => void;
isPending: boolean;
error?: string | null;
capabilities?: ProviderCapabilities;
}
export function EditChannelSheet({
@@ -728,6 +744,7 @@ export function EditChannelSheet({
onSubmit,
isPending,
error,
capabilities,
}: EditChannelSheetProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -1027,6 +1044,7 @@ export function EditChannelSheet({
onChange={(b) => updateBlock(idx, b)}
onRemove={() => removeBlock(idx)}
onSelect={() => setSelectedBlockId(block.id)}
capabilities={capabilities}
/>
))}
</div>