From dee1212a76bba07255e4ff4cc6ab01d239d5a1fc Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 31 Jul 2025 09:50:49 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=8E=A8=E8=8D=90=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=BC=82=E6=AD=A5API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/recommend.py | 230 +++++++-------- app/api/endpoints/system.py | 8 +- app/chain/recommend.py | 440 +++++++++++++++++++++-------- app/modules/themoviedb/__init__.py | 146 ++++++++++ requirements.in | 1 + 5 files changed, 585 insertions(+), 240 deletions(-) diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index 317a374d..17516cb2 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -3,11 +3,11 @@ from typing import Any, List, Optional from fastapi import APIRouter, Depends from app import schemas +from app.chain.recommend import RecommendChain from app.core.event import eventmanager from app.core.security import verify_token -from app.schemas.types import ChainEventType -from app.chain.recommend import RecommendChain from app.schemas import RecommendSourceEventData +from app.schemas.types import ChainEventType router = APIRouter() @@ -29,163 +29,163 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/bangumi_calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo]) -def bangumi_calendar(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def bangumi_calendar(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览Bangumi每日放送 """ - return RecommendChain().bangumi_calendar(page=page, count=count) + return await RecommendChain().async_bangumi_calendar(page=page, count=count) @router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]) -def douban_showing(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def douban_showing(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣正在热映 """ - return RecommendChain().douban_movie_showing(page=page, count=count) + return await RecommendChain().async_douban_movie_showing(page=page, count=count) @router.get("/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo]) -def douban_movies(sort: Optional[str] = "R", - tags: Optional[str] = "", - page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def douban_movies(sort: Optional[str] = "R", + tags: Optional[str] = "", + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣电影信息 """ - return RecommendChain().douban_movies(sort=sort, tags=tags, page=page, count=count) + return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count) @router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo]) -def douban_tvs(sort: Optional[str] = "R", - tags: Optional[str] = "", - page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 浏览豆瓣剧集信息 - """ - return RecommendChain().douban_tvs(sort=sort, tags=tags, page=page, count=count) - - -@router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo]) -def douban_movie_top250(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 浏览豆瓣剧集信息 - """ - return RecommendChain().douban_movie_top250(page=page, count=count) - - -@router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo]) -def douban_tv_weekly_chinese(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 中国每周剧集口碑榜 - """ - return RecommendChain().douban_tv_weekly_chinese(page=page, count=count) - - -@router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo]) -def douban_tv_weekly_global(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 全球每周剧集口碑榜 - """ - return RecommendChain().douban_tv_weekly_global(page=page, count=count) - - -@router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo]) -def douban_tv_animation(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 热门动画剧集 - """ - return RecommendChain().douban_tv_animation(page=page, count=count) - - -@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo]) -def douban_movie_hot(page: Optional[int] = 1, +async def douban_tvs(sort: Optional[str] = "R", + tags: Optional[str] = "", + page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ + 浏览豆瓣剧集信息 + """ + return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count) + + +@router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo]) +async def douban_movie_top250(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 浏览豆瓣剧集信息 + """ + return await RecommendChain().async_douban_movie_top250(page=page, count=count) + + +@router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo]) +async def douban_tv_weekly_chinese(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 中国每周剧集口碑榜 + """ + return await RecommendChain().async_douban_tv_weekly_chinese(page=page, count=count) + + +@router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo]) +async def douban_tv_weekly_global(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 全球每周剧集口碑榜 + """ + return await RecommendChain().async_douban_tv_weekly_global(page=page, count=count) + + +@router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo]) +async def douban_tv_animation(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 热门动画剧集 + """ + return await RecommendChain().async_douban_tv_animation(page=page, count=count) + + +@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo]) +async def douban_movie_hot(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ 热门电影 """ - return RecommendChain().douban_movie_hot(page=page, count=count) + return await RecommendChain().async_douban_movie_hot(page=page, count=count) @router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo]) -def douban_tv_hot(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def douban_tv_hot(page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 热门电视剧 """ - return RecommendChain().douban_tv_hot(page=page, count=count) + return await RecommendChain().async_douban_tv_hot(page=page, count=count) @router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo]) -def tmdb_movies(sort_by: Optional[str] = "popularity.desc", - with_genres: Optional[str] = "", - with_original_language: Optional[str] = "", - with_keywords: Optional[str] = "", - with_watch_providers: Optional[str] = "", - vote_average: Optional[float] = 0.0, - vote_count: Optional[int] = 0, - release_date: Optional[str] = "", - page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def tmdb_movies(sort_by: Optional[str] = "popularity.desc", + with_genres: Optional[str] = "", + with_original_language: Optional[str] = "", + with_keywords: Optional[str] = "", + with_watch_providers: Optional[str] = "", + vote_average: Optional[float] = 0.0, + vote_count: Optional[int] = 0, + release_date: Optional[str] = "", + page: Optional[int] = 1, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览TMDB电影信息 """ - return RecommendChain().tmdb_movies(sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page) + return await RecommendChain().async_tmdb_movies(sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + with_keywords=with_keywords, + with_watch_providers=with_watch_providers, + vote_average=vote_average, + vote_count=vote_count, + release_date=release_date, + page=page) @router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo]) -def tmdb_tvs(sort_by: Optional[str] = "popularity.desc", - with_genres: Optional[str] = "", - with_original_language: Optional[str] = "", - with_keywords: Optional[str] = "", - with_watch_providers: Optional[str] = "", - vote_average: Optional[float] = 0.0, - vote_count: Optional[int] = 0, - release_date: Optional[str] = "", - page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc", + with_genres: Optional[str] = "", + with_original_language: Optional[str] = "", + with_keywords: Optional[str] = "", + with_watch_providers: Optional[str] = "", + vote_average: Optional[float] = 0.0, + vote_count: Optional[int] = 0, + release_date: Optional[str] = "", + page: Optional[int] = 1, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览TMDB剧集信息 """ - return RecommendChain().tmdb_tvs(sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page) + return await RecommendChain().async_tmdb_tvs(sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + with_keywords=with_keywords, + with_watch_providers=with_watch_providers, + vote_average=vote_average, + vote_count=vote_count, + release_date=release_date, + page=page) @router.get("/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo]) -def tmdb_trending(page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def tmdb_trending(page: Optional[int] = 1, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ TMDB流行趋势 """ - return RecommendChain().tmdb_trending(page=page) + return await RecommendChain().async_tmdb_trending(page=page) diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index fc473787..06aa501e 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -2,15 +2,14 @@ import asyncio import io import json import re -import tempfile from collections import deque from datetime import datetime from typing import Optional, Union, Annotated +import aiofiles import pillow_avif # noqa 用于自动注册AVIF支持 from PIL import Image from aiopath import AsyncPath -from app.helper.sites import SitesHelper # noqa # noqa from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response from fastapi.responses import StreamingResponse @@ -29,6 +28,7 @@ from app.helper.mediaserver import MediaServerHelper from app.helper.message import MessageHelper from app.helper.progress import ProgressHelper from app.helper.rule import RuleHelper +from app.helper.sites import SitesHelper # noqa # noqa from app.helper.subscribe import SubscribeHelper from app.helper.system import SystemHelper from app.log import logger @@ -121,8 +121,8 @@ async def fetch_image( try: if not await cache_path.parent.exists(): await cache_path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file: - tmp_file.write(content) + async with aiofiles.tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file: + await tmp_file.write(content) temp_path = AsyncPath(tmp_file.name) await temp_path.replace(cache_path) except Exception as e: diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 7e58176c..bbd46b8c 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -1,10 +1,11 @@ +import asyncio import io -import tempfile -from pathlib import Path from typing import List, Optional +import aiofiles import pillow_avif # noqa 用于自动注册AVIF支持 from PIL import Image +from aiopath import AsyncPath from app.chain import ChainBase from app.chain.bangumi import BangumiChain @@ -15,7 +16,7 @@ from app.core.config import settings, global_vars from app.log import logger from app.schemas import MediaType from app.utils.common import log_execution_time -from app.utils.http import RequestUtils +from app.utils.http import AsyncRequestUtils from app.utils.security import SecurityUtils from app.utils.singleton import Singleton @@ -34,127 +35,13 @@ class RecommendChain(ChainBase, metaclass=Singleton): def refresh_recommend(self): """ - 刷新推荐 + 刷新推荐数据 - 同步包装器 """ - logger.debug("Starting to refresh Recommend data.") - cache_backend.clear(region=recommend_cache_region) - logger.debug("Recommend Cache has been cleared.") - - # 推荐来源方法 - recommend_methods = [ - self.tmdb_movies, - self.tmdb_tvs, - self.tmdb_trending, - self.bangumi_calendar, - self.douban_movie_showing, - self.douban_movies, - self.douban_tvs, - self.douban_movie_top250, - self.douban_tv_weekly_chinese, - self.douban_tv_weekly_global, - self.douban_tv_animation, - self.douban_movie_hot, - self.douban_tv_hot, - ] - - # 缓存并刷新所有推荐数据 - recommends = [] - # 记录哪些方法已完成 - methods_finished = set() - # 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历 - for page in range(1, self.cache_max_pages + 1): - for method in recommend_methods: - if global_vars.is_system_stopped: - return - if method in methods_finished: - continue - logger.debug(f"Fetch {method.__name__} data for page {page}.") - data = method(page=page) - if not data: - logger.debug("All recommendation methods have finished fetching data. Ending pagination early.") - methods_finished.add(method) - continue - recommends.extend(data) - # 如果所有方法都已经完成,提前结束循环 - if len(methods_finished) == len(recommend_methods): - break - - # 缓存收集到的海报 - self.__cache_posters(recommends) - logger.debug("Recommend data refresh completed.") - - def __cache_posters(self, datas: List[dict]): - """ - 提取 poster_path 并缓存图片 - :param datas: 数据列表 - """ - if not settings.GLOBAL_IMAGE_CACHE: - return - - for data in datas: - if global_vars.is_system_stopped: - return - poster_path = data.get("poster_path") - if poster_path: - poster_url = poster_path.replace("original", "w500") - logger.debug(f"Caching poster image: {poster_url}") - self.__fetch_and_save_image(poster_url) - - @staticmethod - def __fetch_and_save_image(url: str): - """ - 请求并保存图片 - :param url: 图片路径 - """ - if not settings.GLOBAL_IMAGE_CACHE or not url: - return - - # 生成缓存路径 - sanitized_path = SecurityUtils.sanitize_url_path(url) - cache_path = settings.CACHE_PATH / "images" / sanitized_path - - # 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择 - if not cache_path.suffix: - cache_path = cache_path.with_suffix(".jpg") - - # 确保缓存路径和文件类型合法 - if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES): - logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}") - return - - # 本地存在缓存图片,则直接跳过 - if cache_path.exists(): - logger.debug(f"Cache hit: Image already exists at {cache_path}") - return - - # 请求远程图片 - referer = "https://movie.douban.com/" if "doubanio.com" in url else None - proxies = settings.PROXY if not referer else None - response = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer).get_res(url=url) - if not response: - logger.debug(f"Empty response for URL: {url}") - return - - # 验证下载的内容是否为有效图片 try: - Image.open(io.BytesIO(response.content)).verify() + asyncio.run(self.async_refresh_recommend()) except Exception as e: - logger.debug(f"Invalid image format for URL {url}: {e}") - return - - if not cache_path: - return - - try: - if not cache_path.parent.exists(): - cache_path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file: - tmp_file.write(response.content) - temp_path = Path(tmp_file.name) - temp_path.replace(cache_path) - logger.debug(f"Successfully cached image at {cache_path} for URL: {url}") - except Exception as e: - logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}") + logger.error(f"刷新推荐数据失败:{str(e)}") + raise @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) @@ -310,3 +197,314 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ tvs = DoubanChain().tv_hot(page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] + + # 异步版本的方法 + async def async_refresh_recommend(self): + """ + 异步刷新推荐 + """ + logger.debug("Starting to async refresh Recommend data.") + cache_backend.clear(region=recommend_cache_region) + logger.debug("Recommend Cache has been cleared.") + + # 推荐来源方法 + recommend_methods = [ + self.async_tmdb_movies, + self.async_tmdb_tvs, + self.async_tmdb_trending, + self.async_bangumi_calendar, + self.async_douban_movie_showing, + self.async_douban_movies, + self.async_douban_tvs, + self.async_douban_movie_top250, + self.async_douban_tv_weekly_chinese, + self.async_douban_tv_weekly_global, + self.async_douban_tv_animation, + self.async_douban_movie_hot, + self.async_douban_tv_hot, + ] + + # 缓存并刷新所有推荐数据 + recommends = [] + # 记录哪些方法已完成 + methods_finished = set() + # 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历 + for page in range(1, self.cache_max_pages + 1): + # 为每个页面并发执行所有方法 + tasks = [] + for method in recommend_methods: + if global_vars.is_system_stopped: + return + if method in methods_finished: + continue + tasks.append(self._async_fetch_method_data(method, page, methods_finished)) + + # 并发执行所有任务 + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, list) and result: + recommends.extend(result) + + # 如果所有方法都已经完成,提前结束循环 + if len(methods_finished) == len(recommend_methods): + break + + # 缓存收集到的海报 + await self.__async_cache_posters(recommends) + logger.debug("Async recommend data refresh completed.") + + @staticmethod + async def _async_fetch_method_data(method, page: int, methods_finished: set): + """ + 异步获取方法数据的辅助函数 + """ + try: + logger.debug(f"Async fetch {method.__name__} data for page {page}.") + data = await method(page=page) + if not data: + logger.debug(f"Method {method.__name__} finished fetching data. Ending pagination early.") + methods_finished.add(method) + return [] + return data + except Exception as e: + logger.error(f"Error fetching data from {method.__name__}: {e}") + methods_finished.add(method) + return [] + + async def __async_cache_posters(self, datas: List[dict]): + """ + 异步提取 poster_path 并缓存图片 + :param datas: 数据列表 + """ + if not settings.GLOBAL_IMAGE_CACHE: + return + + tasks = [] + for data in datas: + if global_vars.is_system_stopped: + return + poster_path = data.get("poster_path") + if poster_path: + poster_url = poster_path.replace("original", "w500") + logger.debug(f"Async caching poster image: {poster_url}") + tasks.append(self.__async_fetch_and_save_image(poster_url)) + + # 并发缓存图片 + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + @staticmethod + async def __async_fetch_and_save_image(url: str): + """ + 异步请求并保存图片 + :param url: 图片路径 + """ + if not settings.GLOBAL_IMAGE_CACHE or not url: + return + + # 生成缓存路径 + base_path = AsyncPath(settings.CACHE_PATH) + sanitized_path = SecurityUtils.sanitize_url_path(url) + cache_path = base_path / "images" / sanitized_path + + # 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择 + if not cache_path.suffix: + cache_path = cache_path.with_suffix(".jpg") + + # 确保缓存路径和文件类型合法 + if not await SecurityUtils.async_is_safe_path(base_path=base_path, + user_path=cache_path, + allowed_suffixes=settings.SECURITY_IMAGE_SUFFIXES): + logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}") + return + + # 本地存在缓存图片,则直接跳过 + if await cache_path.exists(): + logger.debug(f"Cache hit: Image already exists at {cache_path}") + return + + # 请求远程图片 + referer = "https://movie.douban.com/" if "doubanio.com" in url else None + proxies = settings.PROXY if not referer else None + response = await AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT, + proxies=proxies, referer=referer).get_res(url=url) + if not response: + logger.debug(f"Empty response for URL: {url}") + return + + # 验证下载的内容是否为有效图片 + try: + Image.open(io.BytesIO(response.content)).verify() + except Exception as e: + logger.debug(f"Invalid image format for URL {url}: {e}") + return + + if not cache_path: + return + + try: + if not await cache_path.parent.exists(): + await cache_path.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file: + await tmp_file.write(response.content) + temp_path = AsyncPath(tmp_file.name) + await temp_path.replace(cache_path) + logger.debug(f"Successfully cached image at {cache_path} for URL: {url}") + except Exception as e: + logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}") + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_tmdb_movies(self, sort_by: Optional[str] = "popularity.desc", + with_genres: Optional[str] = "", + with_original_language: Optional[str] = "", + with_keywords: Optional[str] = "", + with_watch_providers: Optional[str] = "", + vote_average: Optional[float] = 0.0, + vote_count: Optional[int] = 0, + release_date: Optional[str] = "", + page: Optional[int] = 1) -> List[dict]: + """ + 异步TMDB热门电影 + """ + movies = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.MOVIE, + sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + with_keywords=with_keywords, + with_watch_providers=with_watch_providers, + vote_average=vote_average, + vote_count=vote_count, + release_date=release_date, + page=page) + return [movie.to_dict() for movie in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", + with_genres: Optional[str] = "", + with_original_language: Optional[str] = "zh|en|ja|ko", + with_keywords: Optional[str] = "", + with_watch_providers: Optional[str] = "", + vote_average: Optional[float] = 0.0, + vote_count: Optional[int] = 0, + release_date: Optional[str] = "", + page: Optional[int] = 1) -> List[dict]: + """ + 异步TMDB热门电视剧 + """ + tvs = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.TV, + sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + with_keywords=with_keywords, + with_watch_providers=with_watch_providers, + vote_average=vote_average, + vote_count=vote_count, + release_date=release_date, + page=page) + return [tv.to_dict() for tv in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]: + """ + 异步TMDB流行趋势 + """ + infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page) + return [info.to_dict() for info in infos] if infos else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步Bangumi每日放送 + """ + medias = await BangumiChain().async_run_module("async_bangumi_calendar") + return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣正在热映 + """ + movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", + page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣最新电影 + """ + movies = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.MOVIE, + sort=sort, tags=tags, page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "", + page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣最新电视剧 + """ + tvs = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.TV, + sort=sort, tags=tags, page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣电影TOP250 + """ + movies = await DoubanChain().async_run_module("async_movie_top250", page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣国产剧集榜 + """ + tvs = await DoubanChain().async_run_module("async_tv_weekly_chinese", page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣全球剧集榜 + """ + tvs = await DoubanChain().async_run_module("async_tv_weekly_global", page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣热门动漫 + """ + tvs = await DoubanChain().async_run_module("async_tv_animation", page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣热门电影 + """ + movies = await DoubanChain().async_run_module("async_movie_hot", page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region) + async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """ + 异步豆瓣热门电视剧 + """ + tvs = await DoubanChain().async_run_module("async_tv_hot", page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 8634a33b..324003a5 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -782,6 +782,152 @@ class TheMovieDbModule(_ModuleBase): return [MediaInfo(tmdb_info=info) for info in infos] return [] + async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]: + """ + TMDB流行趋势(异步版本) + :param page: 第几页 + :return: TMDB信息列表 + """ + trending = await self.tmdb.async_discover_trending(page=page) + if trending: + return [MediaInfo(tmdb_info=info) for info in trending] + return [] + + async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: + """ + 根据合集ID查询集合(异步版本) + :param collection_id: 合集ID + """ + results = await self.tmdb.async_get_collection(collection_id) + if results: + return [MediaInfo(tmdb_info=info) for info in results] + return [] + + async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: + """ + 根据TMDBID查询themoviedb所有季信息(异步版本) + :param tmdbid: TMDBID + """ + tmdb_info = await self.tmdb.async_get_info(tmdbid=tmdbid, mtype=MediaType.TV) + if not tmdb_info: + return [] + return [schemas.TmdbSeason(**sea) + for sea in tmdb_info.get("seasons", []) if sea.get("season_number")] + + async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: + """ + 根据剧集组ID查询themoviedb所有季集信息(异步版本) + :param group_id: 剧集组ID + """ + group_seasons = await self.tmdb.async_get_tv_group_seasons(group_id) + if not group_seasons: + return [] + return [schemas.TmdbSeason( + season_number=sea.get("order"), + name=sea.get("name"), + episode_count=len(sea.get("episodes") or []), + air_date=sea.get("episodes")[0].get("air_date") if sea.get("episodes") else None, + ) for sea in group_seasons] + + async def async_tmdb_episodes(self, tmdbid: int, season: int, + episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: + """ + 根据TMDBID查询某季的所有集信息(异步版本) + :param tmdbid: TMDBID + :param season: 季 + :param episode_group: 剧集组 + """ + if episode_group: + season_info = await self.tmdb.async_get_tv_group_detail(episode_group, season=season) + else: + season_info = await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season) + if not season_info or not season_info.get("episodes"): + return [] + return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")] + + async def async_tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]: + """ + 根据TMDBID查询类似电影(异步版本) + :param tmdbid: TMDBID + """ + similar = await self.tmdb.async_get_movie_similar(tmdbid=tmdbid) + if similar: + return [MediaInfo(tmdb_info=info) for info in similar] + return [] + + async def async_tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]: + """ + 根据TMDBID查询类似电视剧(异步版本) + :param tmdbid: TMDBID + """ + similar = await self.tmdb.async_get_tv_similar(tmdbid=tmdbid) + if similar: + return [MediaInfo(tmdb_info=info) for info in similar] + return [] + + async def async_tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]: + """ + 根据TMDBID查询推荐电影(异步版本) + :param tmdbid: TMDBID + """ + recommend = await self.tmdb.async_get_movie_recommend(tmdbid=tmdbid) + if recommend: + return [MediaInfo(tmdb_info=info) for info in recommend] + return [] + + async def async_tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]: + """ + 根据TMDBID查询推荐电视剧(异步版本) + :param tmdbid: TMDBID + """ + recommend = await self.tmdb.async_get_tv_recommend(tmdbid=tmdbid) + if recommend: + return [MediaInfo(tmdb_info=info) for info in recommend] + return [] + + async def async_tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: + """ + 根据TMDBID查询电影演职员表(异步版本) + :param tmdbid: TMDBID + :param page: 页码 + """ + credit_infos = await self.tmdb.async_get_movie_credits(tmdbid=tmdbid, page=page) + if credit_infos: + return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] + return [] + + async def async_tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: + """ + 根据TMDBID查询电视剧演职员表(异步版本) + :param tmdbid: TMDBID + :param page: 页码 + """ + credit_infos = await self.tmdb.async_get_tv_credits(tmdbid=tmdbid, page=page) + if credit_infos: + return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] + return [] + + async def async_tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson: + """ + 根据TMDBID查询人物详情(异步版本) + :param person_id: 人物ID + """ + detail = await self.tmdb.async_get_person_detail(person_id=person_id) + if detail: + return schemas.MediaPerson(source="themoviedb", **detail) + return schemas.MediaPerson() + + async def async_tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: + """ + 根据TMDBID查询人物参演作品(异步版本) + :param person_id: 人物ID + :param page: 页码 + """ + infos = await self.tmdb.async_get_person_credits(person_id=person_id, page=page) + if infos: + return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos] + return [] + def clear_cache(self): """ 清除缓存 diff --git a/requirements.in b/requirements.in index d1b7766a..caf7abda 100644 --- a/requirements.in +++ b/requirements.in @@ -6,6 +6,7 @@ fastapi~=0.115.14 passlib~=1.7.4 PyJWT~=2.10.1 python-multipart~=0.0.9 +aiofiles~=24.1.0 alembic~=1.16.2 bcrypt~=4.0.1 regex~=2024.11.6