mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-22 05:02:15 +08:00
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:
@@ -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),
|
||||
|
||||
@@ -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详情
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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查询豆瓣媒体信息
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
清空整理记录
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -33,35 +33,32 @@ def list_exposed_tools():
|
||||
获取 MCP 可见工具列表
|
||||
"""
|
||||
return [
|
||||
tool for tool in moviepilot_tool_manager.list_tools()
|
||||
tool
|
||||
for tool in moviepilot_tool_manager.list_tools()
|
||||
if tool.name not in MCP_HIDDEN_TOOLS
|
||||
]
|
||||
|
||||
|
||||
def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]:
|
||||
def create_jsonrpc_response(
|
||||
request_id: Union[str, int, None], result: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
创建 JSON-RPC 成功响应
|
||||
"""
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": result
|
||||
}
|
||||
response = {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
return response
|
||||
|
||||
|
||||
def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: str, data: Any = None) -> Dict[
|
||||
str, Any]:
|
||||
def create_jsonrpc_error(
|
||||
request_id: Union[str, int, None], code: int, message: str, data: Any = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
创建 JSON-RPC 错误响应
|
||||
"""
|
||||
error = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
}
|
||||
"error": {"code": code, "message": message},
|
||||
}
|
||||
if data is not None:
|
||||
error["error"]["data"] = data
|
||||
@@ -70,12 +67,11 @@ def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message:
|
||||
|
||||
@router.post("", summary="MCP JSON-RPC 端点", response_model=None)
|
||||
async def mcp_jsonrpc(
|
||||
request: Request,
|
||||
_: Annotated[str, Depends(verify_apikey)] = None
|
||||
request: Request, _: Annotated[str, Depends(verify_apikey)] = None
|
||||
) -> Union[JSONResponse, Response]:
|
||||
"""
|
||||
MCP 标准 JSON-RPC 2.0 端点
|
||||
|
||||
|
||||
处理所有 MCP 协议消息(初始化、工具列表、工具调用等)
|
||||
"""
|
||||
try:
|
||||
@@ -84,14 +80,14 @@ async def mcp_jsonrpc(
|
||||
logger.error(f"解析请求体失败: {e}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_jsonrpc_error(None, -32700, "Parse error", str(e))
|
||||
content=create_jsonrpc_error(None, -32700, "Parse error", str(e)),
|
||||
)
|
||||
|
||||
# 验证 JSON-RPC 格式
|
||||
if not isinstance(body, dict) or body.get("jsonrpc") != "2.0":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request")
|
||||
content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request"),
|
||||
)
|
||||
|
||||
method = body.get("method")
|
||||
@@ -114,7 +110,7 @@ async def mcp_jsonrpc(
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "initialized must be a notification"}
|
||||
content={"error": "initialized must be a notification"},
|
||||
)
|
||||
|
||||
# 处理工具列表请求
|
||||
@@ -134,20 +130,22 @@ async def mcp_jsonrpc(
|
||||
# 未知方法
|
||||
else:
|
||||
return JSONResponse(
|
||||
content=create_jsonrpc_error(request_id, -32601, f"Method not found: {method}")
|
||||
content=create_jsonrpc_error(
|
||||
request_id, -32601, f"Method not found: {method}"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"MCP 请求参数错误: {e}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=create_jsonrpc_error(request_id, -32602, "Invalid params", str(e))
|
||||
content=create_jsonrpc_error(request_id, -32602, "Invalid params", str(e)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理 MCP 请求失败: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e))
|
||||
content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e)),
|
||||
)
|
||||
|
||||
|
||||
@@ -158,7 +156,9 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
protocol_version = params.get("protocolVersion")
|
||||
client_info = params.get("clientInfo", {})
|
||||
|
||||
logger.info(f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}")
|
||||
logger.info(
|
||||
f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}"
|
||||
)
|
||||
|
||||
# 版本协商:选择客户端和服务器都支持的版本
|
||||
negotiated_version = MCP_PROTOCOL_VERSION
|
||||
@@ -168,7 +168,9 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
logger.info(f"使用客户端协议版本: {negotiated_version}")
|
||||
else:
|
||||
# 客户端版本不支持,使用服务器默认版本
|
||||
logger.warning(f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}")
|
||||
logger.warning(
|
||||
f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}"
|
||||
)
|
||||
|
||||
return {
|
||||
"protocolVersion": negotiated_version,
|
||||
@@ -176,14 +178,14 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"tools": {
|
||||
"listChanged": False # 暂不支持工具列表变更通知
|
||||
},
|
||||
"logging": {}
|
||||
"logging": {},
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "MoviePilot",
|
||||
"version": APP_VERSION,
|
||||
"description": "MoviePilot MCP Server - 电影自动化管理工具",
|
||||
},
|
||||
"instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。"
|
||||
"instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。",
|
||||
}
|
||||
|
||||
|
||||
@@ -199,13 +201,11 @@ async def handle_tools_list() -> Dict[str, Any]:
|
||||
mcp_tool = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.input_schema
|
||||
"inputSchema": tool.input_schema,
|
||||
}
|
||||
mcp_tools.append(mcp_tool)
|
||||
|
||||
return {
|
||||
"tools": mcp_tools
|
||||
}
|
||||
return {"tools": mcp_tools}
|
||||
|
||||
|
||||
async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -224,30 +224,18 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": result_text
|
||||
}
|
||||
]
|
||||
}
|
||||
return {"content": [{"type": "text", "text": result_text}]}
|
||||
except Exception as e:
|
||||
logger.error(f"工具调用失败: {tool_name}, 错误: {e}", exc_info=True)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"错误: {str(e)}"
|
||||
}
|
||||
],
|
||||
"isError": True
|
||||
"content": [{"type": "text", "text": f"错误: {str(e)}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("", summary="终止 MCP 会话", response_model=None)
|
||||
async def delete_mcp_session(
|
||||
_: Annotated[str, Depends(verify_apikey)] = None
|
||||
_: Annotated[str, Depends(verify_apikey)] = None,
|
||||
) -> Union[JSONResponse, Response]:
|
||||
"""
|
||||
终止 MCP 会话(无状态模式下仅返回成功)
|
||||
@@ -257,13 +245,12 @@ async def delete_mcp_session(
|
||||
|
||||
# ==================== 兼容的 RESTful API 端点 ====================
|
||||
|
||||
|
||||
@router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]])
|
||||
async def list_tools(
|
||||
_: Annotated[str, Depends(verify_apikey)]
|
||||
) -> Any:
|
||||
async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
获取所有可用的工具列表
|
||||
|
||||
|
||||
返回每个工具的名称、描述和参数定义
|
||||
"""
|
||||
try:
|
||||
@@ -276,7 +263,7 @@ async def list_tools(
|
||||
tool_dict = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.input_schema
|
||||
"inputSchema": tool.input_schema,
|
||||
}
|
||||
tools_list.append(tool_dict)
|
||||
|
||||
@@ -288,12 +275,11 @@ async def list_tools(
|
||||
|
||||
@router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse)
|
||||
async def call_tool(
|
||||
request: schemas.ToolCallRequest,
|
||||
_: Annotated[str, Depends(verify_apikey)] = None
|
||||
request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
|
||||
) -> Any:
|
||||
"""
|
||||
调用指定的工具
|
||||
|
||||
|
||||
Returns:
|
||||
工具执行结果
|
||||
"""
|
||||
@@ -301,28 +287,23 @@ async def call_tool(
|
||||
if request.tool_name in MCP_HIDDEN_TOOLS:
|
||||
raise ValueError(f"工具 '{request.tool_name}' 未找到")
|
||||
|
||||
result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments)
|
||||
|
||||
return schemas.ToolCallResponse(
|
||||
success=True,
|
||||
result=result_text
|
||||
result_text = await moviepilot_tool_manager.call_tool(
|
||||
request.tool_name, request.arguments
|
||||
)
|
||||
|
||||
return schemas.ToolCallResponse(success=True, result=result_text)
|
||||
except Exception as e:
|
||||
logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True)
|
||||
return schemas.ToolCallResponse(
|
||||
success=False,
|
||||
error=f"调用工具失败: {str(e)}"
|
||||
)
|
||||
return schemas.ToolCallResponse(success=False, error=f"调用工具失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tools/{tool_name}", summary="获取工具详情", response_model=Dict[str, Any])
|
||||
async def get_tool_info(
|
||||
tool_name: str,
|
||||
_: Annotated[str, Depends(verify_apikey)]
|
||||
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
||||
) -> Any:
|
||||
"""
|
||||
获取指定工具的详细信息
|
||||
|
||||
|
||||
Returns:
|
||||
工具的详细信息,包括名称、描述和参数定义
|
||||
"""
|
||||
@@ -336,7 +317,7 @@ async def get_tool_info(
|
||||
return {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.input_schema
|
||||
"inputSchema": tool.input_schema,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"工具 '{tool_name}' 未找到")
|
||||
@@ -347,14 +328,17 @@ async def get_tool_info(
|
||||
raise HTTPException(status_code=500, detail=f"获取工具信息失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tools/{tool_name}/schema", summary="获取工具参数Schema", response_model=Dict[str, Any])
|
||||
@router.get(
|
||||
"/tools/{tool_name}/schema",
|
||||
summary="获取工具参数Schema",
|
||||
response_model=Dict[str, Any],
|
||||
)
|
||||
async def get_tool_schema(
|
||||
tool_name: str,
|
||||
_: Annotated[str, Depends(verify_apikey)]
|
||||
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
||||
) -> Any:
|
||||
"""
|
||||
获取指定工具的参数Schema(JSON Schema格式)
|
||||
|
||||
|
||||
Returns:
|
||||
工具的JSON Schema定义
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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="验证失败")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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流行趋势
|
||||
"""
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
查询支持的整理方式
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
触发系统升级并重启(仅管理员)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=媒体服务器名
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
删除工作流
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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="未找到该电视剧!")
|
||||
|
||||
@@ -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 下载加密数据
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
from ..tmdb import TMDb
|
||||
|
||||
try:
|
||||
from urllib import quote
|
||||
except ImportError:
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
class TV(TMDb):
|
||||
_urls = {
|
||||
|
||||
@@ -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="标题",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user