import { Activity, AlertCircle, ArrowDown, ArrowLeft, ArrowUp, Bell, Bookmark, Boxes, CheckCircle2, ChevronLeft, ChevronRight, Clock3, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Film, Folder, FolderPlus, HardDrive, Grid2X2, GripVertical, Image as ImageIcon, KeyRound, Library, List, ListPlus, LockKeyhole, LogOut, Menu, MoreVertical, Maximize, Monitor, Moon, Pause, Pencil, PictureInPicture, Play, Plus, RefreshCw, Search, Settings, Share2, ShieldCheck, SkipBack, SkipForward, Sparkles, Sun, Tags, ThumbsUp, Trash2, Upload, UserCircle2, Users, Volume2, VolumeX, Wifi, X } from "lucide-react"; import { type CSSProperties, type DragEvent as ReactDragEvent, FormEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type TouchEvent as ReactTouchEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type HlsInstance from "hls.js"; import { api, appBase, appMediaUrl, appUrl, downloadGeneratedBackup, restoreBackup, setCsrf, setGatewayMediaToken, setGatewaySession, uploadImage } from "./api"; type Page = "home" | "search" | "library" | "series" | "tags" | "actors" | "uploads" | "profile" | "settings" | "player"; type Source = { id: string; kind: "local" | "webdav"; name: string; status: string; enabled: boolean; last_scan_at?: string; last_error?: string; has_password?:boolean; config: Record; }; type Video = { id: string; source_id: string; display_name: string; title: string; source_key: string; source_name: string; source_kind: string; source_driver: string; source_mode?: string; size_bytes: number; modified_at?: string; duration_ms?: number; width?: number; height?: number; status: string; available: boolean; error?: string; catalog_code?: string; studio?: string; series?: string; release_date?: string; description?: string; actors: string[]; tags: string[]; collection_id?:string;collection_name?:string;collection_position?:number; collection_item_id?:string;collection_parent_id?:string|null;collection_path?:string[]; tag_items: TagItem[]; liked: boolean; favorited: boolean; progress_ms: number; completed: boolean; last_played_at?: string; created_at?: string; updated_at?: string; thumbnail_url?: string; playback_url: string; download_url: string; }; type Result = { frame_id: string; video_id: string; display_name: string; source_name: string; source_kind: string; timestamp_ms: number; segment_start_ms: number; segment_end_ms: number; thumbnail_url: string; playback_url: string; score: number; near_duplicate?: boolean; match_sources: string[]; match_details?: Array<{type:string;text:string;start_ms:number;end_ms:number}>; catalog_code?: string; }; type SearchCoverage = {available:boolean;model_version?:string;total:number;indexed:number;searchable:number;empty:number;queued:number;running:number;failed:number;low_quality?:number;pending:number;percent:number;current_model_percent:number}; type TranscriptItem = {id:string;start_ms:number;end_ms:number;raw_text:string;language?:string;quality_score?:number}; type TranscriptPage = {status:"ready"|"filtered"|"low_quality"|"empty"|"queued"|"running"|"failed"|"unavailable"|"pending";model_version?:string;indexed_version?:string;detected_language?:string;quality_score?:number;quality_state:"ready"|"filtered"|"low_quality"|"empty"|"legacy";rejected_segments:number;quality_flags:string[];aggregate_risk?:boolean;repeated_phrases?:{text:string;count:number}[];quality_score_semantics?:"rule_check";items:TranscriptItem[];page:number;page_size:number;total:number;pages:number;job?:Pick}; type SpeechConfig = {language_policy:"zh_priority"|"auto"|"zh";quality_profile:"speed"|"balanced"|"accuracy";model_variant:"small"|"medium";index_revision:number}; type RecognitionType = "visual"|"ocr"|"person"|"audio"|"subtitle"|"metadata"; type UploadTask = { id: string; filename: string; title?: string; source_id: string; status: string; progress: number; bytes_received: number; size_bytes: number; message?: string; error?: string; relative_path: string; chunk_size: number; total_chunks: number; received_chunks: number[]; stage?:string;stage_label?:string;stages?:Array<{key:string;state:"pending"|"active"|"completed"|"failed"|"cancelled";progress:number;label:string}>;failure_stage?:"transfer"|"commit"|"catalog"; can_cancel?:boolean;can_retry?:boolean; content_sha256?:string;deduplicated?:boolean;retry_count?:number;next_retry_at?:string; transferred_bytes?:number;resume_mode?:"restart"|"offset"|"deduplicated"; phase?:"receiving"|"queued"|"encrypting"|"transferring"|"external_copying"|"external_verifying"|"indexing"|"completed"|"failed"|"cancelled"; storage_backend?:"legacy_webdav"|"openlist_native";receive_progress?:number; external_progress?:number;external_status?:string;external_task_ref?:string; collection_id?:string;collection_parent_id?:string;tag_ids?:string[];origin?:"browser"|"webdav"; }; type UploadPage = {items:UploadTask[];status_items:UploadTask[];active_count:number;failed_count:number;page:number;page_size:number;total:number;pages:number}; type DownloadTask = { id:string;kind:"url"|"magnet"|"torrent";display_name:string;source_id:string;relative_path:string; status:string;progress:number;total_bytes:number;completed_bytes:number;download_speed:number; error?:string;files:string[];created_at:string; }; type Person = { id: string; name: string; face_count: number; thumbnail_url?: string; is_named: boolean; hidden?: boolean; actor_id?: string }; type Actor = { id: string; name: string; video_count: number; thumbnail_url?: string; person_id?: string; aliases?: string[] }; type JobLane = "ai"|"transfer"|"download"|"scan"; type InferenceDiagnostics = {requested_device?:string;actual_device?:string;primary_backend?:string;language_candidates?:string[];selected_language?:string;fallback_scope?:"job"|"component"|null;fallback_reason?:string|null;gpu_quality_score?:number|null;cpu_verification_quality?:number|null}; type Job = { id:string;kind:string;lane?:JobLane;status:string;progress:number;message?:string;error?:string;attempts:number;created_at:string;started_at?:string;finished_at?:string;heartbeat_at?:string;pause_reason?:string;cancel_requested?:number;inference_diagnostics?:InferenceDiagnostics }; type JobPage = {items:Job[];page:number;page_size:number;total:number;pages:number;retryable_failed_count:number}; type FileItem = { key: string; name: string; is_dir: boolean; size_bytes: number; modified_at?: string }; type TrashItem = { id: string; source_id: string; display_name: string; original_key: string; size_bytes: number; deleted_at: string; expires_at: string }; type TagItem = { id: string; name: string; group_id: string; group_name: string; selection_mode: "single"|"multi" }; type Tag = TagItem & { ai_enabled: boolean; ai_method: "visual"|"text"; ai_description: string; ai_threshold: number; match_terms: string[]; video_count: number }; type TagGroup = { id: string; name: string; selection_mode: "single"|"multi"; sort_order: number; tag_count: number; video_count: number; tags: Tag[] }; type Suggestion = { id: string; video_id: string; video_title?: string; tag_id: string; tag_name: string; group_id: string; group_name: string; selection_mode: "single"|"multi"; confidence: number; status: string; evidence: Array<{kind:string;text?:string;frame_id?:string;thumbnail_url?:string;timestamp_ms?:number;start_ms?:number;score?:number}> }; type ThemePreference = "system"|"light"|"dark"; type CollectionViewMode = "index"|"detail"|"manage"; type CollectionRoute = {mode:CollectionViewMode;id?:string}; type Preferences = { autoplay: boolean; mask_covers: boolean; theme:ThemePreference; home_video_columns:1|2; upload_paths: Record; notifications_seen_at?: string }; type ActivityItem = { id: string; type: string; kind: string; status: string; message?: string; error?: string; timestamp: string; progress?: number }; type BackupStatus = { empty:boolean;can_restore:boolean;blockers:Record;business_counts:Record;supported_format_versions:number[];max_upload_bytes:number;max_extract_bytes:number }; type BackupResult = { scope:"keys"|"full";created_at:string;source_app_version:string;counts:Record;scan_jobs:Array<{source_id:string;job_id:string}>;warnings:string[] }; type BackupExport = {id:string;job_id?:string;scope:"keys"|"full";status:"queued"|"running"|"completed"|"failed";filename?:string;size_bytes?:number;error?:string;created_at?:string;finished_at?:string;expires_at?:string}; type Profile = {nickname:string;avatar:{text:string;color:string;accent?:string};role:string;created_at?:string;counts:{favorites:number;likes:number;history:number;continue_watching:number}}; type CollectionItem = {id:string;name:string;description:string;cover_video_id?:string;video_count:number;duration_ms:number;thumbnail_url?:string;created_at?:string;updated_at?:string}; type HomeVideoSection = {id:string;title:string;total:number;items:Video[];tag_id?:string;group_name?:string}; type HomeFeed = {video_count:number;updated_at?:string;recent:HomeVideoSection;collections:{total:number;items:CollectionItem[]};categories:HomeVideoSection[];people:Actor[];unorganized:HomeVideoSection|null}; type CollectionTreeItem = {id:string;collection_id:string;parent_id?:string|null;kind:"group"|"video";name?:string;video_id?:string;position:number;path:string[];duration_ms:number;video_count?:number;children?:CollectionTreeItem[];video?:Video}; type CollectionDetail = CollectionItem & {videos:Video[];items:CollectionTreeItem[]}; type VideoMarker = {id:string;video_id:string;position_ms:number;title?:string;created_at:string;updated_at:string}; type StorageEntry = {bytes:number|null;available:boolean;mode?:string;reason?:string;active_bytes?:number;reclaimable_bytes?:number;entries?:number;children?:Record;locations?:Array<{source_id:string;name:string;path:string;bytes:number;available:boolean;reason?:string}>;instances?:Array<{source_id:string;instance_id:string;state:string;active_refs:number;cache_bytes:number;runtime_bytes:number;last_access_at?:string|null}>}; type StorageUsage = {schema_version?:number;collected_at?:string;disk:{total_bytes:number;used_bytes:number;available_bytes:number};app_bytes:number;categories:Record;cached_seconds?:number}; type SourceCapacity = {source_id:string;scope:"local"|"remote";status:"available"|"unsupported"|"unreachable";total_bytes:number;used_bytes:number;available_bytes:number;provider:"local"|"rclone"|"webdav"|"manual";collected_at:string;cached_seconds:number;reason?:string}; type ResourceLaneStatus = {limit:number;running:number;queued:number;paused:number;pressure_reason?:string}; type DatabaseResourceStatus = {engine:"postgresql";journal_mode:"server";writer_queue_depth:number;writer_active:boolean;last_wait_ms:number;max_wait_ms:number;lock_retry_count:number;slow_transaction_count:number;pool_size?:number;pool_in_use?:number;pool_idle?:number;pool_waiters?:number}; type ResourceProfile = "quiet"|"balanced"|"turbo"; type ResourceIoStatus = {supported:boolean;state:"normal"|"paused"|"emergency";scope?:"data_volume"|"system";some_avg10:number;some_avg60:number;full_avg10:number;full_avg60:number;effective_limit_bytes:number;volume_supported?:boolean;volume_sampled?:boolean;volume_device?:string;volume_mount_point?:string;volume_in_flight?:number;volume_utilization_percent?:number}; type AiGpuMode = "low"|"balanced"|"throughput"; type ResourceStatus = {profile:ResourceProfile;ai_gpu_mode?:AiGpuMode;ai_gpu_profile?:{mode:AiGpuMode;selected_mode?:AiGpuMode;degraded?:boolean;hint:string;streams:number;batch_size:number};gpu?:{supported:boolean;utilization_percent?:number|null;engines:Record;memory_supported:boolean;memory_total_bytes?:number|null;memory_used_bytes?:number|null;collector?:string|null;reason?:string|null;collected_at:string};manual_pause:boolean;pressure_reason?:string;running_jobs:number;paused_jobs:number;cpu_percent:number;memory_total_bytes:number;memory_available_bytes:number;disk_available_bytes:number;database?:DatabaseResourceStatus;lanes?:Record;policy:{cpu_pause_percent:number;cpu_resume_percent:number;memory_reserve_mb:number;memory_reserve_percent:number;disk_reserve_gb:number};io?:ResourceIoStatus;remote_cache?:{limit_bytes:number;used_bytes:number;active_bytes:number;evictable_bytes:number;entries:number;downloading:number};rclone?:{managed:number;orphaned:number;draining:number;verifying:number;unresolved:number;reclaimed:number;last_checked_at?:string;next_check_seconds:number;reasons:Record;cache_bytes:number;vfs_cache_bytes?:number;active_cache_bytes?:number;reclaimable_cache_bytes?:number;runtime_bytes?:number;encryption_staging_bytes?:number;instances?:StorageEntry["instances"]}}; type WebDavServerConfig = {enabled:boolean;source_id:string|null;relative_path:string;url:string;username:string;direct_access:boolean;direct_port:number;gateway_supported:false}; type PullRefreshPhase = "idle"|"pulling"|"ready"|"refreshing"|"settling"; type PlayerPointerState = {id:number;x:number;y:number;position:number;preview:number;axis:"pending"|"horizontal"|"vertical";held:boolean;priorRate:number;pointerType:string;controlsVisibleAtStart:boolean}; type AccessMode = "direct" | "gateway"; type ModelComponent = "visual"|"ocr"|"faces"|"audio"; type ModelInstallation = {job_id:string;status:"queued"|"running"|"failed"|"completed";progress:number;message?:string;error?:string}; type LoadStatus = {phase:"idle"|"loading"|"ready"|"error";refreshing:boolean;error?:string}; type AppResource = "home"|"sources"|"videos"|"uploads"|"downloads"|"people"|"actors"|"groups"|"preferences"|"activity"|"profile"|"collections"; type AppHistoryState = | {kind:"page";page:Page;scrollY:number;videoId?:string;position?:number;homeSection?:string;collectionId?:string;collectionMode?:CollectionViewMode} | {kind:"more";page:Page;scrollY:number}; type DialogRequest = { kind:"confirm"|"input"|"choice";title:string;message?:string;defaultValue?:string;danger?:boolean; confirmLabel?:string;cancelLabel?:string; resolve:(value:boolean|string|null)=>void; }; function normalizeCollectionDetail(value:CollectionDetail):CollectionDetail{ const videos=Array.isArray(value.videos)?value.videos:[]; if(Array.isArray(value.items))return {...value,videos}; return {...value,videos,items:videos.map((video,position)=>({id:video.collection_item_id||`legacy-${video.id}`,collection_id:value.id,parent_id:null,kind:"video" as const,video_id:video.id,position,path:[],duration_ms:video.duration_ms||0,video}))}; } let showAppDialog:((request:DialogRequest)=>void)|null=null; let showToast:((message:string,type?:"success"|"error")=>void)|null=null; function appConfirm(title:string,message="",danger=false){return new Promise(resolve=>showAppDialog?.({kind:"confirm",title,message,danger,resolve:value=>resolve(value===true)}))} function appPrompt(title:string,defaultValue="",message=""){return new Promise(resolve=>showAppDialog?.({kind:"input",title,message,defaultValue,resolve:value=>resolve(typeof value==="string"?value:null)}))} function appChoice(title:string,message:string,confirmLabel:string,cancelLabel:string,danger=false){return new Promise(resolve=>showAppDialog?.({kind:"choice",title,message,confirmLabel,cancelLabel,danger,resolve:value=>resolve(typeof value==="boolean"?value:null)}))} function toast(message:string,type:"success"|"error"="success"){showToast?.(message,type)} const idleLoad=():LoadStatus=>({phase:"idle",refreshing:false}); function mergeLoadStatus(...values:LoadStatus[]):LoadStatus{ const initial=values.find(value=>value.phase==="idle"||value.phase==="loading"); if(initial)return {phase:initial.phase,refreshing:false,error:initial.error}; const failed=values.find(value=>value.phase==="error"); if(failed)return failed; return {phase:"ready",refreshing:values.some(value=>value.refreshing),error:values.find(value=>value.error)?.error}; } function AsyncNotice({label="正在加载数据…",compact=false}:{label?:string;compact?:boolean}){ return
{label}
; } function AsyncBoundary({status,label,onRetry,children}:{status:LoadStatus;label:string;onRetry:()=>void;children:ReactNode}){ if(status.phase==="idle"||status.phase==="loading")return
; if(status.phase==="error")return

{label}加载失败

{status.error||"暂时无法读取数据,请稍后重试。"}

; return <>{(status.refreshing||status.error)&&
{status.error?:}{status.error?`${label}更新失败:${status.error}`:`正在更新${label}…`}{status.error&&}
}{children}; } function mobileScrollContainer(){ if(!window.matchMedia("(max-width: 720px)").matches)return null; return document.querySelector(".content"); } function scrollPosition(){return mobileScrollContainer()?.scrollTop??window.scrollY} function scrollToPosition(top=0,behavior:ScrollBehavior="auto"){ const container=mobileScrollContainer(); if(container){container.scrollTo({top,behavior});return} window.scrollTo({top,behavior}); } function scrollInstant(top=0){ const target=mobileScrollContainer();const root=target||document.documentElement;const prior=root.style.scrollBehavior; root.style.scrollBehavior="auto";scrollToPosition(top,"auto");root.style.scrollBehavior=prior; } const THEME_STORAGE_KEY="imagefind:theme"; function storedTheme():ThemePreference{ const value=document.documentElement.dataset.themePreference||localStorage.getItem(THEME_STORAGE_KEY); return value==="light"||value==="dark"?value:"system"; } function applyTheme(theme:ThemePreference){ const dark=theme==="dark"||(theme==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches); document.documentElement.dataset.theme=dark?"dark":"light"; document.documentElement.dataset.themePreference=theme; document.documentElement.style.colorScheme=dark?"dark":"light"; document.querySelector('meta[name="theme-color"]')?.setAttribute("content",dark?"#0f1115":"#f7f8fa"); localStorage.setItem(THEME_STORAGE_KEY,theme); } function AppDialogHost(){ const [request,setRequest]=useState(null);const [value,setValue]=useState("");const input=useRef(null); const close=useCallback((result:boolean|string|null)=>setRequest(current=>{current?.resolve(result);return null}),[]); useEffect(()=>{showAppDialog=next=>{setRequest(next);setValue(next.defaultValue||"")};return()=>{showAppDialog=null}},[]); useEffect(()=>{if(!request)return;input.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==="Escape")close(null)};window.addEventListener("keydown",key);return()=>window.removeEventListener("keydown",key)},[close,request]); if(!request)return null; const dismiss=request.kind==="confirm"?false:null; return
{if(event.target===event.currentTarget)close(dismiss)}}>
{event.preventDefault();close(request.kind==="input"?value:true)}}>

ImageFind

{request.title}

{request.message&&

{request.message}

}{request.kind==="input"&&}
} function ToastHost(){ const [items,setItems]=useState>([]); useEffect(()=>{showToast=(message,type="success")=>{const id=Date.now()+Math.random();setItems(current=>[...current,{id,message,type}]);window.setTimeout(()=>setItems(current=>current.filter(item=>item.id!==id)),2600)};return()=>{showToast=null}},[]); return
{items.map(item=>
{item.type==="success"?:}{item.message}
)}
} function formatTime(ms = 0) { const total = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`; } function formatSize(value = 0) { if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); return `${(value / 1024 ** index).toFixed(index > 2 ? 2 : index ? 1 : 0)} ${units[index]}`; } const mediaExtensionPattern="(?:mp4|m4v|mov|mkv|webm|avi|wmv|flv|ts|m2ts|mts|mpg|mpeg|3gp)"; function cleanMediaTitle(value:string){ const raw=(value||"").trim(); const leaf=raw.split(/[\\/]/).pop()?.trim()||raw; const hashOnly=leaf.match(/^([a-f\d]{32,64})(?:\.[a-z\d]{2,5})*$/i); if(hashOnly)return `未命名影片 · ${hashOnly[1].slice(0,8)}`; let title=leaf .replace(new RegExp(`\\.[a-f\\d]{32,64}(?:\\.${mediaExtensionPattern})*$`,"i"),"") .replace(new RegExp(`^(?:[a-f\\d]{32,64})[\\s._-]+`,"i"),"") .replace(new RegExp(`(?:\\.${mediaExtensionPattern})+$`,"i"),"") .replace(/_+/g," ") .replace(/^[\s.\-_·—–]+|[\s.\-_·—–]+$/g,"") .trim(); if(!title)title=leaf.replace(/\.[^.]+$/,"").trim(); if(/^[a-f\d]{32,64}$/i.test(title))return `未命名影片 · ${title.slice(0,8)}`; return title||"未命名影片"; } function mediaTitle(video:Pick){ const title=(video.title||"").trim();const fallback=(video.display_name||"").trim(); return title&&title!==fallback?title:cleanMediaTitle(fallback||title); } function resourceIoSummary(io?:ResourceIoStatus){ if(!io?.supported)return "当前系统不提供 I/O 指标,继续使用 CPU、内存和剩余空间保护"; const limit=`后台上限 ${formatSize(io.effective_limit_bytes||0)}/s`; if(io.scope==="data_volume"){ if(!io.volume_sampled)return `正在采样应用数据卷${io.volume_mount_point?` ${io.volume_mount_point}`:""} · ${limit}`; return `应用数据卷利用率 ${Number(io.volume_utilization_percent||0).toFixed(1)}% · 全局等待 ${Number(io.full_avg10||0).toFixed(1)}%(仅供参考) · ${limit}`; } return `整机部分等待 ${Number(io.some_avg10||0).toFixed(1)}% · 全部等待 ${Number(io.full_avg10||0).toFixed(1)}% · ${limit}`; } const storageLabels:Record={database:"数据库",ai_index:"AI 索引",models:"模型",thumbnails:"缩略图",preview_cache:"预览与搜索图片",remote_cache:"远程访问缓存",upload_staging:"上传与下载暂存",other:"其他应用数据",remote_media_cache:"AI 远程媒体",rclone_vfs_cache:"rclone VFS 缓存",rclone_runtime:"rclone 运行文件",upload_receive:"上传接收暂存",download_staging:"后台下载暂存",encryption_work:"加密工作目录",openlist_local_staging:"OpenList 本地中转",query_images:"搜索图片",hls_preview:"HLS 预览"}; function storageLabel(key:string){return storageLabels[key]||key} function storageEntryNote(value:StorageEntry){ if(value.available===false)return value.reason||"当前无法统计"; const active=value.active_bytes||0, reclaimable=value.reclaimable_bytes||0; if(active||reclaimable)return `活动 ${formatSize(active)} · 可清理 ${formatSize(reclaimable)}`; return ""; } function StorageBreakdown({children}:{children:Record}){ return
{Object.entries(children).map(([key,value])=>
{storageLabel(key)}{storageEntryNote(value)&&{storageEntryNote(value)}}{value.locations?.length? {value.locations.length} 个 OpenList 中转路径:null}
{value.available===false?"无法统计":formatSize(value.bytes||0)}
)}
; } function AvatarMark({profile,className="",loading=false}:{profile:Profile|null;className?:string;loading?:boolean}){ if(loading)return ; const primary=profile?.avatar.color||"#3974dc";const accent=profile?.avatar.accent||"#6f9df1"; return {profile?.avatar.text||"IF"}; } function jobLabel(kind:string){ return ({index_video:"解析视频资料",transcribe_audio:"识别视频音频",scan_source:"扫描媒体库",refresh_path:"刷新上传文件",install_models:"安装 AI 模型",prepare_ai_runtime:"修复 AI 运行环境",suggest_tags:"生成标签建议",transfer_upload:"传输上传文件",aria2_download:"后台下载"} as Record)[kind]||kind.replaceAll("_"," "); } const jobLaneLabels:Record={ai:"AI 识别",transfer:"上传转存",download:"后台下载",scan:"来源扫描"}; function jobLaneForKind(kind:string):JobLane{ if(["transfer_upload","refresh_path"].includes(kind))return "transfer"; if(kind==="aria2_download")return "download"; if(kind==="scan_source")return "scan"; return "ai"; } function sourceLabel(source?: Source | Video) { if (!source) return "私有媒体库"; const mode = "config" in source ? source.config.mode : source.source_mode; const driver = "config" in source ? source.config.driver : source.source_driver; const backend = "config" in source ? source.config.storage_backend : undefined; if (backend === "openlist_native") return mode === "encrypted" ? "OpenList 后台加密库" : "OpenList 后台复制"; if (driver === "alist") return mode === "encrypted" ? "AList 加密库" : "AList 直连"; return ("config" in source ? source.kind : source.source_kind) === "local" ? "本地媒体库" : "WebDAV"; } function normalizeUploadPage(value:UploadPage|UploadTask[],requestedPage:number,pageSize=10):UploadPage{ if(!Array.isArray(value))return value; const active=value.filter(item=>["receiving","queued","transferring","indexing"].includes(item.status)); const failed=value.filter(item=>item.status==="failed");const pages=Math.max(1,Math.ceil(value.length/pageSize));const page=Math.min(Math.max(1,requestedPage),pages); return {items:value.slice((page-1)*pageSize,page*pageSize),status_items:[...active,...failed],active_count:active.length,failed_count:failed.length,page,page_size:pageSize,total:value.length,pages}; } function UploadStatusButton({uploads,uploadCounts,downloads=[],onUpload,onCenter,label="上传",alwaysCenter=false,className="",loading=false}:{uploads:UploadTask[];uploadCounts?:Pick;downloads?:DownloadTask[];onUpload:()=>void;onCenter:()=>void;label?:string;alwaysCenter?:boolean;className?:string;loading?:boolean}){ const activeUploads=uploads.filter(item=>["receiving","queued","transferring","indexing"].includes(item.status)); const activeDownloads=downloads.filter(item=>["queued","downloading","transferring"].includes(item.status)); const failedUploads=uploads.filter(item=>item.status==="failed");const failedDownloads=downloads.filter(item=>item.status==="failed"); const activeCount=(uploadCounts?.active_count??activeUploads.length)+activeDownloads.length;const failedCount=(uploadCounts?.failed_count??failedUploads.length)+failedDownloads.length; const previous=useRef(activeCount);const [success,setSuccess]=useState(false); useEffect(()=>{if(previous.current>0&&!activeCount&&!failedCount){setSuccess(true);const timer=window.setTimeout(()=>setSuccess(false),1800);previous.current=activeCount;return()=>window.clearTimeout(timer)}previous.current=activeCount},[activeCount,failedCount]); const count=failedCount||activeCount;const mode=loading?"loading":failedCount?"failed":activeCount?"active":success?"success":"idle"; const firstDownload=activeDownloads[0]; const stage=failedCount?`${failedCount} 个任务失败`:activeUploads[0]?.message||(firstDownload?`${firstDownload.display_name} · 后台下载 ${Math.round(firstDownload.progress*100)}%`:"传输任务进行中"); const description=mode==="loading"?"正在读取传输任务状态":mode==="idle"?"选择影片上传或新建后台下载":mode==="success"?"传输任务已完成":`${stage},共 ${count} 个任务`; const icon=mode==="loading"?:mode==="failed"?:mode==="success"?:activeDownloads.length&&!activeUploads.length?:; return } function PreferenceSwitch({title,description,checked,onChange,disabled=false}:{title:string;description:string;checked:boolean;onChange:(value:boolean)=>void;disabled?:boolean}){ return
{title}{description}
; } function ThemeSelector({value,onChange}:{value:ThemePreference;onChange:(value:ThemePreference)=>void}){ const options:Array<[ThemePreference,string,ReactNode]>=[["system","跟随系统",],["light","浅色",],["dark","深色",]]; return
外观主题跟随系统时会随手机或电脑的外观自动切换
{options.map(([key,label,icon])=>)}
; } function AuthScreen({ configured, onReady }: { configured: boolean; onReady: () => void }) { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [rememberDevice, setRememberDevice] = useState(true); async function submit(event: FormEvent) { event.preventDefault(); setError(""); if (!configured && password !== confirm) return setError("两次输入的密码不一致"); setBusy(true); try { const value = await api<{ csrf_token: string }>(configured ? "/auth/login" : "/setup", { method: "POST", body: JSON.stringify({ password, remember_device: rememberDevice }) }); setCsrf(value.csrf_token); onReady(); } catch (reason) { setError((reason as Error).message); } finally { setBusy(false); } } return
ImageFind

私有媒体空间

{configured ? "进入你的私有空间" : "创建管理员"}

{configured ? "媒体、搜索记录与 AI 索引仅保存在你的 NAS。" : "设置独立密码,保护本机私有媒体库。"}

{!configured && } {error &&
{error}
}

单管理员 · 本地 AI · 不上传分析数据

; } function GatewayAccessScreen({message}:{message:string}){ return
ImageFind

飞牛网关验证

需要飞牛管理员身份

{message}

; } function Thumb({ src, title, loading="lazy" }: { src?: string; title: string; loading?: "eager"|"lazy" }) { const url=appMediaUrl(src); const [settled,setSettled]=useState<{url:string;state:"loaded"|"error"}|null>(null); const state: "loading"|"loaded"|"error"|"missing"=!url?"missing":settled?.url===url?settled.state:"loading"; return {url&&{title}setSettled({url,state:"loaded"})} onError={()=>setSettled({url,state:"error"})}/>} {state!=="loaded"&&{state==="loading"?:state==="error"?:}{state==="loading"?"封面加载中":state==="error"?"封面加载失败":"暂无封面"}} ; } function VideoCard({ video, onPlay, hidden, selecting=false, selected=false, onSelect }: { video:Video;onPlay:(video:Video)=>void;hidden:boolean;selecting?:boolean;selected?:boolean;onSelect?:(video:Video)=>void }) { const displayTitle=mediaTitle(video); const summary=[video.catalog_code,video.actors.slice(0,2).join(" / ")].filter(Boolean).join(" · "); const categoryTags=[...new Map(video.tag_items.map(tag=>[tag.id,tag])).values()]; const visibleTags=categoryTags.slice(0,2);const hiddenTagCount=Math.max(0,categoryTags.length-visibleTags.length); const categoryDescription=categoryTags.length?`,分类 ${categoryTags.map(tag=>tag.name).join("、")}`:""; const activate=()=>selecting?onSelect?.(video):onPlay(video); return
{if(event.key==="Enter"||event.key===" "){event.preventDefault();activate()}}}>
{!!visibleTags.length&&} {formatTime(video.duration_ms)} {selecting&&

{displayTitle}

{summary&&

{summary}

}
; } function useRailState(itemCount:number,density:number){ const ref=useRef(null);const [edge,setEdge]=useState({start:true,end:false}); const update=useCallback(()=>{const node=ref.current;if(!node)return;setEdge({start:node.scrollLeft<=2,end:node.scrollLeft+node.clientWidth>=node.scrollWidth-2})},[]); useLayoutEffect(()=>{const node=ref.current;if(!node)return;update();const resize=new ResizeObserver(update);resize.observe(node);node.addEventListener("scroll",update,{passive:true});return()=>{resize.disconnect();node.removeEventListener("scroll",update)}},[itemCount,density,update]); const move=(direction:-1|1)=>{const node=ref.current;if(!node)return;node.scrollBy({left:direction*node.clientWidth*.86,behavior:window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth"})}; return {ref,edge,move}; } function HomeVideoRail({section,columns,hidden,onPlay,onViewAll,showLayout,onColumns}:{section:HomeVideoSection;columns:1|2;hidden:boolean;onPlay:(video:Video)=>void;onViewAll:()=>void;showLayout?:boolean;onColumns?:(value:1|2)=>void}){ const {ref:trackRef,edge,move}=useRailState(section.items.length,columns); void showLayout;void onColumns; return

{section.title}

{section.group_name?`${section.group_name} · `:""}{section.total} 部影片

{section.items.map(video=>
; } function HomeCollectionRail({collections,total,onOpen,onViewAll}:{collections:CollectionItem[];total:number;onOpen:(id:string)=>void;onViewAll:()=>void}){ const {ref:trackRef,edge,move}=useRailState(collections.length,2); return

合集

{total} 个适合连续观看的合集

{collections.map(collection=>)}
; } function ContinueCard({video,onPlay,hidden}:{video:Video;onPlay:(video:Video,position?:number)=>void;hidden:boolean}){ const progress=Math.min(100,video.duration_ms?video.progress_ms/video.duration_ms*100:0); const displayTitle=mediaTitle(video); return
onPlay(video,video.progress_ms)} onKeyDown={event=>{if(event.key==="Enter"||event.key===" "){event.preventDefault();onPlay(video,video.progress_ms)}}}>
{formatTime(video.duration_ms)}

{displayTitle}

上次看到 {formatTime(video.progress_ms)} · 已观看 {Math.round(progress)}%

} function PlayerPage({ video, startMs, videos, collections, onBack, onPlay, onEdit, onDeleted, preferences, onPreferences, onState, onCollectionsChanged }: { video: Video; startMs: number; videos: Video[]; onBack: () => void; onPlay: (video: Video) => void; onEdit: () => void; onDeleted:()=>void|Promise;collections:CollectionItem[];preferences: Preferences; onPreferences:(value:Partial)=>void; onState:(video:Video,state:Partial