初始化提交

This commit is contained in:
lvyulong
2026-06-12 14:59:20 +08:00
parent c1d00901a8
commit 00718f47f3
85 changed files with 15799 additions and 0 deletions

31
app/controllers/base.py Normal file
View File

@@ -0,0 +1,31 @@
from uuid import uuid4
from fastapi import Request
from app.config import config
from app.models.exception import HttpException
def get_task_id(request: Request):
task_id = request.headers.get("x-task-id")
if not task_id:
task_id = uuid4()
return str(task_id)
def get_api_key(request: Request):
api_key = request.headers.get("x-api-key")
return api_key
def verify_token(request: Request):
token = get_api_key(request)
if token != config.app.get("api_key", ""):
request_id = get_task_id(request)
request_url = request.url
user_agent = request.headers.get("user-agent")
raise HttpException(
task_id=request_id,
status_code=401,
message=f"invalid token: {request_url}, {user_agent}",
)

View File

@@ -0,0 +1,87 @@
import threading
from typing import Any, Callable, Dict
from loguru import logger
class TaskQueueFullError(ValueError):
pass
class TaskManager:
def __init__(self, max_concurrent_tasks: int, max_queued_tasks: int = 100):
self.max_concurrent_tasks = max_concurrent_tasks
self.max_queued_tasks = max_queued_tasks
self.current_tasks = 0
self.lock = threading.Lock()
self.queue = self.create_queue()
def create_queue(self):
raise NotImplementedError()
def add_task(self, func: Callable, *args: Any, **kwargs: Any):
with self.lock:
if self.current_tasks < self.max_concurrent_tasks:
logger.info(
f"add task: {func.__name__}, current_tasks: {self.current_tasks}"
)
self.execute_task(func, *args, **kwargs)
else:
queue_size = self.queue_size()
# 并发数已满时才进入排队。队列必须有上限,否则匿名接口可以持续
# 堆积任务对象和请求参数,最终造成内存耗尽或第三方 API 成本失控。
if queue_size >= self.max_queued_tasks:
logger.warning(
f"reject task: {func.__name__}, queue_size: {queue_size}, "
f"max_queued_tasks: {self.max_queued_tasks}"
)
raise TaskQueueFullError("task queue is full, please try again later")
logger.info(
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}, "
f"queue_size: {queue_size}"
)
self.enqueue({"func": func, "args": args, "kwargs": kwargs})
def execute_task(self, func: Callable, *args: Any, **kwargs: Any):
thread = threading.Thread(
target=self.run_task, args=(func, *args), kwargs=kwargs
)
thread.start()
def run_task(self, func: Callable, *args: Any, **kwargs: Any):
try:
with self.lock:
self.current_tasks += 1
func(*args, **kwargs) # call the function here, passing *args and **kwargs.
finally:
self.task_done()
def check_queue(self):
with self.lock:
if (
self.current_tasks < self.max_concurrent_tasks
and not self.is_queue_empty()
):
task_info = self.dequeue()
func = task_info["func"]
args = task_info.get("args", ())
kwargs = task_info.get("kwargs", {})
self.execute_task(func, *args, **kwargs)
def task_done(self):
with self.lock:
self.current_tasks -= 1
self.check_queue()
def enqueue(self, task: Dict):
raise NotImplementedError()
def dequeue(self):
raise NotImplementedError()
def is_queue_empty(self):
raise NotImplementedError()
def queue_size(self):
raise NotImplementedError()

View File

@@ -0,0 +1,21 @@
from queue import Queue
from typing import Dict
from app.controllers.manager.base_manager import TaskManager
class InMemoryTaskManager(TaskManager):
def create_queue(self):
return Queue(maxsize=self.max_queued_tasks)
def enqueue(self, task: Dict):
self.queue.put(task)
def dequeue(self):
return self.queue.get()
def is_queue_empty(self):
return self.queue.empty()
def queue_size(self):
return self.queue.qsize()

View File

@@ -0,0 +1,64 @@
import json
from typing import Dict
import redis
from app.controllers.manager.base_manager import TaskManager
from app.models.schema import VideoParams
from app.services import task as tm
FUNC_MAP = {
"start": tm.start,
# 'start_test': tm.start_test
}
class RedisTaskManager(TaskManager):
def __init__(
self,
max_concurrent_tasks: int,
redis_url: str,
max_queued_tasks: int = 100,
):
self.redis_client = redis.Redis.from_url(redis_url)
super().__init__(max_concurrent_tasks, max_queued_tasks=max_queued_tasks)
def create_queue(self):
return "task_queue"
def enqueue(self, task: Dict):
task_with_serializable_params = task.copy()
if "params" in task["kwargs"] and isinstance(
task["kwargs"]["params"], VideoParams
):
task_with_serializable_params["kwargs"]["params"] = task["kwargs"][
"params"
].dict()
# 将函数对象转换为其名称
task_with_serializable_params["func"] = task["func"].__name__
self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params))
def dequeue(self):
task_json = self.redis_client.lpop(self.queue)
if task_json:
task_info = json.loads(task_json)
# 将函数名称转换回函数对象
task_info["func"] = FUNC_MAP[task_info["func"]]
if "params" in task_info["kwargs"] and isinstance(
task_info["kwargs"]["params"], dict
):
task_info["kwargs"]["params"] = VideoParams(
**task_info["kwargs"]["params"]
)
return task_info
return None
def is_queue_empty(self):
return self.redis_client.llen(self.queue) == 0
def queue_size(self):
return self.redis_client.llen(self.queue)

13
app/controllers/ping.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, Request
router = APIRouter()
@router.get(
"/ping",
tags=["Health Check"],
description="检查服务可用性",
response_description="pong",
)
def ping(request: Request) -> str:
return "pong"

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
def new_router(dependencies=None):
router = APIRouter()
router.tags = ["V1"]
router.prefix = "/api/v1"
# 将认证依赖项应用于所有路由
if dependencies:
router.dependencies = dependencies
return router

47
app/controllers/v1/llm.py Normal file
View File

@@ -0,0 +1,47 @@
from fastapi import Request
from app.controllers.v1.base import new_router
from app.models.schema import (
VideoScriptRequest,
VideoScriptResponse,
VideoTermsRequest,
VideoTermsResponse,
)
from app.services import llm
from app.utils import utils
# authentication dependency
# router = new_router(dependencies=[Depends(base.verify_token)])
router = new_router()
@router.post(
"/scripts",
response_model=VideoScriptResponse,
summary="Create a script for the video",
)
def generate_video_script(request: Request, body: VideoScriptRequest):
video_script = llm.generate_script(
video_subject=body.video_subject,
language=body.video_language,
paragraph_number=body.paragraph_number,
video_script_prompt=body.video_script_prompt,
custom_system_prompt=body.custom_system_prompt,
)
response = {"video_script": video_script}
return utils.get_response(200, response)
@router.post(
"/terms",
response_model=VideoTermsResponse,
summary="Generate video terms based on the video script",
)
def generate_video_terms(request: Request, body: VideoTermsRequest):
video_terms = llm.generate_terms(
video_subject=body.video_subject,
video_script=body.video_script,
amount=body.amount,
)
response = {"video_terms": video_terms}
return utils.get_response(200, response)

403
app/controllers/v1/video.py Normal file
View File

@@ -0,0 +1,403 @@
import glob
import os
import pathlib
import shutil
from typing import Union
from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile
from fastapi.params import File
from fastapi.responses import FileResponse, StreamingResponse
from loguru import logger
from app.config import config
from app.controllers import base
from app.controllers.manager.base_manager import TaskQueueFullError
from app.controllers.manager.memory_manager import InMemoryTaskManager
from app.controllers.manager.redis_manager import RedisTaskManager
from app.controllers.v1.base import new_router
from app.models.exception import HttpException
from app.models.schema import (
AudioRequest,
BgmRetrieveResponse,
BgmUploadResponse,
SubtitleRequest,
TaskDeletionResponse,
TaskQueryRequest,
TaskQueryResponse,
TaskResponse,
TaskVideoRequest,
VideoMaterialUploadResponse,
VideoMaterialRetrieveResponse
)
from app.services import state as sm
from app.services import task as tm
from app.utils import file_security, utils
# 认证依赖项
# router = new_router(dependencies=[Depends(base.verify_token)])
router = new_router()
_enable_redis = config.app.get("enable_redis", False)
_redis_host = config.app.get("redis_host", "localhost")
_redis_port = config.app.get("redis_port", 6379)
_redis_db = config.app.get("redis_db", 0)
_redis_password = config.app.get("redis_password", None)
_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5)
_max_queued_tasks = config.app.get("max_queued_tasks", 100)
redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}"
# 根据配置选择合适的任务管理器
if _enable_redis:
task_manager = RedisTaskManager(
max_concurrent_tasks=_max_concurrent_tasks,
redis_url=redis_url,
max_queued_tasks=_max_queued_tasks,
)
else:
task_manager = InMemoryTaskManager(
max_concurrent_tasks=_max_concurrent_tasks,
max_queued_tasks=_max_queued_tasks,
)
def _sanitize_upload_filename(filename: str, request_id: str) -> str:
# 浏览器或客户端有时会附带目录信息,甚至可能夹带 ../ 这类穿越片段。
# 这里只保留纯文件名,避免上传接口把文件写到目标目录之外。
normalized_name = (filename or "").replace("\\", "/").split("/")[-1].strip()
if not normalized_name or normalized_name in {".", ".."}:
raise HttpException(
task_id=request_id,
status_code=400,
message=f"{request_id}: invalid filename",
)
return normalized_name
def _resolve_path_within_directory(base_dir: str, unsafe_path: str, request_id: str) -> str:
try:
return file_security.resolve_path_within_directory(base_dir, unsafe_path)
except ValueError as exc:
logger.warning(
f"reject unsafe file path, request_id: {request_id}, path: {unsafe_path}, "
f"error: {str(exc)}"
)
raise HttpException(
task_id=request_id,
status_code=404 if str(exc) == "file does not exist" else 403,
message=f"{request_id}: invalid file path",
)
def _task_file_to_uri(file: str, endpoint: str, task_dir: str, request_id: str) -> str:
if not isinstance(file, str):
return file
if file.startswith(("http://", "https://")):
return file
try:
resolved_path = file_security.resolve_path_within_directory(task_dir, file)
except ValueError as exc:
# 任务状态理论上只应保存任务目录内的产物路径。这里不再继续拼接 URL
# 避免把异常路径包装成可访问链接;同时保留原值,便于排查历史脏数据。
logger.warning(
f"skip unsafe task output path, request_id: {request_id}, path: {file}, "
f"error: {str(exc)}"
)
return file
relative_path = os.path.relpath(resolved_path, task_dir).replace("\\", "/")
uri_path = f"tasks/{relative_path}"
if endpoint:
return f"{endpoint.rstrip('/')}/{uri_path}"
return f"/{uri_path}"
@router.post("/videos", response_model=TaskResponse, summary="Generate a short video")
def create_video(
background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest
):
return create_task(request, body, stop_at="video")
@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only")
def create_subtitle(
background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest
):
return create_task(request, body, stop_at="subtitle")
@router.post("/audio", response_model=TaskResponse, summary="Generate audio only")
def create_audio(
background_tasks: BackgroundTasks, request: Request, body: AudioRequest
):
return create_task(request, body, stop_at="audio")
def create_task(
request: Request,
body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest],
stop_at: str,
):
task_id = utils.get_uuid()
request_id = base.get_task_id(request)
try:
task = {
"task_id": task_id,
"request_id": request_id,
"params": body.model_dump(),
}
sm.state.update_task(task_id)
task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at)
logger.success(f"Task created: {utils.to_json(task)}")
return utils.get_response(200, task)
except TaskQueueFullError as e:
sm.state.delete_task(task_id)
logger.warning(
f"reject task because queue is full, request_id: {request_id}, task_id: {task_id}"
)
raise HttpException(
task_id=task_id, status_code=429, message=f"{request_id}: {str(e)}"
)
except ValueError as e:
raise HttpException(
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
)
from fastapi import Query
@router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks")
def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)):
request_id = base.get_task_id(request)
tasks, total = sm.state.get_all_tasks(page, page_size)
response = {
"tasks": tasks,
"total": total,
"page": page,
"page_size": page_size,
}
return utils.get_response(200, response)
@router.get(
"/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status"
)
def get_task(
request: Request,
task_id: str = Path(..., description="Task ID"),
query: TaskQueryRequest = Depends(),
):
request_id = base.get_task_id(request)
endpoint = config.app.get("endpoint", "").rstrip("/")
task = sm.state.get_task(task_id)
if task:
task_dir = utils.task_dir()
response_task = dict(task)
if "videos" in task:
response_task["videos"] = [
_task_file_to_uri(v, endpoint, task_dir, request_id)
for v in task["videos"]
]
if "combined_videos" in task:
response_task["combined_videos"] = [
_task_file_to_uri(v, endpoint, task_dir, request_id)
for v in task["combined_videos"]
]
return utils.get_response(200, response_task)
raise HttpException(
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
)
@router.delete(
"/tasks/{task_id}",
response_model=TaskDeletionResponse,
summary="Delete a generated short video task",
)
def delete_video(request: Request, task_id: str = Path(..., description="Task ID")):
request_id = base.get_task_id(request)
task = sm.state.get_task(task_id)
if task:
tasks_dir = utils.task_dir()
current_task_dir = os.path.join(tasks_dir, task_id)
if os.path.exists(current_task_dir):
shutil.rmtree(current_task_dir)
sm.state.delete_task(task_id)
logger.success(f"video deleted: {utils.to_json(task)}")
return utils.get_response(200)
raise HttpException(
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
)
@router.get(
"/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files"
)
def get_bgm_list(request: Request):
suffix = "*.mp3"
song_dir = utils.song_dir()
files = glob.glob(os.path.join(song_dir, suffix))
bgm_list = []
for file in files:
filename = os.path.basename(file)
bgm_list.append(
{
"name": filename,
"size": os.path.getsize(file),
# 只返回文件名,避免把服务器绝对路径暴露给调用方。
# 服务端后续会把该文件名解析回 songs 白名单目录。
"file": filename,
}
)
response = {"files": bgm_list}
return utils.get_response(200, response)
@router.post(
"/musics",
response_model=BgmUploadResponse,
summary="Upload the BGM file to the songs directory",
)
def upload_bgm_file(request: Request, file: UploadFile = File(...)):
request_id = base.get_task_id(request)
safe_filename = _sanitize_upload_filename(file.filename, request_id)
# check file ext
if safe_filename.lower().endswith("mp3"):
song_dir = utils.song_dir()
save_path = os.path.join(song_dir, safe_filename)
# save file
with open(save_path, "wb+") as buffer:
# If the file already exists, it will be overwritten
file.file.seek(0)
buffer.write(file.file.read())
response = {"file": safe_filename}
return utils.get_response(200, response)
raise HttpException(
"", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
)
@router.get(
"/video_materials", response_model=VideoMaterialRetrieveResponse, summary="Retrieve local video materials"
)
def get_video_materials_list(request: Request):
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
local_videos_dir = utils.storage_dir("local_videos", create=True)
files = []
for suffix in allowed_suffixes:
files.extend(glob.glob(os.path.join(local_videos_dir, f"*.{suffix}")))
# 文件系统枚举顺序不稳定,直接返回会导致“顺序拼接”在不同机器或不同
# 时刻表现不一致。这里统一按文件名排序,至少保证服务端返回顺序可预测。
files.sort(key=lambda file_path: os.path.basename(file_path).lower())
video_materials_list = []
for file in files:
filename = os.path.basename(file)
video_materials_list.append(
{
"name": filename,
"size": os.path.getsize(file),
# 与 BGM 一样,只返回文件名;创建任务时再在 local_videos
# 白名单目录内解析,避免 API 泄露宿主机绝对路径。
"file": filename,
}
)
response = {"files": video_materials_list}
return utils.get_response(200, response)
@router.post(
"/video_materials",
response_model=VideoMaterialUploadResponse,
summary="Upload the video material file to the local videos directory",
)
def upload_video_material_file(request: Request, file: UploadFile = File(...)):
request_id = base.get_task_id(request)
safe_filename = _sanitize_upload_filename(file.filename, request_id)
# check file ext
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
normalized_filename = safe_filename.lower()
# 统一按小写扩展名校验,兼容 .MOV 这类大写后缀文件。
if normalized_filename.endswith(allowed_suffixes):
local_videos_dir = utils.storage_dir("local_videos", create=True)
save_path = os.path.join(local_videos_dir, safe_filename)
# save file
with open(save_path, "wb+") as buffer:
# If the file already exists, it will be overwritten
file.file.seek(0)
buffer.write(file.file.read())
response = {"file": safe_filename}
return utils.get_response(200, response)
raise HttpException(
"", status_code=400, message=f"{request_id}: Only files with extensions {', '.join(allowed_suffixes)} can be uploaded"
)
@router.get("/stream/{file_path:path}")
async def stream_video(request: Request, file_path: str):
request_id = base.get_task_id(request)
tasks_dir = utils.task_dir()
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
range_header = request.headers.get("Range")
video_size = os.path.getsize(video_path)
start, end = 0, video_size - 1
length = video_size
if range_header:
range_ = range_header.split("bytes=")[1]
start, end = [int(part) if part else None for part in range_.split("-")]
if start is None:
start = video_size - end
end = video_size - 1
if end is None:
end = video_size - 1
length = end - start + 1
def file_iterator(file_path, offset=0, bytes_to_read=None):
with open(file_path, "rb") as f:
f.seek(offset, os.SEEK_SET)
remaining = bytes_to_read or video_size
while remaining > 0:
bytes_to_read = min(4096, remaining)
data = f.read(bytes_to_read)
if not data:
break
remaining -= len(data)
yield data
response = StreamingResponse(
file_iterator(video_path, start, length), media_type="video/mp4"
)
response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}"
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Length"] = str(length)
response.status_code = 206 # Partial Content
return response
@router.get("/download/{file_path:path}")
async def download_video(request: Request, file_path: str):
"""
download video
:param request: Request request
:param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
:return: video file
"""
request_id = base.get_task_id(request)
tasks_dir = utils.task_dir()
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
file_path = pathlib.Path(video_path)
filename = file_path.stem
extension = file_path.suffix
headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"}
return FileResponse(
path=video_path,
headers=headers,
filename=f"{filename}{extension}",
media_type=f"video/{extension[1:]}",
)