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:
@@ -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>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
useGenerateSchedule,
|
||||
} from "@/hooks/use-channels";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useRescanLibrary } from "@/hooks/use-library";
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -34,11 +36,14 @@ export default function DashboardPage() {
|
||||
const { token } = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: channels, isLoading, error } = useChannels();
|
||||
const { data: config } = useConfig();
|
||||
const capabilities = config?.provider_capabilities;
|
||||
|
||||
const createChannel = useCreateChannel();
|
||||
const updateChannel = useUpdateChannel();
|
||||
const deleteChannel = useDeleteChannel();
|
||||
const generateSchedule = useGenerateSchedule();
|
||||
const rescanLibrary = useRescanLibrary();
|
||||
|
||||
// Channel ordering — persisted to localStorage
|
||||
const [channelOrder, setChannelOrder] = useState<string[]>([]);
|
||||
@@ -226,6 +231,22 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{capabilities?.rescan && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
rescanLibrary.mutate(undefined, {
|
||||
onSuccess: (d) => toast.success(`Rescan complete: ${d.items_found} files found`),
|
||||
onError: () => toast.error("Rescan failed"),
|
||||
})
|
||||
}
|
||||
disabled={rescanLibrary.isPending}
|
||||
title="Rescan local files directory"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${rescanLibrary.isPending ? "animate-spin" : ""}`} />
|
||||
Rescan library
|
||||
</Button>
|
||||
)}
|
||||
{channels && channels.length > 0 && (
|
||||
<Button
|
||||
onClick={handleRegenerateAll}
|
||||
@@ -346,6 +367,7 @@ export default function DashboardPage() {
|
||||
onSubmit={handleEdit}
|
||||
isPending={updateChannel.isPending}
|
||||
error={updateChannel.error?.message}
|
||||
capabilities={capabilities}
|
||||
/>
|
||||
|
||||
<ScheduleSheet
|
||||
|
||||
@@ -17,6 +17,8 @@ interface VideoPlayerProps {
|
||||
/** Active subtitle track index, or -1 to disable. */
|
||||
subtitleTrack?: number;
|
||||
muted?: boolean;
|
||||
/** Force direct-file mode (skips hls.js even for .m3u8 URLs). */
|
||||
streamingProtocol?: "hls" | "direct_file";
|
||||
onStreamError?: () => void;
|
||||
onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void;
|
||||
/** Called when the browser blocks autoplay and user interaction is required. */
|
||||
@@ -34,6 +36,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
initialOffset = 0,
|
||||
subtitleTrack = -1,
|
||||
muted = false,
|
||||
streamingProtocol,
|
||||
onStreamError,
|
||||
onSubtitleTracksChange,
|
||||
onNeedsInteraction,
|
||||
@@ -75,7 +78,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
onSubtitleTracksChange?.([]);
|
||||
setIsBuffering(true);
|
||||
|
||||
const isHls = src.includes(".m3u8");
|
||||
const isHls = streamingProtocol !== "direct_file" && src.includes(".m3u8");
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
@@ -117,10 +120,18 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
// Plain MP4 fallback
|
||||
// Plain MP4 / direct file: seek to offset after metadata loads.
|
||||
video.src = src;
|
||||
video.addEventListener(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
if (initialOffset > 0) video.currentTime = initialOffset;
|
||||
video.muted = mutedRef.current;
|
||||
video.play().catch(() => onNeedsInteraction?.());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
video.load();
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
13
k-tv-frontend/hooks/use-config.ts
Normal file
13
k-tv-frontend/hooks/use-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ConfigResponse } from "@/lib/types";
|
||||
|
||||
export function useConfig() {
|
||||
return useQuery<ConfigResponse>({
|
||||
queryKey: ["config"],
|
||||
queryFn: () => api.config.get(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import type { MediaFilter } from "@/lib/types";
|
||||
@@ -23,27 +23,39 @@ export function useCollections() {
|
||||
* All series are loaded upfront so the series picker can filter client-side
|
||||
* without a request per keystroke.
|
||||
*/
|
||||
export function useSeries(collectionId?: string) {
|
||||
export function useSeries(collectionId?: string, opts?: { enabled?: boolean }) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["library", "series", collectionId ?? null],
|
||||
queryFn: () => api.library.series(token!, collectionId),
|
||||
enabled: !!token,
|
||||
enabled: !!token && (opts?.enabled ?? true),
|
||||
staleTime: STALE,
|
||||
});
|
||||
}
|
||||
|
||||
/** List available genres, optionally scoped to a content type. */
|
||||
export function useGenres(contentType?: string) {
|
||||
export function useGenres(contentType?: string, opts?: { enabled?: boolean }) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["library", "genres", contentType ?? null],
|
||||
queryFn: () => api.library.genres(token!, contentType),
|
||||
enabled: !!token,
|
||||
enabled: !!token && (opts?.enabled ?? true),
|
||||
staleTime: STALE,
|
||||
});
|
||||
}
|
||||
|
||||
/** Trigger a local-files rescan. Only available when `provider_capabilities.rescan` is true. */
|
||||
export function useRescanLibrary() {
|
||||
const { token } = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.files.rescan(token!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["library"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch items matching a filter for the block editor's "Preview results" panel.
|
||||
* Pass `enabled: false` until the user explicitly requests a preview.
|
||||
|
||||
@@ -142,6 +142,11 @@ export const api = {
|
||||
},
|
||||
},
|
||||
|
||||
files: {
|
||||
rescan: (token: string) =>
|
||||
request<{ items_found: number }>("/files/rescan", { method: "POST", token }),
|
||||
},
|
||||
|
||||
schedule: {
|
||||
generate: (channelId: string, token: string) =>
|
||||
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
|
||||
|
||||
@@ -82,8 +82,22 @@ export interface ScheduleConfig {
|
||||
|
||||
// Config
|
||||
|
||||
export type StreamingProtocol = "hls" | "direct_file";
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
collections: boolean;
|
||||
series: boolean;
|
||||
genres: boolean;
|
||||
tags: boolean;
|
||||
decade: boolean;
|
||||
search: boolean;
|
||||
streaming_protocol: StreamingProtocol;
|
||||
rescan: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigResponse {
|
||||
allow_registration: boolean;
|
||||
provider_capabilities: ProviderCapabilities;
|
||||
}
|
||||
|
||||
// Auth
|
||||
|
||||
Reference in New Issue
Block a user