From 5a585839ba1a0ec86f03fd02189d99067ea5c5f2 Mon Sep 17 00:00:00 2001 From: DDSRem <73049927+DDSRem@users.noreply.github.com> Date: Wed, 13 May 2026 18:59:03 +0800 Subject: [PATCH] chore: remove unused imports and fix function name conflicts (#5764) - Remove unused imports in anthropic.py, tmdbv3api/__init__.py, tv.py, test files - Rename conflicting function names in subscribe.py and webhook.py - Clean up unused re-exports in tmdbv3api/__init__.py (15 unused exports) - Apply consistent formatting across API endpoints --- app/api/endpoints/anthropic.py | 14 +- app/api/endpoints/bangumi.py | 68 ++- app/api/endpoints/dashboard.py | 59 ++- app/api/endpoints/discover.py | 161 ++++--- app/api/endpoints/douban.py | 52 ++- app/api/endpoints/download.py | 97 +++-- app/api/endpoints/history.py | 132 +++--- app/api/endpoints/llm.py | 55 +-- app/api/endpoints/login.py | 26 +- app/api/endpoints/mcp.py | 126 +++--- app/api/endpoints/media.py | 187 +++++--- app/api/endpoints/message.py | 101 +++-- app/api/endpoints/mfa.py | 307 +++++++------ app/api/endpoints/notification.py | 94 ++-- app/api/endpoints/openai.py | 36 +- app/api/endpoints/plugin.py | 267 ++++++++---- app/api/endpoints/recommend.py | 241 +++++++---- app/api/endpoints/search.py | 454 +++++++++++++------- app/api/endpoints/site.py | 262 ++++++----- app/api/endpoints/storage.py | 105 +++-- app/api/endpoints/subscribe.py | 443 +++++++++++-------- app/api/endpoints/system.py | 125 +++--- app/api/endpoints/tmdb.py | 107 +++-- app/api/endpoints/torrent.py | 139 ++++-- app/api/endpoints/transfer.py | 78 +++- app/api/endpoints/user.py | 93 ++-- app/api/endpoints/webhook.py | 16 +- app/api/endpoints/workflow.py | 149 ++++--- app/api/openai_utils.py | 30 +- app/api/servarr.py | 380 ++++++++-------- app/api/servcookie.py | 23 +- app/modules/themoviedb/tmdbv3api/objs/tv.py | 5 - tests/test_feishu.py | 311 ++++++++++---- tests/test_jellyfin.py | 69 ++- 34 files changed, 2974 insertions(+), 1838 deletions(-) diff --git a/app/api/endpoints/anthropic.py b/app/api/endpoints/anthropic.py index dde2d192..6e1a1dc2 100644 --- a/app/api/endpoints/anthropic.py +++ b/app/api/endpoints/anthropic.py @@ -1,6 +1,5 @@ import asyncio import json -import time import uuid from typing import AsyncIterator, List, Optional @@ -11,9 +10,12 @@ from app import schemas from app.api.endpoints.openai import ( MODEL_ID, _CollectingMoviePilotAgent, - _error_response as _openai_error_response, ) -from app.api.openai_utils import build_anthropic_messages, build_prompt, build_session_id +from app.api.openai_utils import ( + build_anthropic_messages, + build_prompt, + build_session_id, +) from app.core.config import settings from app.core.security import anthropic_api_key_header from app.schemas.types import MessageChannel @@ -91,7 +93,11 @@ async def _stream_anthropic_response( pass -@router.post("/messages", summary="Anthropic compatible messages", response_model=schemas.AnthropicMessagesResponse) +@router.post( + "/messages", + summary="Anthropic compatible messages", + response_model=schemas.AnthropicMessagesResponse, +) async def messages( payload: schemas.AnthropicMessagesRequest, x_api_key: Optional[str] = Security(anthropic_api_key_header), diff --git a/app/api/endpoints/bangumi.py b/app/api/endpoints/bangumi.py index 50bb94ff..ef0b9811 100644 --- a/app/api/endpoints/bangumi.py +++ b/app/api/endpoints/bangumi.py @@ -10,60 +10,82 @@ from app.core.security import verify_token router = APIRouter() -@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson]) -async def bangumi_credits(bangumiid: int, - page: Optional[int] = 1, - count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/credits/{bangumiid}", + summary="查询Bangumi演职员表", + response_model=List[schemas.MediaPerson], +) +async def bangumi_credits( + bangumiid: int, + page: Optional[int] = 1, + count: Optional[int] = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询Bangumi演职员表 """ persons = await BangumiChain().async_bangumi_credits(bangumiid) if persons: - return persons[(page - 1) * count: page * count] + return persons[(page - 1) * count : page * count] return [] -@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo]) -async def bangumi_recommend(bangumiid: int, - page: Optional[int] = 1, - count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/recommend/{bangumiid}", + summary="查询Bangumi推荐", + response_model=List[schemas.MediaInfo], +) +async def bangumi_recommend( + bangumiid: int, + page: Optional[int] = 1, + count: Optional[int] = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询Bangumi推荐 """ medias = await BangumiChain().async_bangumi_recommend(bangumiid) if medias: - return [media.to_dict() for media in medias[(page - 1) * count: page * count]] + return [media.to_dict() for media in medias[(page - 1) * count : page * count]] return [] -@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson) -async def bangumi_person(person_id: int, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson +) +async def bangumi_person( + person_id: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据人物ID查询人物详情 """ return await BangumiChain().async_person_detail(person_id=person_id) -@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo]) -async def bangumi_person_credits(person_id: int, - page: Optional[int] = 1, - count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/person/credits/{person_id}", + summary="人物参演作品", + response_model=List[schemas.MediaInfo], +) +async def bangumi_person_credits( + person_id: int, + page: Optional[int] = 1, + count: Optional[int] = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据人物ID查询人物参演作品 """ medias = await BangumiChain().async_person_credits(person_id=person_id) if medias: - return [media.to_dict() for media in medias[(page - 1) * count: page * count]] + return [media.to_dict() for media in medias[(page - 1) * count : page * count]] return [] @router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo) -async def bangumi_info(bangumiid: int, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def bangumi_info( + bangumiid: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询Bangumi详情 """ diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index 6500952b..d0482e06 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -18,11 +18,15 @@ router = APIRouter() @router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic) -def statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def statistic( + name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询媒体数量统计信息 """ - media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic(name) + media_statistics: Optional[List[schemas.Statistic]] = ( + DashboardChain().media_statistic(name) + ) if media_statistics: # 汇总各媒体库统计信息 ret_statistic = schemas.Statistic() @@ -42,7 +46,9 @@ def statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(veri return schemas.Statistic() -@router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic) +@router.get( + "/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic +) def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询媒体数量统计信息 API_TOKEN认证(?token=xxx) @@ -65,13 +71,12 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any: if _usage: total += _usage.total available += _usage.available - return schemas.Storage( - total_storage=total, - used_storage=total - available - ) + return schemas.Storage(total_storage=total, used_storage=total - available) -@router.get("/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage) +@router.get( + "/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage +) def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询本地存储空间信息 API_TOKEN认证(?token=xxx) @@ -88,13 +93,17 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo) -def downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def downloader( + name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询下载器信息 """ # 下载目录空间 download_dirs = DirectoryHelper().get_local_download_dirs() - _, free_space = SystemUtils.space_usage([Path(d.download_path) for d in download_dirs]) + _, free_space = SystemUtils.space_usage( + [Path(d.download_path) for d in download_dirs] + ) # 下载器信息 downloader_info = schemas.DownloaderInfo() transfer_infos = DashboardChain().downloader_info(name) @@ -108,7 +117,11 @@ def downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(ver return downloader_info -@router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo) +@router.get( + "/downloader2", + summary="下载器信息(API_TOKEN)", + response_model=schemas.DownloaderInfo, +) def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) @@ -124,7 +137,11 @@ async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return Scheduler().list() -@router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo]) +@router.get( + "/schedule2", + summary="后台服务(API_TOKEN)", + response_model=List[schemas.ScheduleInfo], +) async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) @@ -133,9 +150,11 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.get("/transfer", summary="文件整理统计", response_model=List[int]) -async def transfer(days: Optional[int] = 7, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def transfer( + days: Optional[int] = 7, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询文件整理统计信息 """ @@ -167,7 +186,11 @@ def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return SystemUtils.memory_usage() -@router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int]) +@router.get( + "/memory2", + summary="获取当前内存使用量和使用率(API_TOKEN)", + response_model=List[int], +) def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前内存使用率 API_TOKEN认证(?token=xxx) @@ -183,7 +206,9 @@ def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return SystemUtils.network_usage() -@router.get("/network2", summary="获取当前网络流量(API_TOKEN)", response_model=List[int]) +@router.get( + "/network2", summary="获取当前网络流量(API_TOKEN)", response_model=List[int] +) def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前网络流量 API_TOKEN认证(?token=xxx) diff --git a/app/api/endpoints/discover.py b/app/api/endpoints/discover.py index 05c8c0e4..19a07f66 100644 --- a/app/api/endpoints/discover.py +++ b/app/api/endpoints/discover.py @@ -14,7 +14,11 @@ from app.schemas.types import ChainEventType, MediaType router = APIRouter() -@router.get("/source", summary="获取探索数据源", response_model=List[schemas.DiscoverMediaSource]) +@router.get( + "/source", + summary="获取探索数据源", + response_model=List[schemas.DiscoverMediaSource], +) def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取探索数据源 @@ -31,100 +35,123 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo]) -async def bangumi(type: Optional[int] = 2, - cat: Optional[int] = None, - sort: Optional[str] = 'rank', - year: Optional[str] = None, - page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def bangumi( + type: Optional[int] = 2, + cat: Optional[int] = None, + sort: Optional[str] = "rank", + year: Optional[str] = None, + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 探索Bangumi """ - medias = await BangumiChain().async_discover(type=type, cat=cat, sort=sort, year=year, - limit=count, offset=(page - 1) * count) + medias = await BangumiChain().async_discover( + type=type, cat=cat, sort=sort, year=year, limit=count, offset=(page - 1) * count + ) if medias: return [media.to_dict() for media in medias] return [] -@router.get("/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo]) -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: +@router.get( + "/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo] +) +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: """ 浏览豆瓣电影信息 """ - movies = await DoubanChain().async_douban_discover(mtype=MediaType.MOVIE, - sort=sort, tags=tags, page=page, count=count) + movies = await DoubanChain().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 [] -@router.get("/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo]) -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: +@router.get( + "/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo] +) +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: """ 浏览豆瓣剧集信息 """ - tvs = await DoubanChain().async_douban_discover(mtype=MediaType.TV, - sort=sort, tags=tags, page=page, count=count) + tvs = await DoubanChain().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 [] -@router.get("/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo]) -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: +@router.get( + "/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo] +) +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电影信息 """ - movies = await TmdbChain().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) + movies = await TmdbChain().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 [] @router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[schemas.MediaInfo]) -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: +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剧集信息 """ - tvs = await TmdbChain().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) + tvs = await TmdbChain().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 [] diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 803fbf1d..06f12429 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -11,19 +11,28 @@ from app.schemas import MediaType router = APIRouter() -@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson) -async def douban_person(person_id: int, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson +) +async def douban_person( + person_id: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据人物ID查询人物详情 """ return await DoubanChain().async_person_detail(person_id=person_id) -@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo]) -async def douban_person_credits(person_id: int, - page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/person/credits/{person_id}", + summary="人物参演作品", + response_model=List[schemas.MediaInfo], +) +async def douban_person_credits( + person_id: int, + page: Optional[int] = 1, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据人物ID查询人物参演作品 """ @@ -33,10 +42,14 @@ async def douban_person_credits(person_id: int, return [] -@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson]) -async def douban_credits(doubanid: str, - type_name: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/credits/{doubanid}/{type_name}", + summary="豆瓣演员阵容", + response_model=List[schemas.MediaPerson], +) +async def douban_credits( + doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据豆瓣ID查询演员阵容,type_name: 电影/电视剧 """ @@ -48,10 +61,14 @@ async def douban_credits(doubanid: str, return [] -@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo]) -async def douban_recommend(doubanid: str, - type_name: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/recommend/{doubanid}/{type_name}", + summary="豆瓣推荐电影/电视剧", + response_model=List[schemas.MediaInfo], +) +async def douban_recommend( + doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧 """ @@ -68,8 +85,9 @@ async def douban_recommend(doubanid: str, @router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo) -async def douban_info(doubanid: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def douban_info( + doubanid: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据豆瓣ID查询豆瓣媒体信息 """ diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 405775f9..683db414 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -19,8 +19,8 @@ router = APIRouter() @router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent]) def current( - name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询正在下载的任务 """ @@ -29,11 +29,12 @@ def current( @router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response) def download( - media_in: schemas.MediaInfo, - torrent_in: schemas.TorrentInfo, - downloader: Annotated[str | None, Body()] = None, - save_path: Annotated[str | None, Body()] = None, - current_user: User = Depends(get_current_active_user)) -> Any: + media_in: schemas.MediaInfo, + torrent_in: schemas.TorrentInfo, + downloader: Annotated[str | None, Body()] = None, + save_path: Annotated[str | None, Body()] = None, + current_user: User = Depends(get_current_active_user), +) -> Any: """ 添加下载任务(含媒体信息) """ @@ -49,28 +50,31 @@ def download( torrentinfo.site_downloader = downloader # 上下文 context = Context( - meta_info=metainfo, - media_info=mediainfo, - torrent_info=torrentinfo + meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo + ) + did = DownloadChain().download_single( + context=context, + username=current_user.name, + save_path=save_path, + source="Manual", ) - did = DownloadChain().download_single(context=context, username=current_user.name, - save_path=save_path, source="Manual") if not did: return schemas.Response(success=False, message="任务添加失败") - return schemas.Response(success=True, data={ - "download_id": did - }) + return schemas.Response(success=True, data={"download_id": did}) -@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response) +@router.post( + "/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response +) def add( - torrent_in: schemas.TorrentInfo, - tmdbid: Annotated[int | None, Body()] = None, - doubanid: Annotated[str | None, Body()] = None, - downloader: Annotated[str | None, Body()] = None, - # 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 - save_path: Annotated[str | None, Body()] = None, - current_user: User = Depends(get_current_active_user)) -> Any: + torrent_in: schemas.TorrentInfo, + tmdbid: Annotated[int | None, Body()] = None, + doubanid: Annotated[str | None, Body()] = None, + downloader: Annotated[str | None, Body()] = None, + # 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 + save_path: Annotated[str | None, Body()] = None, + current_user: User = Depends(get_current_active_user), +) -> Any: """ 添加下载任务(不含媒体信息) """ @@ -95,24 +99,27 @@ def add( torrentinfo.from_dict(torrent_in.model_dump()) # 上下文 context = Context( - meta_info=metainfo, - media_info=mediainfo, - torrent_info=torrentinfo + meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo ) - did = DownloadChain().download_single(context=context, username=current_user.name, - downloader=downloader, save_path=save_path, source="Manual") + did = DownloadChain().download_single( + context=context, + username=current_user.name, + downloader=downloader, + save_path=save_path, + source="Manual", + ) if not did: return schemas.Response(success=False, message="任务添加失败") - return schemas.Response(success=True, data={ - "download_id": did - }) + return schemas.Response(success=True, data={"download_id": did}) @router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response) def start( - hashString: str, name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + hashString: str, + name: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 开如下载任务 """ @@ -121,8 +128,11 @@ def start( @router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response) -def stop(hashString: str, name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def stop( + hashString: str, + name: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 暂停下载任务 """ @@ -137,11 +147,17 @@ async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders) if downloaders: - return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")] + return [ + {"name": d.get("name"), "type": d.get("type")} + for d in downloaders + if d.get("enabled") + ] return [] -@router.get("/paths", summary="查询可用下载路径", response_model=List[schemas.DownloadDirectory]) +@router.get( + "/paths", summary="查询可用下载路径", response_model=List[schemas.DownloadDirectory] +) def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询可直接用于下载接口 save_path 参数的下载路径 @@ -165,8 +181,11 @@ def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response) -def delete(hashString: str, name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def delete( + hashString: str, + name: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 删除下载任务 """ diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index ffe43775..00bbbd07 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -18,7 +18,10 @@ from app.db import get_async_db, get_db from app.db.models import User from app.db.models.downloadhistory import DownloadHistory, DownloadFiles from app.db.models.transferhistory import TransferHistory -from app.db.user_oper import get_current_active_superuser_async, get_current_active_superuser +from app.db.user_oper import ( + get_current_active_superuser_async, + get_current_active_superuser, +) from app.helper.progress import ProgressHelper from app.schemas.types import EventType @@ -34,7 +37,9 @@ def normalize_history_ids(history_ids: list[int]) -> list[int]: return normalized_ids -def build_manual_redo_template_context(history: TransferHistory) -> dict[str, int | str]: +def build_manual_redo_template_context( + history: TransferHistory, +) -> dict[str, int | str]: """仅负责把整理历史对象映射成 System Tasks 需要的模板变量。""" src_fileitem = history.src_fileitem or {} source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else "" @@ -92,7 +97,7 @@ def build_manual_redo_prompt(history: Any) -> str: def build_batch_manual_redo_template_context( - histories: list[Any], + histories: list[Any], ) -> dict[str, int | str]: """仅负责把多条整理历史对象映射成批量 System Tasks 需要的模板变量。""" return { @@ -155,9 +160,9 @@ def _start_ai_redo_task(history_id: int, prompt: str, progress_key: str): def _start_batch_ai_redo_task( - history_ids: list[int], - prompt: str, - progress_key: str, + history_ids: list[int], + prompt: str, + progress_key: str, ): """在后台线程中启动批量 AI 重新整理任务,并通过 ProgressHelper 实时更新进度。""" progress = ProgressHelper(progress_key) @@ -200,11 +205,17 @@ def _start_batch_ai_redo_task( asyncio.run_coroutine_threadsafe(runner(), global_vars.loop) -@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory]) -async def download_history(page: Optional[int] = 1, - count: Optional[int] = 30, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/download", + summary="查询下载历史记录", + response_model=List[schemas.DownloadHistory], +) +async def download_history( + page: Optional[int] = 1, + count: Optional[int] = 30, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询下载历史记录 """ @@ -212,9 +223,11 @@ async def download_history(page: Optional[int] = 1, @router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response) -async def delete_download_history(history_in: schemas.DownloadHistory, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def delete_download_history( + history_in: schemas.DownloadHistory, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 删除下载历史记录 """ @@ -223,12 +236,14 @@ async def delete_download_history(history_in: schemas.DownloadHistory, @router.get("/transfer", summary="查询整理记录", response_model=schemas.Response) -async def transfer_history(title: Optional[str] = None, - page: Optional[int] = 1, - count: Optional[int] = 30, - status: Optional[bool] = None, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def transfer_history( + title: Optional[str] = None, + page: Optional[int] = 1, + count: Optional[int] = 30, + status: Optional[bool] = None, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询整理记录 """ @@ -242,26 +257,35 @@ async def transfer_history(title: Optional[str] = None, if title: words = jieba.cut(title, HMM=False) title = "%".join(words) - total = await TransferHistory.async_count_by_title(db, title=title, status=status) - result = await TransferHistory.async_list_by_title(db, title=title, page=page, - count=count, status=status) + total = await TransferHistory.async_count_by_title( + db, title=title, status=status + ) + result = await TransferHistory.async_list_by_title( + db, title=title, page=page, count=count, status=status + ) else: - result = await TransferHistory.async_list_by_page(db, page=page, count=count, status=status) + result = await TransferHistory.async_list_by_page( + db, page=page, count=count, status=status + ) total = await TransferHistory.async_count(db, status=status) - return schemas.Response(success=True, - data={ - "list": [item.to_dict() for item in result], - "total": total, - }) + return schemas.Response( + success=True, + data={ + "list": [item.to_dict() for item in result], + "total": total, + }, + ) @router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response) -def delete_transfer_history(history_in: schemas.TransferHistory, - deletesrc: Optional[bool] = False, - deletedest: Optional[bool] = False, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_superuser)) -> Any: +def delete_transfer_history( + history_in: schemas.TransferHistory, + deletesrc: Optional[bool] = False, + deletedest: Optional[bool] = False, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_superuser), +) -> Any: """ 删除整理记录 """ @@ -278,27 +302,30 @@ def delete_transfer_history(history_in: schemas.TransferHistory, src_fileitem = schemas.FileItem(**history.src_fileitem) state = StorageChain().delete_media_file(src_fileitem) if not state: - return schemas.Response(success=False, message=f"{src_fileitem.path} 删除失败") + return schemas.Response( + success=False, message=f"{src_fileitem.path} 删除失败" + ) # 删除下载记录中关联的文件 DownloadFiles.delete_by_fullpath(db, Path(src_fileitem.path).as_posix()) # 发送事件 eventmanager.send_event( EventType.DownloadFileDeleted, - { - "src": history.src, - "hash": history.download_hash - } + {"src": history.src, "hash": history.download_hash}, ) # 删除记录 TransferHistory.delete(db, history_in.id) return schemas.Response(success=True) -@router.post("/transfer/{history_id}/ai-redo", summary="智能助手重新整理", response_model=schemas.Response) +@router.post( + "/transfer/{history_id}/ai-redo", + summary="智能助手重新整理", + response_model=schemas.Response, +) def ai_redo_transfer_history( - history_id: int, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_superuser), + history_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_superuser), ) -> Any: """ 手动触发单条历史记录的 AI 重新整理,并返回进度键。 @@ -321,11 +348,13 @@ def ai_redo_transfer_history( return schemas.Response(success=True, data={"progress_key": progress_key}) -@router.post("/transfer/ai-redo", summary="智能助手批量重新整理", response_model=schemas.Response) +@router.post( + "/transfer/ai-redo", summary="智能助手批量重新整理", response_model=schemas.Response +) def batch_ai_redo_transfer_history( - payload: schemas.BatchTransferHistoryRedoRequest, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_superuser), + payload: schemas.BatchTransferHistoryRedoRequest, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_superuser), ) -> Any: """ 手动触发多条历史记录的 AI 批量重新整理,并返回进度键。 @@ -349,7 +378,8 @@ def batch_ai_redo_transfer_history( if missing_ids: return schemas.Response( success=False, - message="整理记录不存在: " + ", ".join(str(history_id) for history_id in missing_ids), + message="整理记录不存在: " + + ", ".join(str(history_id) for history_id in missing_ids), ) prompt = build_batch_manual_redo_prompt(histories) @@ -367,8 +397,10 @@ def batch_ai_redo_transfer_history( @router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response) -async def empty_transfer_history(db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async)) -> Any: +async def empty_transfer_history( + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 清空整理记录 """ diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index e2763492..2c1b1bd9 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -63,12 +63,12 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str @router.get("/models", summary="获取LLM模型列表", response_model=schemas.Response) async def get_llm_models( - provider: str, - api_key: Optional[str] = None, - base_url: Optional[str] = None, - base_url_preset: Optional[str] = None, - force_refresh: Optional[bool] = False, - _: User = Depends(get_current_active_user_async), + provider: str, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + base_url_preset: Optional[str] = None, + force_refresh: Optional[bool] = False, + _: User = Depends(get_current_active_user_async), ): """ 获取指定 provider 的模型目录。 @@ -96,7 +96,7 @@ async def get_llm_models( @router.get("/providers", summary="获取LLM提供商目录", response_model=schemas.Response) async def get_llm_providers( - _: User = Depends(get_current_active_user_async), + _: User = Depends(get_current_active_user_async), ): """ 返回前端可直接渲染的 provider 目录。 @@ -114,9 +114,9 @@ async def get_llm_providers( response_model=schemas.Response, ) async def start_llm_provider_auth( - payload: LlmProviderAuthStartRequest, - request: Request, - _: User = Depends(get_current_active_superuser_async), + payload: LlmProviderAuthStartRequest, + request: Request, + _: User = Depends(get_current_active_superuser_async), ): """ 启动 provider 授权会话。 @@ -125,7 +125,9 @@ async def start_llm_provider_auth( callback_url = None if payload.provider == "chatgpt" and payload.method == "browser_oauth": callback_url = str( - request.url_for("llm_provider_auth_callback", provider_id=payload.provider) + request.url_for( + "llm_provider_auth_callback", provider_id=payload.provider + ) ) result = await LLMProviderManager().start_auth( payload.provider, @@ -143,8 +145,8 @@ async def start_llm_provider_auth( response_model=schemas.Response, ) async def get_llm_provider_auth_session( - session_id: str, - _: User = Depends(get_current_active_superuser_async), + session_id: str, + _: User = Depends(get_current_active_superuser_async), ): """ 查询授权会话状态。 @@ -162,8 +164,8 @@ async def get_llm_provider_auth_session( response_model=schemas.Response, ) async def poll_llm_provider_auth_session( - session_id: str, - _: User = Depends(get_current_active_superuser_async), + session_id: str, + _: User = Depends(get_current_active_superuser_async), ): """ 轮询 device code / OAuth 会话状态。 @@ -181,8 +183,8 @@ async def poll_llm_provider_auth_session( response_model=schemas.Response, ) async def delete_llm_provider_auth( - provider_id: str, - _: User = Depends(get_current_active_superuser_async), + provider_id: str, + _: User = Depends(get_current_active_superuser_async), ): """ 删除已保存的 provider 授权信息。 @@ -201,11 +203,11 @@ async def delete_llm_provider_auth( name="llm_provider_auth_callback", ) async def llm_provider_auth_callback( - provider_id: str, - code: Optional[str] = None, - state: Optional[str] = None, - error: Optional[str] = None, - error_description: Optional[str] = None, + provider_id: str, + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + error_description: Optional[str] = None, ): """ 处理需要浏览器回跳的 OAuth provider。 @@ -222,8 +224,8 @@ async def llm_provider_auth_callback( @router.post("/test", summary="测试LLM调用", response_model=schemas.Response) async def llm_test( - payload: Annotated[Optional[LlmTestRequest], Body()] = None, - _: User = Depends(get_current_active_superuser_async), + payload: Annotated[Optional[LlmTestRequest], Body()] = None, + _: User = Depends(get_current_active_superuser_async), ): """ 使用传入配置或当前已保存配置执行一次最小 LLM 调用。 @@ -250,9 +252,8 @@ async def llm_test( if not payload.enabled: return schemas.Response(success=False, message="请先启用智能助手", data=data) - if ( - payload.provider not in {"chatgpt", "github-copilot"} - and (not payload.api_key or not payload.api_key.strip()) + if payload.provider not in {"chatgpt", "github-copilot"} and ( + not payload.api_key or not payload.api_key.strip() ): return schemas.Response( success=False, diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 865218d7..cf543c59 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -18,15 +18,15 @@ router = APIRouter() @router.post("/access-token", summary="获取token", response_model=schemas.Token) def login_access_token( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - otp_password: Annotated[str | None, Form()] = None + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + otp_password: Annotated[str | None, Form()] = None, ) -> Any: """ 获取认证Token """ - success, user_or_message = UserChain().user_authenticate(username=form_data.username, - password=form_data.password, - mfa_code=otp_password) + success, user_or_message = UserChain().user_authenticate( + username=form_data.username, password=form_data.password, mfa_code=otp_password + ) if not success: # 如果是需要MFA验证,返回特殊标识 @@ -34,21 +34,24 @@ def login_access_token( raise HTTPException( status_code=401, detail="需要双重验证,请提供验证码或使用通行密钥", - headers={"X-MFA-Required": "true"} + headers={"X-MFA-Required": "true"}, ) raise HTTPException(status_code=401, detail="用户名或密码错误") # 用户等级 level = SitesHelper().auth_level # 是否显示配置向导 - show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE + show_wizard = ( + not SystemConfigOper().get(SystemConfigKey.SetupWizardState) + and not settings.ADVANCED_MODE + ) return schemas.Token( access_token=security.create_access_token( userid=user_or_message.id, username=user_or_message.name, super_user=user_or_message.is_superuser, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), - level=level + level=level, ), token_type="bearer", super_user=user_or_message.is_superuser, @@ -57,7 +60,7 @@ def login_access_token( avatar=user_or_message.avatar, level=level, permissions=user_or_message.permissions or {}, - wizard=show_wizard + wizard=show_wizard, ) @@ -68,10 +71,7 @@ def wallpaper() -> Any: """ url = WallpaperHelper().get_wallpaper() if url: - return schemas.Response( - success=True, - message=url - ) + return schemas.Response(success=True, message=url) return schemas.Response(success=False) diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index 76c81284..e23f5868 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -33,35 +33,32 @@ def list_exposed_tools(): 获取 MCP 可见工具列表 """ return [ - tool for tool in moviepilot_tool_manager.list_tools() + tool + for tool in moviepilot_tool_manager.list_tools() if tool.name not in MCP_HIDDEN_TOOLS ] -def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]: +def create_jsonrpc_response( + request_id: Union[str, int, None], result: Any +) -> Dict[str, Any]: """ 创建 JSON-RPC 成功响应 """ - response = { - "jsonrpc": "2.0", - "id": request_id, - "result": result - } + response = {"jsonrpc": "2.0", "id": request_id, "result": result} return response -def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: str, data: Any = None) -> Dict[ - str, Any]: +def create_jsonrpc_error( + request_id: Union[str, int, None], code: int, message: str, data: Any = None +) -> Dict[str, Any]: """ 创建 JSON-RPC 错误响应 """ error = { "jsonrpc": "2.0", "id": request_id, - "error": { - "code": code, - "message": message - } + "error": {"code": code, "message": message}, } if data is not None: error["error"]["data"] = data @@ -70,12 +67,11 @@ def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: @router.post("", summary="MCP JSON-RPC 端点", response_model=None) async def mcp_jsonrpc( - request: Request, - _: Annotated[str, Depends(verify_apikey)] = None + request: Request, _: Annotated[str, Depends(verify_apikey)] = None ) -> Union[JSONResponse, Response]: """ MCP 标准 JSON-RPC 2.0 端点 - + 处理所有 MCP 协议消息(初始化、工具列表、工具调用等) """ try: @@ -84,14 +80,14 @@ async def mcp_jsonrpc( logger.error(f"解析请求体失败: {e}") return JSONResponse( status_code=400, - content=create_jsonrpc_error(None, -32700, "Parse error", str(e)) + content=create_jsonrpc_error(None, -32700, "Parse error", str(e)), ) # 验证 JSON-RPC 格式 if not isinstance(body, dict) or body.get("jsonrpc") != "2.0": return JSONResponse( status_code=400, - content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request") + content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request"), ) method = body.get("method") @@ -114,7 +110,7 @@ async def mcp_jsonrpc( else: return JSONResponse( status_code=400, - content={"error": "initialized must be a notification"} + content={"error": "initialized must be a notification"}, ) # 处理工具列表请求 @@ -134,20 +130,22 @@ async def mcp_jsonrpc( # 未知方法 else: return JSONResponse( - content=create_jsonrpc_error(request_id, -32601, f"Method not found: {method}") + content=create_jsonrpc_error( + request_id, -32601, f"Method not found: {method}" + ) ) except ValueError as e: logger.warning(f"MCP 请求参数错误: {e}") return JSONResponse( status_code=400, - content=create_jsonrpc_error(request_id, -32602, "Invalid params", str(e)) + content=create_jsonrpc_error(request_id, -32602, "Invalid params", str(e)), ) except Exception as e: logger.error(f"处理 MCP 请求失败: {e}", exc_info=True) return JSONResponse( status_code=500, - content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e)) + content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e)), ) @@ -158,7 +156,9 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]: protocol_version = params.get("protocolVersion") client_info = params.get("clientInfo", {}) - logger.info(f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}") + logger.info( + f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}" + ) # 版本协商:选择客户端和服务器都支持的版本 negotiated_version = MCP_PROTOCOL_VERSION @@ -168,7 +168,9 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]: logger.info(f"使用客户端协议版本: {negotiated_version}") else: # 客户端版本不支持,使用服务器默认版本 - logger.warning(f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}") + logger.warning( + f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}" + ) return { "protocolVersion": negotiated_version, @@ -176,14 +178,14 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]: "tools": { "listChanged": False # 暂不支持工具列表变更通知 }, - "logging": {} + "logging": {}, }, "serverInfo": { "name": "MoviePilot", "version": APP_VERSION, "description": "MoviePilot MCP Server - 电影自动化管理工具", }, - "instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。" + "instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。", } @@ -199,13 +201,11 @@ async def handle_tools_list() -> Dict[str, Any]: mcp_tool = { "name": tool.name, "description": tool.description, - "inputSchema": tool.input_schema + "inputSchema": tool.input_schema, } mcp_tools.append(mcp_tool) - return { - "tools": mcp_tools - } + return {"tools": mcp_tools} async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]: @@ -224,30 +224,18 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]: result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments) - return { - "content": [ - { - "type": "text", - "text": result_text - } - ] - } + return {"content": [{"type": "text", "text": result_text}]} except Exception as e: logger.error(f"工具调用失败: {tool_name}, 错误: {e}", exc_info=True) return { - "content": [ - { - "type": "text", - "text": f"错误: {str(e)}" - } - ], - "isError": True + "content": [{"type": "text", "text": f"错误: {str(e)}"}], + "isError": True, } @router.delete("", summary="终止 MCP 会话", response_model=None) async def delete_mcp_session( - _: Annotated[str, Depends(verify_apikey)] = None + _: Annotated[str, Depends(verify_apikey)] = None, ) -> Union[JSONResponse, Response]: """ 终止 MCP 会话(无状态模式下仅返回成功) @@ -257,13 +245,12 @@ async def delete_mcp_session( # ==================== 兼容的 RESTful API 端点 ==================== + @router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]]) -async def list_tools( - _: Annotated[str, Depends(verify_apikey)] -) -> Any: +async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 获取所有可用的工具列表 - + 返回每个工具的名称、描述和参数定义 """ try: @@ -276,7 +263,7 @@ async def list_tools( tool_dict = { "name": tool.name, "description": tool.description, - "inputSchema": tool.input_schema + "inputSchema": tool.input_schema, } tools_list.append(tool_dict) @@ -288,12 +275,11 @@ async def list_tools( @router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse) async def call_tool( - request: schemas.ToolCallRequest, - _: Annotated[str, Depends(verify_apikey)] = None + request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None ) -> Any: """ 调用指定的工具 - + Returns: 工具执行结果 """ @@ -301,28 +287,23 @@ async def call_tool( if request.tool_name in MCP_HIDDEN_TOOLS: raise ValueError(f"工具 '{request.tool_name}' 未找到") - result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments) - - return schemas.ToolCallResponse( - success=True, - result=result_text + result_text = await moviepilot_tool_manager.call_tool( + request.tool_name, request.arguments ) + + return schemas.ToolCallResponse(success=True, result=result_text) except Exception as e: logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True) - return schemas.ToolCallResponse( - success=False, - error=f"调用工具失败: {str(e)}" - ) + return schemas.ToolCallResponse(success=False, error=f"调用工具失败: {str(e)}") @router.get("/tools/{tool_name}", summary="获取工具详情", response_model=Dict[str, Any]) async def get_tool_info( - tool_name: str, - _: Annotated[str, Depends(verify_apikey)] + tool_name: str, _: Annotated[str, Depends(verify_apikey)] ) -> Any: """ 获取指定工具的详细信息 - + Returns: 工具的详细信息,包括名称、描述和参数定义 """ @@ -336,7 +317,7 @@ async def get_tool_info( return { "name": tool.name, "description": tool.description, - "inputSchema": tool.input_schema + "inputSchema": tool.input_schema, } raise HTTPException(status_code=404, detail=f"工具 '{tool_name}' 未找到") @@ -347,14 +328,17 @@ async def get_tool_info( raise HTTPException(status_code=500, detail=f"获取工具信息失败: {str(e)}") -@router.get("/tools/{tool_name}/schema", summary="获取工具参数Schema", response_model=Dict[str, Any]) +@router.get( + "/tools/{tool_name}/schema", + summary="获取工具参数Schema", + response_model=Dict[str, Any], +) async def get_tool_schema( - tool_name: str, - _: Annotated[str, Depends(verify_apikey)] + tool_name: str, _: Annotated[str, Depends(verify_apikey)] ) -> Any: """ 获取指定工具的参数Schema(JSON Schema格式) - + Returns: 工具的JSON Schema定义 """ diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index f130faaf..1e364cb3 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -20,10 +20,14 @@ from app.schemas.types import ChainEventType router = APIRouter() -@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context) -async def recognize(title: str, - subtitle: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context +) +async def recognize( + title: str, + subtitle: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据标题、副标题识别媒体信息 """ @@ -35,11 +39,16 @@ async def recognize(title: str, return schemas.Context() -@router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context) -async def recognize2(_: Annotated[str, Depends(verify_apitoken)], - title: str, - subtitle: Optional[str] = None - ) -> Any: +@router.get( + "/recognize2", + summary="识别种子媒体信息(API_TOKEN)", + response_model=schemas.Context, +) +async def recognize2( + _: Annotated[str, Depends(verify_apitoken)], + title: str, + subtitle: Optional[str] = None, +) -> Any: """ 根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx) """ @@ -47,9 +56,12 @@ async def recognize2(_: Annotated[str, Depends(verify_apitoken)], return await recognize(title, subtitle) -@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context) -async def recognize_file(path: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context +) +async def recognize_file( + path: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据文件路径识别媒体信息 """ @@ -60,9 +72,14 @@ async def recognize_file(path: str, return schemas.Context() -@router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context) -async def recognize_file2(path: str, - _: Annotated[str, Depends(verify_apitoken)]) -> Any: +@router.get( + "/recognize_file2", + summary="识别文件媒体信息(API_TOKEN)", + response_model=schemas.Context, +) +async def recognize_file2( + path: str, _: Annotated[str, Depends(verify_apitoken)] +) -> Any: """ 根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx) """ @@ -71,11 +88,13 @@ async def recognize_file2(path: str, @router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict]) -async def search(title: str, - type: Optional[str] = "media", - page: int = 1, - count: int = 8, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def search( + title: str, + type: Optional[str] = "media", + page: int = 1, + count: int = 8, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息 """ @@ -94,7 +113,9 @@ async def search(title: str, result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": collections = await media_chain.async_search_collections(name=title) - result = [collection.to_dict() for collection in collections] if collections else [] + result = ( + [collection.to_dict() for collection in collections] if collections else [] + ) else: # person persons = await media_chain.async_search_persons(name=title) result = [person.model_dump() for person in persons] if persons else [] @@ -103,17 +124,21 @@ async def search(title: str, return [] # 排序和分页 - setting_order = settings.SEARCH_SOURCE.split(',') if settings.SEARCH_SOURCE else [] + setting_order = settings.SEARCH_SOURCE.split(",") if settings.SEARCH_SOURCE else [] sort_order = {source: index for index, source in enumerate(setting_order)} sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4)) - return sorted_result[(page - 1) * count:page * count] + return sorted_result[(page - 1) * count : page * count] -@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response) -def scrape(fileitem: schemas.FileItem, - storage: Optional[str] = "local", - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.post( + "/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response +) +def scrape( + fileitem: schemas.FileItem, + storage: Optional[str] = "local", + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 刮削媒体信息 """ @@ -132,12 +157,14 @@ def scrape(fileitem: schemas.FileItem, fileitem=fileitem, meta=context.meta_info, mediainfo=context.media_info, - overwrite=True + overwrite=True, ) return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成") -@router.get("/category/config", summary="获取分类策略配置", response_model=schemas.Response) +@router.get( + "/category/config", summary="获取分类策略配置", response_model=schemas.Response +) def get_category_config(_: User = Depends(get_current_active_user)): """ 获取分类策略配置 @@ -146,8 +173,12 @@ def get_category_config(_: User = Depends(get_current_active_user)): return schemas.Response(success=True, data=config.model_dump()) -@router.post("/category/config", summary="保存分类策略配置", response_model=schemas.Response) -def save_category_config(config: CategoryConfig, _: User = Depends(get_current_active_superuser)): +@router.post( + "/category/config", summary="保存分类策略配置", response_model=schemas.Response +) +def save_category_config( + config: CategoryConfig, _: User = Depends(get_current_active_superuser) +): """ 保存分类策略配置 """ @@ -165,8 +196,14 @@ async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return MediaChain().media_category() or {} -@router.get("/group/seasons/{episode_group}", summary="查询剧集组季信息", response_model=List[schemas.MediaSeason]) -async def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/group/seasons/{episode_group}", + summary="查询剧集组季信息", + response_model=List[schemas.MediaSeason], +) +async def group_seasons( + episode_group: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询剧集组季信息(themoviedb) """ @@ -178,18 +215,24 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) - """ 查询媒体剧集组列表(themoviedb) """ - mediainfo = await MediaChain().async_recognize_media(tmdbid=tmdbid, mtype=MediaType.TV) + mediainfo = await MediaChain().async_recognize_media( + tmdbid=tmdbid, mtype=MediaType.TV + ) if not mediainfo: return [] return mediainfo.episode_groups -@router.get("/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason]) -async def seasons(mediaid: Optional[str] = None, - title: Optional[str] = None, - year: str = None, - season: int = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason] +) +async def seasons( + mediaid: Optional[str] = None, + title: Optional[str] = None, + year: str = None, + season: int = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询媒体季信息 """ @@ -212,28 +255,39 @@ async def seasons(mediaid: Optional[str] = None, ) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": - seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id) + seasons_info = await TmdbChain().async_tmdb_seasons( + tmdbid=mediainfo.tmdb_id + ) if seasons_info: if season is not None: - return [sea for sea in seasons_info if sea.season_number == season] + return [ + sea for sea in seasons_info if sea.season_number == season + ] return seasons_info else: sea = season if season is not None else 1 - return [schemas.MediaSeason( - season_number=sea, - poster_path=mediainfo.poster_path, - name=f"第 {sea} 季", - air_date=mediainfo.release_date, - overview=mediainfo.overview, - vote_average=mediainfo.vote_average, - episode_count=mediainfo.number_of_episodes - )] + return [ + schemas.MediaSeason( + season_number=sea, + poster_path=mediainfo.poster_path, + name=f"第 {sea} 季", + air_date=mediainfo.release_date, + overview=mediainfo.overview, + vote_average=mediainfo.vote_average, + episode_count=mediainfo.number_of_episodes, + ) + ] return [] @router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo) -async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def detail( + mediaid: str, + type_name: str, + title: Optional[str] = None, + year: str = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧 """ @@ -241,26 +295,37 @@ async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year mediainfo = None mediachain = MediaChain() if mediaid.startswith("tmdb:"): - mediainfo = await mediachain.async_recognize_media(tmdbid=int(mediaid[5:]), mtype=mtype) + mediainfo = await mediachain.async_recognize_media( + tmdbid=int(mediaid[5:]), mtype=mtype + ) elif mediaid.startswith("douban:"): - mediainfo = await mediachain.async_recognize_media(doubanid=mediaid[7:], mtype=mtype) + mediainfo = await mediachain.async_recognize_media( + doubanid=mediaid[7:], mtype=mtype + ) elif mediaid.startswith("bangumi:"): - mediainfo = await mediachain.async_recognize_media(bangumiid=int(mediaid[8:]), mtype=mtype) + mediainfo = await mediachain.async_recognize_media( + bangumiid=int(mediaid[8:]), mtype=mtype + ) else: # 广播事件解析媒体信息 event_data = MediaRecognizeConvertEventData( - mediaid=mediaid, - convert_type=settings.RECOGNIZE_SOURCE + mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE + ) + event = await eventmanager.async_send_event( + ChainEventType.MediaRecognizeConvert, event_data ) - event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data) # 使用事件返回的上下文数据 if event and event.event_data and event.event_data.media_dict: event_data: MediaRecognizeConvertEventData = event.event_data new_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": - mediainfo = await mediachain.async_recognize_media(tmdbid=new_id, mtype=mtype) + mediainfo = await mediachain.async_recognize_media( + tmdbid=new_id, mtype=mtype + ) elif event_data.convert_type == "douban": - mediainfo = await mediachain.async_recognize_media(doubanid=new_id, mtype=mtype) + mediainfo = await mediachain.async_recognize_media( + doubanid=new_id, mtype=mtype + ) elif title: # 使用名称识别兜底 meta = MetaInfo(title) diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 18595596..47396f33 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -30,8 +30,11 @@ def start_message_chain(body: Any, form: Any, args: Any): @router.post("/", summary="接收用户消息", response_model=schemas.Response) -async def user_message(background_tasks: BackgroundTasks, request: Request, - _: schemas.TokenPayload = Depends(verify_apitoken)): +async def user_message( + background_tasks: BackgroundTasks, + request: Request, + _: schemas.TokenPayload = Depends(verify_apitoken), +): """ 用户消息响应,配置请求中需要添加参数:token=API_TOKEN&source=消息配置名 """ @@ -106,10 +109,12 @@ async def web_message( @router.get("/web", summary="获取WEB消息", response_model=List[dict]) -async def get_web_message(_: schemas.TokenPayload = Depends(verify_token), - db: AsyncSession = Depends(get_async_db), - page: Optional[int] = 1, - count: Optional[int] = 20): +async def get_web_message( + _: schemas.TokenPayload = Depends(verify_token), + db: AsyncSession = Depends(get_async_db), + page: Optional[int] = 1, + count: Optional[int] = 20, +): """ 获取WEB消息列表 """ @@ -124,8 +129,13 @@ async def get_web_message(_: schemas.TokenPayload = Depends(verify_token), return ret_messages -def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str, - source: Optional[str] = None) -> Any: +def wechat_verify( + echostr: str, + msg_signature: str, + timestamp: Union[str, int], + nonce: str, + source: Optional[str] = None, +) -> Any: """ 微信验证响应 """ @@ -133,21 +143,31 @@ def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], client_configs = ServiceConfigHelper.get_notification_configs() if not client_configs: return "未找到对应的消息配置" - client_config = next((config for config in client_configs if - config.type == "wechat" - and config.enabled - and config.config.get("WECHAT_MODE", "app") != "bot" - and (not source or config.name == source)), None) + client_config = next( + ( + config + for config in client_configs + if config.type == "wechat" + and config.enabled + and config.config.get("WECHAT_MODE", "app") != "bot" + and (not source or config.name == source) + ), + None, + ) if not client_config: return "未找到对应的消息配置" try: - wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'), - sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'), - sReceiveId=client_config.config.get('WECHAT_CORPID')) - ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature, - sTimeStamp=timestamp, - sNonce=nonce, - sEchoStr=echostr) + wxcpt = WXBizMsgCrypt( + sToken=client_config.config.get("WECHAT_TOKEN"), + sEncodingAESKey=client_config.config.get("WECHAT_ENCODING_AESKEY"), + sReceiveId=client_config.config.get("WECHAT_CORPID"), + ) + ret, sEchoStr = wxcpt.VerifyURL( + sMsgSignature=msg_signature, + sTimeStamp=timestamp, + sNonce=nonce, + sEchoStr=echostr, + ) if ret == 0: # 验证URL成功,将sEchoStr返回给企业号 return PlainTextResponse(sEchoStr) @@ -165,21 +185,35 @@ def vocechat_verify() -> Any: @router.get("/", summary="回调请求验证") -def incoming_verify(token: Optional[str] = None, echostr: Optional[str] = None, msg_signature: Optional[str] = None, - timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_apitoken)) -> Any: +def incoming_verify( + token: Optional[str] = None, + echostr: Optional[str] = None, + msg_signature: Optional[str] = None, + timestamp: Union[str, int] = None, + nonce: Optional[str] = None, + source: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_apitoken), +) -> Any: """ 微信/VoceChat等验证响应 """ - logger.info(f"收到验证请求: token={token}, echostr={echostr}, " - f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}") + logger.info( + f"收到验证请求: token={token}, echostr={echostr}, " + f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}" + ) if echostr and msg_signature and timestamp and nonce: return wechat_verify(echostr, msg_signature, timestamp, nonce, source) return vocechat_verify() -@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response) -async def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)): +@router.post( + "/webpush/subscribe", + summary="客户端webpush通知订阅", + response_model=schemas.Response, +) +async def subscribe( + subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token) +): """ 客户端webpush通知订阅 """ @@ -190,8 +224,13 @@ async def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload return schemas.Response(success=True) -@router.post("/webpush/send", summary="发送webpush通知", response_model=schemas.Response) -def send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayload = Depends(verify_token)): +@router.post( + "/webpush/send", summary="发送webpush通知", response_model=schemas.Response +) +def send_notification( + payload: schemas.SubscriptionMessage, + _: schemas.TokenPayload = Depends(verify_token), +): """ 发送webpush通知 """ @@ -201,9 +240,7 @@ def send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayl subscription_info=sub, data=json.dumps(payload.model_dump()), vapid_private_key=settings.VAPID.get("privateKey"), - vapid_claims={ - "sub": settings.VAPID.get("subject") - }, + vapid_claims={"sub": settings.VAPID.get("subject")}, ) except WebPushException as err: logger.error(f"WebPush发送失败: {str(err)}") diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 0b4590cc..ce90761c 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -2,6 +2,7 @@ MFA (Multi-Factor Authentication) API 端点 包含 OTP 和 PassKey 相关功能 """ + from datetime import timedelta from typing import Any, Annotated, Optional @@ -26,6 +27,7 @@ router = APIRouter() # ==================== 辅助函数 ==================== + def _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]: """ 构建凭证列表 @@ -33,13 +35,14 @@ def _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]: :param passkeys: PassKey 列表 :return: 凭证字典列表 """ - return [ - { - 'credential_id': pk.credential_id, - 'transports': pk.transports - } - for pk in passkeys - ] if passkeys else [] + return ( + [ + {"credential_id": pk.credential_id, "transports": pk.transports} + for pk in passkeys + ] + if passkeys + else [] + ) def _extract_and_standardize_credential_id(credential: dict) -> str: @@ -50,16 +53,14 @@ def _extract_and_standardize_credential_id(credential: dict) -> str: :return: 标准化后的 credential_id :raises ValueError: 如果凭证无效 """ - credential_id_raw = credential.get('id') or credential.get('rawId') + credential_id_raw = credential.get("id") or credential.get("rawId") if not credential_id_raw: raise ValueError("无效的凭证") return PassKeyHelper.standardize_credential_id(credential_id_raw) def _verify_passkey_and_update( - credential: dict, - challenge: str, - passkey: PassKey + credential: dict, challenge: str, passkey: PassKey ) -> tuple[bool, int]: """ 验证 PassKey 并更新使用时间和签名计数 @@ -73,7 +74,7 @@ def _verify_passkey_and_update( credential=credential, expected_challenge=challenge, credential_public_key=passkey.public_key, - credential_current_sign_count=passkey.sign_count + credential_current_sign_count=passkey.sign_count, ) if success: @@ -95,23 +96,35 @@ async def _check_user_has_passkey(db: AsyncSession, user_id: int) -> bool: # ==================== 请求模型 ==================== + class OtpVerifyRequest(schemas.BaseModel): """OTP验证请求""" + uri: str otpPassword: str + class OtpDisableRequest(schemas.BaseModel): """OTP禁用请求""" + password: str + class PassKeyDeleteRequest(schemas.BaseModel): """PassKey删除请求""" + passkey_id: int password: str + # ==================== 通用 MFA 接口 ==================== -@router.get('/status/{username}', summary='判断用户是否开启双重验证(MFA)', response_model=schemas.Response) + +@router.get( + "/status/{username}", + summary="判断用户是否开启双重验证(MFA)", + response_model=schemas.Response, +) async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any: """ 检查指定用户是否启用了任何双重验证方式(OTP 或 PassKey) @@ -119,46 +132,53 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> user: User = await User.async_get_by_name(db, username) if not user: return schemas.Response(success=False) - + # 检查是否启用了OTP has_otp = user.is_otp - + # 检查是否有PassKey has_passkey = await _check_user_has_passkey(db, user.id) - + # 只要有任何一种验证方式,就需要双重验证 return schemas.Response(success=(has_otp or has_passkey)) # ==================== OTP 相关接口 ==================== -@router.post('/otp/generate', summary='生成 OTP 验证 URI', response_model=schemas.Response) + +@router.post( + "/otp/generate", summary="生成 OTP 验证 URI", response_model=schemas.Response +) def otp_generate( - current_user: Annotated[User, Depends(get_current_active_user)] + current_user: Annotated[User, Depends(get_current_active_user)], ) -> Any: """生成 OTP 密钥及对应的 URI""" secret, uri = OtpUtils.generate_secret_key(current_user.name) - return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri}) + return schemas.Response(success=secret != "", data={"secret": secret, "uri": uri}) -@router.post('/otp/verify', summary='绑定并验证 OTP', response_model=schemas.Response) +@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response) async def otp_verify( data: OtpVerifyRequest, db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async) + current_user: User = Depends(get_current_active_user_async), ) -> Any: """验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证""" if not OtpUtils.is_legal(data.uri, data.otpPassword): return schemas.Response(success=False, message="验证码错误") - await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(data.uri)) + await current_user.async_update_otp_by_name( + db, current_user.name, True, OtpUtils.get_secret(data.uri) + ) return schemas.Response(success=True) -@router.post('/otp/disable', summary='关闭当前用户的 OTP 验证', response_model=schemas.Response) +@router.post( + "/otp/disable", summary="关闭当前用户的 OTP 验证", response_model=schemas.Response +) async def otp_disable( data: OtpDisableRequest, db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async) + current_user: User = Depends(get_current_active_user_async), ) -> Any: """关闭当前用户的 OTP 验证功能""" # 安全检查:如果存在 PassKey,默认不允许关闭 OTP,除非配置允许 @@ -166,7 +186,7 @@ async def otp_disable( if has_passkey and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: return schemas.Response( success=False, - message="您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证" + message="您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证", ) # 验证密码 @@ -178,13 +198,16 @@ async def otp_disable( # ==================== PassKey 相关接口 ==================== + class PassKeyRegistrationStart(schemas.BaseModel): """PassKey注册开始请求""" + name: str = "通行密钥" class PassKeyRegistrationFinish(schemas.BaseModel): """PassKey注册完成请求""" + credential: dict challenge: str name: str = "通行密钥" @@ -192,18 +215,24 @@ class PassKeyRegistrationFinish(schemas.BaseModel): class PassKeyAuthenticationStart(schemas.BaseModel): """PassKey认证开始请求""" + username: Optional[str] = None class PassKeyAuthenticationFinish(schemas.BaseModel): """PassKey认证完成请求""" + credential: dict challenge: str -@router.post("/passkey/register/start", summary="开始注册 PassKey", response_model=schemas.Response) +@router.post( + "/passkey/register/start", + summary="开始注册 PassKey", + response_model=schemas.Response, +) def passkey_register_start( - current_user: Annotated[User, Depends(get_current_active_user)] + current_user: Annotated[User, Depends(get_current_active_user)], ) -> Any: """开始注册 PassKey - 生成注册选项""" try: @@ -211,53 +240,59 @@ def passkey_register_start( if not current_user.is_otp and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: return schemas.Response( success=False, - message="为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥" + message="为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥", ) # 获取用户已有的PassKey existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id) - existing_credentials = _build_credential_list(existing_passkeys) if existing_passkeys else None + existing_credentials = ( + _build_credential_list(existing_passkeys) if existing_passkeys else None + ) # 生成注册选项 options_json, challenge = PassKeyHelper.generate_registration_options( user_id=current_user.id, username=current_user.name, - display_name=current_user.settings.get('nickname') if current_user.settings else None, - existing_credentials=existing_credentials + display_name=current_user.settings.get("nickname") + if current_user.settings + else None, + existing_credentials=existing_credentials, ) return schemas.Response( - success=True, - data={ - 'options': options_json, - 'challenge': challenge - } + success=True, data={"options": options_json, "challenge": challenge} ) except Exception as e: logger.error(f"生成PassKey注册选项失败: {e}") - return schemas.Response( - success=False, - message=f"生成注册选项失败: {str(e)}" - ) + return schemas.Response(success=False, message=f"生成注册选项失败: {str(e)}") -@router.post("/passkey/register/finish", summary="完成注册 PassKey", response_model=schemas.Response) +@router.post( + "/passkey/register/finish", + summary="完成注册 PassKey", + response_model=schemas.Response, +) def passkey_register_finish( passkey_req: PassKeyRegistrationFinish, - current_user: Annotated[User, Depends(get_current_active_user)] + current_user: Annotated[User, Depends(get_current_active_user)], ) -> Any: """完成注册 PassKey - 验证并保存凭证""" try: # 验证注册响应 - credential_id, public_key, sign_count, aaguid = PassKeyHelper.verify_registration_response( - credential=passkey_req.credential, - expected_challenge=passkey_req.challenge + credential_id, public_key, sign_count, aaguid = ( + PassKeyHelper.verify_registration_response( + credential=passkey_req.credential, + expected_challenge=passkey_req.challenge, + ) ) # 提取transports transports = None - if 'response' in passkey_req.credential and 'transports' in passkey_req.credential['response']: - transports = ','.join(passkey_req.credential['response']['transports']) + if ( + "response" in passkey_req.credential + and "transports" in passkey_req.credential["response"] + ): + transports = ",".join(passkey_req.credential["response"]["transports"]) # 保存到数据库 passkey = PassKey( @@ -267,42 +302,39 @@ def passkey_register_finish( sign_count=sign_count, name=passkey_req.name or "通行密钥", aaguid=aaguid, - transports=transports + transports=transports, ) passkey.create() logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}") - return schemas.Response( - success=True, - message="通行密钥注册成功" - ) + return schemas.Response(success=True, message="通行密钥注册成功") except Exception as e: logger.error(f"注册PassKey失败: {e}") - return schemas.Response( - success=False, - message=f"注册失败: {str(e)}" - ) + return schemas.Response(success=False, message=f"注册失败: {str(e)}") -@router.post("/passkey/authenticate/start", summary="开始 PassKey 认证", response_model=schemas.Response) +@router.post( + "/passkey/authenticate/start", + summary="开始 PassKey 认证", + response_model=schemas.Response, +) def passkey_authenticate_start( - passkey_req: PassKeyAuthenticationStart = Body(...) + passkey_req: PassKeyAuthenticationStart = Body(...), ) -> Any: """开始 PassKey 认证 - 生成认证选项""" try: existing_credentials = None - + # 如果指定了用户名,只允许该用户的PassKey if passkey_req.username: user = User.get_by_name(db=None, name=passkey_req.username) - existing_passkeys = PassKey.get_by_user_id(db=None, user_id=user.id) if user else None + existing_passkeys = ( + PassKey.get_by_user_id(db=None, user_id=user.id) if user else None + ) if not user or not existing_passkeys: - return schemas.Response( - success=False, - message="认证失败" - ) + return schemas.Response(success=False, message="认证失败") existing_credentials = _build_credential_list(existing_passkeys) @@ -312,29 +344,26 @@ def passkey_authenticate_start( ) return schemas.Response( - success=True, - data={ - 'options': options_json, - 'challenge': challenge - } + success=True, data={"options": options_json, "challenge": challenge} ) except Exception as e: logger.error(f"生成PassKey认证选项失败: {e}") - return schemas.Response( - success=False, - message="认证失败" - ) + return schemas.Response(success=False, message="认证失败") -@router.post("/passkey/authenticate/finish", summary="完成 PassKey 认证", response_model=schemas.Token) -def passkey_authenticate_finish( - passkey_req: PassKeyAuthenticationFinish -) -> Any: +@router.post( + "/passkey/authenticate/finish", + summary="完成 PassKey 认证", + response_model=schemas.Token, +) +def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any: """完成 PassKey 认证 - 验证凭证并返回 token""" try: # 提取并标准化凭证ID try: - credential_id = _extract_and_standardize_credential_id(passkey_req.credential) + credential_id = _extract_and_standardize_credential_id( + passkey_req.credential + ) except ValueError as e: logger.warning(f"PassKey认证失败,提供的凭证无效: {e}") raise HTTPException(status_code=401, detail="认证失败") @@ -349,7 +378,7 @@ def passkey_authenticate_finish( success, _ = _verify_passkey_and_update( credential=passkey_req.credential, challenge=passkey_req.challenge, - passkey=passkey + passkey=passkey, ) if not success: @@ -359,7 +388,10 @@ def passkey_authenticate_finish( # 生成token level = SitesHelper().auth_level - show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE + show_wizard = ( + not SystemConfigOper().get(SystemConfigKey.SetupWizardState) + and not settings.ADVANCED_MODE + ) return schemas.Token( access_token=security.create_access_token( @@ -367,7 +399,7 @@ def passkey_authenticate_finish( username=user.name, super_user=user.is_superuser, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), - level=level + level=level, ), token_type="bearer", super_user=user.is_superuser, @@ -376,7 +408,7 @@ def passkey_authenticate_finish( avatar=user.avatar, level=level, permissions=user.permissions or {}, - wizard=show_wizard + wizard=show_wizard, ) except HTTPException: raise @@ -385,80 +417,83 @@ def passkey_authenticate_finish( raise HTTPException(status_code=401, detail="认证失败") -@router.get("/passkey/list", summary="获取当前用户的 PassKey 列表", response_model=schemas.Response) +@router.get( + "/passkey/list", + summary="获取当前用户的 PassKey 列表", + response_model=schemas.Response, +) def passkey_list( - current_user: Annotated[User, Depends(get_current_active_user)] + current_user: Annotated[User, Depends(get_current_active_user)], ) -> Any: """获取当前用户的所有 PassKey""" try: passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id) - - key_list = [ - { - 'id': pk.id, - 'name': pk.name, - 'created_at': pk.created_at.isoformat() if pk.created_at else None, - 'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None, - 'aaguid': pk.aaguid, - 'transports': pk.transports - } - for pk in passkeys - ] if passkeys else [] - return schemas.Response( - success=True, - data=key_list + key_list = ( + [ + { + "id": pk.id, + "name": pk.name, + "created_at": pk.created_at.isoformat() if pk.created_at else None, + "last_used_at": pk.last_used_at.isoformat() + if pk.last_used_at + else None, + "aaguid": pk.aaguid, + "transports": pk.transports, + } + for pk in passkeys + ] + if passkeys + else [] ) + + return schemas.Response(success=True, data=key_list) except Exception as e: logger.error(f"获取PassKey列表失败: {e}") - return schemas.Response( - success=False, - message=f"获取列表失败: {str(e)}" - ) + return schemas.Response(success=False, message=f"获取列表失败: {str(e)}") @router.post("/passkey/delete", summary="删除 PassKey", response_model=schemas.Response) async def passkey_delete( data: PassKeyDeleteRequest, - current_user: User = Depends(get_current_active_user_async) + current_user: User = Depends(get_current_active_user_async), ) -> Any: """删除指定的 PassKey""" try: # 验证密码 - if not security.verify_password(data.password, str(current_user.hashed_password)): + if not security.verify_password( + data.password, str(current_user.hashed_password) + ): return schemas.Response(success=False, message="密码错误") - success = PassKey.delete_by_id(db=None, passkey_id=data.passkey_id, user_id=current_user.id) - - if success: - logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}") - return schemas.Response( - success=True, - message="通行密钥已删除" - ) - else: - return schemas.Response( - success=False, - message="通行密钥不存在或无权删除" - ) - except Exception as e: - logger.error(f"删除PassKey失败: {e}") - return schemas.Response( - success=False, - message=f"删除失败: {str(e)}" + success = PassKey.delete_by_id( + db=None, passkey_id=data.passkey_id, user_id=current_user.id ) + if success: + logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}") + return schemas.Response(success=True, message="通行密钥已删除") + else: + return schemas.Response(success=False, message="通行密钥不存在或无权删除") + except Exception as e: + logger.error(f"删除PassKey失败: {e}") + return schemas.Response(success=False, message=f"删除失败: {str(e)}") -@router.post("/passkey/verify", summary="PassKey 二次验证", response_model=schemas.Response) + +@router.post( + "/passkey/verify", summary="PassKey 二次验证", response_model=schemas.Response +) def passkey_verify_mfa( passkey_req: PassKeyAuthenticationFinish, - current_user: Annotated[User, Depends(get_current_active_user)] + current_user: Annotated[User, Depends(get_current_active_user)], ) -> Any: """使用 PassKey 进行二次验证(MFA)""" try: # 提取并标准化凭证ID try: - credential_id = _extract_and_standardize_credential_id(passkey_req.credential) + credential_id = _extract_and_standardize_credential_id( + passkey_req.credential + ) except ValueError as e: logger.warning(f"PassKey二次验证失败,提供的凭证无效: {e}") return schemas.Response(success=False, message="验证失败") @@ -467,32 +502,22 @@ def passkey_verify_mfa( passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id) if not passkey or passkey.user_id != current_user.id: return schemas.Response( - success=False, - message="通行密钥不存在或不属于当前用户" + success=False, message="通行密钥不存在或不属于当前用户" ) # 验证认证响应并更新 success, _ = _verify_passkey_and_update( credential=passkey_req.credential, challenge=passkey_req.challenge, - passkey=passkey + passkey=passkey, ) if not success: - return schemas.Response( - success=False, - message="通行密钥验证失败" - ) + return schemas.Response(success=False, message="通行密钥验证失败") logger.info(f"用户 {current_user.name} 通过PassKey二次验证成功") - return schemas.Response( - success=True, - message="二次验证成功" - ) + return schemas.Response(success=True, message="二次验证成功") except Exception as e: logger.error(f"PassKey二次验证失败: {e}") - return schemas.Response( - success=False, - message="验证失败" - ) + return schemas.Response(success=False, message="验证失败") diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index 86b0ad2e..b5d8a23a 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -12,11 +12,11 @@ router = APIRouter() def _build_wechatclawbot_temp_client( - source: Optional[str] = None, - WECHATCLAWBOT_BASE_URL: Optional[str] = None, - WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, - WECHATCLAWBOT_ADMINS: Optional[str] = None, - WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, + source: Optional[str] = None, + WECHATCLAWBOT_BASE_URL: Optional[str] = None, + WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, + WECHATCLAWBOT_ADMINS: Optional[str] = None, + WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, ): """基于当前表单配置创建一个临时客户端,用于未保存时的扫码状态预览。""" source_name = str(source or "").strip() @@ -33,13 +33,13 @@ def _build_wechatclawbot_temp_client( def _get_wechatclawbot_client( - source: Optional[str] = None, - fallback_source: Optional[str] = None, - WECHATCLAWBOT_BASE_URL: Optional[str] = None, - WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, - WECHATCLAWBOT_ADMINS: Optional[str] = None, - WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, - allow_temporary: bool = False, + source: Optional[str] = None, + fallback_source: Optional[str] = None, + WECHATCLAWBOT_BASE_URL: Optional[str] = None, + WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, + WECHATCLAWBOT_ADMINS: Optional[str] = None, + WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, + allow_temporary: bool = False, ): """获取已加载的微信 ClawBot 客户端,必要时退回到临时客户端。""" module = ModuleManager().get_running_module("WechatClawBotModule") @@ -87,15 +87,15 @@ def _get_wechatclawbot_client( response_model=schemas.Response, ) def wechatclawbot_status( - source: Optional[str] = None, - fallback_source: Optional[str] = None, - refresh_remote: bool = True, - auto_generate_qrcode: bool = True, - WECHATCLAWBOT_BASE_URL: Optional[str] = None, - WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, - WECHATCLAWBOT_ADMINS: Optional[str] = None, - WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, - _: User = Depends(get_current_active_superuser), + source: Optional[str] = None, + fallback_source: Optional[str] = None, + refresh_remote: bool = True, + auto_generate_qrcode: bool = True, + WECHATCLAWBOT_BASE_URL: Optional[str] = None, + WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, + WECHATCLAWBOT_ADMINS: Optional[str] = None, + WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, + _: User = Depends(get_current_active_superuser), ): """查询微信 ClawBot 登录状态和二维码。""" client, errmsg = _get_wechatclawbot_client( @@ -124,13 +124,13 @@ def wechatclawbot_status( response_model=schemas.Response, ) def refresh_wechatclawbot_qrcode( - source: Optional[str] = None, - fallback_source: Optional[str] = None, - WECHATCLAWBOT_BASE_URL: Optional[str] = None, - WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, - WECHATCLAWBOT_ADMINS: Optional[str] = None, - WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, - _: User = Depends(get_current_active_superuser), + source: Optional[str] = None, + fallback_source: Optional[str] = None, + WECHATCLAWBOT_BASE_URL: Optional[str] = None, + WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, + WECHATCLAWBOT_ADMINS: Optional[str] = None, + WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, + _: User = Depends(get_current_active_superuser), ): """刷新微信 ClawBot 二维码。""" client, errmsg = _get_wechatclawbot_client( @@ -158,13 +158,13 @@ def refresh_wechatclawbot_qrcode( response_model=schemas.Response, ) def logout_wechatclawbot( - source: Optional[str] = None, - fallback_source: Optional[str] = None, - WECHATCLAWBOT_BASE_URL: Optional[str] = None, - WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, - WECHATCLAWBOT_ADMINS: Optional[str] = None, - WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, - _: User = Depends(get_current_active_superuser), + source: Optional[str] = None, + fallback_source: Optional[str] = None, + WECHATCLAWBOT_BASE_URL: Optional[str] = None, + WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, + WECHATCLAWBOT_ADMINS: Optional[str] = None, + WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, + _: User = Depends(get_current_active_superuser), ): """退出微信 ClawBot 登录。""" client, errmsg = _get_wechatclawbot_client( @@ -192,13 +192,13 @@ def logout_wechatclawbot( response_model=schemas.Response, ) def test_wechatclawbot( - source: Optional[str] = None, - fallback_source: Optional[str] = None, - WECHATCLAWBOT_BASE_URL: Optional[str] = None, - WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, - WECHATCLAWBOT_ADMINS: Optional[str] = None, - WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, - _: User = Depends(get_current_active_superuser), + source: Optional[str] = None, + fallback_source: Optional[str] = None, + WECHATCLAWBOT_BASE_URL: Optional[str] = None, + WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None, + WECHATCLAWBOT_ADMINS: Optional[str] = None, + WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None, + _: User = Depends(get_current_active_superuser), ): """测试微信 ClawBot 当前登录态是否可用。""" client, errmsg = _get_wechatclawbot_client( @@ -222,11 +222,11 @@ def test_wechatclawbot( response_model=schemas.Response, ) def migrate_wechatclawbot_cache( - old_source: str, - new_source: str, - cleanup_old: bool = False, - overwrite: bool = False, - _: User = Depends(get_current_active_superuser), + old_source: str, + new_source: str, + cleanup_old: bool = False, + overwrite: bool = False, + _: User = Depends(get_current_active_superuser), ): """在通知名称变更时迁移对应的微信 ClawBot 登录缓存。""" success, message = WechatClawBot.migrate_cached_state( diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index 21a35b82..6c244fd7 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -251,9 +251,15 @@ def _check_auth( return None -@router.get("/models", summary="OpenAI compatible models", response_model=schemas.OpenAIModelListResponse) +@router.get( + "/models", + summary="OpenAI compatible models", + response_model=schemas.OpenAIModelListResponse, +) async def list_models( - credentials: Optional[HTTPAuthorizationCredentials] = Security(openai_bearer_scheme), + credentials: Optional[HTTPAuthorizationCredentials] = Security( + openai_bearer_scheme + ), ): auth_error = _check_auth(credentials) if auth_error: @@ -272,7 +278,9 @@ async def list_models( async def chat_completions( payload: schemas.OpenAIChatCompletionsRequest, request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Security(openai_bearer_scheme), + credentials: Optional[HTTPAuthorizationCredentials] = Security( + openai_bearer_scheme + ), ): auth_error = _check_auth(credentials) if auth_error: @@ -304,7 +312,9 @@ async def chat_completions( ) try: - prompt, images = build_prompt(payload.messages, use_server_session=use_server_session) + prompt, images = build_prompt( + payload.messages, use_server_session=use_server_session + ) except ValueError as exc: return _error_response(str(exc), 400, code="invalid_messages") @@ -353,10 +363,16 @@ async def chat_completions( return JSONResponse(content=build_completion_payload(content, MODEL_ID)) -@router.post("/responses", summary="OpenAI compatible responses", response_model=schemas.OpenAIResponsesResponse) +@router.post( + "/responses", + summary="OpenAI compatible responses", + response_model=schemas.OpenAIResponsesResponse, +) async def responses( payload: schemas.OpenAIResponsesRequest, - credentials: Optional[HTTPAuthorizationCredentials] = Security(openai_bearer_scheme), + credentials: Optional[HTTPAuthorizationCredentials] = Security( + openai_bearer_scheme + ), ): auth_error = _check_auth(credentials) if auth_error: @@ -377,7 +393,9 @@ async def responses( code="unsupported_stream", ) - normalized_messages = build_responses_input(payload.input, instructions=payload.instructions) + normalized_messages = build_responses_input( + payload.input, instructions=payload.instructions + ) if not normalized_messages: return _error_response( "`input` must include at least one usable message.", @@ -386,7 +404,9 @@ async def responses( ) try: - prompt, images = build_prompt(normalized_messages, use_server_session=bool(payload.user)) + prompt, images = build_prompt( + normalized_messages, use_server_session=bool(payload.user) + ) except ValueError as exc: return _error_response(str(exc), 400, code="invalid_input") diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index c51f6409..33d64187 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -16,7 +16,10 @@ from app.core.plugin import PluginManager from app.core.security import verify_apikey, verify_token from app.db.models import User from app.db.systemconfig_oper import SystemConfigOper -from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async +from app.db.user_oper import ( + get_current_active_superuser, + get_current_active_superuser_async, +) from app.factory import app from app.helper.plugin import PluginHelper from app.log import logger @@ -76,7 +79,10 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str): auth_mode = api.pop("auth", "apikey") dependencies = api.setdefault("dependencies", []) if not allow_anonymous: - if auth_mode == "bear" and Depends(verify_token) not in dependencies: + if ( + auth_mode == "bear" + and Depends(verify_token) not in dependencies + ): dependencies.append(Depends(verify_token)) elif Depends(verify_apikey) not in dependencies: dependencies.append(Depends(verify_apikey)) @@ -140,8 +146,11 @@ def register_plugin(plugin_id: str): @router.get("/", summary="所有插件", response_model=List[schemas.Plugin]) -async def all_plugins(_: User = Depends(get_current_active_superuser_async), - state: Optional[str] = "all", force: bool = False) -> List[schemas.Plugin]: +async def all_plugins( + _: User = Depends(get_current_active_superuser_async), + state: Optional[str] = "all", + force: bool = False, +) -> List[schemas.Plugin]: """ 查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all """ @@ -159,8 +168,11 @@ async def all_plugins(_: User = Depends(get_current_active_superuser_async), local_repo_plugins = plugin_manager.get_local_repo_plugins() # 在线插件 online_plugins = await plugin_manager.async_get_online_plugins(force) - candidate_plugins = plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []) \ - if online_plugins or local_repo_plugins else [] + candidate_plugins = ( + plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []) + if online_plugins or local_repo_plugins + else [] + ) if not candidate_plugins: # 没有获取在线插件 if state == "market": @@ -208,8 +220,12 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return await PluginHelper().async_get_statistic() -@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response) -def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: +@router.get( + "/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response +) +def reload_plugin( + plugin_id: str, _: User = Depends(get_current_active_superuser) +) -> Any: """ 重新加载插件 """ @@ -221,10 +237,12 @@ def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser @router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response) -async def install(plugin_id: str, - repo_url: Optional[str] = "", - force: Optional[bool] = False, - _: User = Depends(get_current_active_superuser_async)) -> Any: +async def install( + plugin_id: str, + repo_url: Optional[str] = "", + force: Optional[bool] = False, + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 安装插件 """ @@ -238,21 +256,23 @@ async def install(plugin_id: str, # 插件不存在或需要强制安装,下载安装并注册插件 if repo_url: state, msg = await plugin_helper.async_install( - pid=plugin_id, - repo_url=repo_url, - force_install=force + pid=plugin_id, repo_url=repo_url, force_install=force ) # 安装失败则直接响应 if not state: return schemas.Response(success=False, message=msg) else: # repo_url 为空时,也直接响应 - return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置") + return schemas.Response( + success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置" + ) # 安装插件 if plugin_id not in install_plugins: install_plugins.append(plugin_id) # 保存设置 - await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins) + await SystemConfigOper().async_set( + SystemConfigKey.UserInstalledPlugins, install_plugins + ) # 重新加载插件 await run_in_threadpool(reload_plugin, plugin_id) return schemas.Response(success=True) @@ -268,7 +288,11 @@ async def remotes(token: str) -> Any: return PluginManager().get_plugin_remotes() -@router.get("/sidebar_nav", summary="获取插件侧栏导航项", response_model=List[schemas.PluginSidebarNavItem]) +@router.get( + "/sidebar_nav", + summary="获取插件侧栏导航项", + response_model=List[schemas.PluginSidebarNavItem], +) def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 聚合已启用 Vue 插件声明的侧栏入口(get_sidebar_nav),供前端主界面侧栏展示。 @@ -277,15 +301,19 @@ def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/form/{plugin_id}", summary="获取插件表单页面") -def plugin_form(plugin_id: str, - _: User = Depends(get_current_active_superuser)) -> dict: +def plugin_form( + plugin_id: str, _: User = Depends(get_current_active_superuser) +) -> dict: """ 根据插件ID获取插件配置表单或Vue组件URL """ plugin_manager = PluginManager() plugin_instance = plugin_manager.running_plugins.get(plugin_id) if not plugin_instance: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"插件 {plugin_id} 不存在或未加载", + ) # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() @@ -294,7 +322,7 @@ def plugin_form(plugin_id: str, return { "render_mode": render_mode, "conf": conf, - "model": plugin_manager.get_plugin_config(plugin_id) or model + "model": plugin_manager.get_plugin_config(plugin_id) or model, } except Exception as e: logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}") @@ -302,29 +330,33 @@ def plugin_form(plugin_id: str, @router.get("/page/{plugin_id}", summary="获取插件数据页面") -def plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict: +def plugin_page( + plugin_id: str, _: User = Depends(get_current_active_superuser) +) -> dict: """ 根据插件ID获取插件数据页面 """ plugin_instance = PluginManager().running_plugins.get(plugin_id) if not plugin_instance: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"插件 {plugin_id} 不存在或未加载", + ) # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() try: page = plugin_instance.get_page() - return { - "render_mode": render_mode, - "page": page or [] - } + return {"render_mode": render_mode, "page": page or []} except Exception as e: logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}") return {} @router.get("/dashboard/meta", summary="获取所有插件仪表板元信息") -def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]: +def plugin_dashboard_meta( + _: schemas.TokenPayload = Depends(verify_token), +) -> List[dict]: """ 获取所有插件仪表板元信息 """ @@ -332,8 +364,12 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li @router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") -def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]: +def plugin_dashboard_by_key( + plugin_id: str, + key: str, + user_agent: Annotated[str | None, Header()] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Optional[schemas.PluginDashboard]: """ 根据插件ID获取插件仪表板 """ @@ -341,17 +377,23 @@ def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str @router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置") -def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard: +def plugin_dashboard( + plugin_id: str, + user_agent: Annotated[str | None, Header()] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> schemas.PluginDashboard: """ 根据插件ID获取插件仪表板 """ return plugin_dashboard_by_key(plugin_id, "", user_agent) -@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response) -def reset_plugin(plugin_id: str, - _: User = Depends(get_current_active_superuser)) -> Any: +@router.get( + "/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response +) +def reset_plugin( + plugin_id: str, _: User = Depends(get_current_active_superuser) +) -> Any: """ 根据插件ID重置插件配置及数据 """ @@ -372,42 +414,54 @@ async def plugin_static_file(plugin_id: str, filepath: str): """ # 基础安全检查 if ".." in filepath or ".." in plugin_id: - logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") + logger.warning( + f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}" + ) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") - plugin_base_dir = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() - plugin_file_path = plugin_base_dir / filepath.lstrip('/') + plugin_base_dir = ( + AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() + ) + plugin_file_path = plugin_base_dir / filepath.lstrip("/") try: resolved_base = await plugin_base_dir.resolve() resolved_file = await plugin_file_path.resolve() except Exception: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path" + ) if not resolved_file.is_relative_to(resolved_base): - logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") + logger.warning( + f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}" + ) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") if not await plugin_file_path.exists(): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在" + ) if not await plugin_file_path.is_file(): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件" + ) # 判断 MIME 类型 response_type, _ = mimetypes.guess_type(str(plugin_file_path)) suffix = plugin_file_path.suffix.lower() # 强制修正 .mjs 和 .js 的 MIME 类型 - if suffix in ['.js', '.mjs']: - response_type = 'application/javascript' - elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正 - response_type = 'text/css' + if suffix in [".js", ".mjs"]: + response_type = "application/javascript" + elif suffix == ".css" and not response_type: # 如果 guess_type 没猜对 css,也修正 + response_type = "text/css" elif not response_type: # 对于其他猜不出的类型 - response_type = 'application/octet-stream' + response_type = "application/octet-stream" try: # 异步生成器函数,用于流式读取文件 async def file_generator(): - async with aiofiles.open(plugin_file_path, mode='rb') as file: + async with aiofiles.open(plugin_file_path, mode="rb") as file: # 8KB 块大小 while chunk := await file.read(8192): yield chunk @@ -415,15 +469,22 @@ async def plugin_static_file(plugin_id: str, filepath: str): return StreamingResponse( file_generator(), media_type=response_type, - headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"} + headers={ + "Content-Disposition": f"inline; filename={plugin_file_path.name}" + }, ) except Exception as e: - logger.error(f"Error creating/sending StreamingResponse for {plugin_file_path}: {e}", exc_info=True) + logger.error( + f"Error creating/sending StreamingResponse for {plugin_file_path}: {e}", + exc_info=True, + ) raise HTTPException(status_code=500, detail="Internal Server Error") @router.get("/folders", summary="获取插件文件夹配置", response_model=dict) -async def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> dict: +async def get_plugin_folders( + _: User = Depends(get_current_active_superuser_async), +) -> dict: """ 获取插件文件夹分组配置 """ @@ -436,7 +497,9 @@ async def get_plugin_folders(_: User = Depends(get_current_active_superuser_asyn @router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response) -async def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> Any: +async def save_plugin_folders( + folders: dict, _: User = Depends(get_current_active_superuser_async) +) -> Any: """ 保存插件文件夹分组配置 """ @@ -448,9 +511,12 @@ async def save_plugin_folders(folders: dict, _: User = Depends(get_current_activ return schemas.Response(success=False, message=str(e)) -@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response) -async def create_plugin_folder(folder_name: str, - _: User = Depends(get_current_active_superuser_async)) -> Any: +@router.post( + "/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response +) +async def create_plugin_folder( + folder_name: str, _: User = Depends(get_current_active_superuser_async) +) -> Any: """ 创建新的插件文件夹 """ @@ -458,14 +524,19 @@ async def create_plugin_folder(folder_name: str, if folder_name not in folders: folders[folder_name] = [] SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) - return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 创建成功") + return schemas.Response( + success=True, message=f"文件夹 '{folder_name}' 创建成功" + ) else: return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在") -@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response) -async def delete_plugin_folder(folder_name: str, - _: User = Depends(get_current_active_superuser_async)) -> Any: +@router.delete( + "/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response +) +async def delete_plugin_folder( + folder_name: str, _: User = Depends(get_current_active_superuser_async) +) -> Any: """ 删除插件文件夹 """ @@ -473,27 +544,40 @@ async def delete_plugin_folder(folder_name: str, if folder_name in folders: del folders[folder_name] await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) - return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 删除成功") + return schemas.Response( + success=True, message=f"文件夹 '{folder_name}' 删除成功" + ) else: return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在") -@router.put("/folders/{folder_name}/plugins", summary="更新文件夹中的插件", response_model=schemas.Response) -async def update_folder_plugins(folder_name: str, plugin_ids: List[str], - _: User = Depends(get_current_active_superuser_async)) -> Any: +@router.put( + "/folders/{folder_name}/plugins", + summary="更新文件夹中的插件", + response_model=schemas.Response, +) +async def update_folder_plugins( + folder_name: str, + plugin_ids: List[str], + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 更新指定文件夹中的插件列表 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} folders[folder_name] = plugin_ids await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) - return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新") + return schemas.Response( + success=True, message=f"文件夹 '{folder_name}' 中的插件已更新" + ) -@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response) -def clone_plugin(plugin_id: str, - clone_data: dict, - _: User = Depends(get_current_active_superuser)) -> Any: +@router.post( + "/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response +) +def clone_plugin( + plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser) +) -> Any: """ 创建插件分身 """ @@ -504,7 +588,7 @@ def clone_plugin(plugin_id: str, name=clone_data.get("name", ""), description=clone_data.get("description", ""), version=clone_data.get("version", ""), - icon=clone_data.get("icon", "") + icon=clone_data.get("icon", ""), ) if success: @@ -521,8 +605,9 @@ def clone_plugin(plugin_id: str, @router.get("/{plugin_id}", summary="获取插件配置") -async def plugin_config(plugin_id: str, - _: User = Depends(get_current_active_superuser_async)) -> dict: +async def plugin_config( + plugin_id: str, _: User = Depends(get_current_active_superuser_async) +) -> dict: """ 根据插件ID获取插件配置信息 """ @@ -530,8 +615,9 @@ async def plugin_config(plugin_id: str, @router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response) -def set_plugin_config(plugin_id: str, conf: dict, - _: User = Depends(get_current_active_superuser)) -> Any: +def set_plugin_config( + plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser) +) -> Any: """ 更新插件配置 """ @@ -546,8 +632,9 @@ def set_plugin_config(plugin_id: str, conf: dict, @router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response) -def uninstall_plugin(plugin_id: str, - _: User = Depends(get_current_active_superuser)) -> Any: +def uninstall_plugin( + plugin_id: str, _: User = Depends(get_current_active_superuser) +) -> Any: """ 卸载插件 """ @@ -599,9 +686,9 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): # 查找原插件所在的文件夹 target_folder = None for folder_name, folder_data in folders.items(): - if isinstance(folder_data, dict) and 'plugins' in folder_data: + if isinstance(folder_data, dict) and "plugins" in folder_data: # 新格式:{"plugins": [...], "order": ..., "icon": ...} - if original_plugin_id in folder_data['plugins']: + if original_plugin_id in folder_data["plugins"]: target_folder = folder_name break elif isinstance(folder_data, list): @@ -613,21 +700,27 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): # 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中 if target_folder: folder_data = folders[target_folder] - if isinstance(folder_data, dict) and 'plugins' in folder_data: + if isinstance(folder_data, dict) and "plugins" in folder_data: # 新格式 - if clone_plugin_id not in folder_data['plugins']: - folder_data['plugins'].append(clone_plugin_id) - logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中") + if clone_plugin_id not in folder_data["plugins"]: + folder_data["plugins"].append(clone_plugin_id) + logger.info( + f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中" + ) elif isinstance(folder_data, list): # 旧格式 if clone_plugin_id not in folder_data: folder_data.append(clone_plugin_id) - logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中") + logger.info( + f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中" + ) # 保存更新后的文件夹配置 config_oper.set(SystemConfigKey.PluginFolders, folders) else: - logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立") + logger.info( + f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立" + ) except Exception as e: logger.error(f"处理插件文件夹时出错:{str(e)}") @@ -649,10 +742,10 @@ def _remove_plugin_from_folders(plugin_id: str): # 遍历所有文件夹,移除指定插件 for folder_name, folder_data in folders.items(): - if isinstance(folder_data, dict) and 'plugins' in folder_data: + if isinstance(folder_data, dict) and "plugins" in folder_data: # 新格式:{"plugins": [...], "order": ..., "icon": ...} - if plugin_id in folder_data['plugins']: - folder_data['plugins'].remove(plugin_id) + if plugin_id in folder_data["plugins"]: + folder_data["plugins"].remove(plugin_id) logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") modified = True elif isinstance(folder_data, list): diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index 17516cb2..b22caf18 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -12,7 +12,11 @@ from app.schemas.types import ChainEventType router = APIRouter() -@router.get("/source", summary="获取推荐数据源", response_model=List[schemas.RecommendMediaSource]) +@router.get( + "/source", + summary="获取推荐数据源", + response_model=List[schemas.RecommendMediaSource], +) def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取推荐数据源 @@ -28,104 +32,156 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return [] -@router.get("/bangumi_calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo]) -async def bangumi_calendar(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/bangumi_calendar", + summary="Bangumi每日放送", + response_model=List[schemas.MediaInfo], +) +async def bangumi_calendar( + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 浏览Bangumi每日放送 """ return await RecommendChain().async_bangumi_calendar(page=page, count=count) -@router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]) -async def douban_showing(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo] +) +async def douban_showing( + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 浏览豆瓣正在热映 """ return await RecommendChain().async_douban_movie_showing(page=page, count=count) -@router.get("/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo]) -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: +@router.get( + "/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo] +) +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 await RecommendChain().async_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]) -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: +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) + 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: +@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: +@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: +@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: +@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: +@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 await RecommendChain().async_douban_movie_hot(page=page, count=count) -@router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo]) -async def douban_tv_hot(page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo] +) +async def douban_tv_hot( + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 热门电视剧 """ @@ -133,58 +189,69 @@ async def douban_tv_hot(page: Optional[int] = 1, @router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo]) -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: +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 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) + 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]) -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: +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 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) + 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]) -async def tmdb_trending(page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo] +) +async def tmdb_trending( + page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ TMDB流行趋势 """ diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index 56f696c5..d082edbe 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -47,17 +47,15 @@ def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict: return merged_event merged_event = dict(pending_event) - merged_event.update({ - key: value - for key, value in event.items() - if key != "items" - }) + merged_event.update({key: value for key, value in event.items() if key != "items"}) merged_event["type"] = "append" merged_event["items"] = [*(pending_event.get("items") or []), *items] return merged_event -async def _iter_batched_search_events(event_source: AsyncIterator[dict]) -> AsyncIterator[dict]: +async def _iter_batched_search_events( + event_source: AsyncIterator[dict], +) -> AsyncIterator[dict]: """ 对搜索流事件做轻量批处理,避免站点结果集中返回时产生过密 SSE。 """ @@ -90,7 +88,10 @@ async def _iter_batched_search_events(event_source: AsyncIterator[dict]) -> Asyn if event.get("type") == "append": pending_append_event = _merge_append_event(pending_append_event, event) - if len(pending_append_event.get("items") or []) >= _SSE_APPEND_MAX_ITEMS: + if ( + len(pending_append_event.get("items") or []) + >= _SSE_APPEND_MAX_ITEMS + ): yield pending_append_event pending_append_event = None continue @@ -121,20 +122,17 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di # 精确搜索会先发送 replace,再发送 done。done 再带整包 items 只会重复占用带宽和前端内存。 if event.get("type") == "replace" and event.get("items"): has_sent_final_replace = True - elif event.get("type") == "done" and has_sent_final_replace and event.get("stage") == "done" and event.get("items"): - event = { - key: value - for key, value in event.items() - if key != "items" - } + elif ( + event.get("type") == "done" + and has_sent_final_replace + and event.get("stage") == "done" + and event.get("items") + ): + event = {key: value for key, value in event.items() if key != "items"} yield _sse_event(event) except Exception as err: logger.error(f"渐进式搜索出错:{err}", exc_info=True) - yield _sse_event({ - "type": "error", - "success": False, - "message": str(err) - }) + yield _sse_event({"type": "error", "success": False, "message": str(err)}) @router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context]) @@ -147,15 +145,17 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/media/{mediaid}/stream", summary="渐进式精确搜索资源") -async def search_by_id_stream(request: Request, - mediaid: str, - mtype: Optional[str] = None, - area: Optional[str] = "title", - title: Optional[str] = None, - year: Optional[str] = None, - season: Optional[str] = None, - sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_resource_token)) -> Any: +async def search_by_id_stream( + request: Request, + mediaid: str, + mtype: Optional[str] = None, + area: Optional[str] = "title", + title: Optional[str] = None, + year: Optional[str] = None, + season: Optional[str] = None, + sites: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_resource_token), +) -> Any: """ 根据TMDBID/豆瓣ID渐进式搜索站点资源,返回格式为SSE """ @@ -172,77 +172,138 @@ async def search_by_id_stream(request: Request, if mediaid.startswith("tmdb:"): tmdbid = int(mediaid.replace("tmdb:", "")) if settings.RECOGNIZE_SOURCE == "douban": - doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=media_type) + doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid( + tmdbid=tmdbid, mtype=media_type + ) if doubaninfo: - torrents = search_chain.async_search_by_id_stream(doubanid=doubaninfo.get("id"), - mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + doubanid=doubaninfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: - yield {"type": "error", "success": False, "message": "未识别到豆瓣媒体信息"} + yield { + "type": "error", + "success": False, + "message": "未识别到豆瓣媒体信息", + } return else: - torrents = search_chain.async_search_by_id_stream(tmdbid=tmdbid, mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + tmdbid=tmdbid, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) elif mediaid.startswith("douban:"): doubanid = mediaid.replace("douban:", "") if settings.RECOGNIZE_SOURCE == "themoviedb": - tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=media_type) + tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid( + doubanid=doubanid, mtype=media_type + ) if tmdbinfo: - if tmdbinfo.get('season') and not media_season: - media_season = tmdbinfo.get('season') - torrents = search_chain.async_search_by_id_stream(tmdbid=tmdbinfo.get("id"), - mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + if tmdbinfo.get("season") and not media_season: + media_season = tmdbinfo.get("season") + torrents = search_chain.async_search_by_id_stream( + tmdbid=tmdbinfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: - yield {"type": "error", "success": False, "message": "未识别到TMDB媒体信息"} + yield { + "type": "error", + "success": False, + "message": "未识别到TMDB媒体信息", + } return else: - torrents = search_chain.async_search_by_id_stream(doubanid=doubanid, mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + doubanid=doubanid, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) elif mediaid.startswith("bangumi:"): bangumiid = int(mediaid.replace("bangumi:", "")) if settings.RECOGNIZE_SOURCE == "themoviedb": - tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid) + tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid( + bangumiid=bangumiid + ) if tmdbinfo: - torrents = search_chain.async_search_by_id_stream(tmdbid=tmdbinfo.get("id"), - mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + tmdbid=tmdbinfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: - yield {"type": "error", "success": False, "message": "未识别到TMDB媒体信息"} + yield { + "type": "error", + "success": False, + "message": "未识别到TMDB媒体信息", + } return else: - doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid) + doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid( + bangumiid=bangumiid + ) if doubaninfo: - torrents = search_chain.async_search_by_id_stream(doubanid=doubaninfo.get("id"), - mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + doubanid=doubaninfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: - yield {"type": "error", "success": False, "message": "未识别到豆瓣媒体信息"} + yield { + "type": "error", + "success": False, + "message": "未识别到豆瓣媒体信息", + } return else: event_data = MediaRecognizeConvertEventData( - mediaid=mediaid, - convert_type=settings.RECOGNIZE_SOURCE + mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE + ) + event = await eventmanager.async_send_event( + ChainEventType.MediaRecognizeConvert, event_data ) - event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data) if event and event.event_data: event_data = event.event_data if event_data.media_dict: search_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": - torrents = search_chain.async_search_by_id_stream(tmdbid=search_id, mtype=media_type, - area=area, season=media_season, - sites=site_list, cache_local=True) + torrents = search_chain.async_search_by_id_stream( + tmdbid=search_id, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) elif event_data.convert_type == "douban": - torrents = search_chain.async_search_by_id_stream(doubanid=search_id, mtype=media_type, - area=area, season=media_season, - sites=site_list, cache_local=True) + torrents = search_chain.async_search_by_id_stream( + doubanid=search_id, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: if not title: yield {"type": "error", "success": False, "message": "未知的媒体ID"} @@ -261,15 +322,23 @@ async def search_by_id_stream(request: Request, ) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": - torrents = search_chain.async_search_by_id_stream(tmdbid=mediainfo.tmdb_id, - mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + tmdbid=mediainfo.tmdb_id, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: - torrents = search_chain.async_search_by_id_stream(doubanid=mediainfo.douban_id, - mtype=media_type, area=area, - season=media_season, sites=site_list, - cache_local=True) + torrents = search_chain.async_search_by_id_stream( + doubanid=mediainfo.douban_id, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) if not torrents: yield {"type": "error", "success": False, "message": "未搜索到任何资源"} @@ -278,18 +347,22 @@ async def search_by_id_stream(request: Request, async for event in torrents: yield event - return StreamingResponse(_stream_search_events(request, event_source()), media_type="text/event-stream") + return StreamingResponse( + _stream_search_events(request, event_source()), media_type="text/event-stream" + ) @router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response) -async def search_by_id(mediaid: str, - mtype: Optional[str] = None, - area: Optional[str] = "title", - title: Optional[str] = None, - year: Optional[str] = None, - season: Optional[str] = None, - sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def search_by_id( + mediaid: str, + mtype: Optional[str] = None, + area: Optional[str] = "title", + title: Optional[str] = None, + year: Optional[str] = None, + season: Optional[str] = None, + sites: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi: """ @@ -313,72 +386,121 @@ async def search_by_id(mediaid: str, tmdbid = int(mediaid.replace("tmdb:", "")) if settings.RECOGNIZE_SOURCE == "douban": # 通过TMDBID识别豆瓣ID - doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=media_type) + doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid( + tmdbid=tmdbid, mtype=media_type + ) if doubaninfo: - torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"), - mtype=media_type, area=area, season=media_season, - sites=site_list, cache_local=True) + torrents = await search_chain.async_search_by_id( + doubanid=doubaninfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: return schemas.Response(success=False, message="未识别到豆瓣媒体信息") else: - torrents = await search_chain.async_search_by_id(tmdbid=tmdbid, mtype=media_type, area=area, - season=media_season, - sites=site_list, cache_local=True) + torrents = await search_chain.async_search_by_id( + tmdbid=tmdbid, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) elif mediaid.startswith("douban:"): doubanid = mediaid.replace("douban:", "") if settings.RECOGNIZE_SOURCE == "themoviedb": # 通过豆瓣ID识别TMDBID - tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=media_type) + tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid( + doubanid=doubanid, mtype=media_type + ) if tmdbinfo: - if tmdbinfo.get('season') and not media_season: - media_season = tmdbinfo.get('season') - torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"), - mtype=media_type, area=area, season=media_season, - sites=site_list, cache_local=True) + if tmdbinfo.get("season") and not media_season: + media_season = tmdbinfo.get("season") + torrents = await search_chain.async_search_by_id( + tmdbid=tmdbinfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: return schemas.Response(success=False, message="未识别到TMDB媒体信息") else: - torrents = await search_chain.async_search_by_id(doubanid=doubanid, mtype=media_type, area=area, - season=media_season, - sites=site_list, cache_local=True) + torrents = await search_chain.async_search_by_id( + doubanid=doubanid, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) elif mediaid.startswith("bangumi:"): bangumiid = int(mediaid.replace("bangumi:", "")) if settings.RECOGNIZE_SOURCE == "themoviedb": # 通过BangumiID识别TMDBID - tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid) + tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid( + bangumiid=bangumiid + ) if tmdbinfo: - torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"), - mtype=media_type, area=area, season=media_season, - sites=site_list, cache_local=True) + torrents = await search_chain.async_search_by_id( + tmdbid=tmdbinfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: return schemas.Response(success=False, message="未识别到TMDB媒体信息") else: # 通过BangumiID识别豆瓣ID - doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid) + doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid( + bangumiid=bangumiid + ) if doubaninfo: - torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"), - mtype=media_type, area=area, season=media_season, - sites=site_list, cache_local=True) + torrents = await search_chain.async_search_by_id( + doubanid=doubaninfo.get("id"), + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) else: return schemas.Response(success=False, message="未识别到豆瓣媒体信息") else: # 未知前缀,广播事件解析媒体信息 event_data = MediaRecognizeConvertEventData( - mediaid=mediaid, - convert_type=settings.RECOGNIZE_SOURCE + mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE + ) + event = await eventmanager.async_send_event( + ChainEventType.MediaRecognizeConvert, event_data ) - event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: MediaRecognizeConvertEventData = event.event_data if event_data.media_dict: search_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": - torrents = await search_chain.async_search_by_id(tmdbid=search_id, mtype=media_type, area=area, - season=media_season, cache_local=True) + torrents = await search_chain.async_search_by_id( + tmdbid=search_id, + mtype=media_type, + area=area, + season=media_season, + cache_local=True, + ) elif event_data.convert_type == "douban": - torrents = await search_chain.async_search_by_id(doubanid=search_id, mtype=media_type, area=area, - season=media_season, cache_local=True) + torrents = await search_chain.async_search_by_id( + doubanid=search_id, + mtype=media_type, + area=area, + season=media_season, + cache_local=True, + ) else: if not title: return schemas.Response(success=False, message="未知的媒体ID") @@ -397,73 +519,89 @@ async def search_by_id(mediaid: str, ) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": - torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type, - area=area, - season=media_season, cache_local=True) + torrents = await search_chain.async_search_by_id( + tmdbid=mediainfo.tmdb_id, + mtype=media_type, + area=area, + season=media_season, + cache_local=True, + ) else: - torrents = await search_chain.async_search_by_id(doubanid=mediainfo.douban_id, mtype=media_type, - area=area, - season=media_season, cache_local=True) + torrents = await search_chain.async_search_by_id( + doubanid=mediainfo.douban_id, + mtype=media_type, + area=area, + season=media_season, + cache_local=True, + ) # 返回搜索结果 if not torrents: return schemas.Response(success=False, message="未搜索到任何资源") else: - return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents]) + return schemas.Response( + success=True, data=[torrent.to_dict() for torrent in torrents] + ) @router.get("/title/stream", summary="渐进式模糊搜索资源") -async def search_by_title_stream(request: Request, - keyword: Optional[str] = None, - page: Optional[int] = 0, - sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_resource_token)) -> Any: +async def search_by_title_stream( + request: Request, + keyword: Optional[str] = None, + page: Optional[int] = 0, + sites: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_resource_token), +) -> Any: """ 根据名称渐进式模糊搜索站点资源,返回格式为SSE """ event_source = SearchChain().async_search_by_title_stream( - title=keyword, - page=page, - sites=_parse_site_list(sites), - cache_local=True + title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True + ) + return StreamingResponse( + _stream_search_events(request, event_source), media_type="text/event-stream" ) - return StreamingResponse(_stream_search_events(request, event_source), media_type="text/event-stream") @router.get("/title", summary="模糊搜索资源", response_model=schemas.Response) -async def search_by_title(keyword: Optional[str] = None, - page: Optional[int] = 0, - sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def search_by_title( + keyword: Optional[str] = None, + page: Optional[int] = 0, + sites: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源 """ torrents = await SearchChain().async_search_by_title( - title=keyword, page=page, - sites=_parse_site_list(sites), - cache_local=True + title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True ) if not torrents: return schemas.Response(success=False, message="未搜索到任何资源") - return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents]) + return schemas.Response( + success=True, data=[torrent.to_dict() for torrent in torrents] + ) @router.post("/recommend", summary="AI推荐资源", response_model=schemas.Response) async def recommend_search_results( - filtered_indices: Optional[List[int]] = Body(None, embed=True, description="筛选后的索引列表"), - check_only: bool = Body(False, embed=True, description="仅检查状态,不启动新任务"), - force: bool = Body(False, embed=True, description="强制重新推荐,清除旧结果"), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + filtered_indices: Optional[List[int]] = Body( + None, embed=True, description="筛选后的索引列表" + ), + check_only: bool = Body(False, embed=True, description="仅检查状态,不启动新任务"), + force: bool = Body(False, embed=True, description="强制重新推荐,清除旧结果"), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ AI推荐资源 - 轮询接口 前端轮询此接口,发送筛选后的索引(如果有筛选) 后端根据请求变化自动取消旧任务并启动新任务 - + 参数: - filtered_indices: 筛选后的索引列表(可选,为空或不提供时使用所有结果) - check_only: 仅检查状态(首次打开页面时使用,避免触发不必要的重新推理) - force: 强制重新推荐(清除旧结果并重新启动) - + 返回数据结构: { "success": bool, @@ -477,9 +615,9 @@ async def recommend_search_results( # 从缓存获取上次搜索结果 results = await SearchChain().async_last_search_results() or [] if not results: - return schemas.Response(success=False, message="没有可用的搜索结果", data={ - "status": "error" - }) + return schemas.Response( + success=False, message="没有可用的搜索结果", data={"status": "error"} + ) recommend_chain = SearchChain() @@ -487,16 +625,12 @@ async def recommend_search_results( if force: # 检查功能是否启用 if not recommend_chain.is_ai_recommend_enabled: - return schemas.Response(success=True, data={ - "status": "disabled" - }) + return schemas.Response(success=True, data={"status": "disabled"}) logger.info("收到新推荐请求,清除旧结果并启动新任务") recommend_chain.cancel_ai_recommend() recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 直接返回运行中状态 - return schemas.Response(success=True, data={ - "status": "running" - }) + return schemas.Response(success=True, data={"status": "running"}) # 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测) if check_only: @@ -505,7 +639,9 @@ async def recommend_search_results( # 如果有错误,将错误信息放到message中 if current_status.get("status") == "error": error_msg = current_status.pop("error", "未知错误") - return schemas.Response(success=False, message=error_msg, data=current_status) + return schemas.Response( + success=False, message=error_msg, data=current_status + ) return schemas.Response(success=True, data=current_status) # 获取当前状态(会检测请求是否变化) @@ -519,9 +655,7 @@ async def recommend_search_results( if status_data["status"] == "idle": recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 立即返回运行中状态 - return schemas.Response(success=True, data={ - "status": "running" - }) + return schemas.Response(success=True, data={"status": "running"}) # 如果有错误,将错误信息放到message中 if status_data.get("status") == "error": diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 7ef98f35..6077056e 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -21,7 +21,10 @@ from app.db.models.sitestatistic import SiteStatistic from app.db.models.siteuserdata import SiteUserData from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper -from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async +from app.db.user_oper import ( + get_current_active_superuser, + get_current_active_superuser_async, +) from app.helper.sites import SitesHelper # noqa from app.scheduler import Scheduler from app.schemas.types import SystemConfigKey, EventType @@ -31,8 +34,10 @@ router = APIRouter() @router.get("/", summary="所有站点", response_model=List[schemas.Site]) -async def read_sites(db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser)) -> List[dict]: +async def read_sites( + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser), +) -> List[dict]: """ 获取站点列表 """ @@ -41,10 +46,10 @@ async def read_sites(db: AsyncSession = Depends(get_async_db), @router.post("/", summary="新增站点", response_model=schemas.Response) async def add_site( - *, - db: AsyncSession = Depends(get_async_db), - site_in: schemas.Site, - _: User = Depends(get_current_active_superuser) + *, + db: AsyncSession = Depends(get_async_db), + site_in: schemas.Site, + _: User = Depends(get_current_active_superuser), ) -> Any: """ 新增站点 @@ -52,11 +57,15 @@ async def add_site( if not site_in.url: return schemas.Response(success=False, message="站点地址不能为空") if SitesHelper().auth_level < 2: - return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!") + return schemas.Response( + success=False, message="用户未通过认证,无法使用站点功能!" + ) domain = StringUtils.get_url_domain(site_in.url) site_info = await SitesHelper().async_get_indexer(domain) if not site_info: - return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确") + return schemas.Response( + success=False, message="该站点不支持,请检查站点域名是否正确" + ) if await Site.async_get_by_domain(db, domain): return schemas.Response(success=False, message=f"{domain} 站点己存在") # 保存站点信息 @@ -70,18 +79,16 @@ async def add_site( site = Site(**site_in.model_dump()) site.create(db) # 通知站点更新 - await eventmanager.async_send_event(EventType.SiteUpdated, { - "domain": domain - }) + await eventmanager.async_send_event(EventType.SiteUpdated, {"domain": domain}) return schemas.Response(success=True) @router.put("/", summary="更新站点", response_model=schemas.Response) async def update_site( - *, - db: AsyncSession = Depends(get_async_db), - site_in: schemas.Site, - _: User = Depends(get_current_active_superuser) + *, + db: AsyncSession = Depends(get_async_db), + site_in: schemas.Site, + _: User = Depends(get_current_active_superuser), ) -> Any: """ 更新站点信息 @@ -95,18 +102,23 @@ async def update_site( site_in.domain = StringUtils.get_url_domain(site_in.url) await site.async_update(db, site_in.model_dump()) # 通知站点更新 - await eventmanager.async_send_event(EventType.SiteUpdated, { - "site_id": site_in.id, - "domain": site_in.domain, - "name": site_in.name, - "site_url": site_in.url - }) + await eventmanager.async_send_event( + EventType.SiteUpdated, + { + "site_id": site_in.id, + "domain": site_in.domain, + "name": site_in.name, + "site_url": site_in.url, + }, + ) return schemas.Response(success=True) @router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response) -async def cookie_cloud_sync(background_tasks: BackgroundTasks, - _: User = Depends(get_current_active_superuser_async)) -> Any: +async def cookie_cloud_sync( + background_tasks: BackgroundTasks, + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 运行CookieCloud同步站点信息 """ @@ -115,8 +127,9 @@ async def cookie_cloud_sync(background_tasks: BackgroundTasks, @router.get("/reset", summary="重置站点", response_model=schemas.Response) -def reset(db: AsyncSession = Depends(get_db), - _: User = Depends(get_current_active_superuser)) -> Any: +def reset( + db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser) +) -> Any: """ 清空所有站点数据并重新同步CookieCloud站点信息 """ @@ -126,18 +139,18 @@ def reset(db: AsyncSession = Depends(get_db), # 启动定时服务 Scheduler().start("cookiecloud", manual=True) # 插件站点删除 - eventmanager.send_event(EventType.SiteDeleted, - { - "site_id": "*" - }) + eventmanager.send_event(EventType.SiteDeleted, {"site_id": "*"}) return schemas.Response(success=True, message="站点已重置!") -@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response) +@router.post( + "/priorities", summary="批量更新站点优先级", response_model=schemas.Response +) async def update_sites_priority( - priorities: List[dict], - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async)) -> Any: + priorities: List[dict], + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 批量更新站点优先级 """ @@ -148,14 +161,17 @@ async def update_sites_priority( return schemas.Response(success=True) -@router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response) +@router.get( + "/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response +) def update_cookie( - site_id: int, - username: str, - password: str, - code: Optional[str] = None, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_superuser)) -> Any: + site_id: int, + username: str, + password: str, + code: Optional[str] = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_superuser), +) -> Any: """ 使用用户密码更新站点Cookie """ @@ -167,18 +183,20 @@ def update_cookie( detail=f"站点 {site_id} 不存在!", ) # 更新Cookie - state, message = SiteChain().update_cookie(site_info=site_info, - username=username, - password=password, - two_step_code=code) + state, message = SiteChain().update_cookie( + site_info=site_info, username=username, password=password, two_step_code=code + ) return schemas.Response(success=state, message=message) -@router.post("/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response) +@router.post( + "/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response +) def refresh_userdata( - site_id: int, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_superuser)) -> Any: + site_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_superuser), +) -> Any: """ 刷新站点用户数据 """ @@ -190,15 +208,22 @@ def refresh_userdata( ) indexer = SitesHelper().get_indexer(site.domain) if not indexer: - return schemas.Response(success=False, message="站点不支持索引或未通过用户认证!") + return schemas.Response( + success=False, message="站点不支持索引或未通过用户认证!" + ) user_data = SiteChain().refresh_userdata(site=indexer) or {} return schemas.Response(success=True, data=user_data) -@router.get("/userdata/latest", summary="查询所有站点最新用户数据", response_model=List[schemas.SiteUserData]) +@router.get( + "/userdata/latest", + summary="查询所有站点最新用户数据", + response_model=List[schemas.SiteUserData], +) async def read_userdata_latest( - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async)) -> Any: + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 查询所有站点最新用户数据 """ @@ -208,12 +233,15 @@ async def read_userdata_latest( return [user_data.to_dict() for user_data in user_datas] -@router.get("/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response) +@router.get( + "/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response +) async def read_userdata( - site_id: int, - workdate: Optional[str] = None, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async)) -> Any: + site_id: int, + workdate: Optional[str] = None, + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 查询站点用户数据 """ @@ -223,16 +251,20 @@ async def read_userdata( status_code=404, detail=f"站点 {site_id} 不存在", ) - user_datas = await SiteUserData.async_get_by_domain(db, domain=site.domain, workdate=workdate) + user_datas = await SiteUserData.async_get_by_domain( + db, domain=site.domain, workdate=workdate + ) if not user_datas: return schemas.Response(success=False, data=[]) return schemas.Response(success=True, data=[data.to_dict() for data in user_datas]) @router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response) -def test_site(site_id: int, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def test_site( + site_id: int, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 测试站点是否可用 """ @@ -247,9 +279,11 @@ def test_site(site_id: int, @router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response) -async def site_icon(site_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def site_icon( + site_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 获取站点图标:base64或者url """ @@ -262,15 +296,19 @@ async def site_icon(site_id: int, icon = await SiteIcon.async_get_by_domain(db, site.domain) if not icon: return schemas.Response(success=False, message="站点图标不存在!") - return schemas.Response(success=True, data={ - "icon": icon.base64 if icon.base64 else icon.url - }) + return schemas.Response( + success=True, data={"icon": icon.base64 if icon.base64 else icon.url} + ) -@router.get("/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory]) -async def site_category(site_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory] +) +async def site_category( + site_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 获取站点分类 """ @@ -286,7 +324,7 @@ async def site_category(site_id: int, status_code=404, detail=f"站点 {site.domain} 不支持", ) - category: Dict[str, List[dict]] = indexer.get('category') or [] + category: Dict[str, List[dict]] = indexer.get("category") or [] if not category: return [] result = [] @@ -297,13 +335,17 @@ async def site_category(site_id: int, return result -@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo]) -async def site_resource(site_id: int, - keyword: Optional[str] = None, - cat: Optional[str] = None, - page: Optional[int] = 0, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async)) -> Any: +@router.get( + "/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo] +) +async def site_resource( + site_id: int, + keyword: Optional[str] = None, + cat: Optional[str] = None, + page: Optional[int] = 0, + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), +) -> Any: """ 浏览站点资源 """ @@ -313,7 +355,9 @@ async def site_resource(site_id: int, status_code=404, detail=f"站点 {site_id} 不存在", ) - torrents = await TorrentsChain().async_browse(domain=site.domain, keyword=keyword, cat=cat, page=page) + torrents = await TorrentsChain().async_browse( + domain=site.domain, keyword=keyword, cat=cat, page=page + ) if not torrents: return [] return [torrent.to_dict() for torrent in torrents] @@ -321,9 +365,9 @@ async def site_resource(site_id: int, @router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site) async def read_site_by_domain( - site_url: str, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) + site_url: str, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 通过域名获取站点信息 @@ -338,11 +382,15 @@ async def read_site_by_domain( return site -@router.get("/statistic/{site_url}", summary="特定站点统计信息", response_model=schemas.SiteStatistic) +@router.get( + "/statistic/{site_url}", + summary="特定站点统计信息", + response_model=schemas.SiteStatistic, +) async def read_statistic_by_domain( - site_url: str, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) + site_url: str, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 通过域名获取站点统计信息 @@ -354,10 +402,12 @@ async def read_statistic_by_domain( return schemas.SiteStatistic(domain=domain) -@router.get("/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic]) +@router.get( + "/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic] +) async def read_statistics( - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 获取所有站点统计信息 @@ -366,8 +416,10 @@ async def read_statistics( @router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site]) -async def read_rss_sites(db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]: +async def read_rss_sites( + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> List[dict]: """ 获取站点列表 """ @@ -394,8 +446,7 @@ async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> di @router.post("/auth", summary="用户站点认证", response_model=schemas.Response) def auth_site( - auth_info: schemas.SiteAuth, - _: User = Depends(get_current_active_superuser) + auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser) ) -> Any: """ 用户站点认证 @@ -412,7 +463,9 @@ def auth_site( return schemas.Response(success=status, message=msg) -@router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response) +@router.get( + "/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response +) async def site_mapping(_: User = Depends(get_current_active_superuser_async)): """ 获取站点域名到名称的映射关系 @@ -437,9 +490,9 @@ async def support_sites(_: User = Depends(get_current_active_superuser_async)): @router.get("/{site_id}", summary="站点详情", response_model=schemas.Site) async def read_site( - site_id: int, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async) + site_id: int, + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), ) -> Any: """ 通过ID获取站点信息 @@ -455,17 +508,14 @@ async def read_site( @router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response) async def delete_site( - site_id: int, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async) + site_id: int, + db: AsyncSession = Depends(get_async_db), + _: User = Depends(get_current_active_superuser_async), ) -> Any: """ 删除站点 """ await Site.async_delete(db, site_id) # 插件站点删除 - await eventmanager.async_send_event(EventType.SiteDeleted, - { - "site_id": site_id - }) + await eventmanager.async_send_event(EventType.SiteDeleted, {"site_id": site_id}) return schemas.Response(success=True) diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 8ecaadf8..04b0598a 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -12,7 +12,10 @@ from app.chain.transfer import TransferChain from app.core.config import settings from app.core.security import verify_token from app.db.models import User -from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async +from app.db.user_oper import ( + get_current_active_superuser, + get_current_active_superuser_async, +) from app.helper.progress import ProgressHelper from app.schemas.types import ProgressKey from app.utils.string import StringUtils @@ -31,7 +34,9 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: return schemas.Response(success=False, message=errmsg) -@router.get("/auth_url/{name}", summary="获取 OAuth2 授权 URL", response_model=schemas.Response) +@router.get( + "/auth_url/{name}", summary="获取 OAuth2 授权 URL", response_model=schemas.Response +) def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取 OAuth2 授权 URL @@ -43,8 +48,12 @@ def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response) -def check(name: str, ck: Optional[str] = None, t: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def check( + name: str, + ck: Optional[str] = None, + t: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 二维码登录确认 """ @@ -58,9 +67,7 @@ def check(name: str, ck: Optional[str] = None, t: Optional[str] = None, @router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response) -def save(name: str, - conf: dict, - _: User = Depends(get_current_active_superuser)) -> Any: +def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any: """ 保存存储配置 """ @@ -69,8 +76,7 @@ def save(name: str, @router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response) -def reset(name: str, - _: User = Depends(get_current_active_superuser)) -> Any: +def reset(name: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 重置存储配置 """ @@ -79,9 +85,11 @@ def reset(name: str, @router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem]) -def list_files(fileitem: schemas.FileItem, - sort: Optional[str] = 'updated_at', - _: User = Depends(get_current_active_superuser)) -> Any: +def list_files( + fileitem: schemas.FileItem, + sort: Optional[str] = "updated_at", + _: User = Depends(get_current_active_superuser), +) -> Any: """ 查询当前目录下所有目录和文件 :param fileitem: 文件项 @@ -99,9 +107,11 @@ def list_files(fileitem: schemas.FileItem, @router.post("/mkdir", summary="创建目录", response_model=schemas.Response) -def mkdir(fileitem: schemas.FileItem, - name: str, - _: User = Depends(get_current_active_superuser)) -> Any: +def mkdir( + fileitem: schemas.FileItem, + name: str, + _: User = Depends(get_current_active_superuser), +) -> Any: """ 创建目录 :param fileitem: 文件项 @@ -117,8 +127,9 @@ def mkdir(fileitem: schemas.FileItem, @router.post("/delete", summary="删除文件或目录", response_model=schemas.Response) -def delete(fileitem: schemas.FileItem, - _: User = Depends(get_current_active_superuser)) -> Any: +def delete( + fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser) +) -> Any: """ 删除文件或目录 :param fileitem: 文件项 @@ -131,8 +142,9 @@ def delete(fileitem: schemas.FileItem, @router.post("/download", summary="下载文件") -def download(fileitem: schemas.FileItem, - _: User = Depends(get_current_active_superuser)) -> Any: +def download( + fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser) +) -> Any: """ 下载文件或目录 :param fileitem: 文件项 @@ -146,8 +158,9 @@ def download(fileitem: schemas.FileItem, @router.post("/image", summary="预览图片") -def image(fileitem: schemas.FileItem, - _: User = Depends(get_current_active_superuser)) -> Any: +def image( + fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser) +) -> Any: """ 下载文件或目录 :param fileitem: 文件项 @@ -161,10 +174,12 @@ def image(fileitem: schemas.FileItem, @router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response) -def rename(fileitem: schemas.FileItem, - new_name: str, - recursive: Optional[bool] = False, - _: User = Depends(get_current_active_superuser)) -> Any: +def rename( + fileitem: schemas.FileItem, + new_name: str, + recursive: Optional[bool] = False, + _: User = Depends(get_current_active_superuser), +) -> Any: """ 重命名文件或目录 :param fileitem: 文件项 @@ -189,8 +204,9 @@ def rename(fileitem: schemas.FileItem, handled = 0 for sub_file in sub_files: handled += 1 - progress.update(value=handled / total * 100, - text=f"正在处理 {sub_file.name} ...") + progress.update( + value=handled / total * 100, text=f"正在处理 {sub_file.name} ..." + ) if sub_file.type == "dir": continue if not sub_file.extension: @@ -204,20 +220,25 @@ def rename(fileitem: schemas.FileItem, ) if not context or not context.media_info: progress.end() - return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") + return schemas.Response( + success=False, message=f"{sub_path.name} 未识别到媒体信息" + ) new_path = transferchain.recommend_name( - meta=context.meta_info, - mediainfo=context.media_info + meta=context.meta_info, mediainfo=context.media_info ) if not new_path: progress.end() - return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称") - ret: schemas.Response = rename(fileitem=sub_file, - new_name=Path(new_path).name, - recursive=False) + return schemas.Response( + success=False, message=f"{sub_path.name} 未识别到新名称" + ) + ret: schemas.Response = rename( + fileitem=sub_file, new_name=Path(new_path).name, recursive=False + ) if not ret.success: progress.end() - return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!") + return schemas.Response( + success=False, message=f"{sub_path.name} 重命名失败!" + ) progress.end() # 重命名自己 result = StorageChain().rename_file(fileitem, new_name) @@ -226,7 +247,9 @@ def rename(fileitem: schemas.FileItem, return schemas.Response(success=False) -@router.get("/usage/{name}", summary="存储空间信息", response_model=schemas.StorageUsage) +@router.get( + "/usage/{name}", summary="存储空间信息", response_model=schemas.StorageUsage +) def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 查询存储空间 @@ -237,8 +260,14 @@ def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any: return schemas.StorageUsage() -@router.get("/transtype/{name}", summary="支持的整理方式获取", response_model=schemas.StorageTransType) -async def transtype(name: str, _: User = Depends(get_current_active_superuser_async)) -> Any: +@router.get( + "/transtype/{name}", + summary="支持的整理方式获取", + response_model=schemas.StorageTransType, +) +async def transtype( + name: str, _: User = Depends(get_current_active_superuser_async) +) -> Any: """ 查询支持的整理方式 """ diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 14807b1b..9416d53c 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -25,26 +25,36 @@ from app.schemas.types import MediaType, EventType, SystemConfigKey router = APIRouter() -def start_subscribe_add(title: str, year: str, - mtype: MediaType, tmdbid: int, season: int, username: str): +def start_subscribe_add( + title: str, year: str, mtype: MediaType, tmdbid: int, season: int, username: str +): """ 启动订阅任务 """ - SubscribeChain().add(title=title, year=year, - mtype=mtype, tmdbid=tmdbid, season=season, username=username) + SubscribeChain().add( + title=title, + year=year, + mtype=mtype, + tmdbid=tmdbid, + season=season, + username=username, + ) @router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe]) async def read_subscribes( - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询所有订阅 """ return await Subscribe.async_list(db) -@router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe]) +@router.get( + "/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe] +) async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询所有订阅 API_TOKEN认证(?token=xxx) @@ -54,9 +64,9 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.post("/", summary="新增订阅", response_model=schemas.Response) async def create_subscribe( - *, - subscribe_in: schemas.Subscribe, - current_user: User = Depends(get_current_active_user_async), + *, + subscribe_in: schemas.Subscribe, + current_user: User = Depends(get_current_active_user_async), ) -> schemas.Response: """ 新增订阅 @@ -82,21 +92,18 @@ async def create_subscribe( subscribe_dict = subscribe_in.model_dump() if subscribe_in.id: subscribe_dict.pop("id", None) - sid, message = await SubscribeChain().async_add(mtype=mtype, - title=title, - exist_ok=True, - **subscribe_dict) - return schemas.Response( - success=bool(sid), message=message, data={"id": sid} + sid, message = await SubscribeChain().async_add( + mtype=mtype, title=title, exist_ok=True, **subscribe_dict ) + return schemas.Response(success=bool(sid), message=message, data={"id": sid}) @router.put("/", summary="更新订阅", response_model=schemas.Response) async def update_subscribe( - *, - subscribe_in: schemas.Subscribe, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) + *, + subscribe_in: schemas.Subscribe, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 更新订阅信息 @@ -115,9 +122,9 @@ async def update_subscribe( elif subscribe_in.total_episode: # 总集数增加时,缺失集数也要增加 if subscribe_in.total_episode > (subscribe.total_episode or 0): - subscribe_dict["lack_episode"] = (subscribe.lack_episode - + (subscribe_in.total_episode - - (subscribe.total_episode or 0))) + subscribe_dict["lack_episode"] = subscribe.lack_episode + ( + subscribe_in.total_episode - (subscribe.total_episode or 0) + ) # 是否手动修改过总集数 if subscribe_in.total_episode != subscribe.total_episode: subscribe_dict["manual_total_episode"] = 1 @@ -126,20 +133,24 @@ async def update_subscribe( # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subscribe_in.id) # 发送订阅调整事件 - await eventmanager.async_send_event(EventType.SubscribeModified, { - "subscribe_id": subscribe_in.id, - "old_subscribe_info": old_subscribe_dict, - "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, - }) + await eventmanager.async_send_event( + EventType.SubscribeModified, + { + "subscribe_id": subscribe_in.id, + "old_subscribe_info": old_subscribe_dict, + "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, + }, + ) return schemas.Response(success=True) @router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response) async def update_subscribe_status( - subid: int, - state: str, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + subid: int, + state: str, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 更新订阅状态 """ @@ -150,27 +161,29 @@ async def update_subscribe_status( if state not in valid_states: return schemas.Response(success=False, message="无效的订阅状态") old_subscribe_dict = subscribe.to_dict() - await subscribe.async_update(db, { - "state": state - }) + await subscribe.async_update(db, {"state": state}) # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subid) # 发送订阅调整事件 - await eventmanager.async_send_event(EventType.SubscribeModified, { - "subscribe_id": subid, - "old_subscribe_info": old_subscribe_dict, - "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, - }) + await eventmanager.async_send_event( + EventType.SubscribeModified, + { + "subscribe_id": subid, + "old_subscribe_info": old_subscribe_dict, + "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, + }, + ) return schemas.Response(success=True) @router.get("/media/{mediaid}", summary="查询订阅", response_model=schemas.Subscribe) async def subscribe_mediaid( - mediaid: str, - season: Optional[int] = None, - title: Optional[str] = None, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + mediaid: str, + season: Optional[int] = None, + title: Optional[str] = None, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban: """ @@ -203,14 +216,15 @@ async def subscribe_mediaid( meta = MetaInfo(title) if season is not None: meta.begin_season = season - result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season) + result = await Subscribe.async_get_by_title( + db, title=meta.name, season=meta.begin_season + ) return result if result else Subscribe() @router.get("/refresh", summary="刷新订阅", response_model=schemas.Response) -def refresh_subscribes( - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def refresh_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 刷新所有订阅 """ @@ -220,9 +234,10 @@ def refresh_subscribes( @router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response) async def reset_subscribes( - subid: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + subid: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 重置订阅 """ @@ -231,28 +246,35 @@ async def reset_subscribes( # 在更新之前获取旧数据 old_subscribe_dict = subscribe.to_dict() # 更新订阅 - await subscribe.async_update(db, { - "note": [], - "lack_episode": subscribe.total_episode, - "current_priority": None, - "episode_priority": {}, - "state": "R" - }) + await subscribe.async_update( + db, + { + "note": [], + "lack_episode": subscribe.total_episode, + "current_priority": None, + "episode_priority": {}, + "state": "R", + }, + ) # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subid) # 发送订阅调整事件 - await eventmanager.async_send_event(EventType.SubscribeModified, { - "subscribe_id": subid, - "old_subscribe_info": old_subscribe_dict, - "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, - }) + await eventmanager.async_send_event( + EventType.SubscribeModified, + { + "subscribe_id": subid, + "old_subscribe_info": old_subscribe_dict, + "subscribe_info": updated_subscribe.to_dict() + if updated_subscribe + else {}, + }, + ) return schemas.Response(success=True) return schemas.Response(success=False, message="订阅不存在") @router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response) -def check_subscribes( - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def check_subscribes(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 刷新订阅 TMDB 信息 """ @@ -262,49 +284,44 @@ def check_subscribes( @router.get("/search", summary="搜索所有订阅", response_model=schemas.Response) async def search_subscribes( - background_tasks: BackgroundTasks, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + background_tasks: BackgroundTasks, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 搜索所有订阅 """ background_tasks.add_task( Scheduler().start, job_id="subscribe_search", - **{ - "sid": None, - "state": 'R', - "manual": True - } + **{"sid": None, "state": "R", "manual": True}, ) return schemas.Response(success=True) -@router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response) +@router.get( + "/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response +) async def search_subscribe( - subscribe_id: int, - background_tasks: BackgroundTasks, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + subscribe_id: int, + background_tasks: BackgroundTasks, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据订阅编号搜索订阅 """ background_tasks.add_task( Scheduler().start, job_id="subscribe_search", - **{ - "sid": subscribe_id, - "state": None, - "manual": True - } + **{"sid": subscribe_id, "state": None, "manual": True}, ) return schemas.Response(success=True) @router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response) async def delete_subscribe_by_mediaid( - mediaid: str, - season: Optional[int] = None, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) + mediaid: str, + season: Optional[int] = None, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 根据TMDBID或豆瓣ID删除订阅 tmdb:/douban: @@ -333,16 +350,21 @@ async def delete_subscribe_by_mediaid( subscribe_id = subscribe.id await Subscribe.async_delete(db, subscribe_id) # 发送事件 - await eventmanager.async_send_event(EventType.SubscribeDeleted, { - "subscribe_id": subscribe_id, - "subscribe_info": subscribe_info - }) + await eventmanager.async_send_event( + EventType.SubscribeDeleted, + {"subscribe_id": subscribe_id, "subscribe_info": subscribe_info}, + ) return schemas.Response(success=True) -@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response) -async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks, - authorization: Annotated[str | None, Header()] = None) -> Any: +@router.post( + "/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response +) +async def seerr_subscribe( + request: Request, + background_tasks: BackgroundTasks, + authorization: Annotated[str | None, Header()] = None, +) -> Any: """ Jellyseerr/Overseerr网络勾子通知订阅 """ @@ -361,49 +383,66 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks, if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]: return schemas.Response(success=False, message="不支持的通知类型") subject = req_json.get("subject") - media_type = MediaType.MOVIE if req_json.get("media", {}).get("media_type") == "movie" else MediaType.TV + media_type = ( + MediaType.MOVIE + if req_json.get("media", {}).get("media_type") == "movie" + else MediaType.TV + ) tmdbId = req_json.get("media", {}).get("tmdbId") if not media_type or not tmdbId or not subject: return schemas.Response(success=False, message="请求参数不正确") user_name = req_json.get("request", {}).get("requestedBy_username") # 添加订阅 if media_type == MediaType.MOVIE: - background_tasks.add_task(start_subscribe_add, - mtype=media_type, - tmdbid=tmdbId, - title=subject, - year="", - season=0, - username=user_name) + background_tasks.add_task( + start_subscribe_add, + mtype=media_type, + tmdbid=tmdbId, + title=subject, + year="", + season=0, + username=user_name, + ) else: seasons = [] for extra in req_json.get("extra", []): if extra.get("name") == "Requested Seasons": - seasons = [int(str(sea).strip()) for sea in extra.get("value").split(", ") if str(sea).isdigit()] + seasons = [ + int(str(sea).strip()) + for sea in extra.get("value").split(", ") + if str(sea).isdigit() + ] break for season in seasons: - background_tasks.add_task(start_subscribe_add, - mtype=media_type, - tmdbid=tmdbId, - title=subject, - year="", - season=season, - username=user_name) + background_tasks.add_task( + start_subscribe_add, + mtype=media_type, + tmdbid=tmdbId, + title=subject, + year="", + season=season, + username=user_name, + ) return schemas.Response(success=True) -@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe]) +@router.get( + "/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe] +) async def subscribe_history( - mtype: str, - page: Optional[int] = 1, - count: Optional[int] = 30, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + mtype: str, + page: Optional[int] = 1, + count: Optional[int] = 30, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询电影/电视剧订阅历史 """ - histories = await SubscribeHistory.async_list_by_type(db, mtype=mtype, page=page, count=count) + histories = await SubscribeHistory.async_list_by_type( + db, mtype=mtype, page=page, count=count + ) result = [] for history in histories: history_item = schemas.Subscribe.model_validate(history, from_attributes=True) @@ -414,11 +453,13 @@ async def subscribe_history( return result -@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response) -async def delete_subscribe( - history_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) +@router.delete( + "/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response +) +async def delete_subscribe_history( + history_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 删除订阅历史 @@ -427,17 +468,22 @@ async def delete_subscribe( return schemas.Response(success=True) -@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo]) +@router.get( + "/popular", + summary="热门订阅(基于用户共享数据)", + response_model=List[schemas.MediaInfo], +) async def popular_subscribes( - stype: str, - page: Optional[int] = 1, - count: Optional[int] = 30, - min_sub: Optional[int] = None, - genre_id: Optional[int] = None, - min_rating: Optional[float] = None, - max_rating: Optional[float] = None, - sort_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + stype: str, + page: Optional[int] = 1, + count: Optional[int] = 30, + min_sub: Optional[int] = None, + genre_id: Optional[int] = None, + min_rating: Optional[float] = None, + max_rating: Optional[float] = None, + sort_type: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询热门订阅 """ @@ -448,7 +494,7 @@ async def popular_subscribes( genre_id=genre_id, min_rating=min_rating, max_rating=max_rating, - sort_type=sort_type + sort_type=sort_type, ) if subscribes: ret_medias = [] @@ -484,22 +530,30 @@ async def popular_subscribes( return [] -@router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe]) +@router.get( + "/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe] +) async def user_subscribes( - username: str, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + username: str, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询用户订阅 """ return await Subscribe.async_list_by_username(db, username) -@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo) +@router.get( + "/files/{subscribe_id}", + summary="订阅相关文件信息", + response_model=schemas.SubscrbieInfo, +) def subscribe_files( - subscribe_id: int, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + subscribe_id: int, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 订阅相关文件信息 """ @@ -511,22 +565,24 @@ def subscribe_files( @router.post("/share", summary="分享订阅", response_model=schemas.Response) async def subscribe_share( - sub: schemas.SubscribeShare, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + sub: schemas.SubscribeShare, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 分享订阅 """ - state, errmsg = await SubscribeHelper().async_sub_share(subscribe_id=sub.subscribe_id, - share_title=sub.share_title, - share_comment=sub.share_comment, - share_user=sub.share_user) + state, errmsg = await SubscribeHelper().async_sub_share( + subscribe_id=sub.subscribe_id, + share_title=sub.share_title, + share_comment=sub.share_comment, + share_user=sub.share_user, + ) return schemas.Response(success=state, message=errmsg) @router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response) async def subscribe_share_delete( - share_id: int, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + share_id: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 删除分享 """ @@ -536,8 +592,9 @@ async def subscribe_share_delete( @router.post("/fork", summary="复用订阅", response_model=schemas.Response) async def subscribe_fork( - sub: schemas.SubscribeShare, - current_user: User = Depends(get_current_active_user_async)) -> Any: + sub: schemas.SubscribeShare, + current_user: User = Depends(get_current_active_user_async), +) -> Any: """ 复用订阅 """ @@ -546,8 +603,9 @@ async def subscribe_fork( for key in list(sub_dict.keys()): if not hasattr(schemas.Subscribe(), key): sub_dict.pop(key) - result = await create_subscribe(subscribe_in=schemas.Subscribe(**sub_dict), - current_user=current_user) + result = await create_subscribe( + subscribe_in=schemas.Subscribe(**sub_dict), current_user=current_user + ) if result.success: await SubscribeHelper().async_sub_fork(share_id=sub.id) return result @@ -563,42 +621,51 @@ async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) @router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response) async def follow_subscriber( - share_uid: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ Follow订阅分享人 """ subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid not in subscribers: subscribers.append(share_uid) - await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers) + await SystemConfigOper().async_set( + SystemConfigKey.FollowSubscribers, subscribers + ) return schemas.Response(success=True) -@router.delete("/follow", summary="取消Follow订阅分享人", response_model=schemas.Response) +@router.delete( + "/follow", summary="取消Follow订阅分享人", response_model=schemas.Response +) async def unfollow_subscriber( - share_uid: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 取消Follow订阅分享人 """ subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid in subscribers: subscribers.remove(share_uid) - await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers) + await SystemConfigOper().async_set( + SystemConfigKey.FollowSubscribers, subscribers + ) return schemas.Response(success=True) -@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare]) -async def popular_subscribes( - name: Optional[str] = None, - page: Optional[int] = 1, - count: Optional[int] = 30, - genre_id: Optional[int] = None, - min_rating: Optional[float] = None, - max_rating: Optional[float] = None, - sort_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare] +) +async def subscribe_shares( + name: Optional[str] = None, + page: Optional[int] = 1, + count: Optional[int] = 30, + genre_id: Optional[int] = None, + min_rating: Optional[float] = None, + max_rating: Optional[float] = None, + sort_type: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询分享的订阅 """ @@ -609,12 +676,18 @@ async def popular_subscribes( genre_id=genre_id, min_rating=min_rating, max_rating=max_rating, - sort_type=sort_type + sort_type=sort_type, ) -@router.get("/share/statistics", summary="查询订阅分享统计", response_model=List[schemas.SubscribeShareStatistics]) -async def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/share/statistics", + summary="查询订阅分享统计", + response_model=List[schemas.SubscribeShareStatistics], +) +async def subscribe_share_statistics( + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询订阅分享统计 返回每个分享人分享的媒体数量以及总的复用人次 @@ -624,9 +697,10 @@ async def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_to @router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe) async def read_subscribe( - subscribe_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + subscribe_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据订阅编号查询订阅信息 """ @@ -637,9 +711,9 @@ async def read_subscribe( @router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response) async def delete_subscribe( - subscribe_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token) + subscribe_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 删除订阅信息 @@ -650,13 +724,12 @@ async def delete_subscribe( subscribe_info = subscribe.to_dict() await Subscribe.async_delete(db, subscribe_id) # 发送事件 - await eventmanager.async_send_event(EventType.SubscribeDeleted, { - "subscribe_id": subscribe_id, - "subscribe_info": subscribe_info - }) + await eventmanager.async_send_event( + EventType.SubscribeDeleted, + {"subscribe_id": subscribe_id, "subscribe_info": subscribe_info}, + ) # 统计订阅 - SubscribeHelper().sub_done_async({ - "tmdbid": subscribe.tmdbid, - "doubanid": subscribe.doubanid - }) + SubscribeHelper().sub_done_async( + {"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid} + ) return schemas.Response(success=True) diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index c7830b3b..75af0205 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -65,7 +65,9 @@ def _match_nettest_prefix(url: str, prefix: str) -> bool: if (parsed_url.hostname or "").lower() != (parsed_prefix.hostname or "").lower(): return False url_port = parsed_url.port or (443 if parsed_url.scheme.lower() == "https" else 80) - prefix_port = parsed_prefix.port or (443 if parsed_prefix.scheme.lower() == "https" else 80) + prefix_port = parsed_prefix.port or ( + 443 if parsed_prefix.scheme.lower() == "https" else 80 + ) if url_port != prefix_port: return False return parsed_url.path.startswith(parsed_prefix.path or "/") @@ -193,14 +195,18 @@ def _build_nettest_rules() -> list[dict[str, Any]]: "id": "github_proxy_web", "name": "github.com", "icon": "github", - "url": f"{github_proxy}{github_readme_url}" if github_proxy else github_readme_url, + "url": f"{github_proxy}{github_readme_url}" + if github_proxy + else github_readme_url, "proxy": True, "allowed_redirect_prefixes": [ "https://github.com/", *((f"{github_proxy}https://github.com/",) if github_proxy else ()), ], "expected_text": "MoviePilot", - "invalid_message": "Github加速代理已失效,请检查配置" if github_proxy else "无效响应", + "invalid_message": "Github加速代理已失效,请检查配置" + if github_proxy + else "无效响应", "proxy_name": "Github加速代理" if github_proxy else "", "headers": settings.GITHUB_HEADERS, }, @@ -229,14 +235,22 @@ def _build_nettest_rules() -> list[dict[str, Any]]: "id": "github_proxy_raw", "name": "raw.githubusercontent.com", "icon": "github", - "url": f"{github_proxy}{raw_readme_url}" if github_proxy else raw_readme_url, + "url": f"{github_proxy}{raw_readme_url}" + if github_proxy + else raw_readme_url, "proxy": True, "allowed_redirect_prefixes": [ "https://raw.githubusercontent.com/", - *((f"{github_proxy}https://raw.githubusercontent.com/",) if github_proxy else ()), + *( + (f"{github_proxy}https://raw.githubusercontent.com/",) + if github_proxy + else () + ), ], "expected_text": "MoviePilot", - "invalid_message": "Github加速代理已失效,请检查配置" if github_proxy else "无效响应", + "invalid_message": "Github加速代理已失效,请检查配置" + if github_proxy + else "无效响应", "proxy_name": "Github加速代理" if github_proxy else "", "headers": settings.GITHUB_HEADERS, }, @@ -257,6 +271,7 @@ def _build_nettest_rules() -> list[dict[str, Any]]: ) return rules + def _validate_nettest_url(url: str) -> Optional[str]: """ 对实际请求地址做基础安全校验。 @@ -276,7 +291,9 @@ def _validate_nettest_url(url: str) -> Optional[str]: return None -def _get_nettest_rule(url: Optional[str] = None, target_id: Optional[str] = None) -> Optional[dict[str, Any]]: +def _get_nettest_rule( + url: Optional[str] = None, target_id: Optional[str] = None +) -> Optional[dict[str, Any]]: """ 根据 target_id 或历史兼容参数匹配网络测试规则。 @@ -325,12 +342,12 @@ async def _close_nettest_response(response: Any) -> None: async def fetch_image( - url: str, - proxy: Optional[bool] = None, - use_cache: bool = False, - if_none_match: Optional[str] = None, - cookies: Optional[str | dict] = None, - allowed_domains: Optional[set[str]] = None, + url: str, + proxy: Optional[bool] = None, + use_cache: bool = False, + if_none_match: Optional[str] = None, + cookies: Optional[str | dict] = None, + allowed_domains: Optional[set[str]] = None, ) -> Optional[Response]: """ 处理图片缓存逻辑,支持HTTP缓存和磁盘缓存 @@ -370,12 +387,12 @@ async def fetch_image( @router.get("/img/{proxy}", summary="图片代理") async def proxy_img( - imgurl: str, - proxy: bool = False, - cache: bool = False, - use_cookies: bool = False, - if_none_match: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + imgurl: str, + proxy: bool = False, + cache: bool = False, + use_cookies: bool = False, + if_none_match: Annotated[str | None, Header()] = None, + _: schemas.TokenPayload = Depends(verify_resource_token), ) -> Response: """ 图片代理,可选是否使用代理服务器,支持 HTTP 缓存 @@ -404,9 +421,9 @@ async def proxy_img( @router.get("/cache/image", summary="图片缓存") async def cache_img( - url: str, - if_none_match: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + url: str, + if_none_match: Annotated[str | None, Header()] = None, + _: schemas.TokenPayload = Depends(verify_resource_token), ) -> Response: """ 本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存 @@ -502,7 +519,7 @@ async def get_env_setting(_: User = Depends(get_current_active_user_async)): @router.post("/env", summary="更新系统配置", response_model=schemas.Response) async def set_env_setting( - env: dict, _: User = Depends(get_current_active_superuser_async) + env: dict, _: User = Depends(get_current_active_superuser_async) ): """ 更新系统环境变量(仅管理员) @@ -537,9 +554,9 @@ async def set_env_setting( @router.get("/progress/{process_type}", summary="实时进度") async def get_progress( - request: Request, - process_type: str, - _: schemas.TokenPayload = Depends(verify_resource_token), + request: Request, + process_type: str, + _: schemas.TokenPayload = Depends(verify_resource_token), ): """ 实时获取处理进度,返回格式为SSE @@ -574,9 +591,9 @@ async def get_setting(key: str, _: User = Depends(get_current_active_user_async) @router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response) async def set_setting( - key: str, - value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, - _: User = Depends(get_current_active_superuser_async), + key: str, + value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, + _: User = Depends(get_current_active_superuser_async), ): """ 更新系统设置(仅管理员) @@ -610,9 +627,9 @@ async def set_setting( @router.get("/message", summary="实时消息") async def get_message( - request: Request, - role: Optional[str] = "system", - _: schemas.TokenPayload = Depends(verify_resource_token), + request: Request, + role: Optional[str] = "system", + _: schemas.TokenPayload = Depends(verify_resource_token), ): """ 实时获取系统消息,返回格式为SSE @@ -635,10 +652,10 @@ async def get_message( @router.get("/logging", summary="实时日志") async def get_logging( - request: Request, - length: Optional[int] = 50, - logfile: Optional[str] = "moviepilot.log", - _: schemas.TokenPayload = Depends(verify_resource_token), + request: Request, + length: Optional[int] = 50, + logfile: Optional[str] = "moviepilot.log", + _: schemas.TokenPayload = Depends(verify_resource_token), ): """ 实时获取系统日志 @@ -649,7 +666,7 @@ async def get_logging( log_path = base_path / logfile if not await SecurityUtils.async_is_safe_path( - base_path=base_path, user_path=log_path, allowed_suffixes={".log"} + base_path=base_path, user_path=log_path, allowed_suffixes={".log"} ): raise HTTPException(status_code=404, detail="Not Found") @@ -666,7 +683,7 @@ async def get_logging( # 读取历史日志 async with aiofiles.open( - log_path, mode="r", encoding="utf-8", errors="ignore" + log_path, mode="r", encoding="utf-8", errors="ignore" ) as f: # 优化大文件读取策略 if file_size > 100 * 1024: @@ -678,7 +695,7 @@ async def get_logging( # 找到第一个完整的行 first_newline = content.find("\n") if first_newline != -1: - content = content[first_newline + 1:] + content = content[first_newline + 1 :] else: # 小文件直接读取全部内容 content = await f.read() @@ -686,7 +703,7 @@ async def get_logging( # 按行分割并添加到队列,只保留非空行 lines = [line.strip() for line in content.splitlines() if line.strip()] # 只取最后N行 - for line in lines[-max(length, 50):]: + for line in lines[-max(length, 50) :]: lines_queue.append(line) # 输出历史日志 @@ -695,7 +712,7 @@ async def get_logging( # 实时监听新日志 async with aiofiles.open( - log_path, mode="r", encoding="utf-8", errors="ignore" + log_path, mode="r", encoding="utf-8", errors="ignore" ) as f: # 移动文件指针到文件末尾,继续监听新增内容 await f.seek(0, 2) @@ -734,7 +751,7 @@ async def get_logging( try: # 使用 aiofiles 异步读取文件 async with aiofiles.open( - log_path, mode="r", encoding="utf-8", errors="ignore" + log_path, mode="r", encoding="utf-8", errors="ignore" ) as file: text = await file.read() # 倒序输出 @@ -766,10 +783,10 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)): @router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response) def ruletest( - title: str, - rulegroup_name: str, - subtitle: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + title: str, + rulegroup_name: str, + subtitle: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), ): """ 过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索 @@ -804,7 +821,9 @@ def ruletest( ) -@router.get("/nettest/targets", summary="获取网络测试目标", response_model=schemas.Response) +@router.get( + "/nettest/targets", summary="获取网络测试目标", response_model=schemas.Response +) async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)): """ 获取网络测试目标。 @@ -827,10 +846,10 @@ async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)): @router.get("/nettest", summary="测试网络连通性") async def nettest( - target_id: Optional[str] = None, - url: Optional[str] = None, - include: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + target_id: Optional[str] = None, + url: Optional[str] = None, + include: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), ): """ 测试内置目标的网络连通性。 @@ -961,8 +980,8 @@ def restart_system(_: User = Depends(get_current_active_superuser)): @router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response) def upgrade_system( - mode: Annotated[str | None, Body()] = None, - _: User = Depends(get_current_active_superuser), + mode: Annotated[str | None, Body()] = None, + _: User = Depends(get_current_active_superuser), ): """ 触发系统升级并重启(仅管理员) diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index b74c34fc..d6c88ac3 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -10,8 +10,12 @@ from app.schemas.types import MediaType router = APIRouter() -@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason]) -async def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason] +) +async def tmdb_seasons( + tmdbid: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据TMDBID查询themoviedb所有季信息 """ @@ -21,10 +25,14 @@ async def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_tok return [] -@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo]) -async def tmdb_similar(tmdbid: int, - type_name: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/similar/{tmdbid}/{type_name}", + summary="类似电影/电视剧", + response_model=List[schemas.MediaInfo], +) +async def tmdb_similar( + tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧 """ @@ -40,10 +48,14 @@ async def tmdb_similar(tmdbid: int, return [] -@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo]) -async def tmdb_recommend(tmdbid: int, - type_name: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/recommend/{tmdbid}/{type_name}", + summary="推荐电影/电视剧", + response_model=List[schemas.MediaInfo], +) +async def tmdb_recommend( + tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧 """ @@ -59,25 +71,37 @@ async def tmdb_recommend(tmdbid: int, return [] -@router.get("/collection/{collection_id}", summary="系列合集详情", response_model=List[schemas.MediaInfo]) -async def tmdb_collection(collection_id: int, - page: Optional[int] = 1, - count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/collection/{collection_id}", + summary="系列合集详情", + response_model=List[schemas.MediaInfo], +) +async def tmdb_collection( + collection_id: int, + page: Optional[int] = 1, + count: Optional[int] = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据合集ID查询合集详情 """ medias = await TmdbChain().async_tmdb_collection(collection_id=collection_id) if medias: - return [media.to_dict() for media in medias][(page - 1) * count:page * count] + return [media.to_dict() for media in medias][(page - 1) * count : page * count] return [] -@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson]) -async def tmdb_credits(tmdbid: int, - type_name: str, - page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/credits/{tmdbid}/{type_name}", + summary="演员阵容", + response_model=List[schemas.MediaPerson], +) +async def tmdb_credits( + tmdbid: int, + type_name: str, + page: Optional[int] = 1, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据TMDBID查询演员阵容,type_name: 电影/电视剧 """ @@ -91,19 +115,28 @@ async def tmdb_credits(tmdbid: int, return persons or [] -@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson) -async def tmdb_person(person_id: int, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson +) +async def tmdb_person( + person_id: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 根据人物ID查询人物详情 """ return await TmdbChain().async_person_detail(person_id=person_id) -@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo]) -async def tmdb_person_credits(person_id: int, - page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/person/credits/{person_id}", + summary="人物参演作品", + response_model=List[schemas.MediaInfo], +) +async def tmdb_person_credits( + person_id: int, + page: Optional[int] = 1, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据人物ID查询人物参演作品 """ @@ -113,10 +146,20 @@ async def tmdb_person_credits(person_id: int, return [] -@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode]) -async def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get( + "/{tmdbid}/{season}", + summary="TMDB季所有集", + response_model=List[schemas.TmdbEpisode], +) +async def tmdb_season_episodes( + tmdbid: int, + season: int, + episode_group: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 根据TMDBID查询某季的所有信信息 """ - return await TmdbChain().async_tmdb_episodes(tmdbid=tmdbid, season=season, episode_group=episode_group) + return await TmdbChain().async_tmdb_episodes( + tmdbid=tmdbid, season=season, episode_group=episode_group + ) diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 78d9fa45..180c7d33 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -9,7 +9,10 @@ from app.core.config import settings from app.core.context import MediaInfo from app.core.metainfo import MetaInfo from app.db.models import User -from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async +from app.db.user_oper import ( + get_current_active_superuser, + get_current_active_superuser_async, +) from app.utils.crypto import HashUtils router = APIRouter() @@ -35,35 +38,56 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): torrent_data = [] for domain, contexts in cache_info.items(): for context in contexts: - torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") - torrent_data.append({ - "hash": torrent_hash, - "domain": domain, - "title": context.torrent_info.title, - "description": context.torrent_info.description, - "size": context.torrent_info.size, - "pubdate": context.torrent_info.pubdate, - "site_name": context.torrent_info.site_name, - "media_name": context.media_info.title if context.media_info else "", - "media_year": context.media_info.year if context.media_info else "", - "media_type": context.media_info.type if context.media_info else "", - "season_episode": context.meta_info.season_episode if context.meta_info else "", - "resource_term": context.meta_info.resource_term if context.meta_info else "", - "enclosure": context.torrent_info.enclosure, - "page_url": context.torrent_info.page_url, - "poster_path": context.media_info.get_poster_image() if context.media_info else "", - "backdrop_path": context.media_info.get_backdrop_image() if context.media_info else "" - }) + torrent_hash = HashUtils.md5( + f"{context.torrent_info.title}{context.torrent_info.description}" + ) + torrent_data.append( + { + "hash": torrent_hash, + "domain": domain, + "title": context.torrent_info.title, + "description": context.torrent_info.description, + "size": context.torrent_info.size, + "pubdate": context.torrent_info.pubdate, + "site_name": context.torrent_info.site_name, + "media_name": context.media_info.title + if context.media_info + else "", + "media_year": context.media_info.year if context.media_info else "", + "media_type": context.media_info.type if context.media_info else "", + "season_episode": context.meta_info.season_episode + if context.meta_info + else "", + "resource_term": context.meta_info.resource_term + if context.meta_info + else "", + "enclosure": context.torrent_info.enclosure, + "page_url": context.torrent_info.page_url, + "poster_path": context.media_info.get_poster_image() + if context.media_info + else "", + "backdrop_path": context.media_info.get_backdrop_image() + if context.media_info + else "", + } + ) - return schemas.Response(success=True, data={ - "count": torrent_count, - "sites": len(cache_info), - "data": torrent_data - }) + return schemas.Response( + success=True, + data={"count": torrent_count, "sites": len(cache_info), "data": torrent_data}, + ) -@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存", response_model=schemas.Response) -async def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser_async)): +@router.delete( + "/cache/{domain}/{torrent_hash}", + summary="删除指定种子缓存", + response_model=schemas.Response, +) +async def delete_cache( + domain: str, + torrent_hash: str, + _: User = Depends(get_current_active_superuser_async), +): """ 删除指定的种子缓存 :param domain: 站点域名 @@ -83,8 +107,12 @@ async def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_cur # 查找并删除指定种子 original_count = len(cache_data[domain]) cache_data[domain] = [ - context for context in cache_data[domain] - if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash + context + for context in cache_data[domain] + if HashUtils.md5( + f"{context.torrent_info.title}{context.torrent_info.description}" + ) + != torrent_hash ] if len(cache_data[domain]) == original_count: @@ -128,15 +156,26 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)): total_count = sum(len(torrents) for torrents in result.values()) sites_count = len(result) - return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子") + return schemas.Response( + success=True, + message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子", + ) except Exception as e: return schemas.Response(success=False, message=f"刷新失败:{str(e)}") -@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response) -async def reidentify_cache(domain: str, torrent_hash: str, - tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - _: User = Depends(get_current_active_superuser_async)): +@router.post( + "/cache/reidentify/{domain}/{torrent_hash}", + summary="重新识别种子", + response_model=schemas.Response, +) +async def reidentify_cache( + domain: str, + torrent_hash: str, + tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, + _: User = Depends(get_current_active_superuser_async), +): """ 重新识别指定的种子 :param domain: 站点域名 @@ -159,7 +198,12 @@ async def reidentify_cache(domain: str, torrent_hash: str, # 查找指定种子 target_context = None for context in cache_data[domain]: - if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash: + if ( + HashUtils.md5( + f"{context.torrent_info.title}{context.torrent_info.description}" + ) + == torrent_hash + ): target_context = context break @@ -167,10 +211,15 @@ async def reidentify_cache(domain: str, torrent_hash: str, return schemas.Response(success=False, message="未找到指定的种子") # 重新识别 - meta = MetaInfo(title=target_context.torrent_info.title, subtitle=target_context.torrent_info.description) + meta = MetaInfo( + title=target_context.torrent_info.title, + subtitle=target_context.torrent_info.description, + ) if tmdbid or doubanid: # 手动指定媒体信息 - mediainfo = await media_chain.async_recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid) + mediainfo = await media_chain.async_recognize_media( + meta=meta, tmdbid=tmdbid, doubanid=doubanid + ) else: # 自动重新识别 mediainfo = await media_chain.async_recognize_by_meta(meta) @@ -188,10 +237,16 @@ async def reidentify_cache(domain: str, torrent_hash: str, # 保存更新后的缓存 await torrents_chain.async_save_cache(cache_data, TorrentsChain().cache_file) - return schemas.Response(success=True, message="重新识别完成", data={ - "media_name": mediainfo.title if mediainfo else "", - "media_year": mediainfo.year if mediainfo else "", - "media_type": mediainfo.type.value if mediainfo and mediainfo.type else "" - }) + return schemas.Response( + success=True, + message="重新识别完成", + data={ + "media_name": mediainfo.title if mediainfo else "", + "media_year": mediainfo.year if mediainfo else "", + "media_type": mediainfo.type.value + if mediainfo and mediainfo.type + else "", + }, + ) except Exception as e: return schemas.Response(success=False, message=f"重新识别失败:{str(e)}") diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 1aca6995..3101ba20 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -21,8 +21,9 @@ router = APIRouter() @router.get("/name", summary="查询整理后的名称", response_model=schemas.Response) -def query_name(path: str, filetype: str, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def query_name( + path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询整理后的名称 :param path: 文件路径 @@ -35,7 +36,9 @@ def query_name(path: str, filetype: str, ) if not context or not context.media_info: return schemas.Response(success=False, message="未识别到媒体信息") - new_path = TransferChain().recommend_name(meta=context.meta_info, mediainfo=context.media_info) + new_path = TransferChain().recommend_name( + meta=context.meta_info, mediainfo=context.media_info + ) if not new_path: return schemas.Response(success=False, message="未识别到新名称") if filetype == "dir": @@ -54,9 +57,7 @@ def query_name(path: str, filetype: str, new_name = parents[0].name else: new_name = Path(new_path).name - return schemas.Response(success=True, data={ - "name": new_name - }) + return schemas.Response(success=True, data={"name": new_name}) @router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob]) @@ -68,8 +69,12 @@ async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return TransferChain().get_queue_tasks() -@router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response) -async def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.delete( + "/queue", summary="从整理队列中删除任务", response_model=schemas.Response +) +async def remove_queue( + fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 查询整理队列 :param fileitem: 文件项 @@ -82,10 +87,12 @@ async def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Dep @router.post("/manual", summary="手动转移", response_model=schemas.Response) -def manual_transfer(transer_item: ManualTransferItem, - background: Optional[bool] = False, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_superuser)) -> Any: +def manual_transfer( + transer_item: ManualTransferItem, + background: Optional[bool] = False, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_superuser), +) -> Any: """ 手动转移,文件或历史记录,支持自定义剧集识别格式 :param transer_item: 手工整理项 @@ -101,7 +108,9 @@ def manual_transfer(transer_item: ManualTransferItem, # 查询历史记录 history: TransferHistory = TransferHistory.get(db, transer_item.logid) if not history: - return schemas.Response(success=False, message=f"整理记录不存在,ID:{transer_item.logid}") + return schemas.Response( + success=False, message=f"整理记录不存在,ID:{transer_item.logid}" + ) # 强制转移 force = True downloader = history.downloader @@ -118,21 +127,38 @@ def manual_transfer(transer_item: ManualTransferItem, dest_fileitem = FileItem(**history.dest_fileitem) state = StorageChain().delete_media_file(dest_fileitem) if not state: - return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败") + return schemas.Response( + success=False, message=f"{dest_fileitem.path} 删除失败" + ) # 从历史数据获取信息 if transer_item.from_history: - transer_item.type_name = history.type if history.type else transer_item.type_name - transer_item.tmdbid = int(history.tmdbid) if history.tmdbid else transer_item.tmdbid - transer_item.doubanid = str(history.doubanid) if history.doubanid else transer_item.doubanid - transer_item.season = int(str(history.seasons).replace("S", "")) if history.seasons else transer_item.season - transer_item.episode_group = history.episode_group or transer_item.episode_group + transer_item.type_name = ( + history.type if history.type else transer_item.type_name + ) + transer_item.tmdbid = ( + int(history.tmdbid) if history.tmdbid else transer_item.tmdbid + ) + transer_item.doubanid = ( + str(history.doubanid) if history.doubanid else transer_item.doubanid + ) + transer_item.season = ( + int(str(history.seasons).replace("S", "")) + if history.seasons + else transer_item.season + ) + transer_item.episode_group = ( + history.episode_group or transer_item.episode_group + ) if history.episodes: if "-" in str(history.episodes): # E01-E03多集合并 episode_start, episode_end = str(history.episodes).split("-") episode_list: list[int] = [] - for i in range(int(episode_start.replace("E", "")), int(episode_end.replace("E", "")) + 1): + for i in range( + int(episode_start.replace("E", "")), + int(episode_end.replace("E", "")) + 1, + ): episode_list.append(i) transer_item.episode_detail = ",".join(str(e) for e in episode_list) else: @@ -151,11 +177,17 @@ def manual_transfer(transer_item: ManualTransferItem, try: mtype = MediaType(type_name) except ValueError: - return schemas.Response(success=False, message=f"不支持的媒体类型:{type_name}") + return schemas.Response( + success=False, message=f"不支持的媒体类型:{type_name}" + ) # 自定义格式 epformat = None - if transer_item.episode_offset or transer_item.episode_part \ - or transer_item.episode_detail or transer_item.episode_format: + if ( + transer_item.episode_offset + or transer_item.episode_part + or transer_item.episode_detail + or transer_item.episode_format + ): epformat = schemas.EpisodeFormat( format=transer_item.episode_format, detail=transer_item.episode_detail, diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index 39b3b7a2..d4d96062 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -9,8 +9,11 @@ from app import schemas from app.core.security import get_password_hash from app.db import get_async_db from app.db.models.user import User -from app.db.user_oper import get_current_active_superuser_async, \ - get_current_active_user_async, get_current_active_user +from app.db.user_oper import ( + get_current_active_superuser_async, + get_current_active_user_async, + get_current_active_user, +) from app.db.userconfig_oper import UserConfigOper router = APIRouter() @@ -18,8 +21,8 @@ router = APIRouter() @router.get("/", summary="所有用户", response_model=List[schemas.User]) async def list_users( - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_superuser_async), + db: AsyncSession = Depends(get_async_db), + current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 查询用户列表 @@ -29,10 +32,10 @@ async def list_users( @router.post("/", summary="新增用户", response_model=schemas.Response) async def create_user( - *, - db: AsyncSession = Depends(get_async_db), - user_in: schemas.UserCreate, - current_user: User = Depends(get_current_active_superuser_async), + *, + db: AsyncSession = Depends(get_async_db), + user_in: schemas.UserCreate, + current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 新增用户 @@ -50,10 +53,10 @@ async def create_user( @router.put("/", summary="更新用户", response_model=schemas.Response) async def update_user( - *, - db: AsyncSession = Depends(get_async_db), - user_in: schemas.UserUpdate, - current_user: User = Depends(get_current_active_superuser_async), + *, + db: AsyncSession = Depends(get_async_db), + user_in: schemas.UserUpdate, + current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 更新用户 @@ -61,10 +64,12 @@ async def update_user( user_info = user_in.model_dump() if user_info.get("password"): # 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项 - pattern = r'^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$' + pattern = r"^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$" if not re.match(pattern, user_info.get("password")): - return schemas.Response(success=False, - message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位") + return schemas.Response( + success=False, + message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位", + ) user_info["hashed_password"] = get_password_hash(user_info["password"]) user_info.pop("password") user = await current_user.async_get_by_id(db, user_id=user_info["id"]) @@ -84,7 +89,7 @@ async def update_user( @router.get("/current", summary="当前登录用户信息", response_model=schemas.User) async def read_current_user( - current_user: User = Depends(get_current_active_user_async) + current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 当前登录用户信息 @@ -92,9 +97,15 @@ async def read_current_user( return current_user -@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response) -async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...), - _: User = Depends(get_current_active_user_async)): +@router.post( + "/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response +) +async def upload_avatar( + user_id: int, + db: AsyncSession = Depends(get_async_db), + file: UploadFile = File(...), + _: User = Depends(get_current_active_user_async), +): """ 上传用户头像 """ @@ -104,29 +115,24 @@ async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), user = await User.async_get(db, user_id) if not user: return schemas.Response(success=False, message="用户不存在") - await user.async_update(db, { - "avatar": f"data:image/ico;base64,{file_base64}" - }) + await user.async_update(db, {"avatar": f"data:image/ico;base64,{file_base64}"}) return schemas.Response(success=True, message=file.filename) @router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response) -def get_config(key: str, - current_user: User = Depends(get_current_active_user)): +def get_config(key: str, current_user: User = Depends(get_current_active_user)): """ 查询用户配置 """ value = UserConfigOper().get(username=current_user.name, key=key) - return schemas.Response(success=True, data={ - "value": value - }) + return schemas.Response(success=True, data={"value": value}) @router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response) def set_config( - key: str, - value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, - current_user: User = Depends(get_current_active_user), + key: str, + value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, + current_user: User = Depends(get_current_active_user), ): """ 更新用户配置 @@ -137,10 +143,10 @@ def set_config( @router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response) async def delete_user_by_id( - *, - db: AsyncSession = Depends(get_async_db), - user_id: int, - current_user: User = Depends(get_current_active_superuser_async), + *, + db: AsyncSession = Depends(get_async_db), + user_id: int, + current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 通过唯一ID删除用户 @@ -154,10 +160,10 @@ async def delete_user_by_id( @router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response) async def delete_user_by_name( - *, - db: AsyncSession = Depends(get_async_db), - user_name: str, - current_user: User = Depends(get_current_active_superuser_async), + *, + db: AsyncSession = Depends(get_async_db), + user_name: str, + current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 通过用户名删除用户 @@ -171,9 +177,9 @@ async def delete_user_by_name( @router.get("/{username}", summary="用户详情", response_model=schemas.User) async def read_user_by_name( - username: str, - current_user: User = Depends(get_current_active_user_async), - db: AsyncSession = Depends(get_async_db), + username: str, + current_user: User = Depends(get_current_active_user_async), + db: AsyncSession = Depends(get_async_db), ) -> Any: """ 查询用户详情 @@ -187,8 +193,5 @@ async def read_user_by_name( if user == current_user: return user if not current_user.is_superuser: - raise HTTPException( - status_code=400, - detail="用户权限不足" - ) + raise HTTPException(status_code=400, detail="用户权限不足") return user diff --git a/app/api/endpoints/webhook.py b/app/api/endpoints/webhook.py index 7244072b..6bb675d6 100644 --- a/app/api/endpoints/webhook.py +++ b/app/api/endpoints/webhook.py @@ -17,10 +17,11 @@ def start_webhook_chain(body: Any, form: Any, args: Any): @router.post("/", summary="Webhook消息响应", response_model=schemas.Response) -async def webhook_message(background_tasks: BackgroundTasks, - request: Request, - _: Annotated[str, Depends(verify_apitoken)] - ) -> Any: +async def webhook_message( + background_tasks: BackgroundTasks, + request: Request, + _: Annotated[str, Depends(verify_apitoken)], +) -> Any: """ Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名 """ @@ -32,8 +33,11 @@ async def webhook_message(background_tasks: BackgroundTasks, @router.get("/", summary="Webhook消息响应", response_model=schemas.Response) -async def webhook_message(background_tasks: BackgroundTasks, - request: Request, _: Annotated[str, Depends(verify_apitoken)]) -> Any: +async def webhook_message_get( + background_tasks: BackgroundTasks, + request: Request, + _: Annotated[str, Depends(verify_apitoken)], +) -> Any: """ Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名 """ diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index 123cfa77..3c671793 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -24,8 +24,10 @@ router = APIRouter() @router.get("/", summary="所有工作流", response_model=List[schemas.Workflow]) -async def list_workflows(db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def list_workflows( + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 获取工作流列表 """ @@ -33,9 +35,11 @@ async def list_workflows(db: AsyncSession = Depends(get_async_db), @router.post("/", summary="创建工作流", response_model=schemas.Response) -async def create_workflow(workflow: schemas.Workflow, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def create_workflow( + workflow: schemas.Workflow, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 创建工作流 """ @@ -53,7 +57,9 @@ async def create_workflow(workflow: schemas.Workflow, @router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict]) -def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def list_plugin_actions( + plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 获取所有动作 """ @@ -73,33 +79,40 @@ async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> An """ 获取所有事件类型 """ - return [{ - "title": EVENT_TYPE_NAMES.get(event_type, event_type.name), - "value": event_type.value - } for event_type in EventType] + return [ + { + "title": EVENT_TYPE_NAMES.get(event_type, event_type.name), + "value": event_type.value, + } + for event_type in EventType + ] @router.post("/share", summary="分享工作流", response_model=schemas.Response) async def workflow_share( - workflow: schemas.WorkflowShare, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + workflow: schemas.WorkflowShare, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 分享工作流 """ if not workflow.id or not workflow.share_title or not workflow.share_user: - return schemas.Response(success=False, message="请填写工作流ID、分享标题和分享人") + return schemas.Response( + success=False, message="请填写工作流ID、分享标题和分享人" + ) - state, errmsg = await WorkflowHelper().async_workflow_share(workflow_id=workflow.id, - share_title=workflow.share_title or "", - share_comment=workflow.share_comment or "", - share_user=workflow.share_user or "") + state, errmsg = await WorkflowHelper().async_workflow_share( + workflow_id=workflow.id, + share_title=workflow.share_title or "", + share_comment=workflow.share_comment or "", + share_user=workflow.share_user or "", + ) return schemas.Response(success=state, message=errmsg) @router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response) async def workflow_share_delete( - share_id: int, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + share_id: int, _: schemas.TokenPayload = Depends(verify_token) +) -> Any: """ 删除分享 """ @@ -109,9 +122,10 @@ async def workflow_share_delete( @router.post("/fork", summary="复用工作流", response_model=schemas.Response) async def workflow_fork( - workflow: schemas.WorkflowShare, - db: AsyncSession = Depends(get_async_db), - _: schemas.User = Depends(verify_token)) -> Any: + workflow: schemas.WorkflowShare, + db: AsyncSession = Depends(get_async_db), + _: schemas.User = Depends(verify_token), +) -> Any: """ 复用工作流 """ @@ -141,11 +155,13 @@ async def workflow_fork( "timer": workflow.timer, "trigger_type": workflow.trigger_type or "timer", "event_type": workflow.event_type, - "event_conditions": json.loads(workflow.event_conditions or "{}") if workflow.event_conditions else {}, + "event_conditions": json.loads(workflow.event_conditions or "{}") + if workflow.event_conditions + else {}, "actions": actions, "flows": flows, "context": context, - "state": "P" # 默认暂停状态 + "state": "P", # 默认暂停状态 } # 检查名称是否重复 @@ -163,22 +179,29 @@ async def workflow_fork( return schemas.Response(success=True, message="复用成功") -@router.get("/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare]) +@router.get( + "/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare] +) async def workflow_shares( - name: Optional[str] = None, - page: Optional[int] = 1, - count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: + name: Optional[str] = None, + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 查询分享的工作流 """ return await WorkflowHelper().async_get_shares(name=name, page=page, count=count) -@router.post("/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response) -def run_workflow(workflow_id: int, - from_begin: Optional[bool] = True, - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.post( + "/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response +) +def run_workflow( + workflow_id: int, + from_begin: Optional[bool] = True, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 执行工作流 """ @@ -188,10 +211,14 @@ def run_workflow(workflow_id: int, return schemas.Response(success=True) -@router.post("/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response) -def start_workflow(workflow_id: int, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.post( + "/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response +) +def start_workflow( + workflow_id: int, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 启用工作流 """ @@ -209,10 +236,14 @@ def start_workflow(workflow_id: int, return schemas.Response(success=True) -@router.post("/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response) -def pause_workflow(workflow_id: int, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.post( + "/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response +) +def pause_workflow( + workflow_id: int, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 停用工作流 """ @@ -233,10 +264,14 @@ def pause_workflow(workflow_id: int, return schemas.Response(success=True) -@router.post("/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response) -async def reset_workflow(workflow_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.post( + "/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response +) +async def reset_workflow( + workflow_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 重置工作流 """ @@ -253,9 +288,11 @@ async def reset_workflow(workflow_id: int, @router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow) -async def get_workflow(workflow_id: int, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def get_workflow( + workflow_id: int, + db: AsyncSession = Depends(get_async_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 获取工作流详情 """ @@ -263,9 +300,11 @@ async def get_workflow(workflow_id: int, @router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response) -def update_workflow(workflow: schemas.Workflow, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def update_workflow( + workflow: schemas.Workflow, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 更新工作流 """ @@ -288,9 +327,11 @@ def update_workflow(workflow: schemas.Workflow, @router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response) -def delete_workflow(workflow_id: int, - db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +def delete_workflow( + workflow_id: int, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: """ 删除工作流 """ diff --git a/app/api/openai_utils.py b/app/api/openai_utils.py index 4fcbbbe8..38e6b892 100644 --- a/app/api/openai_utils.py +++ b/app/api/openai_utils.py @@ -51,11 +51,15 @@ def extract_text_and_images(content: Any) -> Tuple[str, List[str]]: data = source.get("data") media_type = source.get("media_type") or "image/png" if data and str(data).strip(): - image_urls.append(f"data:{media_type};base64,{str(data).strip()}") + image_urls.append( + f"data:{media_type};base64,{str(data).strip()}" + ) return "\n".join(text_parts).strip(), image_urls -def build_prompt(messages: List[Any], use_server_session: bool) -> Tuple[str, List[str]]: +def build_prompt( + messages: List[Any], use_server_session: bool +) -> Tuple[str, List[str]]: system_texts: List[str] = [] transcript: List[str] = [] latest_user_text = "" @@ -97,7 +101,9 @@ def build_prompt(messages: List[Any], use_server_session: bool) -> Tuple[str, Li else: prompt_parts.append("当前用户消息:\n请结合图片内容回复。") - return "\n\n".join(part for part in prompt_parts if part).strip(), latest_user_images + return "\n\n".join( + part for part in prompt_parts if part + ).strip(), latest_user_images def build_session_id(session_key: str, prefix: str) -> str: @@ -153,18 +159,24 @@ def build_responses_input( content = item.get("content") messages.append({"role": role, "content": content}) elif item.get("role") and "content" in item: - messages.append({"role": item.get("role"), "content": item.get("content")}) + messages.append( + {"role": item.get("role"), "content": item.get("content")} + ) return messages - if isinstance(input_data, dict) and input_data.get("role") and "content" in input_data: - messages.append({"role": input_data.get("role"), "content": input_data.get("content")}) + if ( + isinstance(input_data, dict) + and input_data.get("role") + and "content" in input_data + ): + messages.append( + {"role": input_data.get("role"), "content": input_data.get("content")} + ) return messages -def build_anthropic_messages( - system: Any, messages: List[Any] -) -> List[Dict[str, Any]]: +def build_anthropic_messages(system: Any, messages: List[Any]) -> List[Dict[str, Any]]: normalized: List[Dict[str, Any]] = [] system_text, _ = extract_text_and_images(system) if system_text: diff --git a/app/api/servarr.py b/app/api/servarr.py index 1fdcbcb5..2752156d 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -16,7 +16,7 @@ from app.schemas import RadarrMovie, SonarrSeries from app.schemas.types import MediaType from version import APP_VERSION -arr_router = APIRouter(tags=['servarr']) +arr_router = APIRouter(tags=["servarr"]) @arr_router.get("/system/status", summary="系统状态") @@ -51,7 +51,7 @@ async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any: "build": 0, "revision": 0, "majorRevision": 0, - "minorRevision": 0 + "minorRevision": 0, }, "authentication": "none", "migrationVersion": 0, @@ -62,14 +62,14 @@ async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any: "build": 0, "revision": 0, "majorRevision": 0, - "minorRevision": 0 + "minorRevision": 0, }, "runtimeName": "", "startTime": "", "packageVersion": "", "packageAuthor": "jxxghp", "packageUpdateMechanism": "builtIn", - "packageUpdateMechanismMessage": "" + "packageUpdateMechanismMessage": "", } @@ -92,24 +92,15 @@ async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any: "id": 0, "name": "默认", "source": "0", - "resolution": 0 + "resolution": 0, }, - "items": [ - "string" - ], - "allowed": True + "items": ["string"], + "allowed": True, } ], "minFormatScore": 0, "cutoffFormatScore": 0, - "formatItems": [ - { - "id": 0, - "format": 0, - "name": "默认", - "score": 0 - } - ] + "formatItems": [{"id": 0, "format": 0, "name": "默认", "score": 0}], } ] @@ -125,7 +116,7 @@ async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any: "path": "/", "accessible": True, "freeSpace": 0, - "unmappedFolders": [] + "unmappedFolders": [], } ] @@ -135,12 +126,7 @@ async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr标签 """ - return [ - { - "id": 1, - "label": "默认" - } - ] + return [{"id": 1, "label": "默认"}] @arr_router.get("/languageprofile", summary="语言") @@ -148,29 +134,25 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr语言 """ - return [{ - "id": 1, - "name": "默认", - "upgradeAllowed": True, - "cutoff": { + return [ + { "id": 1, - "name": "默认" - }, - "languages": [ - { - "id": 1, - "language": { - "id": 1, - "name": "默认" - }, - "allowed": True - } - ] - }] + "name": "默认", + "upgradeAllowed": True, + "cutoff": {"id": 1, "name": "默认"}, + "languages": [ + {"id": 1, "language": {"id": 1, "name": "默认"}, "allowed": True} + ], + } + ] -@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie]) -async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: +@arr_router.get( + "/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie] +) +async def arr_movies( + _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db) +) -> Any: """ 查询Rardar电影 """ @@ -245,23 +227,29 @@ async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession for subscribe in subscribes: if subscribe.type != MediaType.MOVIE.value: continue - result.append(RadarrMovie( - id=subscribe.id, - title=subscribe.name, - year=subscribe.year, - isAvailable=True, - monitored=True, - tmdbId=subscribe.tmdbid, - imdbId=subscribe.imdbid, - profileId=1, - qualityProfileId=1, - hasFile=False - )) + result.append( + RadarrMovie( + id=subscribe.id, + title=subscribe.name, + year=subscribe.year, + isAvailable=True, + monitored=True, + tmdbId=subscribe.tmdbid, + imdbId=subscribe.imdbid, + profileId=1, + qualityProfileId=1, + hasFile=False, + ) + ) return result -@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie]) -def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any: +@arr_router.get( + "/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie] +) +def arr_movie_lookup( + term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db) +) -> Any: """ 查询Rardar电影 term: `tmdb:${id}` 存在和不存在均不能返回错误 @@ -290,25 +278,32 @@ def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: S subid = None monitored = False - return [RadarrMovie( - id=subid, - title=mediainfo.title, - year=mediainfo.year, - isAvailable=True, - monitored=monitored, - tmdbId=mediainfo.tmdb_id, - imdbId=mediainfo.imdb_id, - titleSlug=mediainfo.original_title, - folderName=mediainfo.title_year, - profileId=1, - qualityProfileId=1, - hasFile=hasfile - )] + return [ + RadarrMovie( + id=subid, + title=mediainfo.title, + year=mediainfo.year, + isAvailable=True, + monitored=monitored, + tmdbId=mediainfo.tmdb_id, + imdbId=mediainfo.imdb_id, + titleSlug=mediainfo.original_title, + folderName=mediainfo.title_year, + profileId=1, + qualityProfileId=1, + hasFile=hasfile, + ) + ] -@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie) -async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db)) -> Any: +@arr_router.get( + "/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie +) +async def arr_movie( + mid: int, + _: Annotated[str, Depends(verify_apikey)], + db: AsyncSession = Depends(get_async_db), +) -> Any: """ 查询Rardar电影订阅 """ @@ -324,49 +319,47 @@ async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], imdbId=subscribe.imdbid, profileId=1, qualityProfileId=1, - hasFile=False + hasFile=False, ) else: - raise HTTPException( - status_code=404, - detail="未找到该电影!" - ) + raise HTTPException(status_code=404, detail="未找到该电影!") @arr_router.post("/movie", summary="新增电影订阅") -async def arr_add_movie(_: Annotated[str, Depends(verify_apikey)], - movie: RadarrMovie, - db: AsyncSession = Depends(get_async_db) - ) -> Any: +async def arr_add_movie( + _: Annotated[str, Depends(verify_apikey)], + movie: RadarrMovie, + db: AsyncSession = Depends(get_async_db), +) -> Any: """ 新增Rardar电影订阅 """ # 检查订阅是否已存在 subscribe = await Subscribe.async_get_by_tmdbid(db, movie.tmdbId) if subscribe: - return { - "id": subscribe.id - } + return {"id": subscribe.id} # 添加订阅 - sid, message = await SubscribeChain().async_add(title=movie.title, - year=movie.year, - mtype=MediaType.MOVIE, - tmdbid=movie.tmdbId, - username="Seerr") + sid, message = await SubscribeChain().async_add( + title=movie.title, + year=movie.year, + mtype=MediaType.MOVIE, + tmdbid=movie.tmdbId, + username="Seerr", + ) if sid: - return { - "id": sid - } + return {"id": sid} else: - raise HTTPException( - status_code=500, - detail=f"添加订阅失败:{message}" - ) + raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}") -@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response) -async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db)) -> Any: +@arr_router.delete( + "/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response +) +async def arr_remove_movie( + mid: int, + _: Annotated[str, Depends(verify_apikey)], + db: AsyncSession = Depends(get_async_db), +) -> Any: """ 删除Rardar电影订阅 """ @@ -375,14 +368,15 @@ async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], await subscribe.async_delete(db, mid) return schemas.Response(success=True) else: - raise HTTPException( - status_code=404, - detail="未找到该电影!" - ) + raise HTTPException(status_code=404, detail="未找到该电影!") -@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries]) -async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: +@arr_router.get( + "/series", summary="所有剧集", response_model=List[schemas.SonarrSeries] +) +async def arr_series( + _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db) +) -> Any: """ 查询Sonarr剧集 """ @@ -494,31 +488,37 @@ async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession for subscribe in subscribes: if subscribe.type != MediaType.TV.value: continue - result.append(SonarrSeries( - id=subscribe.id, - title=subscribe.name, - seasonCount=1, - seasons=[{ - "seasonNumber": subscribe.season, - "monitored": True, - }], - remotePoster=subscribe.poster, - year=subscribe.year, - tmdbId=subscribe.tmdbid, - tvdbId=subscribe.tvdbid, - imdbId=subscribe.imdbid, - profileId=1, - languageProfileId=1, - qualityProfileId=1, - isAvailable=True, - monitored=True, - hasFile=False - )) + result.append( + SonarrSeries( + id=subscribe.id, + title=subscribe.name, + seasonCount=1, + seasons=[ + { + "seasonNumber": subscribe.season, + "monitored": True, + } + ], + remotePoster=subscribe.poster, + year=subscribe.year, + tmdbId=subscribe.tmdbid, + tvdbId=subscribe.tvdbid, + imdbId=subscribe.imdbid, + profileId=1, + languageProfileId=1, + qualityProfileId=1, + isAvailable=True, + monitored=True, + hasFile=False, + ) + ) return result @arr_router.get("/series/lookup", summary="查询剧集") -def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any: +def arr_series_lookup( + term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db) +) -> Any: """ 查询Sonarr剧集 term: `tvdb:${id}` title """ @@ -542,13 +542,19 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: continue # 季信息(只取默认季类型,排除特别季) - sea_num = len([season for season in tvdbinfo.get('seasons') if - season['type']['id'] == tvdbinfo.get('defaultSeasonType') and season['number'] > 0]) + sea_num = len( + [ + season + for season in tvdbinfo.get("seasons") + if season["type"]["id"] == tvdbinfo.get("defaultSeasonType") + and season["number"] > 0 + ] + ) if sea_num: seas = list(range(1, int(sea_num) + 1)) # 根据TVDB查询媒体信息 - meta = MetaInfo(tvdbinfo.get('name')) + meta = MetaInfo(tvdbinfo.get("name")) meta.type = MediaType.TV mediainfo = MediaChain().recognize_by_meta( meta, @@ -573,24 +579,30 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: sub_seas = [sub.season for sub in subscribes] for sea in seas: if sea in sub_seas: - seasons.append({ - "seasonNumber": sea, - "monitored": True, - }) + seasons.append( + { + "seasonNumber": sea, + "monitored": True, + } + ) else: - seasons.append({ - "seasonNumber": sea, - "monitored": False, - }) + seasons.append( + { + "seasonNumber": sea, + "monitored": False, + } + ) subid = subscribes[-1].id else: subid = None monitored = False for sea in seas: - seasons.append({ - "seasonNumber": sea, - "monitored": False, - }) + seasons.append( + { + "seasonNumber": sea, + "monitored": False, + } + ) sonarr_series = SonarrSeries( id=subid, title=mediainfo.title, @@ -612,8 +624,11 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: @arr_router.get("/series/{tid}", summary="剧集详情") -async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db)) -> Any: +async def arr_serie( + tid: int, + _: Annotated[str, Depends(verify_apikey)], + db: AsyncSession = Depends(get_async_db), +) -> Any: """ 查询Sonarr剧集 """ @@ -623,10 +638,12 @@ async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)], id=subscribe.id, title=subscribe.name, seasonCount=1, - seasons=[{ - "seasonNumber": subscribe.season, - "monitored": True, - }], + seasons=[ + { + "seasonNumber": subscribe.season, + "monitored": True, + } + ], year=subscribe.year, remotePoster=subscribe.poster, tmdbId=subscribe.tmdbid, @@ -637,61 +654,58 @@ async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)], qualityProfileId=1, isAvailable=True, monitored=True, - hasFile=False + hasFile=False, ) else: - raise HTTPException( - status_code=404, - detail="未找到该电视剧!" - ) + raise HTTPException(status_code=404, detail="未找到该电视剧!") @arr_router.post("/series", summary="新增剧集订阅") -async def arr_add_series(tv: schemas.SonarrSeries, - _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db)) -> Any: +async def arr_add_series( + tv: schemas.SonarrSeries, + _: Annotated[str, Depends(verify_apikey)], + db: AsyncSession = Depends(get_async_db), +) -> Any: """ 新增Sonarr剧集订阅 """ # 检查订阅是否存在 left_seasons = [] for season in tv.seasons: - subscribe = await Subscribe.async_get_by_tmdbid(db, tmdbid=tv.tmdbId, - season=season.get("seasonNumber")) + subscribe = await Subscribe.async_get_by_tmdbid( + db, tmdbid=tv.tmdbId, season=season.get("seasonNumber") + ) if subscribe: continue left_seasons.append(season) # 全部已存在订阅 if not left_seasons: - return { - "id": 1 - } + return {"id": 1} # 剩下的添加订阅 sid = 0 message = "" for season in left_seasons: if not season.get("monitored"): continue - sid, message = await SubscribeChain().async_add(title=tv.title, - year=tv.year, - season=season.get("seasonNumber"), - tmdbid=tv.tmdbId, - mtype=MediaType.TV, - username="Seerr") + sid, message = await SubscribeChain().async_add( + title=tv.title, + year=tv.year, + season=season.get("seasonNumber"), + tmdbid=tv.tmdbId, + mtype=MediaType.TV, + username="Seerr", + ) if sid: - return { - "id": sid - } + return {"id": sid} else: - raise HTTPException( - status_code=500, - detail=f"添加订阅失败:{message}" - ) + raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}") @arr_router.put("/series", summary="更新剧集订阅") -async def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]) -> Any: +async def arr_update_series( + tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)] +) -> Any: """ 更新Sonarr剧集订阅 """ @@ -699,8 +713,11 @@ async def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends( @arr_router.delete("/series/{tid}", summary="删除剧集订阅") -async def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db)) -> Any: +async def arr_remove_series( + tid: int, + _: Annotated[str, Depends(verify_apikey)], + db: AsyncSession = Depends(get_async_db), +) -> Any: """ 删除Sonarr剧集订阅 """ @@ -709,7 +726,4 @@ async def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)], await subscribe.async_delete(db, tid) return schemas.Response(success=True) else: - raise HTTPException( - status_code=404, - detail="未找到该电视剧!" - ) + raise HTTPException(status_code=404, detail="未找到该电视剧!") diff --git a/app/api/servcookie.py b/app/api/servcookie.py index f4ee0010..8e7f713d 100644 --- a/app/api/servcookie.py +++ b/app/api/servcookie.py @@ -15,7 +15,6 @@ from app.utils.crypto import CryptoJsUtils, HashUtils class GzipRequest(Request): - async def body(self) -> bytes: if not hasattr(self, "_body"): body = await super().body() @@ -26,7 +25,6 @@ class GzipRequest(Request): class GzipRoute(APIRoute): - def get_route_handler(self) -> Callable: original_route_handler = super().get_route_handler() @@ -46,9 +44,11 @@ async def verify_server_enabled(): return True -cookie_router = APIRouter(route_class=GzipRoute, - tags=["servcookie"], - dependencies=[Depends(verify_server_enabled)]) +cookie_router = APIRouter( + route_class=GzipRoute, + tags=["servcookie"], + dependencies=[Depends(verify_server_enabled)], +) @cookie_router.get("/", response_class=PlainTextResponse) @@ -95,8 +95,9 @@ async def load_encrypt_data(uuid: str) -> Dict[str, Any]: return data -def get_decrypted_cookie_data(uuid: str, password: str, - encrypted: str) -> Optional[Dict[str, Any]]: +def get_decrypted_cookie_data( + uuid: str, password: str, encrypted: str +) -> Optional[Dict[str, Any]]: """ 加载本地加密数据并解密为Cookie """ @@ -118,7 +119,8 @@ def get_decrypted_cookie_data(uuid: str, password: str, @cookie_router.get("/get/{uuid}") async def get_cookie( - uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")]): + uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")], +): """ GET 下载加密数据 """ @@ -127,8 +129,9 @@ async def get_cookie( @cookie_router.post("/get/{uuid}") async def post_cookie( - uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")], - request: Optional[schemas.CookiePassword] = Body(None)): + uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")], + request: Optional[schemas.CookiePassword] = Body(None), +): """ POST 下载加密数据 """ diff --git a/app/modules/themoviedb/tmdbv3api/objs/tv.py b/app/modules/themoviedb/tmdbv3api/objs/tv.py index 64c22bf8..e817a501 100644 --- a/app/modules/themoviedb/tmdbv3api/objs/tv.py +++ b/app/modules/themoviedb/tmdbv3api/objs/tv.py @@ -1,10 +1,5 @@ from ..tmdb import TMDb -try: - from urllib import quote -except ImportError: - from urllib.parse import quote - class TV(TMDb): _urls = { diff --git a/tests/test_feishu.py b/tests/test_feishu.py index 4d7acf13..10aabba4 100644 --- a/tests/test_feishu.py +++ b/tests/test_feishu.py @@ -3,7 +3,6 @@ import asyncio import json import tempfile import unittest -from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import ANY, MagicMock, patch @@ -21,15 +20,20 @@ if "Pinyin2Hanzi" not in sys.modules: from app.modules.feishu import FeishuModule from app.modules.feishu.feishu import Feishu from app.schemas import Notification -from app.schemas.message import ChannelCapability, ChannelCapabilityManager, MessageResponse +from app.schemas.message import ( + ChannelCapability, + ChannelCapabilityManager, + MessageResponse, +) from app.schemas.types import MessageChannel, NotificationType class TestFeishu(unittest.TestCase): @staticmethod def _build_client(**kwargs) -> Feishu: - with patch.object(Feishu, "_build_api_client", return_value=MagicMock()), patch.object( - Feishu, "_start_ws_client" + with ( + patch.object(Feishu, "_build_api_client", return_value=MagicMock()), + patch.object(Feishu, "_start_ws_client"), ): return Feishu( FEISHU_APP_ID="cli_test_app_id", @@ -64,7 +68,21 @@ class TestFeishu(unittest.TestCase): return response @staticmethod - def _build_message_api(create_response=None, patch_response=None, reply_response=None, reaction_create_response=None, reaction_delete_response=None, card_create_response=None, card_settings_response=None, card_content_response=None, image_create_response=None, file_create_response=None, image_get_response=None, file_get_response=None, message_resource_response=None): + def _build_message_api( + create_response=None, + patch_response=None, + reply_response=None, + reaction_create_response=None, + reaction_delete_response=None, + card_create_response=None, + card_settings_response=None, + card_content_response=None, + image_create_response=None, + file_create_response=None, + image_get_response=None, + file_get_response=None, + message_resource_response=None, + ): message_api = SimpleNamespace( create=MagicMock(return_value=create_response), patch=MagicMock(return_value=patch_response), @@ -111,7 +129,11 @@ class TestFeishu(unittest.TestCase): return api_client, message_api @staticmethod - def _resource_response(content: bytes, file_name: str = "resource.bin", content_type: str = "application/octet-stream"): + def _resource_response( + content: bytes, + file_name: str = "resource.bin", + content_type: str = "application/octet-stream", + ): response = MagicMock() response.code = 0 response.file = MagicMock() @@ -169,9 +191,11 @@ class TestFeishu(unittest.TestCase): class _Builder: def __getattr__(self, name): if name.startswith("register_"): + def _register(handler): registered.append(name) return self + return _register raise AttributeError(name) @@ -181,7 +205,10 @@ class TestFeishu(unittest.TestCase): client = self._build_client() fake_builder = _Builder() - with patch("app.modules.feishu.feishu.lark.EventDispatcherHandler.builder", return_value=fake_builder): + with patch( + "app.modules.feishu.feishu.lark.EventDispatcherHandler.builder", + return_value=fake_builder, + ): handler = client._build_event_handler() self.assertEqual(handler, "handler") @@ -190,15 +217,20 @@ class TestFeishu(unittest.TestCase): self.assertIn("register_p2_im_message_reaction_created_v1", registered) self.assertIn("register_p2_im_message_reaction_deleted_v1", registered) self.assertIn("register_p2_im_message_recalled_v1", registered) - self.assertIn("register_p2_im_chat_access_event_bot_p2p_chat_entered_v1", registered) + self.assertIn( + "register_p2_im_chat_access_event_bot_p2p_chat_entered_v1", registered + ) self.assertIn("register_p2_card_action_trigger", registered) def test_parse_message_blocks_non_admin_command(self): client = self._build_client(FEISHU_ADMINS="ou_admin") - with patch("app.modules.feishu.feishu.UserOper.get_name", return_value=None), patch.object( - client, "send_text", return_value={"success": True} - ) as send_text: + with ( + patch("app.modules.feishu.feishu.UserOper.get_name", return_value=None), + patch.object( + client, "send_text", return_value={"success": True} + ) as send_text, + ): result = client.parse_message( { "type": "message", @@ -223,7 +255,10 @@ class TestFeishu(unittest.TestCase): def test_parse_message_maps_feishu_ids_to_moviepilot_username(self): client = self._build_client() - with patch("app.modules.feishu.feishu.UserOper.get_name", return_value="moviepilot-user") as get_name: + with patch( + "app.modules.feishu.feishu.UserOper.get_name", + return_value="moviepilot-user", + ) as get_name: result = client.parse_message( { "type": "message", @@ -317,7 +352,9 @@ class TestFeishu(unittest.TestCase): self.assertEqual(image_element["img_key"], "img_v2_remote") self.assertEqual(content["body"]["elements"][1]["margin"], "12px 12px 0px 12px") self.assertEqual(content["body"]["elements"][2]["margin"], "4px 12px 12px 12px") - self.assertEqual(content["body"]["elements"][-1]["margin"], "0px 12px 12px 12px") + self.assertEqual( + content["body"]["elements"][-1]["margin"], "0px 12px 12px 12px" + ) self.assertEqual(content["body"]["elements"][-1]["tag"], "column_set") def test_send_notification_supports_user_id_target(self): @@ -390,25 +427,33 @@ class TestFeishu(unittest.TestCase): reaction_delete_response=self._success_response(), ) - reaction_id = client.add_message_reaction("om_origin", Feishu.PROCESSING_REACTION_EMOJI) + reaction_id = client.add_message_reaction( + "om_origin", Feishu.PROCESSING_REACTION_EMOJI + ) deleted = client.delete_message_reaction("om_origin", "reaction_1") self.assertEqual(reaction_id, "reaction_1") self.assertTrue(deleted) - create_request = client._api_client.im.v1.message_reaction.create.call_args.args[0] + create_request = ( + client._api_client.im.v1.message_reaction.create.call_args.args[0] + ) self.assertEqual(create_request.message_id, "om_origin") self.assertEqual( create_request.request_body.reaction_type.emoji_type, Feishu.PROCESSING_REACTION_EMOJI, ) - delete_request = client._api_client.im.v1.message_reaction.delete.call_args.args[0] + delete_request = ( + client._api_client.im.v1.message_reaction.delete.call_args.args[0] + ) self.assertEqual(delete_request.message_id, "om_origin") self.assertEqual(delete_request.reaction_id, "reaction_1") def test_send_notification_uses_streaming_card_for_agent_text(self): client = self._build_client() client._api_client, message_api = self._build_message_api( - create_response=self._success_response(message_id="om_stream", chat_id="oc_stream"), + create_response=self._success_response( + message_id="om_stream", chat_id="oc_stream" + ), card_create_response=self._card_create_success_response("card_stream"), ) @@ -422,21 +467,31 @@ class TestFeishu(unittest.TestCase): ) self.assertTrue(result["success"]) - self.assertEqual(result["metadata"]["feishu_streaming"]["card_id"], "card_stream") + self.assertEqual( + result["metadata"]["feishu_streaming"]["card_id"], "card_stream" + ) self.assertEqual(result["metadata"]["feishu_streaming"]["sequence"], 0) card_request = client._api_client.cardkit.v1.card.create.call_args.args[0] self.assertEqual(card_request.request_body.type, "card_json") card_payload = json.loads(card_request.request_body.data) self.assertTrue(card_payload["config"]["streaming_mode"]) - self.assertEqual(card_payload["body"]["elements"][-1]["element_id"], Feishu.STREAM_CARD_BODY_ELEMENT_ID) + self.assertEqual( + card_payload["body"]["elements"][-1]["element_id"], + Feishu.STREAM_CARD_BODY_ELEMENT_ID, + ) message_request = message_api.create.call_args.args[0] self.assertEqual(message_request.request_body.msg_type, "interactive") - self.assertEqual(json.loads(message_request.request_body.content)["data"]["card_id"], "card_stream") + self.assertEqual( + json.loads(message_request.request_body.content)["data"]["card_id"], + "card_stream", + ) def test_send_notification_replies_with_streaming_card_for_agent_text(self): client = self._build_client() client._api_client, message_api = self._build_message_api( - reply_response=self._success_response(message_id="om_reply", chat_id="oc_stream"), + reply_response=self._success_response( + message_id="om_reply", chat_id="oc_stream" + ), card_create_response=self._card_create_success_response("card_stream"), ) @@ -455,7 +510,10 @@ class TestFeishu(unittest.TestCase): reply_request = message_api.reply.call_args.args[0] self.assertEqual(reply_request.message_id, "om_origin") self.assertEqual(reply_request.request_body.msg_type, "interactive") - self.assertEqual(json.loads(reply_request.request_body.content)["data"]["card_id"], "card_stream") + self.assertEqual( + json.loads(reply_request.request_body.content)["data"]["card_id"], + "card_stream", + ) self.assertEqual(result["metadata"]["feishu_streaming"]["sequence"], 0) def test_edit_replied_streaming_card_uses_first_increment_sequence(self): @@ -479,7 +537,9 @@ class TestFeishu(unittest.TestCase): self.assertTrue(success) message_api.patch.assert_not_called() - content_request = client._api_client.cardkit.v1.card_element.content.call_args.args[0] + content_request = ( + client._api_client.cardkit.v1.card_element.content.call_args.args[0] + ) self.assertEqual(content_request.request_body.sequence, 1) def test_edit_message_uses_cardkit_content_for_streaming_card(self): @@ -504,7 +564,9 @@ class TestFeishu(unittest.TestCase): self.assertTrue(success) client._api_client.cardkit.v1.card_element.content.assert_called_once() message_api.patch.assert_not_called() - content_request = client._api_client.cardkit.v1.card_element.content.call_args.args[0] + content_request = ( + client._api_client.cardkit.v1.card_element.content.call_args.args[0] + ) self.assertEqual(content_request.card_id, "card_stream") self.assertEqual(content_request.element_id, Feishu.STREAM_CARD_BODY_ELEMENT_ID) self.assertEqual(content_request.request_body.sequence, 1) @@ -545,7 +607,12 @@ class TestFeishu(unittest.TestCase): { "type": "message", "text": "", - "files": [{"ref": "feishu://file/file_key/report.pdf", "name": "report.pdf"}], + "files": [ + { + "ref": "feishu://file/file_key/report.pdf", + "name": "report.pdf", + } + ], "message_id": "om_file", "chat_id": "oc_chat", "sender": { @@ -567,14 +634,18 @@ class TestFeishu(unittest.TestCase): message_type="image", content=json.dumps({"image_key": "img_v2_evt"}), ) - sender = SimpleNamespace(sender_id=SimpleNamespace(open_id="ou_user_evt", user_id=None)) + sender = SimpleNamespace( + sender_id=SimpleNamespace(open_id="ou_user_evt", user_id=None) + ) event = SimpleNamespace(sender=sender, message=message) with patch.object(client, "_forward_to_message_chain") as forward: client._on_message(SimpleNamespace(event=event)) payload = forward.call_args.args[0] - self.assertEqual(payload["images"][0]["ref"], "feishu://image/om_img_evt/img_v2_evt") + self.assertEqual( + payload["images"][0]["ref"], "feishu://image/om_img_evt/img_v2_evt" + ) def test_on_message_wraps_feishu_audio_ref_with_message_id(self): client = self._build_client() @@ -583,16 +654,23 @@ class TestFeishu(unittest.TestCase): chat_id="oc_chat_evt", chat_type="p2p", message_type="audio", - content=json.dumps({"file_key": "file_audio_evt", "file_name": "voice.opus"}), + content=json.dumps( + {"file_key": "file_audio_evt", "file_name": "voice.opus"} + ), + ) + sender = SimpleNamespace( + sender_id=SimpleNamespace(open_id="ou_user_evt", user_id=None) ) - sender = SimpleNamespace(sender_id=SimpleNamespace(open_id="ou_user_evt", user_id=None)) event = SimpleNamespace(sender=sender, message=message) with patch.object(client, "_forward_to_message_chain") as forward: client._on_message(SimpleNamespace(event=event)) payload = forward.call_args.args[0] - self.assertEqual(payload["audio_refs"], ["feishu://file/om_audio_evt/file_audio_evt/voice.opus"]) + self.assertEqual( + payload["audio_refs"], + ["feishu://file/om_audio_evt/file_audio_evt/voice.opus"], + ) def test_feishu_channel_capabilities_enable_images_and_files(self): self.assertTrue( @@ -650,9 +728,12 @@ class TestFeishu(unittest.TestCase): file_create_response=file_upload_response, ) - with tempfile.NamedTemporaryFile(suffix=".txt") as fp, patch.object( - client, "send_text", return_value={"success": True} - ) as send_text: + with ( + tempfile.NamedTemporaryFile(suffix=".txt") as fp, + patch.object( + client, "send_text", return_value={"success": True} + ) as send_text, + ): fp.write(b"text-bytes") fp.flush() result = client.send_file( @@ -666,7 +747,9 @@ class TestFeishu(unittest.TestCase): client._api_client.im.v1.file.create.assert_called_once() request = message_api.create.call_args.args[0] self.assertEqual(request.request_body.msg_type, "file") - self.assertEqual(json.loads(request.request_body.content)["file_key"], "file_doc") + self.assertEqual( + json.loads(request.request_body.content)["file_key"], "file_doc" + ) send_text.assert_called_once() def test_send_voice_uploads_audio_file_and_optionally_sends_caption(self): @@ -682,7 +765,9 @@ class TestFeishu(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".opus") as fp: fp.write(b"opus-bytes") fp.flush() - with patch.object(client, "send_text", return_value={"success": True}) as send_text: + with patch.object( + client, "send_text", return_value={"success": True} + ) as send_text: result = client.send_voice( voice_path=fp.name, userid="ou_user_8", @@ -692,20 +777,30 @@ class TestFeishu(unittest.TestCase): self.assertTrue(result["success"]) request = message_api.create.call_args.args[0] self.assertEqual(request.request_body.msg_type, "audio") - self.assertEqual(json.loads(request.request_body.content)["file_key"], "file_audio") + self.assertEqual( + json.loads(request.request_body.content)["file_key"], "file_audio" + ) send_text.assert_called_once() def test_download_helpers_return_bytes_and_data_url(self): client = self._build_client() client._api_client, _ = self._build_message_api( - image_get_response=self._resource_response(b"image-bytes", file_name="poster.png", content_type="image/png"), - file_get_response=self._resource_response(b"file-bytes", file_name="report.txt", content_type="text/plain"), - message_resource_response=self._resource_response(b"resource-bytes", file_name="voice.opus", content_type="audio/ogg"), + image_get_response=self._resource_response( + b"image-bytes", file_name="poster.png", content_type="image/png" + ), + file_get_response=self._resource_response( + b"file-bytes", file_name="report.txt", content_type="text/plain" + ), + message_resource_response=self._resource_response( + b"resource-bytes", file_name="voice.opus", content_type="audio/ogg" + ), ) image_download = client.download_image_bytes("img_v2_test") file_download = client.download_file_bytes("file_test") - resource_download = client.download_message_resource_bytes("om_test", "file_test", "audio") + resource_download = client.download_message_resource_bytes( + "om_test", "file_test", "audio" + ) self.assertEqual(image_download[0], b"image-bytes") self.assertEqual(file_download[0], b"file-bytes") @@ -722,9 +817,11 @@ class TestFeishu(unittest.TestCase): "chat_id": "oc_789", } - with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( - module, "check_message", return_value=True - ), patch.object(module, "get_instance", return_value=client): + with ( + patch.object(module, "get_configs", return_value={"feishu-main": conf}), + patch.object(module, "check_message", return_value=True), + patch.object(module, "get_instance", return_value=client), + ): response = module.send_direct_message( Notification( targets={ @@ -757,14 +854,25 @@ class TestFeishu(unittest.TestCase): created_loops.append(loop) return loop - with patch("app.modules.feishu.feishu.lark_ws_client_module.loop", original_loop), patch( - "app.modules.feishu.feishu.lark_ws_client_module._select", - new=MagicMock(return_value=None), - ), patch("app.modules.feishu.feishu.asyncio.new_event_loop", side_effect=_new_loop), patch( - "app.modules.feishu.feishu.lark.ws.Client", return_value=fake_ws_client - ), patch.object( - fake_ws_client, "start", side_effect=lambda: None - ) as mock_start: + with ( + patch( + "app.modules.feishu.feishu.lark_ws_client_module.loop", original_loop + ), + patch( + "app.modules.feishu.feishu.lark_ws_client_module._select", + new=MagicMock(return_value=None), + ), + patch( + "app.modules.feishu.feishu.asyncio.new_event_loop", + side_effect=_new_loop, + ), + patch( + "app.modules.feishu.feishu.lark.ws.Client", return_value=fake_ws_client + ), + patch.object( + fake_ws_client, "start", side_effect=lambda: None + ) as mock_start, + ): client._run_ws_client() self.assertIsNone(client._ws_loop) @@ -784,7 +892,10 @@ class TestFeishu(unittest.TestCase): future = MagicMock() future.result.return_value = None - with patch("app.modules.feishu.feishu.asyncio.run_coroutine_threadsafe", return_value=future) as runner: + with patch( + "app.modules.feishu.feishu.asyncio.run_coroutine_threadsafe", + return_value=future, + ) as runner: client.stop() runner.assert_called_once() @@ -795,13 +906,24 @@ class TestFeishu(unittest.TestCase): client = MagicMock() client.download_image_bytes.return_value = (b"image", "poster.png", "image/png") client.download_file_bytes.return_value = (b"file", "note.txt", "text/plain") - client.download_message_resource_bytes.return_value = (b"image", "poster.png", "image/png") + client.download_message_resource_bytes.return_value = ( + b"image", + "poster.png", + "image/png", + ) - with patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object( - module, "get_instance", return_value=client + with ( + patch.object( + module, "get_config", return_value=SimpleNamespace(name="feishu-main") + ), + patch.object(module, "get_instance", return_value=client), ): - data_url = module.download_feishu_image_to_data_url("feishu://image/om_msg/img_v2_xxx", "feishu-main") - file_bytes = module.download_feishu_file_bytes("feishu://file/file_xxx/note.txt", "feishu-main") + data_url = module.download_feishu_image_to_data_url( + "feishu://image/om_msg/img_v2_xxx", "feishu-main" + ) + file_bytes = module.download_feishu_file_bytes( + "feishu://file/file_xxx/note.txt", "feishu-main" + ) audio_bytes = module.download_feishu_file_bytes( "feishu://file/om_audio/file_audio/voice.opus", "feishu-main", @@ -827,11 +949,18 @@ class TestFeishu(unittest.TestCase): client.add_message_reaction.return_value = "reaction_2" client.delete_message_reaction.return_value = True - with patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object( - module, "get_instance", return_value=client + with ( + patch.object( + module, "get_config", return_value=SimpleNamespace(name="feishu-main") + ), + patch.object(module, "get_instance", return_value=client), ): - reaction_id = module.add_feishu_message_reaction("om_x", "GLANCE", "feishu-main") - deleted = module.delete_feishu_message_reaction("om_x", "reaction_2", "feishu-main") + reaction_id = module.add_feishu_message_reaction( + "om_x", "GLANCE", "feishu-main" + ) + deleted = module.delete_feishu_message_reaction( + "om_x", "reaction_2", "feishu-main" + ) self.assertEqual(reaction_id, "reaction_2") self.assertTrue(deleted) @@ -842,8 +971,11 @@ class TestFeishu(unittest.TestCase): client = MagicMock() client.close_streaming_card.return_value = True - with patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object( - module, "get_instance", return_value=client + with ( + patch.object( + module, "get_config", return_value=SimpleNamespace(name="feishu-main") + ), + patch.object(module, "get_instance", return_value=client), ): success = module.finalize_message( MessageResponse( @@ -862,18 +994,35 @@ class TestFeishu(unittest.TestCase): ) self.assertTrue(success) - client.close_streaming_card.assert_called_once_with(card_id="card_stream", sequence=3) + client.close_streaming_card.assert_called_once_with( + card_id="card_stream", sequence=3 + ) def test_module_post_message_prefers_file_and_voice_paths(self): module = FeishuModule() conf = SimpleNamespace(name="feishu-main") client = MagicMock() - with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( - module, "check_message", return_value=True - ), patch.object(module, "get_instance", return_value=client): - module.post_message(Notification(file_path="/tmp/demo.txt", text="说明", title="标题", userid="ou_user")) - module.post_message(Notification(voice_path="/tmp/demo.opus", voice_caption="语音说明", userid="ou_user")) + with ( + patch.object(module, "get_configs", return_value={"feishu-main": conf}), + patch.object(module, "check_message", return_value=True), + patch.object(module, "get_instance", return_value=client), + ): + module.post_message( + Notification( + file_path="/tmp/demo.txt", + text="说明", + title="标题", + userid="ou_user", + ) + ) + module.post_message( + Notification( + voice_path="/tmp/demo.opus", + voice_caption="语音说明", + userid="ou_user", + ) + ) client.send_file.assert_called_once() client.send_voice.assert_called_once() @@ -883,9 +1032,11 @@ class TestFeishu(unittest.TestCase): conf = SimpleNamespace(name="feishu-main") client = MagicMock() - with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( - module, "check_message", return_value=True - ), patch.object(module, "get_instance", return_value=client): + with ( + patch.object(module, "get_configs", return_value={"feishu-main": conf}), + patch.object(module, "check_message", return_value=True), + patch.object(module, "get_instance", return_value=client), + ): module.post_message( Notification( file_path="/tmp/demo.txt", @@ -917,9 +1068,11 @@ class TestFeishu(unittest.TestCase): } client.send_file.return_value = {"success": True, "message_id": "om_file"} - with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( - module, "check_message", return_value=True - ), patch.object(module, "get_instance", return_value=client): + with ( + patch.object(module, "get_configs", return_value={"feishu-main": conf}), + patch.object(module, "check_message", return_value=True), + patch.object(module, "get_instance", return_value=client), + ): response = module.send_direct_message( Notification( channel=MessageChannel.Feishu, @@ -943,9 +1096,11 @@ class TestFeishu(unittest.TestCase): conf = SimpleNamespace(name="feishu-main") client = MagicMock() - with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( - module, "check_message", return_value=True - ), patch.object(module, "get_instance", return_value=client): + with ( + patch.object(module, "get_configs", return_value={"feishu-main": conf}), + patch.object(module, "check_message", return_value=True), + patch.object(module, "get_instance", return_value=client), + ): module.post_message( Notification( title="标题", diff --git a/tests/test_jellyfin.py b/tests/test_jellyfin.py index 47ae8d7c..cd214604 100644 --- a/tests/test_jellyfin.py +++ b/tests/test_jellyfin.py @@ -3,7 +3,7 @@ import sys import types import unittest from pathlib import Path -from unittest.mock import call, patch +from unittest.mock import patch def _load_jellyfin_module(): @@ -58,8 +58,12 @@ def _load_jellyfin_module(): return urljoin(host, path) log_module.logger = _Logger() - config_module.settings = types.SimpleNamespace(SUPERUSER="admin", USER_AGENT="MoviePilot") - schemas_module.MediaType = types.SimpleNamespace(MOVIE=types.SimpleNamespace(value="movie")) + config_module.settings = types.SimpleNamespace( + SUPERUSER="admin", USER_AGENT="MoviePilot" + ) + schemas_module.MediaType = types.SimpleNamespace( + MOVIE=types.SimpleNamespace(value="movie") + ) schemas_module.MediaServerItem = object schemas_module.MediaServerLibrary = object schemas_module.Statistic = object @@ -90,7 +94,13 @@ def _load_jellyfin_module(): for stub_module in stub_modules.values(): stub_module._jellyfin_test_stub = True - jellyfin_path = Path(__file__).resolve().parents[1] / "app" / "modules" / "jellyfin" / "jellyfin.py" + jellyfin_path = ( + Path(__file__).resolve().parents[1] + / "app" + / "modules" + / "jellyfin" + / "jellyfin.py" + ) spec = importlib.util.spec_from_file_location(module_name, jellyfin_path) module = importlib.util.module_from_spec(spec) assert spec and spec.loader @@ -114,9 +124,15 @@ class _FakeResponse: class JellyfinUserResolutionTest(unittest.TestCase): def test_loader_does_not_leave_stub_modules_in_sys_modules(self): self.assertNotIn("_test_jellyfin_module", sys.modules) - self.assertFalse(getattr(sys.modules.get("app.log"), "_jellyfin_test_stub", False)) - self.assertFalse(getattr(sys.modules.get("app.core.config"), "_jellyfin_test_stub", False)) - self.assertFalse(getattr(sys.modules.get("app.utils.http"), "_jellyfin_test_stub", False)) + self.assertFalse( + getattr(sys.modules.get("app.log"), "_jellyfin_test_stub", False) + ) + self.assertFalse( + getattr(sys.modules.get("app.core.config"), "_jellyfin_test_stub", False) + ) + self.assertFalse( + getattr(sys.modules.get("app.utils.http"), "_jellyfin_test_stub", False) + ) def _build_client(self) -> Jellyfin: client = Jellyfin.__new__(Jellyfin) @@ -134,9 +150,10 @@ class JellyfinUserResolutionTest(unittest.TestCase): {"Id": "alice-id", "Name": "alice", "Policy": {"IsAdministrator": False}}, ] - with patch.object(jellyfin_module, "RequestUtils") as request_utils_cls, patch.object( - jellyfin_module.logger, "warning" - ) as warning_mock: + with ( + patch.object(jellyfin_module, "RequestUtils") as request_utils_cls, + patch.object(jellyfin_module.logger, "warning") as warning_mock, + ): request_utils_cls.return_value.get_res.return_value = _FakeResponse(payload) user_id = client.get_user("alice") @@ -150,7 +167,10 @@ class JellyfinUserResolutionTest(unittest.TestCase): { "Id": "visible-admin-id", "Name": "visible", - "Policy": {"IsAdministrator": True, "EnabledFolders": ["lib-1", "lib-2", "lib-3"]}, + "Policy": { + "IsAdministrator": True, + "EnabledFolders": ["lib-1", "lib-2", "lib-3"], + }, }, { "Id": "full-admin-id", @@ -177,14 +197,18 @@ class JellyfinUserResolutionTest(unittest.TestCase): { "Id": "large-admin-id", "Name": "large", - "Policy": {"IsAdministrator": True, "EnabledFolders": ["lib-1", "lib-2", "lib-3"]}, + "Policy": { + "IsAdministrator": True, + "EnabledFolders": ["lib-1", "lib-2", "lib-3"], + }, }, {"Id": "user-id", "Name": "normal", "Policy": {"IsAdministrator": False}}, ] - with patch.object(jellyfin_module, "RequestUtils") as request_utils_cls, patch.object( - jellyfin_module.logger, "warning" - ) as warning_mock: + with ( + patch.object(jellyfin_module, "RequestUtils") as request_utils_cls, + patch.object(jellyfin_module.logger, "warning") as warning_mock, + ): request_utils_cls.return_value.get_res.return_value = _FakeResponse(payload) user_id = client.get_user("admin") @@ -193,7 +217,9 @@ class JellyfinUserResolutionTest(unittest.TestCase): self.assertGreaterEqual(warning_mock.call_count, 2) warning_messages = [ - call.args[0] for call in warning_mock.call_args_list if call.args and isinstance(call.args[0], str) + call.args[0] + for call in warning_mock.call_args_list + if call.args and isinstance(call.args[0], str) ] self.assertTrue(any("超级管理员" in message for message in warning_messages)) self.assertTrue( @@ -205,7 +231,12 @@ class JellyfinUserResolutionTest(unittest.TestCase): for message in warning_messages ) ) - self.assertTrue(any(("回退" in message) or ("fallback" in message.lower()) for message in warning_messages)) + self.assertTrue( + any( + ("回退" in message) or ("fallback" in message.lower()) + for message in warning_messages + ) + ) def test_get_jellyfin_librarys_returns_empty_when_user_missing(self): client = self._build_client() @@ -223,7 +254,9 @@ class JellyfinUserResolutionTest(unittest.TestCase): client.user = "user-id" with patch.object(jellyfin_module, "RequestUtils") as request_utils_cls: - request_utils_cls.return_value.get_res.return_value = _FakeResponse({"Items": []}) + request_utils_cls.return_value.get_res.return_value = _FakeResponse( + {"Items": []} + ) libraries = client._Jellyfin__get_jellyfin_librarys()