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
+2
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)
+2
View File
@@ -0,0 +1,2 @@
"""插件专属服务端路由集合。"""
+142
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)
+4 -2
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。"""
...
# 约定:每个处理器需定义
+396
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()
+5
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,
}
+7 -1
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)
+4 -1
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")
+11 -2
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