mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-28 02:31:53 +08:00
feat(video-library): implement video library processing and API integration
This commit is contained in:
@@ -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)
|
||||
|
||||
2
domain/plugins/routes/__init__.py
Normal file
2
domain/plugins/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""插件专属服务端路由集合。"""
|
||||
|
||||
142
domain/plugins/routes/video_player.py
Normal file
142
domain/plugins/routes/video_player.py
Normal 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)
|
||||
|
||||
@@ -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。"""
|
||||
...
|
||||
|
||||
# 约定:每个处理器需定义
|
||||
|
||||
396
domain/processors/builtin/video_library.py
Normal file
396
domain/processors/builtin/video_library.py
Normal 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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ProcessorTypeMeta {
|
||||
supported_exts: string[];
|
||||
config_schema: ProcessorTypeField[];
|
||||
produces_file: boolean;
|
||||
supports_directory?: boolean;
|
||||
module_path?: string | null;
|
||||
}
|
||||
|
||||
|
||||
35
web/src/api/videoLibrary.ts
Normal file
35
web/src/api/videoLibrary.ts
Normal 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' }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user