diff --git a/app/api/endpoints/bangumi.py b/app/api/endpoints/bangumi.py index 999ca51f..f9ac4f46 100644 --- a/app/api/endpoints/bangumi.py +++ b/app/api/endpoints/bangumi.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends from app import schemas from app.chain.bangumi import BangumiChain +from app.chain.recommend import RecommendChain from app.core.context import MediaInfo from app.core.security import verify_token @@ -17,10 +18,7 @@ def calendar(page: int = 1, """ 浏览Bangumi每日放送 """ - medias = BangumiChain().calendar() - if medias: - return [media.to_dict() for media in medias[(page - 1) * count: page * count]] - return [] + return RecommendChain().bangumi_calendar(page=page, count=count) @router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson]) diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 3de5ad8d..6be0e8e7 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends from app import schemas from app.chain.douban import DoubanChain +from app.chain.recommend import RecommendChain from app.core.context import MediaInfo from app.core.security import verify_token from app.schemas import MediaType @@ -40,10 +41,7 @@ def movie_showing(page: int = 1, """ 浏览豆瓣正在热映 """ - movies = DoubanChain().movie_showing(page=page, count=count) - if movies: - return [media.to_dict() for media in movies] - return [] + return RecommendChain().douban_movie_showing(page=page, count=count) @router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo]) @@ -55,11 +53,7 @@ def douban_movies(sort: str = "R", """ 浏览豆瓣电影信息 """ - movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE, - sort=sort, tags=tags, page=page, count=count) - if movies: - return [media.to_dict() for media in movies] - return [] + return RecommendChain().douban_movies(sort=sort, tags=tags, page=page, count=count) @router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo]) @@ -71,11 +65,7 @@ def douban_tvs(sort: str = "R", """ 浏览豆瓣剧集信息 """ - tvs = DoubanChain().douban_discover(mtype=MediaType.TV, - sort=sort, tags=tags, page=page, count=count) - if tvs: - return [media.to_dict() for media in tvs] - return [] + return RecommendChain().douban_tvs(sort=sort, tags=tags, page=page, count=count) @router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo]) @@ -85,10 +75,7 @@ def movie_top250(page: int = 1, """ 浏览豆瓣剧集信息 """ - movies = DoubanChain().movie_top250(page=page, count=count) - if movies: - return [media.to_dict() for media in movies] - return [] + return RecommendChain().douban_movie_top250(page=page, count=count) @router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo]) @@ -98,10 +85,7 @@ def tv_weekly_chinese(page: int = 1, """ 中国每周剧集口碑榜 """ - tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) - if tvs: - return [media.to_dict() for media in tvs] - return [] + return RecommendChain().douban_tv_weekly_chinese(page=page, count=count) @router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo]) @@ -111,10 +95,7 @@ def tv_weekly_global(page: int = 1, """ 全球每周剧集口碑榜 """ - tvs = DoubanChain().tv_weekly_global(page=page, count=count) - if tvs: - return [media.to_dict() for media in tvs] - return [] + return RecommendChain().douban_tv_weekly_global(page=page, count=count) @router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo]) @@ -124,10 +105,7 @@ def tv_animation(page: int = 1, """ 热门动画剧集 """ - tvs = DoubanChain().tv_animation(page=page, count=count) - if tvs: - return [media.to_dict() for media in tvs] - return [] + return RecommendChain().douban_tv_animation(page=page, count=count) @router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo]) @@ -137,10 +115,7 @@ def movie_hot(page: int = 1, """ 热门电影 """ - movies = DoubanChain().movie_hot(page=page, count=count) - if movies: - return [media.to_dict() for media in movies] - return [] + return RecommendChain().douban_movie_hot(page=page, count=count) @router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo]) @@ -150,10 +125,7 @@ def tv_hot(page: int = 1, """ 热门电视剧 """ - tvs = DoubanChain().tv_hot(page=page, count=count) - if tvs: - return [media.to_dict() for media in tvs] - return [] + return RecommendChain().douban_tv_hot(page=page, count=count) @router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson]) diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index 7deb70d6..18cb2d94 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -3,6 +3,7 @@ from typing import List, Any from fastapi import APIRouter, Depends from app import schemas +from app.chain.recommend import RecommendChain from app.chain.tmdb import TmdbChain from app.core.security import verify_token from app.schemas.types import MediaType @@ -108,14 +109,10 @@ def tmdb_movies(sort_by: str = "popularity.desc", """ 浏览TMDB电影信息 """ - movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - page=page) - if not movies: - return [] - return [movie.to_dict() for movie in movies] + return RecommendChain().tmdb_movies(sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + page=page) @router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo]) @@ -127,26 +124,19 @@ def tmdb_tvs(sort_by: str = "popularity.desc", """ 浏览TMDB剧集信息 """ - tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - page=page) - if not tvs: - return [] - return [tv.to_dict() for tv in tvs] + return RecommendChain().tmdb_tvs(sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + page=page) @router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo]) def tmdb_trending(page: int = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ - 浏览TMDB剧集信息 + TMDB流行趋势 """ - infos = TmdbChain().tmdb_trending(page=page) - if not infos: - return [] - return [info.to_dict() for info in infos] + return RecommendChain().tmdb_trending(page=page) @router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode]) diff --git a/app/chain/recommend.py b/app/chain/recommend.py new file mode 100644 index 00000000..3783ca45 --- /dev/null +++ b/app/chain/recommend.py @@ -0,0 +1,201 @@ +from functools import wraps +from typing import Any, Callable + +from cachetools import TTLCache +from cachetools.keys import hashkey + +from app.chain import ChainBase +from app.chain.bangumi import BangumiChain +from app.chain.douban import DoubanChain +from app.chain.tmdb import TmdbChain +from app.log import logger +from app.schemas import MediaType +from app.utils.common import log_execution_time +from app.utils.singleton import Singleton + +# 推荐相关的专用缓存 +recommend_ttl = 6 * 3600 +recommend_cache = TTLCache(maxsize=256, ttl=recommend_ttl) + + +# 推荐缓存装饰器,避免偶发网络获取数据为空时,页面由于缓存为空长时间渲染异常问题 +def cached_with_empty_check(func: Callable): + """ + 缓存装饰器,用于缓存函数的返回结果,仅在结果非空时进行缓存 + + :param func: 被装饰的函数 + :return: 包装后的函数 + """ + + @wraps(func) + def wrapper(*args, **kwargs): + # 使用 cachetools 缓存,构造缓存键 + cache_key = hashkey(*args, **kwargs) + if cache_key in recommend_cache: + return recommend_cache[cache_key] + result = func(*args, **kwargs) + # 如果返回值为空,则不缓存 + if result in [None, [], {}]: + return result + recommend_cache[cache_key] = result + return result + + return wrapper + + +class RecommendChain(ChainBase, metaclass=Singleton): + """ + 推荐处理链,单例运行 + """ + + def __init__(self): + super().__init__() + self.tmdbchain = TmdbChain() + self.doubanchain = DoubanChain() + self.bangumichain = BangumiChain() + + def refresh_recommend(self): + """ + 刷新推荐 + """ + self.tmdb_movies() + self.tmdb_tvs() + self.tmdb_trending() + self.bangumi_calendar() + 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() + + @log_execution_time(logger=logger) + @cached_with_empty_check + def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "", + with_original_language: str = "", page: int = 1) -> Any: + """ + TMDB热门电影 + """ + movies = self.tmdbchain.tmdb_discover(mtype=MediaType.MOVIE, + sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + page=page) + return [movie.to_dict() for movie in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "", + with_original_language: str = "", page: int = 1) -> Any: + """ + TMDB热门电视剧 + """ + tvs = self.tmdbchain.tmdb_discover(mtype=MediaType.TV, + sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + page=page) + return [tv.to_dict() for tv in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def tmdb_trending(self, page: int = 1) -> Any: + """ + TMDB流行趋势 + """ + infos = self.tmdbchain.tmdb_trending(page=page) + return [info.to_dict() for info in infos] if infos else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any: + """ + Bangumi每日放送 + """ + medias = self.bangumichain.calendar() + return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣正在热映 + """ + movies = self.doubanchain.movie_showing(page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any: + """ + 豆瓣最新电影 + """ + movies = self.doubanchain.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_with_empty_check + def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any: + """ + 豆瓣最新电视剧 + """ + tvs = self.doubanchain.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_with_empty_check + def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣电影TOP250 + """ + movies = self.doubanchain.movie_top250(page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣国产剧集榜 + """ + tvs = self.doubanchain.tv_weekly_chinese(page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣全球剧集榜 + """ + tvs = self.doubanchain.tv_weekly_global(page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣热门动漫 + """ + tvs = self.doubanchain.tv_animation(page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣热门电影 + """ + movies = self.doubanchain.movie_hot(page=page, count=count) + return [media.to_dict() for media in movies] if movies else [] + + @log_execution_time(logger=logger) + @cached_with_empty_check + def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any: + """ + 豆瓣热门电视剧 + """ + tvs = self.doubanchain.tv_hot(page=page, count=count) + return [media.to_dict() for media in tvs] if tvs else [] diff --git a/app/utils/common.py b/app/utils/common.py index 4ba1aa7e..92d9cca6 100644 --- a/app/utils/common.py +++ b/app/utils/common.py @@ -1,5 +1,6 @@ import time -from typing import Any +from functools import wraps +from typing import Any, Callable from app.schemas import ImmediateException @@ -36,3 +37,27 @@ def retry(ExceptionToCheck: Any, return f_retry return deco_retry + + +def log_execution_time(logger: Any = None): + """ + 记录函数执行时间的装饰器 + :param logger: 日志记录器对象,用于记录异常信息 + """ + + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + start_time = time.time() + result = func(*args, **kwargs) + end_time = time.time() + msg = f"{func.__name__} execution time: {end_time - start_time:.2f} seconds" + if logger: + logger.debug(msg) + else: + print(msg) + return result + + return wrapper + + return decorator