feat(video-library): implement video library processing and API integration

This commit is contained in:
shiyu
2025-12-16 18:02:46 +08:00
parent 8cf147bf34
commit 724f551b00
15 changed files with 1319 additions and 110 deletions

View File

@@ -5,9 +5,11 @@ from fastapi.responses import FileResponse
from domain.audit import AuditAction, audit
from domain.plugins.service import PluginService
from domain.plugins.routes import video_player as video_player_routes
from domain.plugins.types import PluginCreate, PluginManifestUpdate, PluginOut
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
router.include_router(video_player_routes.router)
@router.post("", response_model=PluginOut)

View File

@@ -0,0 +1,2 @@
"""插件专属服务端路由集合。"""

View File

@@ -0,0 +1,142 @@
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from api.response import success
from domain.auth.service import get_current_active_user
router = APIRouter(
prefix="/video-player",
tags=["plugins"],
dependencies=[Depends(get_current_active_user)],
)
DATA_ROOT = Path("data/.video")
def _read_json(path: Path) -> Dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _file_mtime_iso(path: Path) -> str:
try:
ts = path.stat().st_mtime
except FileNotFoundError:
return ""
return datetime.fromtimestamp(ts, tz=UTC).isoformat()
def _extract_title(payload: Dict[str, Any]) -> str:
detail = (payload.get("tmdb") or {}).get("detail") or {}
if payload.get("type") == "tv":
return str(detail.get("name") or detail.get("original_name") or "")
return str(detail.get("title") or detail.get("original_title") or "")
def _extract_year(payload: Dict[str, Any]) -> Optional[str]:
detail = (payload.get("tmdb") or {}).get("detail") or {}
value = detail.get("first_air_date") if payload.get("type") == "tv" else detail.get("release_date")
if not value or not isinstance(value, str):
return None
return value[:4] if len(value) >= 4 else value
def _extract_genres(payload: Dict[str, Any]) -> List[str]:
detail = (payload.get("tmdb") or {}).get("detail") or {}
genres = detail.get("genres") or []
out: List[str] = []
if isinstance(genres, list):
for g in genres:
if isinstance(g, dict) and g.get("name"):
out.append(str(g["name"]))
return out
def _summarize(item_id: str, payload: Dict[str, Any], mtime_iso: str) -> Dict[str, Any]:
detail = (payload.get("tmdb") or {}).get("detail") or {}
media_type = payload.get("type") or "unknown"
episodes = payload.get("episodes") or []
seasons = {e.get("season") for e in episodes if isinstance(e, dict) and e.get("season") is not None}
return {
"id": item_id,
"type": media_type,
"title": _extract_title(payload),
"year": _extract_year(payload),
"overview": detail.get("overview"),
"poster_path": detail.get("poster_path"),
"backdrop_path": detail.get("backdrop_path"),
"genres": _extract_genres(payload),
"tmdb_id": (payload.get("tmdb") or {}).get("id"),
"source_path": payload.get("source_path"),
"scraped_at": payload.get("scraped_at"),
"updated_at": mtime_iso,
"episodes_count": len(episodes) if isinstance(episodes, list) else 0,
"seasons_count": len(seasons),
"vote_average": detail.get("vote_average"),
"vote_count": detail.get("vote_count"),
}
def _iter_library_files() -> List[tuple[str, Path]]:
files: List[tuple[str, Path]] = []
for sub in ("tv", "movie"):
folder = DATA_ROOT / sub
if not folder.exists():
continue
for p in folder.glob("*.json"):
if not p.is_file():
continue
files.append((sub, p))
return files
@router.get("/library")
async def list_library(
q: str | None = Query(None, description="搜索关键字(标题/简介)"),
media_type: str | None = Query(None, alias="type", description="tv 或 movie"),
):
items: List[Dict[str, Any]] = []
keyword = (q or "").strip().lower()
type_filter = (media_type or "").strip().lower()
if type_filter and type_filter not in {"tv", "movie"}:
raise HTTPException(status_code=400, detail="type must be tv or movie")
for _sub, path in _iter_library_files():
item_id = path.stem
try:
payload = _read_json(path)
except Exception:
continue
if type_filter and str(payload.get("type") or "").lower() != type_filter:
continue
summary = _summarize(item_id, payload, _file_mtime_iso(path))
if keyword:
haystack = f"{summary.get('title') or ''} {summary.get('overview') or ''}".lower()
if keyword not in haystack:
continue
items.append(summary)
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
return success(items)
@router.get("/library/{item_id}")
async def get_library_item(item_id: str):
candidates = [
DATA_ROOT / "tv" / f"{item_id}.json",
DATA_ROOT / "movie" / f"{item_id}.json",
]
path = next((p for p in candidates if p.exists()), None)
if not path:
raise HTTPException(status_code=404, detail="Item not found")
payload = _read_json(path)
payload["id"] = item_id
payload["updated_at"] = _file_mtime_iso(path)
return success(payload)

View File

@@ -6,9 +6,11 @@ class BaseProcessor(Protocol):
supported_exts: list
config_schema: list
produces_file: bool
supports_directory: bool
requires_input_bytes: bool
async def process(self, input_bytes: bytes, path: str, config: Dict[str, Any]) -> bytes:
"""处理文件内容并返回处理后的内容"""
async def process(self, input_bytes: bytes, path: str, config: Dict[str, Any]) -> Any:
"""处理文件内容/路径并返回结果。produces_file=True 时应返回 bytes/Response。"""
...
# 约定:每个处理器需定义

View File

@@ -0,0 +1,396 @@
import hashlib
import json
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import httpx
from domain.virtual_fs.service import VirtualFSService
from domain.virtual_fs.thumbnail import VIDEO_EXT, is_video_filename
DATA_ROOT = Path("data/.video")
TMDB_BASE_URL = "https://api.themoviedb.org/3"
def _sha1(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def _store_path(media_type: str, source_path: str) -> Path:
subdir = "tv" if media_type == "tv" else "movie"
return DATA_ROOT / subdir / f"{_sha1(source_path)}.json"
def _write_json(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
_CLEAN_TAGS_RE = re.compile(
r"\b("
r"2160p|1080p|720p|480p|4k|hdr|dv|dolby|atmos|"
r"x264|x265|h264|h265|hevc|av1|aac|dts|flac|"
r"bluray|bdrip|web[- ]?dl|webrip|dvdrip|remux|proper|repack"
r")\b",
re.IGNORECASE,
)
def _clean_query_name(raw: str) -> str:
name = raw
name = name.replace(".", " ").replace("_", " ")
name = re.sub(r"\[[^\]]*\]", " ", name)
name = re.sub(r"\([^\)]*\)", " ", name)
name = _CLEAN_TAGS_RE.sub(" ", name)
name = re.sub(r"\s+", " ", name).strip()
return name
def _guess_name_from_path(path: str, is_dir: bool) -> str:
norm = path.rstrip("/") if is_dir else path
p = Path(norm)
raw = p.name if is_dir else p.stem
return _clean_query_name(raw)
def _as_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
if isinstance(value, str):
v = value.strip().lower()
if v in {"1", "true", "yes", "y", "on"}:
return True
if v in {"0", "false", "no", "n", "off"}:
return False
return default
_SXXEYY_RE = re.compile(r"[Ss](\d{1,2})\s*[.\-_ ]*\s*[Ee](\d{1,3})")
_X_RE = re.compile(r"(\d{1,2})x(\d{1,3})", re.IGNORECASE)
_CN_EP_RE = re.compile(r"\s*(\d{1,3})\s*[集话]")
_CN_SEASON_RE = re.compile(r"\s*(\d{1,2})\s*季")
_SEASON_WORD_RE = re.compile(r"Season\s*(\d{1,2})", re.IGNORECASE)
_S_RE = re.compile(r"[Ss](\d{1,2})")
def _parse_season_episode(rel_path: str) -> Tuple[Optional[int], Optional[int]]:
stem = Path(rel_path).stem
m = _SXXEYY_RE.search(stem) or _SXXEYY_RE.search(rel_path)
if m:
return int(m.group(1)), int(m.group(2))
m = _X_RE.search(stem)
if m:
return int(m.group(1)), int(m.group(2))
m = _CN_EP_RE.search(stem)
if m:
episode = int(m.group(1))
season = None
for part in reversed(Path(rel_path).parts[:-1]):
sm = _CN_SEASON_RE.search(part) or _SEASON_WORD_RE.search(part) or _S_RE.search(part)
if sm:
season = int(sm.group(1))
break
return season or 1, episode
m = re.match(r"^(\d{1,3})(?!\d)", stem)
if m:
episode = int(m.group(1))
season = None
for part in reversed(Path(rel_path).parts[:-1]):
sm = _CN_SEASON_RE.search(part) or _SEASON_WORD_RE.search(part) or _S_RE.search(part)
if sm:
season = int(sm.group(1))
break
return season or 1, episode
return None, None
class TMDBClient:
def __init__(self, access_token: str | None, api_key: str | None):
self._access_token = access_token
self._api_key = api_key
@classmethod
def from_env(cls) -> "TMDBClient":
access_token = os.getenv("TMDB_ACCESS_TOKEN")
api_key = os.getenv("TMDB_API_KEY")
if not access_token and not api_key:
raise RuntimeError("缺少 TMDB_ACCESS_TOKEN 或 TMDB_API_KEY")
return cls(access_token=access_token, api_key=api_key)
def _headers(self) -> dict:
headers = {"Accept": "application/json"}
if self._access_token:
headers["Authorization"] = f"Bearer {self._access_token}"
return headers
def _merge_params(self, params: dict) -> dict:
merged = dict(params or {})
if self._api_key:
merged.setdefault("api_key", self._api_key)
return merged
async def get(self, path: str, params: dict) -> dict:
url = f"{TMDB_BASE_URL}{path}"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, headers=self._headers(), params=self._merge_params(params))
resp.raise_for_status()
return resp.json()
class VideoLibraryProcessor:
name = "影视入库"
supported_exts = sorted(VIDEO_EXT)
config_schema = [
{
"key": "name",
"label": "手动名称(可选)",
"type": "string",
"required": False,
"placeholder": "留空则从路径提取",
},
{
"key": "language",
"label": "语言",
"type": "string",
"required": False,
"default": "zh-CN",
},
{
"key": "include_episodes",
"label": "电视剧:保存每集",
"type": "select",
"required": False,
"default": 1,
"options": [
{"label": "", "value": 1},
{"label": "", "value": 0},
],
},
]
produces_file = False
supports_directory = True
requires_input_bytes = False
async def process(self, input_bytes: bytes, path: str, config: Dict[str, Any]) -> Dict[str, Any]:
tmdb = TMDBClient.from_env()
is_dir = await VirtualFSService.path_is_directory(path)
language = str(config.get("language") or "zh-CN")
manual_name = str(config.get("name") or "").strip()
query_name = manual_name or _guess_name_from_path(path, is_dir=is_dir)
scraped_at = datetime.now(UTC).isoformat()
if is_dir:
payload, saved_to = await self._process_tv_dir(tmdb, path, query_name, language, scraped_at, config)
return {
"ok": True,
"type": "tv",
"path": path,
"tmdb_id": payload.get("tmdb", {}).get("id"),
"saved_to": str(saved_to),
}
payload, saved_to = await self._process_movie_file(tmdb, path, query_name, language, scraped_at)
return {
"ok": True,
"type": "movie",
"path": path,
"tmdb_id": payload.get("tmdb", {}).get("id"),
"saved_to": str(saved_to),
}
async def _process_movie_file(
self,
tmdb: TMDBClient,
path: str,
query_name: str,
language: str,
scraped_at: str,
) -> Tuple[dict, Path]:
search = await tmdb.get("/search/movie", {"query": query_name, "language": language})
results = search.get("results") or []
if not results:
raise RuntimeError(f"未找到电影条目:{query_name}")
chosen = results[0] or {}
movie_id = chosen.get("id")
if not movie_id:
raise RuntimeError("TMDB 搜索结果缺少 id")
detail = await tmdb.get(
f"/movie/{movie_id}",
{
"language": language,
"append_to_response": "credits,images,external_ids,videos",
},
)
payload = {
"type": "movie",
"source_path": path,
"query": {"name": query_name, "language": language},
"scraped_at": scraped_at,
"tmdb": {
"id": movie_id,
"search": {"page": search.get("page"), "total_results": search.get("total_results"), "results": results[:5]},
"detail": detail,
},
}
saved_to = _store_path("movie", path)
_write_json(saved_to, payload)
return payload, saved_to
async def _process_tv_dir(
self,
tmdb: TMDBClient,
path: str,
query_name: str,
language: str,
scraped_at: str,
config: Dict[str, Any],
) -> Tuple[dict, Path]:
search = await tmdb.get("/search/tv", {"query": query_name, "language": language})
results = search.get("results") or []
if not results:
raise RuntimeError(f"未找到电视剧条目:{query_name}")
chosen = results[0] or {}
tv_id = chosen.get("id")
if not tv_id:
raise RuntimeError("TMDB 搜索结果缺少 id")
detail = await tmdb.get(
f"/tv/{tv_id}",
{
"language": language,
"append_to_response": "credits,images,external_ids,videos",
},
)
include_episodes = _as_bool(config.get("include_episodes"), True)
episodes: List[dict] = []
seasons_detail: Dict[str, Any] = {}
if include_episodes:
episodes = await self._collect_episode_files(path)
seasons = sorted({ep["season"] for ep in episodes if ep.get("season") is not None})
for season in seasons:
seasons_detail[str(season)] = await tmdb.get(
f"/tv/{tv_id}/season/{int(season)}",
{"language": language},
)
self._attach_tmdb_episode_detail(episodes, seasons_detail)
payload = {
"type": "tv",
"source_path": path,
"query": {"name": query_name, "language": language},
"scraped_at": scraped_at,
"tmdb": {
"id": tv_id,
"search": {"page": search.get("page"), "total_results": search.get("total_results"), "results": results[:5]},
"detail": detail,
"seasons": seasons_detail,
},
"episodes": episodes,
}
saved_to = _store_path("tv", path)
_write_json(saved_to, payload)
return payload, saved_to
async def _collect_episode_files(self, dir_path: str) -> List[dict]:
adapter_instance, adapter_model, root, rel = await VirtualFSService.resolve_adapter_and_rel(dir_path)
rel = rel.rstrip("/")
list_dir = await VirtualFSService._ensure_method(adapter_instance, "list_dir")
stack: List[str] = [rel]
page_size = 200
out: List[dict] = []
while stack:
current_rel = stack.pop()
page = 1
while True:
entries, total = await list_dir(root, current_rel, page, page_size, "name", "asc")
entries = entries or []
if not entries and (total or 0) == 0:
break
for entry in entries:
name = entry.get("name")
if not name:
continue
child_rel = VirtualFSService._join_rel(current_rel, name)
if entry.get("is_dir"):
stack.append(child_rel.rstrip("/"))
continue
if not is_video_filename(name):
continue
absolute_path = VirtualFSService._build_absolute_path(adapter_model.path, child_rel)
rel_in_show = child_rel
if rel and child_rel.startswith(rel.rstrip("/") + "/"):
rel_in_show = child_rel[len(rel.rstrip("/")) + 1 :]
season, episode = _parse_season_episode(rel_in_show)
out.append(
{
"path": absolute_path,
"rel": rel_in_show,
"name": name,
"size": entry.get("size"),
"mtime": entry.get("mtime"),
"season": season,
"episode": episode,
}
)
if total is None or page * page_size >= total:
break
page += 1
return out
def _attach_tmdb_episode_detail(self, episodes: List[dict], seasons_detail: Dict[str, Any]) -> None:
episode_maps: Dict[str, Dict[int, Any]] = {}
for season_str, season_payload in (seasons_detail or {}).items():
items = (season_payload or {}).get("episodes") or []
m: Dict[int, Any] = {}
for item in items:
try:
number = int(item.get("episode_number"))
except Exception:
continue
m[number] = item
episode_maps[season_str] = m
for ep in episodes:
season = ep.get("season")
episode = ep.get("episode")
if season is None or episode is None:
continue
m = episode_maps.get(str(season))
if not m:
continue
detail = m.get(int(episode))
if detail:
ep["tmdb_episode"] = detail
PROCESSOR_TYPE = "video_library"
PROCESSOR_NAME = VideoLibraryProcessor.name
SUPPORTED_EXTS = VideoLibraryProcessor.supported_exts
CONFIG_SCHEMA = VideoLibraryProcessor.config_schema
PROCESSOR_FACTORY = lambda: VideoLibraryProcessor()

View File

@@ -74,6 +74,10 @@ def discover_processors(force_reload: bool = False) -> list[str]:
if produces_file is None and hasattr(sample, "produces_file"):
produces_file = getattr(sample, "produces_file")
supports_directory = getattr(module, "supports_directory", None)
if supports_directory is None and hasattr(sample, "supports_directory"):
supports_directory = getattr(sample, "supports_directory")
module_file = getattr(module, "__file__", None)
module_path: Optional[str] = None
if module_file:
@@ -101,6 +105,7 @@ def discover_processors(force_reload: bool = False) -> list[str]:
"supported_exts": normalized_exts,
"config_schema": schema,
"produces_file": produces_file if produces_file is not None else False,
"supports_directory": supports_directory if supports_directory is not None else False,
"module_path": module_path,
}

View File

@@ -35,14 +35,20 @@ class ProcessorService:
"supported_exts": meta.get("supported_exts", []),
"config_schema": meta["config_schema"],
"produces_file": meta.get("produces_file", False),
"supports_directory": meta.get("supports_directory", False),
"module_path": meta.get("module_path"),
})
return out
@classmethod
async def process_file(cls, req: ProcessRequest):
processor = cls.get_processor(req.processor_type)
if not processor:
raise HTTPException(404, detail="Processor not found")
is_dir = await VirtualFSService.path_is_directory(req.path)
if is_dir and not req.overwrite:
supports_directory = bool(getattr(processor, "supports_directory", False))
if is_dir and not supports_directory and not req.overwrite:
raise HTTPException(400, detail="Directory processing requires overwrite")
save_to = None if is_dir else (req.path if req.overwrite else req.save_to)

View File

@@ -105,7 +105,10 @@ class TaskQueueService:
if not processor:
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
file_content = await VirtualFSService.read_file(path)
requires_input_bytes = bool(getattr(processor, "requires_input_bytes", True))
file_content = b""
if requires_input_bytes:
file_content = await VirtualFSService.read_file(path)
result = await processor.process(file_content, path, auto_task.processor_config)
save_to = auto_task.processor_config.get("save_to")

View File

@@ -23,6 +23,11 @@ class VirtualFSProcessingMixin(VirtualFSTransferMixin):
raise HTTPException(400, detail=f"Processor {processor_type} not found")
actual_is_dir = await cls.path_is_directory(path)
requires_input_bytes = bool(getattr(processor, "requires_input_bytes", True))
if actual_is_dir and bool(getattr(processor, "supports_directory", False)):
if save_to:
raise HTTPException(400, detail="Directory processing does not support custom save_to path")
return await processor.process(b"", path, config)
supported_exts = getattr(processor, "supported_exts", None) or []
allowed_exts = {str(ext).lower().lstrip(".") for ext in supported_exts if isinstance(ext, str)}
@@ -76,7 +81,9 @@ class VirtualFSProcessingMixin(VirtualFSTransferMixin):
if not matches_extension(child_rel):
continue
absolute_path = cls._build_absolute_path(adapter_model.path, child_rel)
data = await cls.read_file(absolute_path)
data = b""
if requires_input_bytes:
data = await cls.read_file(absolute_path)
result = await processor.process(data, absolute_path, config)
if getattr(processor, "produces_file", False):
result_bytes = coerce_result_bytes(result)
@@ -89,7 +96,9 @@ class VirtualFSProcessingMixin(VirtualFSTransferMixin):
return {"processed_files": processed_count}
data = await cls.read_file(path)
data = b""
if requires_input_bytes:
data = await cls.read_file(path)
result = await processor.process(data, path, config)
target_path = save_to

View File

@@ -16,6 +16,7 @@ export interface ProcessorTypeMeta {
supported_exts: string[];
config_schema: ProcessorTypeField[];
produces_file: boolean;
supports_directory?: boolean;
module_path?: string | null;
}

View File

@@ -0,0 +1,35 @@
import request from './client';
export type VideoLibraryMediaType = 'tv' | 'movie';
export interface VideoLibraryItem {
id: string;
type: VideoLibraryMediaType;
title: string;
year?: string | null;
overview?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
genres?: string[];
tmdb_id?: number | null;
source_path?: string | null;
scraped_at?: string | null;
updated_at?: string | null;
episodes_count?: number;
seasons_count?: number;
vote_average?: number | null;
vote_count?: number | null;
}
export const videoLibraryApi = {
list: (params?: { q?: string; type?: VideoLibraryMediaType }) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.type) search.set('type', params.type);
const suffix = search.toString();
return request<VideoLibraryItem[]>(`/plugins/video-player/library${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
},
get: (id: string) =>
request<any>(`/plugins/video-player/library/${encodeURIComponent(id)}`, { method: 'GET' }),
};

View File

@@ -1,97 +1,660 @@
import React, { useMemo, useState } from 'react';
import { Card, Input, Tag, Typography, message } from 'antd';
import { PlayCircleOutlined, SearchOutlined } from '@ant-design/icons';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Button,
Card,
Collapse,
Descriptions,
Divider,
Drawer,
Empty,
Flex,
Image,
Input,
List,
Segmented,
Skeleton,
Space,
Tabs,
Tag,
Typography,
message,
theme,
} from 'antd';
import { PlayCircleOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { AppOpenComponentProps } from '../types';
import { videoLibraryApi, type VideoLibraryItem } from '../../api/videoLibrary';
import { useI18n } from '../../i18n';
import { ensureAppsLoaded, getAppByKey } from '../registry';
import { useAppWindows } from '../../contexts/AppWindowsContext';
import type { VfsEntry } from '../../api/client';
type MockVideoItem = {
id: string;
title: string;
year: number;
duration: string;
tags: string[];
};
type LibraryFilter = 'all' | 'tv' | 'movie';
const MOCK_VIDEOS: MockVideoItem[] = [
{ id: '1', title: '流浪地球 3预告', year: 2025, duration: '00:02:12', tags: ['科幻', '预告'] },
{ id: '2', title: '赛博雨夜', year: 2024, duration: '01:32:10', tags: ['悬疑', '都市'] },
{ id: '3', title: '夏日回声', year: 2023, duration: '01:18:45', tags: ['剧情', '治愈'] },
{ id: '4', title: '荒原追迹', year: 2022, duration: '01:45:02', tags: ['动作', '冒险'] },
{ id: '5', title: '月海电台', year: 2021, duration: '00:49:30', tags: ['纪录片'] },
{ id: '6', title: '时空备忘录', year: 2020, duration: '02:05:11', tags: ['科幻', '爱情'] },
];
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/';
function tmdbImage(path: string | null | undefined, size: string) {
if (!path) return undefined;
return `${TMDB_IMAGE_BASE}${size}${path}`;
}
function splitAbsolutePath(fullPath: string): { dir: string; name: string } | null {
const normalized = fullPath.replace(/\/+$/, '');
const idx = normalized.lastIndexOf('/');
if (idx < 0) return null;
const dir = idx === 0 ? '/' : normalized.slice(0, idx);
const name = normalized.slice(idx + 1);
if (!name) return null;
return { dir, name };
}
export const VideoLibraryApp: React.FC<AppOpenComponentProps> = () => {
const { token } = theme.useToken();
const { t } = useI18n();
const { openWithApp } = useAppWindows();
const [q, setQ] = useState('');
const [filter, setFilter] = useState<LibraryFilter>('all');
const [items, setItems] = useState<VideoLibraryItem[]>([]);
const [loading, setLoading] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [selected, setSelected] = useState<VideoLibraryItem | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<any | null>(null);
const loadLibrary = useCallback(async () => {
setLoading(true);
try {
const list = await videoLibraryApi.list();
setItems(list);
} catch (err: any) {
setItems([]);
const msg = err instanceof Error ? err.message : t('Load failed');
message.error(msg);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
loadLibrary();
}, [loadLibrary]);
const stats = useMemo(() => {
let tv = 0;
let movie = 0;
items.forEach((it) => {
if (it.type === 'tv') tv += 1;
if (it.type === 'movie') movie += 1;
});
return { total: items.length, tv, movie };
}, [items]);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return MOCK_VIDEOS;
return MOCK_VIDEOS.filter(v =>
v.title.toLowerCase().includes(s)
|| v.tags.some(tag => tag.toLowerCase().includes(s)),
);
}, [q]);
return items.filter((v) => {
if (filter !== 'all' && v.type !== filter) return false;
if (!s) return true;
const haystack = `${v.title || ''} ${v.overview || ''} ${(v.genres || []).join(' ')}`.toLowerCase();
return haystack.includes(s);
});
}, [filter, items, q]);
return (
<div style={{ padding: 12, height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<div style={{ flex: 1 }} />
<Input
allowClear
value={q}
onChange={(e) => setQ(e.target.value)}
prefix={<SearchOutlined />}
placeholder="搜索影片/标签"
style={{ maxWidth: 320 }}
/>
const playByPath = useCallback(async (fullPath: string) => {
const splitted = splitAbsolutePath(fullPath);
if (!splitted) return;
await ensureAppsLoaded();
const app = getAppByKey('video-player');
if (!app) {
message.error(t('App "{key}" not found.', { key: 'video-player' }));
return;
}
const entry: VfsEntry = { name: splitted.name, is_dir: false, size: 0, mtime: 0 };
openWithApp(entry, app, splitted.dir);
}, [openWithApp, t]);
const fetchDetail = useCallback(async (item: VideoLibraryItem) => {
setDetailLoading(true);
setDetail(null);
try {
const payload = await videoLibraryApi.get(item.id);
setDetail(payload);
} catch (err: any) {
setDetail(null);
const msg = err instanceof Error ? err.message : t('Load failed');
message.error(msg);
} finally {
setDetailLoading(false);
}
}, [t]);
const openDetail = (item: VideoLibraryItem) => {
setSelected(item);
setDetailOpen(true);
fetchDetail(item);
};
const closeDetail = () => {
setDetailOpen(false);
setSelected(null);
setDetail(null);
};
const renderCover = (item: VideoLibraryItem) => {
const label = item.type === 'tv' ? 'TV' : 'Movie';
const subtitle = item.type === 'tv'
? `${item.seasons_count || 0} ${t('Seasons')} · ${item.episodes_count || 0} ${t('Episodes')}`
: (item.year ? String(item.year) : '');
return (
<div
style={{
height: 150,
background: 'linear-gradient(135deg, #0b1020 0%, #22314a 55%, #2b3b5c 100%)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: 12,
color: 'rgba(255,255,255,0.92)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<Tag color={item.type === 'tv' ? 'geekblue' : 'gold'} style={{ marginInlineEnd: 0 }}>
{label}
</Tag>
<VideoCameraOutlined style={{ fontSize: 18, opacity: 0.9 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.9 }} />
<div style={{ minWidth: 0 }}>
<Typography.Text
strong
style={{ color: 'rgba(255,255,255,0.92)', display: 'block' }}
ellipsis
>
{item.title || '--'}
</Typography.Text>
{subtitle && (
<Typography.Text style={{ color: 'rgba(255,255,255,0.72)', fontSize: 12 }}>
{subtitle}
</Typography.Text>
)}
</div>
</div>
</div>
);
};
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
{filtered.map(v => (
<Card
key={v.id}
hoverable
size="small"
styles={{ body: { padding: 10 } } as any}
cover={(
const detailTitle = useMemo(() => {
const d = detail?.tmdb?.detail;
if (!d) return selected?.title || '';
if (detail?.type === 'tv') return d.name || d.original_name || selected?.title || '';
return d.title || d.original_title || selected?.title || '';
}, [detail, selected?.title]);
const detailPosterUrl = useMemo(() => tmdbImage(detail?.tmdb?.detail?.poster_path, 'w342'), [detail]);
const detailBackdropUrl = useMemo(() => tmdbImage(detail?.tmdb?.detail?.backdrop_path, 'w1280'), [detail]);
const detailGenres = useMemo(() => {
const genres = detail?.tmdb?.detail?.genres;
if (!Array.isArray(genres)) return [];
return genres.map((g: any) => g?.name).filter(Boolean);
}, [detail]);
const detailOverview = useMemo(() => {
const overview = detail?.tmdb?.detail?.overview;
if (!overview) return '';
return String(overview);
}, [detail]);
const castTop = useMemo(() => {
const cast = detail?.tmdb?.detail?.credits?.cast;
if (!Array.isArray(cast)) return [];
return cast.slice(0, 12);
}, [detail]);
const episodesBySeason = useMemo(() => {
if (detail?.type !== 'tv') return [];
const episodes = Array.isArray(detail?.episodes) ? detail.episodes : [];
const map = new Map<number, any[]>();
episodes.forEach((ep: any) => {
const season = typeof ep?.season === 'number' ? ep.season : 1;
if (!map.has(season)) map.set(season, []);
map.get(season)!.push(ep);
});
const seasons = Array.from(map.keys()).sort((a, b) => a - b);
return seasons.map((season) => {
const list = (map.get(season) || []).slice().sort((a, b) => {
const ae = typeof a?.episode === 'number' ? a.episode : 10_000;
const be = typeof b?.episode === 'number' ? b.episode : 10_000;
return ae - be;
});
return { season, episodes: list };
});
}, [detail]);
const renderHero = () => {
const year = detail?.type === 'tv'
? (detail?.tmdb?.detail?.first_air_date || '').slice(0, 4)
: (detail?.tmdb?.detail?.release_date || '').slice(0, 4);
const vote = detail?.tmdb?.detail?.vote_average;
const voteText = typeof vote === 'number' ? vote.toFixed(1) : '--';
return (
<div
style={{
position: 'relative',
padding: 16,
minHeight: 220,
background: detailBackdropUrl
? `url(${detailBackdropUrl}) center / cover no-repeat`
: 'linear-gradient(135deg, #0b1020 0%, #22314a 55%, #2b3b5c 100%)',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, rgba(2,6,23,0.92) 0%, rgba(2,6,23,0.55) 55%, rgba(2,6,23,0.15) 100%)',
}}
/>
<div style={{ position: 'relative', display: 'flex', gap: 16, alignItems: 'flex-end' }}>
<div style={{ width: 132, flex: 'none' }}>
<div style={{ borderRadius: 12, overflow: 'hidden', boxShadow: token.boxShadowTertiary, border: '1px solid rgba(255,255,255,0.18)' }}>
{detailPosterUrl ? (
<Image
src={detailPosterUrl}
alt={detailTitle}
preview={false}
width={132}
height={198}
style={{ objectFit: 'cover', display: 'block' }}
fallback="/logo.svg"
/>
) : (
<div
style={{
height: 120,
background: 'linear-gradient(135deg, #0b1020 0%, #22314a 60%, #2b3b5c 100%)',
width: 132,
height: 198,
background: 'rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.92)',
color: 'rgba(255,255,255,0.75)',
}}
>
<PlayCircleOutlined style={{ fontSize: 44 }} />
<VideoCameraOutlined style={{ fontSize: 28 }} />
</div>
)}
onClick={() => message.info('演示数据:暂未接入真实文件')}
>
<Typography.Text strong ellipsis style={{ display: 'block' }}>
{v.title}
</Typography.Text>
<div style={{ marginTop: 6, display: 'flex', gap: 8, color: 'var(--ant-color-text-tertiary)', fontSize: 12 }}>
<span>{v.year}</span>
<span>·</span>
<span>{v.duration}</span>
</div>
</div>
<div style={{ minWidth: 0, flex: 1, color: 'rgba(255,255,255,0.92)' }}>
<Typography.Title level={3} style={{ margin: 0, color: 'rgba(255,255,255,0.92)' }} ellipsis>
{detailTitle}
</Typography.Title>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', color: 'rgba(255,255,255,0.72)' }}>
{year && <span>{year}</span>}
<span>·</span>
<span>TMDB {voteText}</span>
{detail?.type === 'tv' && (
<>
<span>·</span>
<span>{episodesBySeason.reduce((acc, s) => acc + s.episodes.length, 0)} {t('Episodes')}</span>
</>
)}
</div>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{(detailGenres || []).slice(0, 6).map((g: string) => (
<Tag key={g} color="geekblue" style={{ marginInlineEnd: 0 }}>
{g}
</Tag>
))}
</div>
{detail?.type === 'movie' && (
<div style={{ marginTop: 14 }}>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playByPath(String(detail?.source_path || ''))}
disabled={!detail?.source_path}
>
{t('Play')}
</Button>
</div>
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{v.tags.map(tag => (
<Tag key={tag} style={{ marginInlineEnd: 0 }}>
{tag}
</Tag>
))}
</div>
</Card>
))}
)}
</div>
</div>
</div>
);
};
const renderMeta = () => {
if (!detail?.tmdb?.detail) return null;
const d = detail.tmdb.detail;
const isTv = detail?.type === 'tv';
const release = isTv ? d.first_air_date : d.release_date;
const runtime = isTv ? (Array.isArray(d.episode_run_time) ? d.episode_run_time[0] : undefined) : d.runtime;
const status = d.status;
const language = d.original_language;
const origin = isTv ? d.original_name : d.original_title;
const seasons = isTv ? d.number_of_seasons : undefined;
const eps = isTv ? d.number_of_episodes : undefined;
return (
<Descriptions
size="small"
column={2}
styles={{ label: { width: 110, color: token.colorTextSecondary } }}
style={{ marginTop: 10 }}
>
<Descriptions.Item label={t('Type')}>{isTv ? t('TV') : t('Movies')}</Descriptions.Item>
<Descriptions.Item label="TMDB ID">{String(detail?.tmdb?.id || '')}</Descriptions.Item>
{origin && <Descriptions.Item label={t('Original Title')}>{String(origin)}</Descriptions.Item>}
{release && <Descriptions.Item label={t('Release Date')}>{String(release)}</Descriptions.Item>}
{status && <Descriptions.Item label={t('Status')}>{String(status)}</Descriptions.Item>}
{runtime && <Descriptions.Item label={t('Runtime')}>{String(runtime)} min</Descriptions.Item>}
{language && <Descriptions.Item label={t('Language')}>{String(language).toUpperCase()}</Descriptions.Item>}
{isTv && seasons !== undefined && <Descriptions.Item label={t('Seasons')}>{String(seasons)}</Descriptions.Item>}
{isTv && eps !== undefined && <Descriptions.Item label={t('Episodes')}>{String(eps)}</Descriptions.Item>}
{detail?.source_path && <Descriptions.Item label={t('Source Path')} span={2}>{String(detail.source_path)}</Descriptions.Item>}
</Descriptions>
);
};
const renderCast = () => {
if (!castTop.length) return null;
return (
<div style={{ marginTop: 14 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('Cast')}
</Typography.Title>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{castTop.map((c: any) => {
const avatar = tmdbImage(c?.profile_path, 'w185');
return (
<div
key={String(c?.id || `${c?.name}-${c?.character}`)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 12,
background: token.colorFillQuaternary,
border: `1px solid ${token.colorBorderSecondary}`,
minWidth: 200,
}}
>
<Avatar size={36} src={avatar} style={{ flex: 'none' }}>
{(c?.name || '?').slice(0, 1)}
</Avatar>
<div style={{ minWidth: 0 }}>
<Typography.Text strong ellipsis style={{ display: 'block', maxWidth: 140 }}>
{c?.name || '--'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{c?.character || ''}
</Typography.Text>
</div>
</div>
);
})}
</div>
</div>
);
};
const renderEpisodes = () => {
if (detail?.type !== 'tv') return null;
if (!episodesBySeason.length) {
return (
<Empty description={t('No data')} style={{ marginTop: 24 }} />
);
}
return (
<Collapse
accordion={false}
items={episodesBySeason.map(({ season, episodes }) => ({
key: String(season),
label: `${t('Season')} ${season} · ${episodes.length} ${t('Episodes')}`,
children: (
<List
itemLayout="horizontal"
dataSource={episodes}
renderItem={(ep: any) => {
const seasonNo = typeof ep?.season === 'number' ? ep.season : season;
const epNo = typeof ep?.episode === 'number' ? ep.episode : undefined;
const tmdbEp = ep?.tmdb_episode || {};
const still = tmdbImage(tmdbEp?.still_path, 'w300');
const title = tmdbEp?.name || ep?.name || ep?.rel || '--';
const air = tmdbEp?.air_date ? String(tmdbEp.air_date) : '';
const runtime = tmdbEp?.runtime ? `${tmdbEp.runtime} min` : '';
const sub = [air, runtime].filter(Boolean).join(' · ');
const prefix = epNo !== undefined ? `S${String(seasonNo).padStart(2, '0')}E${String(epNo).padStart(2, '0')}` : `S${String(seasonNo).padStart(2, '0')}`;
return (
<List.Item
style={{ paddingInline: 0 }}
actions={[
<Button
key="play"
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playByPath(String(ep?.path || ''))}
disabled={!ep?.path}
>
{t('Play')}
</Button>,
]}
>
<List.Item.Meta
avatar={still ? (
<Image
src={still}
preview={false}
width={120}
height={68}
style={{ objectFit: 'cover', borderRadius: 10, overflow: 'hidden' }}
fallback="/logo.svg"
/>
) : (
<div
style={{
width: 120,
height: 68,
borderRadius: 10,
background: token.colorFillQuaternary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: token.colorTextTertiary,
}}
>
<VideoCameraOutlined />
</div>
)}
title={(
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Tag style={{ marginInlineEnd: 0 }}>{prefix}</Tag>
<Typography.Text strong ellipsis style={{ display: 'block', maxWidth: 360 }}>
{title}
</Typography.Text>
</div>
)}
description={sub ? <Typography.Text type="secondary">{sub}</Typography.Text> : null}
/>
</List.Item>
);
}}
/>
),
}))}
/>
);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
<div
style={{
padding: 14,
borderRadius: 12,
background: 'linear-gradient(135deg, rgba(15,23,42,0.95) 0%, rgba(30,41,59,0.85) 40%, rgba(17,24,39,0.9) 100%)',
color: 'rgba(255,255,255,0.92)',
boxShadow: token.boxShadowTertiary,
}}
>
<Flex align="center" justify="space-between" gap={12} wrap>
<div style={{ minWidth: 240 }}>
<Typography.Title level={4} style={{ margin: 0, color: 'rgba(255,255,255,0.92)' }}>
{t('Video Library')}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.72)' }}>
{t('Total')}: {stats.total} · {t('Movies')}: {stats.movie} · {t('TV')}: {stats.tv}
</Typography.Text>
</div>
<Space size={10} wrap>
<Segmented
value={filter}
onChange={(v) => setFilter(v as LibraryFilter)}
options={[
{ value: 'all', label: t('All') },
{ value: 'movie', label: t('Movies') },
{ value: 'tv', label: t('TV') },
]}
/>
<Input
allowClear
value={q}
onChange={(e) => setQ(e.target.value)}
prefix={<SearchOutlined />}
placeholder={t('Search')}
style={{ width: 260 }}
/>
<Button icon={<ReloadOutlined />} onClick={loadLibrary} loading={loading}>
{t('Refresh')}
</Button>
</Space>
</Flex>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px 2px' }}>
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }}>
{Array.from({ length: 10 }).map((_, idx) => (
<Card
key={idx}
size="small"
style={{ borderRadius: 12, overflow: 'hidden' }}
cover={<Skeleton.Image active style={{ width: '100%', height: 150 }} />}
>
<Skeleton active paragraph={{ rows: 2 }} />
</Card>
))}
</div>
) : filtered.length === 0 ? (
<Empty description={t('No data')} style={{ marginTop: 48 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }}>
{filtered.map((v) => (
<Card
key={v.id}
hoverable
size="small"
styles={{ body: { padding: 10 } } as any}
style={{
borderRadius: 12,
overflow: 'hidden',
boxShadow: token.boxShadowTertiary,
border: `1px solid ${token.colorBorderSecondary}`,
}}
cover={renderCover(v)}
onClick={() => openDetail(v)}
>
<Typography.Text strong ellipsis style={{ display: 'block' }}>
{v.title || '--'}
</Typography.Text>
<div style={{ marginTop: 6, display: 'flex', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
<span>{v.year || '--'}</span>
{v.type === 'tv' ? (
<>
<span>·</span>
<span>{v.episodes_count || 0} {t('Episodes')}</span>
</>
) : null}
</div>
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{(v.genres || []).slice(0, 3).map((tag) => (
<Tag key={tag} style={{ marginInlineEnd: 0 }}>
{tag}
</Tag>
))}
{(v.genres || []).length > 3 && (
<Tag style={{ marginInlineEnd: 0 }}>+{(v.genres || []).length - 3}</Tag>
)}
</div>
</Card>
))}
</div>
)}
</div>
<Drawer
title={detailTitle || selected?.title || t('Details')}
open={detailOpen}
onClose={closeDetail}
width={900}
destroyOnHidden
getContainer={false}
styles={{ body: { padding: 0 } }}
>
{detailLoading ? (
<div style={{ padding: 16 }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
) : !detail ? (
<Empty description={t('No data')} style={{ marginTop: 48 }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{renderHero()}
<div style={{ padding: 16 }}>
<Tabs
items={[
...(detail?.type === 'tv'
? [{
key: 'episodes',
label: t('Episodes'),
children: renderEpisodes(),
}]
: []),
{
key: 'detail',
label: t('Details'),
children: (
<>
{renderMeta()}
{detailOverview && (
<>
<Divider style={{ margin: '14px 0' }} />
<Typography.Title level={5} style={{ margin: 0 }}>
{t('Overview')}
</Typography.Title>
<Typography.Paragraph style={{ marginTop: 8, whiteSpace: 'pre-wrap' }}>
{detailOverview}
</Typography.Paragraph>
</>
)}
{renderCast()}
</>
),
},
]}
/>
</div>
</div>
)}
</Drawer>
</div>
);
};

View File

@@ -81,19 +81,29 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const targetEntries = entries.filter(e => targetNames.includes(e.name));
let processorSubMenu: ActionMenuItem[] = [];
if (!entry.is_dir && processorTypes.length > 0) {
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
processorSubMenu = processorTypes
.filter(pt => {
const exts = pt.supported_exts;
if (!Array.isArray(exts) || exts.length === 0) return true;
return exts.includes(ext);
})
.map(pt => ({
key: 'processor-' + pt.type,
label: pt.name,
onClick: () => actions.onProcess(entry, pt.type),
}));
if (processorTypes.length > 0) {
if (entry.is_dir) {
processorSubMenu = processorTypes
.filter(pt => !!pt.supports_directory)
.map(pt => ({
key: 'processor-' + pt.type,
label: pt.name,
onClick: () => actions.onProcess(entry, pt.type),
}));
} else {
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
processorSubMenu = processorTypes
.filter(pt => {
const exts = pt.supported_exts;
if (!Array.isArray(exts) || exts.length === 0) return true;
return exts.includes(ext);
})
.map(pt => ({
key: 'processor-' + pt.type,
label: pt.name,
onClick: () => actions.onProcess(entry, pt.type),
}));
}
}
const menuItems: (ActionMenuItem | null)[] = [
@@ -113,7 +123,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
onClick: () => actions.onOpenWith(entry, a.key),
})),
} : null,
!entry.is_dir && processorSubMenu.length > 0 ? {
processorSubMenu.length > 0 ? {
key: 'process',
label: t('Processor'),
icon: <AppstoreAddOutlined />,

View File

@@ -31,6 +31,19 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
const [form] = Form.useForm();
const { t } = useI18n();
const availableProcessors = React.useMemo(() => {
if (!entry) return processorTypes;
if (entry.is_dir) {
return processorTypes.filter(pt => !!pt.supports_directory);
}
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return processorTypes.filter(pt => {
const exts = pt.supported_exts;
if (!Array.isArray(exts) || exts.length === 0) return true;
return exts.includes(ext);
});
}, [entry, processorTypes]);
const selectedProcessorMeta = processorTypes.find(pt => pt.type === selectedProcessor);
// Sync form when modal opens or selected processor changes
@@ -64,7 +77,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
<Form.Item name="processor_type" label={t('Processor')} required>
<Select
onChange={onSelectedProcessorChange}
options={processorTypes.map(pt => ({ value: pt.type, label: pt.name }))}
options={availableProcessors.map(pt => ({ value: pt.type, label: pt.name }))}
placeholder={t('Select a processor')}
/>
</Form.Item>

View File

@@ -145,6 +145,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
}, [selectedProcessorMeta, form]);
const producesFile = selectedProcessorMeta?.produces_file ?? false;
const supportsDirectory = selectedProcessorMeta?.supports_directory ?? false;
const overwriteWatch = Form.useWatch('overwrite', form);
const overwriteValue = producesFile ? !!overwriteWatch : false;
const directoryScope = Form.useWatch('directory_scope', form) ?? 'current';
@@ -250,23 +251,33 @@ const ProcessorsPage = memo(function ProcessorsPage() {
setRunning(true);
const overwriteFlag = producesFile ? !!values.overwrite : false;
if (isDirectory) {
const scope: 'current' | 'recursive' = values.directory_scope || 'current';
let maxDepth: number | null = scope === 'current' ? 0 : null;
if (scope === 'recursive' && typeof values.max_depth === 'number') {
maxDepth = values.max_depth;
if (supportsDirectory) {
const resp = await processorsApi.process({
path: values.path,
processor_type: selectedType,
config: finalConfig,
overwrite: overwriteFlag,
});
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
} else {
const scope: 'current' | 'recursive' = values.directory_scope || 'current';
let maxDepth: number | null = scope === 'current' ? 0 : null;
if (scope === 'recursive' && typeof values.max_depth === 'number') {
maxDepth = values.max_depth;
}
const suffixValue = producesFile && !overwriteFlag && typeof values.suffix === 'string'
? values.suffix.trim() || null
: null;
const resp = await processorsApi.processDirectory({
path: values.path,
processor_type: selectedType,
config: finalConfig,
overwrite: overwriteFlag,
max_depth: maxDepth,
suffix: suffixValue,
});
messageApi.success(`${t('Task submitted')}: ${resp.scheduled}`);
}
const suffixValue = producesFile && !overwriteFlag && typeof values.suffix === 'string'
? values.suffix.trim() || null
: null;
const resp = await processorsApi.processDirectory({
path: values.path,
processor_type: selectedType,
config: finalConfig,
overwrite: overwriteFlag,
max_depth: maxDepth,
suffix: suffixValue,
});
messageApi.success(`${t('Task submitted')}: ${resp.scheduled}`);
} else {
const payload: any = {
path: values.path,
@@ -288,7 +299,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
} finally {
setRunning(false);
}
}, [form, isDirectory, messageApi, producesFile, selectedProcessorMeta, selectedType, t]);
}, [form, isDirectory, messageApi, producesFile, selectedType, supportsDirectory, selectedProcessorMeta, t]);
const selectedConfigPath = pathModalField === 'path'
? (selectedType ? form.getFieldValue('path') : undefined) || '/'
@@ -434,7 +445,16 @@ const ProcessorsPage = memo(function ProcessorsPage() {
<Button onClick={() => openPathSelector('path', 'directory')}>{t('Select Directory')}</Button>
</Flex>
</Form.Item>
{isDirectory && (
{isDirectory && supportsDirectory && (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
<Alert
type="info"
showIcon
message={t('Directory execution will enqueue one task for the directory itself')}
/>
</Space>
)}
{isDirectory && !supportsDirectory && (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
<Alert
type="info"
@@ -477,7 +497,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
</Form.Item>
)}
{isDirectory && producesFile && !overwriteValue && (
{isDirectory && !supportsDirectory && producesFile && !overwriteValue && (
<Form.Item
name="suffix"
label={t('Output suffix')}