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
This commit is contained in:
DDSRem
2026-05-13 18:59:03 +08:00
committed by GitHub
parent fcf6e14ac9
commit 5a585839ba
34 changed files with 2974 additions and 1838 deletions

View File

@@ -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),

View File

@@ -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详情
"""

View File

@@ -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

View File

@@ -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 []

View File

@@ -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查询豆瓣媒体信息
"""

View File

@@ -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,
# 保存路径, 支持<storage>:<path>, 如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,
# 保存路径, 支持<storage>:<path>, 如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:
"""
删除下载任务
"""

View File

@@ -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:
"""
清空整理记录
"""

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:
"""
获取指定工具的参数SchemaJSON Schema格式
Returns:
工具的JSON Schema定义
"""

View File

@@ -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)

View File

@@ -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)}")

View File

@@ -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="验证失败")

View File

@@ -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(

View File

@@ -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")

View File

@@ -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):

View File

@@ -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流行趋势
"""

View File

@@ -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":

View File

@@ -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)

View File

@@ -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:
"""
查询支持的整理方式
"""

View File

@@ -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)

View File

@@ -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),
):
"""
触发系统升级并重启(仅管理员)

View File

@@ -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
)

View File

@@ -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)}")

View File

@@ -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,

View File

@@ -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

View File

@@ -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=媒体服务器名
"""

View File

@@ -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:
"""
删除工作流
"""

View File

@@ -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:

View File

@@ -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="未找到该电视剧!")

View File

@@ -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 下载加密数据
"""

View File

@@ -1,10 +1,5 @@
from ..tmdb import TMDb
try:
from urllib import quote
except ImportError:
from urllib.parse import quote
class TV(TMDb):
_urls = {

View File

@@ -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="标题",

View File

@@ -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()