diff --git a/Dockerfile b/Dockerfile index 647f26f..7caeaa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,58 @@ +FROM python:3.12.7-alpine AS builder +WORKDIR /builder + +RUN apk update && \ + apk add --no-cache \ + build-base \ + linux-headers + +# 安装构建依赖 +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir cython setuptools + +COPY setup.py setup.py +COPY app ./app + +RUN python setup.py + +RUN apk del build-base linux-headers && \ + find app -type f \( -name "*.py" ! -name "main.py" ! -name "__init__.py" -o -name "*.c" \) -delete + FROM python:3.12.7-alpine ENV TZ=Asia/Shanghai -VOLUME ["/config", "/logs", "/media","/fonts"] +VOLUME ["/config", "/logs", "/media", "/fonts"] +EXPOSE 8000 + +# 添加运行时依赖 +RUN apk update && \ + apk add --no-cache \ + tzdata \ + curl \ + build-base \ + linux-headers \ + libgomp && \ + cp /usr/share/zoneinfo/${TZ} /etc/localtime && \ + echo ${TZ} > /etc/timezone -RUN apk update -RUN apk add --no-cache build-base linux-headers tzdata +# 安装Python依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt \ + -i https://pypi.tuna.tsinghua.edu.cn/simple \ + --default-timeout=100 \ + && rm requirements.txt \ + && pip install fastapi==0.109.0 uvicorn==0.27.0 --force-reinstall -COPY requirements.txt requirements.txt -RUN pip install -r requirements.txt && \ - rm requirements.txt +# 清理构建工具 +RUN apk del build-base linux-headers && \ + rm -rf /var/cache/apk/* -COPY app /app +# 从构建阶段复制应用 +COPY --from=builder /builder/app /app -RUN rm -rf /tmp/* +# 最终清理 +RUN apk del curl && \ + rm -rf /var/cache/apk/* && \ + rm -rf /tmp/* -ENTRYPOINT ["python", "/app/main.py"] \ No newline at end of file +ENTRYPOINT ["python", "/app/main.py", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index c9a12c1..c88bdac 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,23 @@ ```bash docker run -d --name autofilm -v ./config:/config -v ./media:/media -v ./logs:/logs akimio/autofilm ``` + +2. Docker 中手动触发特定任务 + ```bash + docker exec autofilm python /app/run.py <任务ID> + ``` + 通过`docker exec`命令可以在运行中的容器内执行run.py脚本来手动触发特定任务。 2. Python 环境运行(Python3.12) ```bash python app/main.py ``` +3. 手动触发特定任务 + ```bash + python app/run.py <任务ID> + ``` + 通过`run.py`可以手动触发配置文件中定义的特定任务,而不需要等待定时任务执行。 + # Strm文件优点 - [x] 轻量化 Emby 服务器,降低 Emby 服务器的性能需求以及硬盘需求 - [x] 运行稳定 @@ -93,4 +105,4 @@ # Star History Star History Chart - \ No newline at end of file + \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 24bd82b..fcdcef9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -98,6 +98,39 @@ def Ani2AlistList(self) -> list[dict[str, Any]]: return ani2alist_list @property + def API_ENABLE(self) -> bool: + """是否启用API服务""" + return self._get_config("Settings", "API_ENABLE", False) + + @property + def API_PORT(self) -> int: + """API服务监听端口""" + return self._get_config("Settings", "API_PORT", 8000) + def _get_config(self, section: str, key: str, default: Any) -> Any: + """统一配置获取方法""" + # 修改前:使用 with self.CONFIG.open() as f: + # 修改后: + def load_config(self): + config_file = self.CONFIG_DIR / "config.yaml" + if config_file.exists(): + with open(config_file, "r", encoding="utf-8") as f: + self._config = safe_load(f) + + @property + def API_PORT(self) -> int: + """API服务监听端口""" + return self._get_config("Settings", "API_PORT", 8000) + def _get_config(self, section: str, key: str, default: Any) -> Any: + """统一配置获取方法""" + with self.CONFIG.open(mode="r", encoding="utf-8") as f: + config = safe_load(f) + return config.get(section, {}).get(key, default) + + @property + def API_KEY(self) -> str: + + """API验证密钥""" + return self._get_config("Settings", "API_KEY", "") def LibraryPosterList(self) -> list[dict[str, Any]]: with self.CONFIG.open(mode="r", encoding="utf-8") as file: library_poster_list = safe_load(file).get("LibraryPosterList", []) diff --git a/app/main.py b/app/main.py index def36cc..3d4d92b 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,9 @@ from app.core import settings, logger from app.extensions import LOGO +from app.modules import Alist2Strm, Ani2Alist +from fastapi import FastAPI +import uvicorn from app.modules import Alist2Strm, Ani2Alist, LibraryPoster @@ -21,12 +24,39 @@ def print_logo() -> None: print(f" {settings.APP_NAME} {settings.APP_VERSION} ".center(65, "=")) print("") +from app.modules.api import router as api_router + +def start_api_server(): + app = FastAPI(title=settings.APP_NAME) + app.include_router(api_router) + + uvicorn.run( + app, + host="0.0.0.0", + port=settings.API_PORT, + log_config=None + ) + +def __mkdir(self) -> None: + """ + 创建目录 + """ + # 修改前:使用 with self.CONFIG_DIR as dir_path: + if not self.CONFIG_DIR.exists(): + self.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + if not self.LOG_DIR.exists(): + self.LOG_DIR.mkdir(parents=True, exist_ok=True) if __name__ == "__main__": print_logo() - logger.info(f"AutoFilm {settings.APP_VERSION} 启动中...") - logger.debug(f"是否开启 DEBUG 模式: {settings.DEBUG}") + + # 启动API服务器 + if settings.API_ENABLE: + from threading import Thread + Thread(target=start_api_server, daemon=True).start() + logger.info(f"API服务已启动在 {settings.API_PORT} 端口") scheduler = AsyncIOScheduler() diff --git a/app/modules/api.py b/app/modules/api.py new file mode 100644 index 0000000..8dbe1d4 --- /dev/null +++ b/app/modules/api.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import APIKeyHeader + +from app.core import settings, logger +from app.modules import Alist2Strm, Ani2Alist + +router = APIRouter() +security = APIKeyHeader(name="X-API-Key", auto_error=False) + +# 修改依赖声明方式(移除类型标注中的str) +async def get_api_key(api_key = Depends(security)): + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing API Key" + ) + if api_key != settings.API_KEY: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API Key" + ) + return api_key + +# 修改路由参数声明(移除类型标注中的str) +@router.post("/trigger/alist2strm") +async def trigger_alist2strm( + server_id: str, + _ = Depends(get_api_key) +): + logger.info(f"API触发Alist2Strm任务:{server_id}") + # 查找对应配置 + server_config = next((s for s in settings.AlistServerList if s["id"] == server_id), None) + if not server_config: + raise HTTPException(status_code=404, detail="Server not found") + + await Alist2Strm(**server_config).run() + return {"status": "success"} + +@router.post("/trigger/ani2alist") +async def trigger_ani2alist( + server_id: str, + _ = Depends(get_api_key) +): + logger.info(f"API触发Ani2Alist任务:{server_id}") + server_config = next((s for s in settings.Ani2AlistList if s["id"] == server_id), None) + if not server_config: + raise HTTPException(status_code=404, detail="Server not found") + + await Ani2Alist(**server_config).run() + return {"status": "success"} \ No newline at end of file diff --git a/app/run.py b/app/run.py new file mode 100644 index 0000000..8cbd86f --- /dev/null +++ b/app/run.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +import sys +import asyncio +from sys import path +from os.path import dirname + +# 添加项目根目录到路径 +path.append(dirname(dirname(__file__))) + +from app.core.config import settings + +# 延迟导入,避免sklearn依赖问题 +# 只在需要时才导入相关模块 +def get_alist2strm(): + try: + from app.modules.alist2strm import Alist2Strm + return Alist2Strm + except ImportError as e: + print(f"导入Alist2Strm模块时出错: {e}") + print("请确保已安装所有依赖库,或者检查Docker环境中是否缺少必要的系统库(如libgomp.so.1)") + sys.exit(1) + + +def get_ani2alist(): + try: + from app.modules.ani2alist import Ani2Alist + return Ani2Alist + except ImportError as e: + print(f"导入Ani2Alist模块时出错: {e}") + print("请确保已安装所有依赖库,或者检查Docker环境中是否缺少必要的系统库(如libgomp.so.1)") + sys.exit(1) + + +def get_libraryposter(): + try: + from app.modules.libraryposter import LibraryPoster + return LibraryPoster + except ImportError as e: + print(f"导入LibraryPoster模块时出错: {e}") + print("请确保已安装所有依赖库,或者检查Docker环境中是否缺少必要的系统库(如libgomp.so.1)") + sys.exit(1) + + +def find_task_by_id(task_list, task_id): + """根据ID查找任务配置""" + for task in task_list: + if task.get('id') == task_id: + return task + return None + + +def main(): + if len(sys.argv) != 2: + print("使用方法: python run.py <任务ID>") + print("可用的任务ID:") + + # 显示所有Alist2Strm任务ID + for task in settings.AlistServerList: + print(f" Alist2Strm: {task.get('id')}") + + # 显示所有Ani2Alist任务ID + for task in settings.Ani2AlistList: + print(f" Ani2Alist: {task.get('id')}") + + # 显示所有LibraryPoster任务ID + for task in settings.LibraryPosterList(): + print(f" LibraryPoster: {task.get('id')}") + + return + + task_id = sys.argv[1] + + # 查找Alist2Strm任务 + alist_task = find_task_by_id(settings.AlistServerList, task_id) + if alist_task: + print(f"正在执行 Alist2Strm 任务: {task_id}") + Alist2Strm = get_alist2strm() + alist_instance = Alist2Strm(**alist_task) + asyncio.run(alist_instance.run()) + return + + # 查找Ani2Alist任务 + ani_task = find_task_by_id(settings.Ani2AlistList, task_id) + if ani_task: + print(f"正在执行 Ani2Alist 任务: {task_id}") + Ani2Alist = get_ani2alist() + ani_instance = Ani2Alist(**ani_task) + asyncio.run(ani_instance.run()) + return + + # 查找LibraryPoster任务 + poster_task = find_task_by_id(settings.LibraryPosterList(), task_id) + if poster_task: + print(f"正在执行 LibraryPoster 任务: {task_id}") + LibraryPoster = get_libraryposter() + poster_instance = LibraryPoster(**poster_task) + asyncio.run(poster_instance.run()) + return + + print(f"未找到ID为 '{task_id}' 的任务") + print("可用的任务ID:") + + # 显示所有Alist2Strm任务ID + for task in settings.AlistServerList: + print(f" Alist2Strm: {task.get('id')}") + + # 显示所有Ani2Alist任务ID + for task in settings.Ani2AlistList: + print(f" Ani2Alist: {task.get('id')}") + + # 显示所有LibraryPoster任务ID + for task in settings.LibraryPosterList(): + print(f" LibraryPoster: {task.get('id')}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/config/config.yaml.example b/config/config.yaml.example index 039eefd..7e5e429 100644 --- a/config/config.yaml.example +++ b/config/config.yaml.example @@ -1,4 +1,7 @@ Settings: + API_ENABLE: True # 启用API接口 + API_PORT: 8000 # API服务端口 + API_KEY: your_secret_key # API访问密钥 DEV: False # 开发者模式(可选,默认 False) Alist2StrmList: diff --git a/requirements.txt b/requirements.txt index 4351893..3c3f174 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,9 @@ aiofile==3.8.8 httpx[http2]==0.27.2 pydantic==2.9.2 pypinyin==0.53.0 +fastapi==0.109.0 +uvicorn==0.27.1 +python-multipart==0.0.9 pillow==11.3.0 numpy==2.3.1 scikit-learn==1.7.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..82af301 --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +import os +from pathlib import Path +from shutil import rmtree + +from setuptools import setup, find_packages # type: ignore +from Cython.Build import cythonize # type: ignore + + +def find_py_files(dir: Path) -> list[str]: + needed_compiled_files = [] + for file in dir.rglob("*.py"): + # 添加排除条件 + #if file.name not in ["main.py", "__init__.py", "api.py"]: + if file.name not in ["main.py"]: + needed_compiled_files.append(file.as_posix()) + return needed_compiled_files + + +if __name__ == "__main__": + files = find_py_files(Path("app")) + print(f"📝 需要编译的文件: {files}") + cpu_count = os.cpu_count() or 1 + print(f"🚀 使用 {cpu_count} 个核心编译") + setup( + ext_modules=cythonize( + files, + compiler_directives={ + "language_level": "3", # 使用Python 3语法 + "annotation_typing": True, # 使用类型注解 + "infer_types": True, # 启用类型推断 + }, + force=True, # 强制重新编译所有文件 + ), + packages=find_packages(where="app"), # 发现app下的所有包 + package_dir={"": "app"}, # 包根目录为app + script_args=["build_ext", "-j", str(cpu_count), "--inplace"], + ) + build_dir = Path("build").absolute() + if build_dir.exists(): + rmtree(build_dir) + print(f"✅ 已清理 build 目录: {build_dir}") + else: + print(f"❌ build 目录不存在: {build_dir}")