mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 01:03:08 +08:00
Compare commits
238 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d0c14ebc | ||
|
|
6ac8455c74 | ||
|
|
143b21631f | ||
|
|
d760facad8 | ||
|
|
3a1a4c5cfe | ||
|
|
c3045e2cd4 | ||
|
|
1efb9af7ab | ||
|
|
e03471159a | ||
|
|
a92e493742 | ||
|
|
225d413ed1 | ||
|
|
184e4ba7d5 | ||
|
|
917cae27b1 | ||
|
|
60e0463051 | ||
|
|
c15022c7d5 | ||
|
|
2a84e3a606 | ||
|
|
fddbbd5714 | ||
|
|
51b8f7c713 | ||
|
|
e97c246741 | ||
|
|
9a81f55ac0 | ||
|
|
a38b702acc | ||
|
|
e4e0605e92 | ||
|
|
8875a8f12c | ||
|
|
4dd1deefa5 | ||
|
|
1f6dc93ea3 | ||
|
|
426e920fff | ||
|
|
1f6bbce326 | ||
|
|
41f89a35fa | ||
|
|
099d7874d7 | ||
|
|
e2367103a1 | ||
|
|
37f8ba7d72 | ||
|
|
c20bd84edd | ||
|
|
b4ee0d2487 | ||
|
|
420fa7645f | ||
|
|
5bb1e72760 | ||
|
|
e2a007b62a | ||
|
|
210813367f | ||
|
|
770a50764e | ||
|
|
e339a22aa4 | ||
|
|
913afed378 | ||
|
|
db3efb4452 | ||
|
|
840351acb7 | ||
|
|
da76a7f299 | ||
|
|
cbd999f88d | ||
|
|
2fa8a266c5 | ||
|
|
08aa749a53 | ||
|
|
2379f04d2a | ||
|
|
0e73598d1c | ||
|
|
964e6eb0e8 | ||
|
|
0430e6c6d4 | ||
|
|
db88358eca | ||
|
|
723e9b0018 | ||
|
|
f3db27a8da | ||
|
|
0fb7a73fc9 | ||
|
|
418e6bd085 | ||
|
|
5a5c4ace6b | ||
|
|
c2c8214075 | ||
|
|
e5d2ade6e6 | ||
|
|
e32b6e07b4 | ||
|
|
cc69d3b8d1 | ||
|
|
1dd3af44b5 | ||
|
|
8ab233baef | ||
|
|
104138b9a7 | ||
|
|
0c8fd5121a | ||
|
|
61f26d331b | ||
|
|
97817cd808 | ||
|
|
45bcc63c06 | ||
|
|
00779d0f10 | ||
|
|
d657bf8ed8 | ||
|
|
4fcdd05e6a | ||
|
|
e6916946a9 | ||
|
|
acd7013dc6 | ||
|
|
039d876e3f | ||
|
|
3fc2c7d6cc | ||
|
|
109164b673 | ||
|
|
673a03e656 | ||
|
|
1e976e6d96 | ||
|
|
8efba30adb | ||
|
|
713d44eac3 | ||
|
|
aea44c1d97 | ||
|
|
1e61e60d73 | ||
|
|
a0e4b4a56e | ||
|
|
983f8fcb03 | ||
|
|
6afdde7dc1 | ||
|
|
6873de7243 | ||
|
|
ee4d6d0db3 | ||
|
|
dee1212a76 | ||
|
|
ceda69aedd | ||
|
|
75ea7d7601 | ||
|
|
8b75d2312c | ||
|
|
ca51880798 | ||
|
|
8b708e8939 | ||
|
|
b6ff9f7196 | ||
|
|
67229fd032 | ||
|
|
d382eab355 | ||
|
|
d8f10e9ac4 | ||
|
|
749aaeb003 | ||
|
|
c5a3bbcecf | ||
|
|
27ac41531b | ||
|
|
423c9af786 | ||
|
|
232759829e | ||
|
|
71f7bc7b1b | ||
|
|
ae4f03e272 | ||
|
|
acb5a7e50b | ||
|
|
c8749b3c9c | ||
|
|
49647e3bb5 | ||
|
|
48d353aa90 | ||
|
|
edec18cacb | ||
|
|
cd8661abc1 | ||
|
|
5f6310f5d6 | ||
|
|
42d955b175 | ||
|
|
21541bc468 | ||
|
|
f14f4e1e9b | ||
|
|
6d1de8a2e4 | ||
|
|
0053d31f84 | ||
|
|
f077a9684b | ||
|
|
2428d58e93 | ||
|
|
5340e3a0a7 | ||
|
|
70dd8f0f1d | ||
|
|
8fa76504c3 | ||
|
|
0899cb4e1d | ||
|
|
ee7a2a70a6 | ||
|
|
d57d1ac15e | ||
|
|
68c29d89c9 | ||
|
|
721648ffdf | ||
|
|
8437f39bf6 | ||
|
|
48b15c60e7 | ||
|
|
e350122125 | ||
|
|
0cce97f373 | ||
|
|
d8cacc0811 | ||
|
|
7abaf70bb8 | ||
|
|
232fe4d15e | ||
|
|
d6d12c0335 | ||
|
|
8e4f12804b | ||
|
|
c21ba5c521 | ||
|
|
dfa3d47261 | ||
|
|
924f59afff | ||
|
|
673b282d6c | ||
|
|
1c761f89e5 | ||
|
|
f61cd969b9 | ||
|
|
e39a130306 | ||
|
|
13b6ea985e | ||
|
|
2f1e55fa1e | ||
|
|
776f629771 | ||
|
|
d9e9edb2c4 | ||
|
|
753c074e59 | ||
|
|
d92c82775a | ||
|
|
215cc09c1f | ||
|
|
7f302c13c7 | ||
|
|
de6a094d10 | ||
|
|
a94e1a8314 | ||
|
|
f5efdd665b | ||
|
|
43e25e8717 | ||
|
|
a8026fefc1 | ||
|
|
fdb36957c9 | ||
|
|
ea433ff807 | ||
|
|
8902fb50d6 | ||
|
|
b6aa013eb3 | ||
|
|
034b43bf70 | ||
|
|
59e9032286 | ||
|
|
52a98efd0a | ||
|
|
90cc91aa7f | ||
|
|
1973a26e83 | ||
|
|
6519ad25ca | ||
|
|
cacfde8166 | ||
|
|
df85873726 | ||
|
|
dfea294cc9 | ||
|
|
d35b855404 | ||
|
|
7a1cbf70e3 | ||
|
|
f260990b86 | ||
|
|
6affbe9b55 | ||
|
|
dbe3a10697 | ||
|
|
3c25306a5d | ||
|
|
17f4d49731 | ||
|
|
e213b5cc64 | ||
|
|
65e5dad44b | ||
|
|
62ad38ea5d | ||
|
|
f98f4c1f77 | ||
|
|
e9f02b58b7 | ||
|
|
05495e481d | ||
|
|
5bb2167b78 | ||
|
|
b4e0ed66cf | ||
|
|
70a0563435 | ||
|
|
955912b832 | ||
|
|
b65ee75b3d | ||
|
|
f642493a38 | ||
|
|
7f1bfb1e07 | ||
|
|
8931e2e016 | ||
|
|
0465fa77c2 | ||
|
|
575d503cb9 | ||
|
|
a4fdbdb9ad | ||
|
|
b9cb781a4e | ||
|
|
a3adf867b7 | ||
|
|
d52cbd2f74 | ||
|
|
8d0003db94 | ||
|
|
b775e89e77 | ||
|
|
0e14b097ba | ||
|
|
51848b8d8d | ||
|
|
72658c3e60 | ||
|
|
036cb6f3b0 | ||
|
|
1a86d96bfa | ||
|
|
f67db38a25 | ||
|
|
028d18826a | ||
|
|
29a605f265 | ||
|
|
4b6959470d | ||
|
|
600767d2bf | ||
|
|
3efbd47ffd | ||
|
|
d17e85217b | ||
|
|
e608089805 | ||
|
|
b852acec28 | ||
|
|
2a3ea8315d | ||
|
|
9271ee833c | ||
|
|
570d4ad1a3 | ||
|
|
dccdf3231a | ||
|
|
b8ee777fd2 | ||
|
|
a2fd3a8d90 | ||
|
|
bbffb1420b | ||
|
|
8ea0a32879 | ||
|
|
8c27b8c33e | ||
|
|
5c61b22c2f | ||
|
|
9da9d765a0 | ||
|
|
f64363728e | ||
|
|
378777dc7c | ||
|
|
6156b9a481 | ||
|
|
8c516c5691 | ||
|
|
bf9a149898 | ||
|
|
277cde8db2 | ||
|
|
e06bdaf53e | ||
|
|
da367bd138 | ||
|
|
d336bcbf1f | ||
|
|
a8aedba6ff | ||
|
|
9ede86c6a3 | ||
|
|
1468f2b082 | ||
|
|
e04ae70f89 | ||
|
|
7f7d2c9ba8 | ||
|
|
d73deef8dc | ||
|
|
f93a1540af | ||
|
|
c8bd9cb716 | ||
|
|
2ed13c7e5b |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -92,6 +92,6 @@ jobs:
|
||||
body: ${{ env.RELEASE_BODY }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
make_latest: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
22
README.md
22
README.md
@@ -18,17 +18,19 @@
|
||||
|
||||
## 主要特性
|
||||
|
||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend),API:http://localhost:3001/docs
|
||||
- 前后端分离,基于FastApi + Vue3。
|
||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||
- 重新设计了用户界面,更加美观易用。
|
||||
|
||||
## 安装使用
|
||||
|
||||
访问官方Wiki:https://wiki.movie-pilot.org
|
||||
官方Wiki:https://wiki.movie-pilot.org
|
||||
|
||||
## 参与开发
|
||||
|
||||
需要 `Python 3.12`、`Node JS v20.12.1`
|
||||
API文档:https://api.movie-pilot.org
|
||||
|
||||
本地运行需要 `Python 3.12`、`Node JS v20.12.1`
|
||||
|
||||
- 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot)
|
||||
```shell
|
||||
@@ -54,6 +56,20 @@ yarn dev
|
||||
```
|
||||
- 参考 [插件开发指引](https://wiki.movie-pilot.org/zh/plugindev) 在 `app/plugins` 目录下开发插件代码
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
|
||||
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
|
||||
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
|
||||
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
|
||||
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
|
||||
|
||||
## 免责申明
|
||||
|
||||
- 本软件仅供学习交流使用,任何人不得将本软件用于商业用途,任何人不得将本软件用于违法犯罪活动,软件对用户行为不知情,一切责任由使用者承担。
|
||||
- 本软件代码开源,基于开源代码进行修改,人为去除相关限制导致软件被分发、传播并造成责任事件的,需由代码修改发布者承担全部责任,不建议对用户认证机制进行规避或修改并公开发布。
|
||||
- 本项目不接受捐赠,没有在任何地方发布捐赠信息页面,软件本身不收费也不提供任何收费相关服务,请仔细辨别避免误导。
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/jxxghp/MoviePilot/graphs/contributors">
|
||||
|
||||
30
app/actions/note.py
Normal file
30
app/actions/note.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from app.actions import BaseAction
|
||||
from app.schemas import ActionContext
|
||||
|
||||
|
||||
class NoteAction(BaseAction):
|
||||
"""
|
||||
备注
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "备注"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "给工作流添加备注"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return {}
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return True
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
return context
|
||||
@@ -1,8 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
from app.api.endpoints import login, user, webhook, message, site, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, monitoring
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -28,3 +28,4 @@ api_router.include_router(discover.router, prefix="/discover", tags=["discover"]
|
||||
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
|
||||
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
|
||||
api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"])
|
||||
api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"])
|
||||
|
||||
@@ -11,63 +11,63 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||
def bangumi_credits(bangumiid: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_credits(bangumiid: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi演职员表
|
||||
"""
|
||||
persons = BangumiChain().bangumi_credits(bangumiid)
|
||||
persons = await BangumiChain().async_bangumi_credits(bangumiid)
|
||||
if persons:
|
||||
return persons[(page - 1) * count: page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_recommend(bangumiid: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_recommend(bangumiid: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi推荐
|
||||
"""
|
||||
medias = BangumiChain().bangumi_recommend(bangumiid)
|
||||
medias = await BangumiChain().async_bangumi_recommend(bangumiid)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def bangumi_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return BangumiChain().person_detail(person_id=person_id)
|
||||
return await BangumiChain().async_person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = BangumiChain().person_credits(person_id=person_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 []
|
||||
|
||||
|
||||
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
|
||||
def bangumi_info(bangumiid: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_info(bangumiid: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi详情
|
||||
"""
|
||||
info = BangumiChain().bangumi_info(bangumiid)
|
||||
info = await BangumiChain().async_bangumi_info(bangumiid)
|
||||
if info:
|
||||
return MediaInfo(bangumi_info=info).to_dict()
|
||||
else:
|
||||
|
||||
@@ -111,7 +111,7 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
|
||||
|
||||
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||
def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询后台服务信息
|
||||
"""
|
||||
@@ -119,7 +119,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo])
|
||||
def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
@@ -127,12 +127,13 @@ def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
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:
|
||||
"""
|
||||
查询文件整理统计信息
|
||||
"""
|
||||
transfer_stat = TransferHistory.statistic(db, days)
|
||||
transfer_stat = await TransferHistory.async_statistic(db, days)
|
||||
return [stat[1] for stat in transfer_stat]
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import DiscoverSourceEventData
|
||||
from app.schemas.types import ChainEventType, MediaType
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -31,100 +31,100 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo])
|
||||
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 = BangumiChain().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])
|
||||
def douban_movies(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_movies(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = DoubanChain().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])
|
||||
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:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = DoubanChain().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])
|
||||
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电影信息
|
||||
"""
|
||||
movies = TmdbChain().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])
|
||||
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 = TmdbChain().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 []
|
||||
|
||||
@@ -12,54 +12,54 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def douban_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return DoubanChain().person_detail(person_id=person_id)
|
||||
return await DoubanChain().async_person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def douban_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = DoubanChain().person_credits(person_id=person_id, page=page)
|
||||
medias = await DoubanChain().async_person_credits(person_id=person_id, page=page)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
return DoubanChain().movie_credits(doubanid=doubanid)
|
||||
return await DoubanChain().async_movie_credits(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
return DoubanChain().tv_credits(doubanid=doubanid)
|
||||
return await DoubanChain().async_tv_credits(doubanid=doubanid)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def douban_recommend(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_recommend(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||
medias = await DoubanChain().async_movie_recommend(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
medias = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||
medias = await DoubanChain().async_tv_recommend(doubanid=doubanid)
|
||||
else:
|
||||
return []
|
||||
if medias:
|
||||
@@ -68,12 +68,12 @@ def douban_recommend(doubanid: str,
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
def douban_info(doubanid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_info(doubanid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询豆瓣媒体信息
|
||||
"""
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
|
||||
doubaninfo = await DoubanChain().async_douban_info(doubanid=doubanid)
|
||||
if doubaninfo:
|
||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
||||
else:
|
||||
|
||||
@@ -116,7 +116,7 @@ def stop(hashString: str, name: Optional[str] = None,
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
|
||||
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用下载器
|
||||
"""
|
||||
|
||||
@@ -2,51 +2,52 @@ from typing import List, Any, Optional
|
||||
|
||||
import jieba
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db, get_db
|
||||
from app.db.models import User
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.db.user_oper import get_current_active_superuser_async, get_current_active_superuser
|
||||
from app.schemas.types import EventType, MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory])
|
||||
def download_history(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def download_history(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载历史记录
|
||||
"""
|
||||
return DownloadHistory.list_by_page(db, page, count)
|
||||
return await DownloadHistory.async_list_by_page(db, page, count)
|
||||
|
||||
|
||||
@router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response)
|
||||
def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
db: Session = Depends(get_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:
|
||||
"""
|
||||
删除下载历史记录
|
||||
"""
|
||||
DownloadHistory.delete(db, history_in.id)
|
||||
await DownloadHistory.async_delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
|
||||
def transfer_history(title: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
status: Optional[bool] = None,
|
||||
db: Session = Depends(get_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:
|
||||
"""
|
||||
查询整理记录
|
||||
"""
|
||||
@@ -60,12 +61,12 @@ def transfer_history(title: Optional[str] = None,
|
||||
if title:
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.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 = TransferHistory.list_by_page(db, page=page, count=count, status=status)
|
||||
total = TransferHistory.count(db, 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={
|
||||
@@ -79,7 +80,7 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
deletesrc: Optional[bool] = False,
|
||||
deletedest: Optional[bool] = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
删除整理记录
|
||||
"""
|
||||
@@ -111,10 +112,10 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
|
||||
|
||||
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
async def empty_transfer_history(db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
清空整理记录
|
||||
"""
|
||||
TransferHistory.truncate(db)
|
||||
await TransferHistory.async_truncate(db)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -8,7 +8,7 @@ from app import schemas
|
||||
from app.chain.user import UserChain
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.wallpaper import WallpaperHelper
|
||||
|
||||
router = APIRouter()
|
||||
@@ -44,7 +44,7 @@ def login_access_token(
|
||||
user_name=user_or_message.name,
|
||||
avatar=user_or_message.avatar,
|
||||
level=level,
|
||||
permissions= user_or_message.permissions or {},
|
||||
permissions=user_or_message.permissions or {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,61 +18,61 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
|
||||
def recognize(title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def recognize(title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
metainfo = MetaInfo(title, subtitle)
|
||||
mediainfo = MediaChain().recognize_by_meta(metainfo)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
|
||||
if mediainfo:
|
||||
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||
def recognize2(_: Annotated[str, Depends(verify_apitoken)],
|
||||
title: str,
|
||||
subtitle: Optional[str] = None
|
||||
) -> Any:
|
||||
async def recognize2(_: Annotated[str, Depends(verify_apitoken)],
|
||||
title: str,
|
||||
subtitle: Optional[str] = None
|
||||
) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return recognize(title, subtitle)
|
||||
return await recognize(title, subtitle)
|
||||
|
||||
|
||||
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
|
||||
def recognize_file(path: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def recognize_file(path: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain().recognize_by_path(path)
|
||||
context = await MediaChain().async_recognize_by_path(path)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||
def recognize_file2(path: str,
|
||||
_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
async def recognize_file2(path: str,
|
||||
_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return recognize_file(path)
|
||||
return await recognize_file(path)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||
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:人物信息
|
||||
"""
|
||||
@@ -86,14 +86,15 @@ def search(title: str,
|
||||
return obj.source
|
||||
|
||||
result = []
|
||||
media_chain = MediaChain()
|
||||
if type == "media":
|
||||
_, medias = MediaChain().search(title=title)
|
||||
_, medias = await media_chain.async_search(title=title)
|
||||
if medias:
|
||||
result = [media.to_dict() for media in medias]
|
||||
elif type == "collection":
|
||||
result = MediaChain().search_collections(name=title)
|
||||
result = await media_chain.async_search_collections(name=title)
|
||||
else:
|
||||
result = MediaChain().search_persons(name=title)
|
||||
result = await media_chain.async_search_persons(name=title)
|
||||
if result:
|
||||
# 按设置的顺序对结果进行排序
|
||||
setting_order = settings.SEARCH_SOURCE.split(',') or []
|
||||
@@ -101,7 +102,8 @@ def search(title: str,
|
||||
for index, source in enumerate(setting_order):
|
||||
sort_order[source] = index
|
||||
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
|
||||
return result[(page - 1) * count:page * count]
|
||||
return result[(page - 1) * count:page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response)
|
||||
@@ -123,13 +125,13 @@ def scrape(fileitem: schemas.FileItem,
|
||||
if storage == "local":
|
||||
if not scrape_path.exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削
|
||||
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
|
||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||
|
||||
|
||||
@router.get("/category", summary="查询自动分类配置", response_model=dict)
|
||||
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询自动分类配置
|
||||
"""
|
||||
@@ -137,37 +139,37 @@ def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/group/seasons/{episode_group}", summary="查询剧集组季信息", response_model=List[schemas.MediaSeason])
|
||||
def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询剧集组季信息(themoviedb)
|
||||
"""
|
||||
return TmdbChain().tmdb_group_seasons(group_id=episode_group)
|
||||
return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group)
|
||||
|
||||
|
||||
@router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict])
|
||||
def seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体剧集组列表(themoviedb)
|
||||
"""
|
||||
mediainfo = MediaChain().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])
|
||||
def seasons(mediaid: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: str = None,
|
||||
season: int = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def seasons(mediaid: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: str = None,
|
||||
season: int = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体季信息
|
||||
"""
|
||||
if mediaid:
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = int(mediaid[5:])
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
||||
if seasons_info:
|
||||
if season:
|
||||
return [sea for sea in seasons_info if sea.season_number == season]
|
||||
@@ -176,17 +178,17 @@ def seasons(mediaid: Optional[str] = None,
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
mediainfo = MediaChain().recognize_media(meta, mtype=MediaType.TV)
|
||||
mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV)
|
||||
if mediainfo:
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=mediainfo.tmdb_id)
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
|
||||
if seasons_info:
|
||||
if season:
|
||||
return [sea for sea in seasons_info if sea.season_number == season]
|
||||
return seasons_info
|
||||
else:
|
||||
sea = season or 1
|
||||
return schemas.MediaSeason(
|
||||
return [schemas.MediaSeason(
|
||||
season_number=sea,
|
||||
poster_path=mediainfo.poster_path,
|
||||
name=f"第 {sea} 季",
|
||||
@@ -194,39 +196,40 @@ def seasons(mediaid: Optional[str] = None,
|
||||
overview=mediainfo.overview,
|
||||
vote_average=mediainfo.vote_average,
|
||||
episode_count=mediainfo.number_of_episodes
|
||||
)
|
||||
)]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
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: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
mediainfo = None
|
||||
mediachain = MediaChain()
|
||||
if mediaid.startswith("tmdb:"):
|
||||
mediainfo = MediaChain().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 = MediaChain().recognize_media(doubanid=mediaid[7:], mtype=mtype)
|
||||
mediainfo = await mediachain.async_recognize_media(doubanid=mediaid[7:], mtype=mtype)
|
||||
elif mediaid.startswith("bangumi:"):
|
||||
mediainfo = MediaChain().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
|
||||
)
|
||||
event = eventmanager.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 = MediaChain().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 = MediaChain().recognize_media(doubanid=new_id, mtype=mtype)
|
||||
mediainfo = await mediachain.async_recognize_media(doubanid=new_id, mtype=mtype)
|
||||
elif title:
|
||||
# 使用名称识别兜底
|
||||
meta = MetaInfo(title)
|
||||
@@ -234,10 +237,10 @@ def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str
|
||||
meta.year = year
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
mediainfo = MediaChain().recognize_media(meta=meta)
|
||||
mediainfo = await mediachain.async_recognize_media(meta=meta)
|
||||
# 识别
|
||||
if mediainfo:
|
||||
MediaChain().obtain_images(mediainfo)
|
||||
await mediachain.async_obtain_images(mediainfo)
|
||||
return mediainfo.to_dict()
|
||||
|
||||
return schemas.MediaInfo()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any, List, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
@@ -9,7 +9,7 @@ from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.db.models import MediaServerItem
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -43,13 +43,13 @@ def play_item(itemid: str, _: schemas.TokenPayload = Depends(verify_token)) -> s
|
||||
|
||||
|
||||
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
|
||||
def exists_local(title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
season: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def exists_local(title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
season: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
@@ -59,7 +59,7 @@ def exists_local(title: Optional[str] = None,
|
||||
# 返回对象
|
||||
ret_info = {}
|
||||
# 本地数据库是否存在
|
||||
exist: MediaServerItem = MediaServerOper(db).exists(
|
||||
exist: MediaServerItem = await MediaServerOper(db).async_exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
)
|
||||
if exist:
|
||||
@@ -148,7 +148,7 @@ def library(server: str, hidden: Optional[bool] = False,
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
|
||||
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用媒体服务器
|
||||
"""
|
||||
|
||||
@@ -3,14 +3,14 @@ from typing import Union, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db
|
||||
from app.db.models import User
|
||||
from app.db.models.message import Message
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
@@ -58,15 +58,15 @@ def web_message(text: str, current_user: User = Depends(get_current_active_super
|
||||
|
||||
|
||||
@router.get("/web", summary="获取WEB消息", response_model=List[dict])
|
||||
def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
|
||||
db: Session = Depends(get_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消息列表
|
||||
"""
|
||||
ret_messages = []
|
||||
messages = Message.list_by_page(db, page=page, count=count)
|
||||
messages = await Message.async_list_by_page(db, page=page, count=count)
|
||||
for message in messages:
|
||||
try:
|
||||
ret_messages.append(message.to_dict())
|
||||
@@ -106,7 +106,7 @@ def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int],
|
||||
return str(err)
|
||||
|
||||
|
||||
def vocechat_verify() -> Any:
|
||||
async def vocechat_verify() -> Any:
|
||||
"""
|
||||
VoceChat验证响应
|
||||
"""
|
||||
@@ -128,7 +128,7 @@ def incoming_verify(token: Optional[str] = None, echostr: Optional[str] = None,
|
||||
|
||||
|
||||
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
|
||||
def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
async def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
客户端webpush通知订阅
|
||||
"""
|
||||
|
||||
409
app/api/endpoints/monitoring.py
Normal file
409
app/api/endpoints/monitoring.py
Normal file
@@ -0,0 +1,409 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app import schemas
|
||||
from app.core.security import verify_apitoken
|
||||
from app.monitoring import monitor, get_metrics_response
|
||||
from app.schemas.monitoring import (
|
||||
PerformanceSnapshot,
|
||||
EndpointStats,
|
||||
ErrorRequest,
|
||||
MonitoringOverview
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/overview", summary="获取监控概览", response_model=schemas.MonitoringOverview)
|
||||
def get_overview(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取完整的监控概览信息
|
||||
"""
|
||||
# 获取性能快照
|
||||
performance = monitor.get_performance_snapshot()
|
||||
|
||||
# 获取最活跃端点
|
||||
top_endpoints = monitor.get_top_endpoints(limit=10)
|
||||
|
||||
# 获取最近错误
|
||||
recent_errors = monitor.get_recent_errors(limit=20)
|
||||
|
||||
# 检查告警
|
||||
alerts = monitor.check_alerts()
|
||||
|
||||
return MonitoringOverview(
|
||||
performance=PerformanceSnapshot(
|
||||
timestamp=performance.timestamp,
|
||||
cpu_usage=performance.cpu_usage,
|
||||
memory_usage=performance.memory_usage,
|
||||
active_requests=performance.active_requests,
|
||||
request_rate=performance.request_rate,
|
||||
avg_response_time=performance.avg_response_time,
|
||||
error_rate=performance.error_rate,
|
||||
slow_requests=performance.slow_requests
|
||||
),
|
||||
top_endpoints=[EndpointStats(**endpoint) for endpoint in top_endpoints],
|
||||
recent_errors=[ErrorRequest(**error) for error in recent_errors],
|
||||
alerts=alerts
|
||||
)
|
||||
|
||||
|
||||
@router.get("/performance", summary="获取性能快照", response_model=schemas.PerformanceSnapshot)
|
||||
def get_performance(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取当前性能快照
|
||||
"""
|
||||
snapshot = monitor.get_performance_snapshot()
|
||||
return PerformanceSnapshot(
|
||||
timestamp=snapshot.timestamp,
|
||||
cpu_usage=snapshot.cpu_usage,
|
||||
memory_usage=snapshot.memory_usage,
|
||||
active_requests=snapshot.active_requests,
|
||||
request_rate=snapshot.request_rate,
|
||||
avg_response_time=snapshot.avg_response_time,
|
||||
error_rate=snapshot.error_rate,
|
||||
slow_requests=snapshot.slow_requests
|
||||
)
|
||||
|
||||
|
||||
@router.get("/endpoints", summary="获取端点统计", response_model=List[schemas.EndpointStats])
|
||||
def get_endpoints(
|
||||
limit: int = Query(10, ge=1, le=50, description="返回的端点数量"),
|
||||
_: str = Depends(verify_apitoken)
|
||||
) -> Any:
|
||||
"""
|
||||
获取最活跃的API端点统计
|
||||
"""
|
||||
endpoints = monitor.get_top_endpoints(limit=limit)
|
||||
return [EndpointStats(**endpoint) for endpoint in endpoints]
|
||||
|
||||
|
||||
@router.get("/errors", summary="获取错误请求", response_model=List[schemas.ErrorRequest])
|
||||
def get_errors(
|
||||
limit: int = Query(20, ge=1, le=100, description="返回的错误数量"),
|
||||
_: str = Depends(verify_apitoken)
|
||||
) -> Any:
|
||||
"""
|
||||
获取最近的错误请求记录
|
||||
"""
|
||||
errors = monitor.get_recent_errors(limit=limit)
|
||||
return [ErrorRequest(**error) for error in errors]
|
||||
|
||||
|
||||
@router.get("/alerts", summary="获取告警信息", response_model=List[str])
|
||||
def get_alerts(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取当前告警信息
|
||||
"""
|
||||
return monitor.check_alerts()
|
||||
|
||||
|
||||
@router.get("/metrics", summary="Prometheus指标")
|
||||
def get_prometheus_metrics(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取Prometheus格式的监控指标
|
||||
"""
|
||||
return get_metrics_response()
|
||||
|
||||
|
||||
@router.get("/dashboard", summary="监控仪表板", response_class=HTMLResponse)
|
||||
def get_dashboard(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取实时监控仪表板HTML页面
|
||||
"""
|
||||
return HTMLResponse(content="""
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MoviePilot 性能监控仪表板</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.metric-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #2196F3;
|
||||
}
|
||||
.metric-label {
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.chart-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.alerts {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.alert-item {
|
||||
color: #856404;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎬 MoviePilot 性能监控仪表板</h1>
|
||||
<button class="refresh-btn" onclick="refreshData()">刷新数据</button>
|
||||
</div>
|
||||
|
||||
<div id="alerts" class="alerts" style="display: none;">
|
||||
<h3>⚠️ 告警信息</h3>
|
||||
<div id="alerts-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="cpu-usage">--</div>
|
||||
<div class="metric-label">CPU使用率 (%)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="memory-usage">--</div>
|
||||
<div class="metric-label">内存使用率 (%)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="active-requests">--</div>
|
||||
<div class="metric-label">活跃请求数</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="request-rate">--</div>
|
||||
<div class="metric-label">请求率 (req/min)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="avg-response-time">--</div>
|
||||
<div class="metric-label">平均响应时间 (s)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="error-rate">--</div>
|
||||
<div class="metric-label">错误率 (%)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>📊 性能趋势</h3>
|
||||
<canvas id="performanceChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>🔥 最活跃端点</h3>
|
||||
<canvas id="endpointsChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let performanceChart, endpointsChart;
|
||||
let performanceData = {
|
||||
labels: [],
|
||||
cpu: [],
|
||||
memory: [],
|
||||
requests: []
|
||||
};
|
||||
|
||||
// 初始化图表
|
||||
function initCharts() {
|
||||
const ctx1 = document.getElementById('performanceChart').getContext('2d');
|
||||
performanceChart = new Chart(ctx1, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: performanceData.labels,
|
||||
datasets: [{
|
||||
label: 'CPU使用率 (%)',
|
||||
data: performanceData.cpu,
|
||||
borderColor: '#2196F3',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: '内存使用率 (%)',
|
||||
data: performanceData.memory,
|
||||
borderColor: '#4CAF50',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: '活跃请求数',
|
||||
data: performanceData.requests,
|
||||
borderColor: '#FF9800',
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const ctx2 = document.getElementById('endpointsChart').getContext('2d');
|
||||
endpointsChart = new Chart(ctx2, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '请求数',
|
||||
data: [],
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.8)'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新性能数据
|
||||
function updatePerformanceData(data) {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
|
||||
performanceData.labels.push(now);
|
||||
performanceData.cpu.push(data.performance.cpu_usage);
|
||||
performanceData.memory.push(data.performance.memory_usage);
|
||||
performanceData.requests.push(data.performance.active_requests);
|
||||
|
||||
// 保持最近20个数据点
|
||||
if (performanceData.labels.length > 20) {
|
||||
performanceData.labels.shift();
|
||||
performanceData.cpu.shift();
|
||||
performanceData.memory.shift();
|
||||
performanceData.requests.shift();
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
performanceChart.data.labels = performanceData.labels;
|
||||
performanceChart.data.datasets[0].data = performanceData.cpu;
|
||||
performanceChart.data.datasets[1].data = performanceData.memory;
|
||||
performanceChart.data.datasets[2].data = performanceData.requests;
|
||||
performanceChart.update();
|
||||
|
||||
// 更新端点图表
|
||||
const endpointLabels = data.top_endpoints.map(e => e.endpoint.substring(0, 20));
|
||||
const endpointData = data.top_endpoints.map(e => e.count);
|
||||
|
||||
endpointsChart.data.labels = endpointLabels;
|
||||
endpointsChart.data.datasets[0].data = endpointData;
|
||||
endpointsChart.update();
|
||||
}
|
||||
|
||||
// 更新指标显示
|
||||
function updateMetrics(data) {
|
||||
document.getElementById('cpu-usage').textContent = data.performance.cpu_usage.toFixed(1);
|
||||
document.getElementById('memory-usage').textContent = data.performance.memory_usage.toFixed(1);
|
||||
document.getElementById('active-requests').textContent = data.performance.active_requests;
|
||||
document.getElementById('request-rate').textContent = data.performance.request_rate.toFixed(0);
|
||||
document.getElementById('avg-response-time').textContent = data.performance.avg_response_time.toFixed(3);
|
||||
document.getElementById('error-rate').textContent = (data.performance.error_rate * 100).toFixed(2);
|
||||
}
|
||||
|
||||
// 更新告警
|
||||
function updateAlerts(alerts) {
|
||||
const alertsDiv = document.getElementById('alerts');
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
|
||||
if (alerts.length > 0) {
|
||||
alertsDiv.style.display = 'block';
|
||||
alertsList.innerHTML = alerts.map(alert =>
|
||||
`<div class="alert-item">⚠️ ${alert}</div>`
|
||||
).join('');
|
||||
} else {
|
||||
alertsDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URL中的token参数
|
||||
function getTokenFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('token');
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
try {
|
||||
const token = getTokenFromUrl();
|
||||
if (!token) {
|
||||
console.error('未找到token参数');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/monitoring/overview?token=${token}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
updateMetrics(data);
|
||||
updatePerformanceData(data);
|
||||
updateAlerts(data.alerts);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取监控数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initCharts();
|
||||
refreshData();
|
||||
|
||||
// 每5秒自动刷新
|
||||
setInterval(refreshData, 5000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
@@ -2,17 +2,21 @@ import mimetypes
|
||||
import shutil
|
||||
from typing import Annotated, Any, List, Optional
|
||||
|
||||
import aiofiles
|
||||
from aiopath import AsyncPath
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from starlette import status
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.command import Command
|
||||
from app.core.config import settings
|
||||
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
|
||||
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
|
||||
@@ -136,13 +140,14 @@ def register_plugin(plugin_id: str):
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
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
|
||||
"""
|
||||
# 本地插件
|
||||
local_plugins = PluginManager().get_local_plugins()
|
||||
plugin_manager = PluginManager()
|
||||
local_plugins = plugin_manager.get_local_plugins()
|
||||
# 已安装插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||
if state == "installed":
|
||||
@@ -151,7 +156,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
# 在线插件
|
||||
online_plugins = PluginManager().get_online_plugins(force)
|
||||
online_plugins = await plugin_manager.async_get_online_plugins(force)
|
||||
if not online_plugins:
|
||||
# 没有获取在线插件
|
||||
if state == "market":
|
||||
@@ -184,7 +189,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
def installed(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def installed(_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询用户已安装插件清单
|
||||
"""
|
||||
@@ -192,15 +197,15 @@ def installed(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -
|
||||
|
||||
|
||||
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
插件安装统计
|
||||
"""
|
||||
return PluginHelper().get_statistic()
|
||||
return await PluginHelper().async_get_statistic()
|
||||
|
||||
|
||||
@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response)
|
||||
def reload_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重新加载插件
|
||||
"""
|
||||
@@ -212,22 +217,23 @@ def reload_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install(plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
force: Optional[bool] = False,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def install(plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
force: Optional[bool] = False,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
# 已安装插件
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
|
||||
plugin_helper = PluginHelper()
|
||||
if not force and plugin_id in PluginManager().get_plugin_ids():
|
||||
PluginHelper().install_reg(pid=plugin_id)
|
||||
await plugin_helper.async_install_reg(pid=plugin_id)
|
||||
else:
|
||||
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||
if repo_url:
|
||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||
state, msg = await plugin_helper.async_install(pid=plugin_id, repo_url=repo_url)
|
||||
# 安装失败则直接响应
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=msg)
|
||||
@@ -238,14 +244,14 @@ def install(plugin_id: str,
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
# 保存设置
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
await run_in_threadpool(reload_plugin, plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
|
||||
def remotes(token: str) -> Any:
|
||||
async def remotes(token: str) -> Any:
|
||||
"""
|
||||
获取插件联邦组件列表
|
||||
"""
|
||||
@@ -256,11 +262,12 @@ def remotes(token: str) -> Any:
|
||||
|
||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||
def plugin_form(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
_: User = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件配置表单或Vue组件URL
|
||||
"""
|
||||
plugin_instance = PluginManager().running_plugins.get(plugin_id)
|
||||
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} 不存在或未加载")
|
||||
|
||||
@@ -271,7 +278,7 @@ def plugin_form(plugin_id: str,
|
||||
return {
|
||||
"render_mode": render_mode,
|
||||
"conf": conf,
|
||||
"model": PluginManager().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)}")
|
||||
@@ -279,7 +286,7 @@ def plugin_form(plugin_id: str,
|
||||
|
||||
|
||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
def plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件数据页面
|
||||
"""
|
||||
@@ -328,7 +335,7 @@ def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()]
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
根据插件ID重置插件配置及数据
|
||||
"""
|
||||
@@ -343,7 +350,7 @@ def reset_plugin(plugin_id: str,
|
||||
|
||||
|
||||
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
|
||||
def plugin_static_file(plugin_id: str, filepath: str):
|
||||
async def plugin_static_file(plugin_id: str, filepath: str):
|
||||
"""
|
||||
获取插件静态文件
|
||||
"""
|
||||
@@ -352,11 +359,11 @@ def plugin_static_file(plugin_id: str, filepath: str):
|
||||
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 = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||
plugin_base_dir = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
|
||||
plugin_file_path = plugin_base_dir / filepath
|
||||
if not plugin_file_path.exists():
|
||||
if not await plugin_file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在")
|
||||
if not plugin_file_path.is_file():
|
||||
if not await plugin_file_path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件")
|
||||
|
||||
# 判断 MIME 类型
|
||||
@@ -371,14 +378,25 @@ def plugin_static_file(plugin_id: str, filepath: str):
|
||||
response_type = 'application/octet-stream'
|
||||
|
||||
try:
|
||||
return FileResponse(plugin_file_path, media_type=response_type)
|
||||
# 异步生成器函数,用于流式读取文件
|
||||
async def file_generator():
|
||||
async with aiofiles.open(plugin_file_path, mode='rb') as file:
|
||||
# 8KB 块大小
|
||||
while chunk := await file.read(8192):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
file_generator(),
|
||||
media_type=response_type,
|
||||
headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating/sending FileResponse 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)
|
||||
def get_plugin_folders(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
async def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> dict:
|
||||
"""
|
||||
获取插件文件夹分组配置
|
||||
"""
|
||||
@@ -391,7 +409,7 @@ def get_plugin_folders(_: schemas.TokenPayload = Depends(get_current_active_supe
|
||||
|
||||
|
||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
|
||||
def save_plugin_folders(folders: dict, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
保存插件文件夹分组配置
|
||||
"""
|
||||
@@ -404,7 +422,8 @@ def save_plugin_folders(folders: dict, _: schemas.TokenPayload = Depends(get_cur
|
||||
|
||||
|
||||
@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response)
|
||||
def create_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def create_plugin_folder(folder_name: str,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
创建新的插件文件夹
|
||||
"""
|
||||
@@ -418,33 +437,35 @@ def create_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response)
|
||||
def delete_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def delete_plugin_folder(folder_name: str,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
删除插件文件夹
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
if folder_name in folders:
|
||||
del folders[folder_name]
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
|
||||
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)
|
||||
def update_folder_plugins(folder_name: str, plugin_ids: List[str], _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
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
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新")
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||
def plugin_config(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
async def plugin_config(plugin_id: str,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件配置信息
|
||||
"""
|
||||
@@ -453,7 +474,7 @@ def plugin_config(plugin_id: str,
|
||||
|
||||
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
|
||||
def set_plugin_config(plugin_id: str, conf: dict,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
更新插件配置
|
||||
"""
|
||||
@@ -469,7 +490,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
|
||||
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
|
||||
def uninstall_plugin(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
卸载插件
|
||||
"""
|
||||
@@ -510,7 +531,7 @@ def uninstall_plugin(plugin_id: str,
|
||||
@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response)
|
||||
def clone_plugin(plugin_id: str,
|
||||
clone_data: dict,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
创建插件分身
|
||||
"""
|
||||
|
||||
@@ -3,11 +3,11 @@ from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.schemas import RecommendSourceEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,163 +29,163 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/bangumi_calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_calendar(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_calendar(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览Bangumi每日放送
|
||||
"""
|
||||
return RecommendChain().bangumi_calendar(page=page, count=count)
|
||||
return await RecommendChain().async_bangumi_calendar(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
def douban_showing(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_showing(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
return RecommendChain().douban_movie_showing(page=page, count=count)
|
||||
return await RecommendChain().async_douban_movie_showing(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
def douban_movies(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_movies(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
return RecommendChain().douban_movies(sort=sort, tags=tags, page=page, count=count)
|
||||
return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
def douban_tvs(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return RecommendChain().douban_tvs(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
def douban_movie_top250(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return RecommendChain().douban_movie_top250(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def douban_tv_weekly_chinese(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
return RecommendChain().douban_tv_weekly_chinese(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def douban_tv_weekly_global(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
return RecommendChain().douban_tv_weekly_global(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
def douban_tv_animation(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
return RecommendChain().douban_tv_animation(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
def douban_movie_hot(page: Optional[int] = 1,
|
||||
async def douban_tvs(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
async def douban_movie_top250(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return await RecommendChain().async_douban_movie_top250(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_weekly_chinese(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_weekly_chinese(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_weekly_global(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_weekly_global(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_animation(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_animation(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
async def douban_movie_hot(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
return RecommendChain().douban_movie_hot(page=page, count=count)
|
||||
return await RecommendChain().async_douban_movie_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||
def douban_tv_hot(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_tv_hot(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
return RecommendChain().douban_tv_hot(page=page, count=count)
|
||||
return await RecommendChain().async_douban_tv_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_movies(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_movies(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
return RecommendChain().tmdb_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return await RecommendChain().async_tmdb_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_tvs(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
return RecommendChain().tmdb_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return await RecommendChain().async_tmdb_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_trending(page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
return RecommendChain().tmdb_trending(page=page)
|
||||
return await RecommendChain().async_tmdb_trending(page=page)
|
||||
|
||||
@@ -16,23 +16,23 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||
def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询搜索结果
|
||||
"""
|
||||
torrents = SearchChain().last_search_results()
|
||||
torrents = await SearchChain().async_last_search_results()
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
|
||||
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:
|
||||
"""
|
||||
@@ -49,55 +49,59 @@ def search_by_id(mediaid: str,
|
||||
else:
|
||||
site_list = None
|
||||
torrents = None
|
||||
media_chain = MediaChain()
|
||||
search_chain = SearchChain()
|
||||
# 根据前缀识别媒体ID
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = int(mediaid.replace("tmdb:", ""))
|
||||
if settings.RECOGNIZE_SOURCE == "douban":
|
||||
# 通过TMDBID识别豆瓣ID
|
||||
doubaninfo = MediaChain().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 = SearchChain().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 = SearchChain().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 = MediaChain().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 = SearchChain().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:
|
||||
torrents = SearchChain().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 = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
||||
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
||||
if tmdbinfo:
|
||||
torrents = SearchChain().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 = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
||||
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
||||
if doubaninfo:
|
||||
torrents = SearchChain().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:
|
||||
@@ -106,18 +110,18 @@ def search_by_id(mediaid: str,
|
||||
mediaid=mediaid,
|
||||
convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = eventmanager.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 = SearchChain().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 = SearchChain().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")
|
||||
@@ -130,14 +134,16 @@ def search_by_id(mediaid: str,
|
||||
if media_season:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
mediainfo = MediaChain().recognize_media(meta=meta)
|
||||
mediainfo = await media_chain.async_recognize_media(meta=meta)
|
||||
if mediainfo:
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
torrents = SearchChain().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 = SearchChain().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="未搜索到任何资源")
|
||||
@@ -146,16 +152,18 @@ def search_by_id(mediaid: str,
|
||||
|
||||
|
||||
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
|
||||
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 = SearchChain().search_by_title(title=keyword, page=page,
|
||||
sites=[int(site) for site in sites.split(",") if site] if sites else None,
|
||||
cache_local=True)
|
||||
torrents = await SearchChain().async_search_by_title(
|
||||
title=keyword, page=page,
|
||||
sites=[int(site) for site in sites.split(",") if site] if sites else None,
|
||||
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])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import List, Any, Dict, Optional
|
||||
|
||||
from app.helper.sites import SitesHelper
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
@@ -10,10 +10,10 @@ from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.command import Command
|
||||
from app.core.event import EventManager
|
||||
from app.core.event import eventmanager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db import get_db, get_async_db
|
||||
from app.db.models import User
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
@@ -21,7 +21,8 @@ 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
|
||||
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
|
||||
from app.utils.string import StringUtils
|
||||
@@ -30,20 +31,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
||||
def read_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = 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]:
|
||||
"""
|
||||
获取站点列表
|
||||
"""
|
||||
return Site.list_order_by_pri(db)
|
||||
return await Site.async_list_order_by_pri(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response)
|
||||
def add_site(
|
||||
async def add_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
site_in: schemas.Site,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
新增站点
|
||||
@@ -53,10 +54,10 @@ def add_site(
|
||||
if SitesHelper().auth_level < 2:
|
||||
return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!")
|
||||
domain = StringUtils.get_url_domain(site_in.url)
|
||||
site_info = SitesHelper().get_indexer(domain)
|
||||
site_info = await SitesHelper().async_get_indexer(domain)
|
||||
if not site_info:
|
||||
return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确")
|
||||
if Site.get_by_domain(db, domain):
|
||||
if await Site.async_get_by_domain(db, domain):
|
||||
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
||||
# 保存站点信息
|
||||
site_in.domain = domain
|
||||
@@ -69,39 +70,39 @@ def add_site(
|
||||
site = Site(**site_in.dict())
|
||||
site.create(db)
|
||||
# 通知站点更新
|
||||
EventManager().send_event(EventType.SiteUpdated, {
|
||||
await eventmanager.async_send_event(EventType.SiteUpdated, {
|
||||
"domain": domain
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/", summary="更新站点", response_model=schemas.Response)
|
||||
def update_site(
|
||||
async def update_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
site_in: schemas.Site,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
更新站点信息
|
||||
"""
|
||||
site = Site.get(db, site_in.id)
|
||||
site = await Site.async_get(db, site_in.id)
|
||||
if not site:
|
||||
return schemas.Response(success=False, message="站点不存在")
|
||||
# 校正地址格式
|
||||
_scheme, _netloc = StringUtils.get_url_netloc(site_in.url)
|
||||
site_in.url = f"{_scheme}://{_netloc}/"
|
||||
site.update(db, site_in.dict())
|
||||
await site.async_update(db, site_in.dict())
|
||||
# 通知站点更新
|
||||
EventManager().send_event(EventType.SiteUpdated, {
|
||||
await eventmanager.async_send_event(EventType.SiteUpdated, {
|
||||
"domain": site_in.domain
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
运行CookieCloud同步站点信息
|
||||
"""
|
||||
@@ -110,7 +111,7 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
|
||||
|
||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||
def reset(db: Session = Depends(get_db),
|
||||
def reset(db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
清空所有站点数据并重新同步CookieCloud站点信息
|
||||
@@ -121,25 +122,25 @@ def reset(db: Session = 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)
|
||||
def update_sites_priority(
|
||||
async def update_sites_priority(
|
||||
priorities: List[dict],
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
批量更新站点优先级
|
||||
"""
|
||||
for priority in priorities:
|
||||
site = Site.get(db, priority.get("id"))
|
||||
site = await Site.async_get(db, priority.get("id"))
|
||||
if site:
|
||||
site.update(db, {"pri": priority.get("pri")})
|
||||
await site.async_update(db, {"pri": priority.get("pri")})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -150,7 +151,7 @@ def update_cookie(
|
||||
password: str,
|
||||
code: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
使用用户密码更新站点Cookie
|
||||
"""
|
||||
@@ -173,7 +174,7 @@ def update_cookie(
|
||||
def refresh_userdata(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
刷新站点用户数据
|
||||
"""
|
||||
@@ -191,34 +192,34 @@ def refresh_userdata(
|
||||
|
||||
|
||||
@router.get("/userdata/latest", summary="查询所有站点最新用户数据", response_model=List[schemas.SiteUserData])
|
||||
def read_userdata_latest(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def read_userdata_latest(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询所有站点最新用户数据
|
||||
"""
|
||||
user_datas = SiteUserData.get_latest(db)
|
||||
user_datas = await SiteUserData.async_get_latest(db)
|
||||
if not user_datas:
|
||||
return []
|
||||
return [user_data.to_dict() for user_data in user_datas]
|
||||
|
||||
|
||||
@router.get("/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response)
|
||||
def read_userdata(
|
||||
async def read_userdata(
|
||||
site_id: int,
|
||||
workdate: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询站点用户数据
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
user_data = SiteUserData.get_by_domain(db, domain=site.domain, workdate=workdate)
|
||||
user_data = await SiteUserData.async_get_by_domain(db, domain=site.domain, workdate=workdate)
|
||||
if not user_data:
|
||||
return schemas.Response(success=False, data=[])
|
||||
return schemas.Response(success=True, data=user_data)
|
||||
@@ -242,19 +243,19 @@ def test_site(site_id: int,
|
||||
|
||||
|
||||
@router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response)
|
||||
def site_icon(site_id: int,
|
||||
db: Session = Depends(get_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
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
icon = SiteIcon.get_by_domain(db, site.domain)
|
||||
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={
|
||||
@@ -263,19 +264,19 @@ def site_icon(site_id: int,
|
||||
|
||||
|
||||
@router.get("/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory])
|
||||
def site_category(site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def site_category(site_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取站点分类
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
indexer = SitesHelper().get_indexer(site.domain)
|
||||
indexer = await SitesHelper().async_get_indexer(site.domain)
|
||||
if not indexer:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -293,38 +294,38 @@ def site_category(site_id: int,
|
||||
|
||||
|
||||
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
|
||||
def site_resource(site_id: int,
|
||||
keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
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:
|
||||
"""
|
||||
浏览站点资源
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
torrents = TorrentsChain().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]
|
||||
|
||||
|
||||
@router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site_by_domain(
|
||||
async def read_site_by_domain(
|
||||
site_url: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
通过域名获取站点信息
|
||||
"""
|
||||
domain = StringUtils.get_url_domain(site_url)
|
||||
site = Site.get_by_domain(db, domain)
|
||||
site = await Site.async_get_by_domain(db, domain)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -334,35 +335,35 @@ def read_site_by_domain(
|
||||
|
||||
|
||||
@router.get("/statistic/{site_url}", summary="特定站点统计信息", response_model=schemas.SiteStatistic)
|
||||
def read_statistic_by_domain(
|
||||
async def read_statistic_by_domain(
|
||||
site_url: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
通过域名获取站点统计信息
|
||||
"""
|
||||
domain = StringUtils.get_url_domain(site_url)
|
||||
sitestatistic = SiteStatistic.get_by_domain(db, domain)
|
||||
sitestatistic = await SiteStatistic.async_get_by_domain(db, domain)
|
||||
if sitestatistic:
|
||||
return sitestatistic
|
||||
return schemas.SiteStatistic(domain=domain)
|
||||
|
||||
|
||||
@router.get("/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic])
|
||||
def read_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
async def read_statistics(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
获取所有站点统计信息
|
||||
"""
|
||||
return SiteStatistic.list(db)
|
||||
return await SiteStatistic.async_list(db)
|
||||
|
||||
|
||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||
def read_rss_sites(db: Session = Depends(get_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]:
|
||||
"""
|
||||
获取站点列表
|
||||
"""
|
||||
@@ -370,7 +371,7 @@ def read_rss_sites(db: Session = Depends(get_db),
|
||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 所有站点
|
||||
all_site = Site.list_order_by_pri(db)
|
||||
all_site = await Site.async_list_order_by_pri(db)
|
||||
if not selected_sites:
|
||||
return all_site
|
||||
|
||||
@@ -380,7 +381,7 @@ def read_rss_sites(db: Session = Depends(get_db),
|
||||
|
||||
|
||||
@router.get("/auth", summary="查询认证站点", response_model=dict)
|
||||
def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
"""
|
||||
获取可认证站点列表
|
||||
"""
|
||||
@@ -408,12 +409,12 @@ def auth_site(
|
||||
|
||||
|
||||
@router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response)
|
||||
def site_mapping(_: User = Depends(get_current_active_superuser)):
|
||||
async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取站点域名到名称的映射关系
|
||||
"""
|
||||
try:
|
||||
sites = SiteOper().list()
|
||||
sites = await SiteOper().async_list()
|
||||
mapping = {}
|
||||
for site in sites:
|
||||
mapping[site.domain] = site.name
|
||||
@@ -422,16 +423,24 @@ def site_mapping(_: User = Depends(get_current_active_superuser)):
|
||||
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/supporting", summary="获取支持的站点列表", response_model=dict)
|
||||
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取支持的站点列表
|
||||
"""
|
||||
return SitesHelper().get_indexsites()
|
||||
|
||||
|
||||
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site(
|
||||
async def read_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)
|
||||
) -> Any:
|
||||
"""
|
||||
通过ID获取站点信息
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -441,18 +450,18 @@ def read_site(
|
||||
|
||||
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||
def delete_site(
|
||||
async def delete_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)
|
||||
) -> Any:
|
||||
"""
|
||||
删除站点
|
||||
"""
|
||||
Site.delete(db, site_id)
|
||||
await Site.async_delete(db, site_id)
|
||||
# 插件站点删除
|
||||
EventManager().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,7 @@ from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.security import verify_token
|
||||
from app.db.models import User
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
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
|
||||
|
||||
@@ -222,7 +222,7 @@ def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
|
||||
|
||||
@router.get("/transtype/{name}", summary="支持的整理方式获取", response_model=schemas.StorageTransType)
|
||||
def transtype(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
async def transtype(name: str, _: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询支持的整理方式
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import List, Any, Annotated, Optional
|
||||
|
||||
import cn2an
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
@@ -11,12 +12,12 @@ from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db, get_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.models.user import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_user
|
||||
from app.db.user_oper import get_current_active_user_async
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType, EventType, SystemConfigKey
|
||||
@@ -34,28 +35,28 @@ def start_subscribe_add(title: str, year: str,
|
||||
|
||||
|
||||
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
|
||||
def read_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
async def read_subscribes(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询所有订阅
|
||||
"""
|
||||
return Subscribe.list(db)
|
||||
return await Subscribe.async_list(db)
|
||||
|
||||
|
||||
@router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe])
|
||||
def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询所有订阅 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return read_subscribes()
|
||||
return await read_subscribes()
|
||||
|
||||
|
||||
@router.post("/", summary="新增订阅", response_model=schemas.Response)
|
||||
def create_subscribe(
|
||||
async def create_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
新增订阅
|
||||
@@ -77,26 +78,26 @@ def create_subscribe(
|
||||
title = None
|
||||
# 订阅用户
|
||||
subscribe_in.username = current_user.name
|
||||
sid, message = SubscribeChain().add(mtype=mtype,
|
||||
title=title,
|
||||
exist_ok=True,
|
||||
**subscribe_in.dict())
|
||||
sid, message = await SubscribeChain().async_add(mtype=mtype,
|
||||
title=title,
|
||||
exist_ok=True,
|
||||
**subscribe_in.dict())
|
||||
return schemas.Response(
|
||||
success=bool(sid), message=message, data={"id": sid}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||
def update_subscribe(
|
||||
async def update_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
更新订阅信息
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subscribe_in.id)
|
||||
subscribe = await Subscribe.async_get(db, subscribe_in.id)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
# 避免更新缺失集数
|
||||
@@ -114,50 +115,55 @@ def update_subscribe(
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
subscribe.update(db, subscribe_dict)
|
||||
# 更新到数据库
|
||||
await subscribe.async_update(db, subscribe_dict)
|
||||
# 重新获取更新后的订阅数据
|
||||
updated_subscribe = await Subscribe.async_get(db, subscribe_in.id)
|
||||
# 发送订阅调整事件
|
||||
eventmanager.send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subscribe.id,
|
||||
await eventmanager.async_send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subscribe_in.id,
|
||||
"old_subscribe_info": old_subscribe_dict,
|
||||
"subscribe_info": subscribe.to_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)
|
||||
def update_subscribe_status(
|
||||
async def update_subscribe_status(
|
||||
subid: int,
|
||||
state: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
更新订阅状态
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subid)
|
||||
subscribe = await Subscribe.async_get(db, subid)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
valid_states = ["R", "P", "S"]
|
||||
if state not in valid_states:
|
||||
return schemas.Response(success=False, message="无效的订阅状态")
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
subscribe.update(db, {
|
||||
await subscribe.async_update(db, {
|
||||
"state": state
|
||||
})
|
||||
# 重新获取更新后的订阅数据
|
||||
updated_subscribe = await Subscribe.async_get(db, subid)
|
||||
# 发送订阅调整事件
|
||||
eventmanager.send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subscribe.id,
|
||||
await eventmanager.async_send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subid,
|
||||
"old_subscribe_info": old_subscribe_dict,
|
||||
"subscribe_info": subscribe.to_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)
|
||||
def subscribe_mediaid(
|
||||
async def subscribe_mediaid(
|
||||
mediaid: str,
|
||||
season: Optional[int] = None,
|
||||
title: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
|
||||
@@ -167,23 +173,23 @@ def subscribe_mediaid(
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return Subscribe()
|
||||
result = Subscribe.exists(db, tmdbid=int(tmdbid), season=season)
|
||||
result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return Subscribe()
|
||||
result = Subscribe.get_by_doubanid(db, doubanid)
|
||||
result = await Subscribe.async_get_by_doubanid(db, doubanid)
|
||||
if not result and title:
|
||||
title_check = True
|
||||
elif mediaid.startswith("bangumi:"):
|
||||
bangumiid = mediaid[8:]
|
||||
if not bangumiid or not str(bangumiid).isdigit():
|
||||
return Subscribe()
|
||||
result = Subscribe.get_by_bangumiid(db, int(bangumiid))
|
||||
result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid))
|
||||
if not result and title:
|
||||
title_check = True
|
||||
else:
|
||||
result = Subscribe.get_by_mediaid(db, mediaid)
|
||||
result = await Subscribe.async_get_by_mediaid(db, mediaid)
|
||||
if not result and title:
|
||||
title_check = True
|
||||
# 使用名称检查订阅
|
||||
@@ -191,7 +197,7 @@ def subscribe_mediaid(
|
||||
meta = MetaInfo(title)
|
||||
if season:
|
||||
meta.begin_season = season
|
||||
result = Subscribe.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()
|
||||
|
||||
@@ -207,26 +213,30 @@ def refresh_subscribes(
|
||||
|
||||
|
||||
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
|
||||
def reset_subscribes(
|
||||
async def reset_subscribes(
|
||||
subid: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
重置订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subid)
|
||||
subscribe = await Subscribe.async_get(db, subid)
|
||||
if subscribe:
|
||||
# 在更新之前获取旧数据
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
subscribe.update(db, {
|
||||
# 更新订阅
|
||||
await subscribe.async_update(db, {
|
||||
"note": [],
|
||||
"lack_episode": subscribe.total_episode,
|
||||
"state": "R"
|
||||
})
|
||||
# 重新获取更新后的订阅数据
|
||||
updated_subscribe = await Subscribe.async_get(db, subid)
|
||||
# 发送订阅调整事件
|
||||
eventmanager.send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subscribe.id,
|
||||
await eventmanager.async_send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subid,
|
||||
"old_subscribe_info": old_subscribe_dict,
|
||||
"subscribe_info": subscribe.to_dict(),
|
||||
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
@@ -243,7 +253,7 @@ def check_subscribes(
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
def search_subscribes(
|
||||
async def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -262,7 +272,7 @@ def search_subscribes(
|
||||
|
||||
|
||||
@router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response)
|
||||
def search_subscribe(
|
||||
async def search_subscribe(
|
||||
subscribe_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -282,10 +292,10 @@ def search_subscribe(
|
||||
|
||||
|
||||
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
||||
def delete_subscribe_by_mediaid(
|
||||
async def delete_subscribe_by_mediaid(
|
||||
mediaid: str,
|
||||
season: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -296,25 +306,28 @@ def delete_subscribe_by_mediaid(
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return schemas.Response(success=False)
|
||||
subscribes = Subscribe().get_by_tmdbid(db, int(tmdbid), season)
|
||||
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
|
||||
delete_subscribes.extend(subscribes)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return schemas.Response(success=False)
|
||||
subscribe = Subscribe().get_by_doubanid(db, doubanid)
|
||||
subscribe = await Subscribe.async_get_by_doubanid(db, doubanid)
|
||||
if subscribe:
|
||||
delete_subscribes.append(subscribe)
|
||||
else:
|
||||
subscribe = Subscribe().get_by_mediaid(db, mediaid)
|
||||
subscribe = await Subscribe.async_get_by_mediaid(db, mediaid)
|
||||
if subscribe:
|
||||
delete_subscribes.append(subscribe)
|
||||
for subscribe in delete_subscribes:
|
||||
Subscribe().delete(db, subscribe.id)
|
||||
# 在删除之前获取订阅信息
|
||||
subscribe_info = subscribe.to_dict()
|
||||
subscribe_id = subscribe.id
|
||||
await Subscribe.async_delete(db, subscribe_id)
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe.to_dict()
|
||||
await eventmanager.async_send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe_id,
|
||||
"subscribe_info": subscribe_info
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -373,33 +386,33 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
|
||||
|
||||
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
|
||||
def subscribe_history(
|
||||
async def subscribe_history(
|
||||
mtype: str,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询电影/电视剧订阅历史
|
||||
"""
|
||||
return SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
||||
return await SubscribeHistory.async_list_by_type(db, mtype=mtype, page=page, count=count)
|
||||
|
||||
|
||||
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
|
||||
def delete_subscribe(
|
||||
async def delete_subscribe(
|
||||
history_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
删除订阅历史
|
||||
"""
|
||||
SubscribeHistory.delete(db, history_id)
|
||||
await SubscribeHistory.async_delete(db, history_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo])
|
||||
def popular_subscribes(
|
||||
async def popular_subscribes(
|
||||
stype: str,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
@@ -408,7 +421,7 @@ def popular_subscribes(
|
||||
"""
|
||||
查询热门订阅
|
||||
"""
|
||||
subscribes = SubscribeHelper().get_statistic(stype=stype, page=page, count=count)
|
||||
subscribes = await SubscribeHelper().async_get_statistic(stype=stype, page=page, count=count)
|
||||
if subscribes:
|
||||
ret_medias = []
|
||||
for sub in subscribes:
|
||||
@@ -444,14 +457,14 @@ def popular_subscribes(
|
||||
|
||||
|
||||
@router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe])
|
||||
def user_subscribes(
|
||||
async def user_subscribes(
|
||||
username: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询用户订阅
|
||||
"""
|
||||
return Subscribe.list_by_username(db, username)
|
||||
return await Subscribe.async_list_by_username(db, username)
|
||||
|
||||
|
||||
@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo)
|
||||
@@ -469,34 +482,34 @@ def subscribe_files(
|
||||
|
||||
|
||||
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
|
||||
def subscribe_share(
|
||||
async def subscribe_share(
|
||||
sub: schemas.SubscribeShare,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
分享订阅
|
||||
"""
|
||||
state, errmsg = SubscribeHelper().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)
|
||||
def subscribe_share_delete(
|
||||
async def subscribe_share_delete(
|
||||
share_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
state, errmsg = SubscribeHelper().share_delete(share_id=share_id)
|
||||
state, errmsg = await SubscribeHelper().async_share_delete(share_id=share_id)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
|
||||
def subscribe_fork(
|
||||
async def subscribe_fork(
|
||||
sub: schemas.SubscribeShare,
|
||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||
current_user: User = Depends(get_current_active_user_async)) -> Any:
|
||||
"""
|
||||
复用订阅
|
||||
"""
|
||||
@@ -505,15 +518,15 @@ def subscribe_fork(
|
||||
for key in list(sub_dict.keys()):
|
||||
if not hasattr(schemas.Subscribe(), key):
|
||||
sub_dict.pop(key)
|
||||
result = 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:
|
||||
SubscribeHelper().sub_fork(share_id=sub.id)
|
||||
await SubscribeHelper().async_sub_fork(share_id=sub.id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
|
||||
def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询已Follow的订阅分享人
|
||||
"""
|
||||
@@ -521,7 +534,7 @@ def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any
|
||||
|
||||
|
||||
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
|
||||
def follow_subscriber(
|
||||
async def follow_subscriber(
|
||||
share_uid: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -530,12 +543,12 @@ def follow_subscriber(
|
||||
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||
if share_uid and share_uid not in subscribers:
|
||||
subscribers.append(share_uid)
|
||||
SystemConfigOper().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)
|
||||
def unfollow_subscriber(
|
||||
async def unfollow_subscriber(
|
||||
share_uid: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -544,12 +557,12 @@ def unfollow_subscriber(
|
||||
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||
if share_uid and share_uid in subscribers:
|
||||
subscribers.remove(share_uid)
|
||||
SystemConfigOper().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])
|
||||
def popular_subscribes(
|
||||
async def popular_subscribes(
|
||||
name: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
@@ -557,38 +570,49 @@ def popular_subscribes(
|
||||
"""
|
||||
查询分享的订阅
|
||||
"""
|
||||
return SubscribeHelper().get_shares(name=name, page=page, count=count)
|
||||
return await SubscribeHelper().async_get_shares(name=name, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/share/statistics", summary="查询订阅分享统计", response_model=List[schemas.SubscribeShareStatistics])
|
||||
async def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询订阅分享统计
|
||||
返回每个分享人分享的媒体数量以及总的复用人次
|
||||
"""
|
||||
return await SubscribeHelper().async_get_share_statistics()
|
||||
|
||||
|
||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||
def read_subscribe(
|
||||
async def read_subscribe(
|
||||
subscribe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据订阅编号查询订阅信息
|
||||
"""
|
||||
if not subscribe_id:
|
||||
return Subscribe()
|
||||
return Subscribe.get(db, subscribe_id)
|
||||
return await Subscribe.async_get(db, subscribe_id)
|
||||
|
||||
|
||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
||||
def delete_subscribe(
|
||||
async def delete_subscribe(
|
||||
subscribe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
删除订阅信息
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subscribe_id)
|
||||
subscribe = await Subscribe.async_get(db, subscribe_id)
|
||||
if subscribe:
|
||||
subscribe.delete(db, subscribe_id)
|
||||
# 在删除之前获取订阅信息
|
||||
subscribe_info = subscribe.to_dict()
|
||||
await Subscribe.async_delete(db, subscribe_id)
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeDeleted, {
|
||||
await eventmanager.async_send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe_id,
|
||||
"subscribe_info": subscribe.to_dict()
|
||||
"subscribe_info": subscribe_info
|
||||
})
|
||||
# 统计订阅
|
||||
SubscribeHelper().sub_done_async({
|
||||
|
||||
@@ -2,15 +2,15 @@ import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Annotated
|
||||
|
||||
import aiofiles
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from PIL import Image
|
||||
from aiopath import AsyncPath
|
||||
from app.helper.sites import SitesHelper # noqa # noqa
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -18,18 +18,17 @@ from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||
from app.core.event import eventmanager
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
@@ -37,7 +36,7 @@ from app.scheduler import Scheduler
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.crypto import HashUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
@@ -45,7 +44,7 @@ from version import APP_VERSION
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def fetch_image(
|
||||
async def fetch_image(
|
||||
url: str,
|
||||
proxy: bool = False,
|
||||
use_disk_cache: bool = False,
|
||||
@@ -65,24 +64,28 @@ def fetch_image(
|
||||
raise HTTPException(status_code=404, detail="Unsafe URL")
|
||||
|
||||
# 后续观察系统性能表现,如果发现磁盘缓存和HTTP缓存无法满足高并发情况下的响应速度需求,可以考虑重新引入内存缓存
|
||||
cache_path = None
|
||||
cache_path: Optional[AsyncPath] = None
|
||||
if use_disk_cache:
|
||||
# 生成缓存路径
|
||||
base_path = AsyncPath(settings.CACHE_PATH)
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = settings.CACHE_PATH / "images" / sanitized_path
|
||||
cache_path = base_path / "images" / sanitized_path
|
||||
|
||||
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
|
||||
if not cache_path.suffix:
|
||||
cache_path = cache_path.with_suffix(".jpg")
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES):
|
||||
if not await SecurityUtils.async_is_safe_path(base_path=base_path,
|
||||
user_path=cache_path,
|
||||
allowed_suffixes=settings.SECURITY_IMAGE_SUFFIXES):
|
||||
raise HTTPException(status_code=400, detail="Invalid cache path or file type")
|
||||
|
||||
# 目前暂不考虑磁盘缓存文件是否过期,后续通过缓存清理机制处理
|
||||
if cache_path.exists():
|
||||
if cache_path and await cache_path.exists():
|
||||
try:
|
||||
content = cache_path.read_bytes()
|
||||
async with cache_path.open('rb') as f:
|
||||
content = await f.read()
|
||||
etag = HashUtils.md5(content)
|
||||
headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7)
|
||||
if if_none_match == etag:
|
||||
@@ -95,19 +98,19 @@ def fetch_image(
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if proxy else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer,
|
||||
accept_type="image/avif,image/webp,image/apng,*/*").get_res(url=url)
|
||||
response = await AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer,
|
||||
accept_type="image/avif,image/webp,image/apng,*/*").get_res(url=url)
|
||||
if not response:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch the image from the remote server")
|
||||
|
||||
# 验证下载的内容是否为有效图片
|
||||
try:
|
||||
Image.open(io.BytesIO(response.content)).verify()
|
||||
content = response.content
|
||||
Image.open(io.BytesIO(content)).verify()
|
||||
except Exception as e:
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Invalid image format")
|
||||
|
||||
content = response.content
|
||||
response_headers = response.headers
|
||||
|
||||
cache_control_header = response_headers.get("Cache-Control", "")
|
||||
@@ -116,12 +119,12 @@ def fetch_image(
|
||||
# 如果需要使用磁盘缓存,则保存到磁盘
|
||||
if use_disk_cache and cache_path:
|
||||
try:
|
||||
if not cache_path.parent.exists():
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
tmp_file.write(content)
|
||||
temp_path = Path(tmp_file.name)
|
||||
temp_path.replace(cache_path)
|
||||
if not await cache_path.parent.exists():
|
||||
await cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
await tmp_file.write(content)
|
||||
temp_path = AsyncPath(tmp_file.name)
|
||||
await temp_path.replace(cache_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path}: {e}")
|
||||
|
||||
@@ -141,7 +144,7 @@ def fetch_image(
|
||||
|
||||
|
||||
@router.get("/img/{proxy}", summary="图片代理")
|
||||
def proxy_img(
|
||||
async def proxy_img(
|
||||
imgurl: str,
|
||||
proxy: bool = False,
|
||||
cache: bool = False,
|
||||
@@ -155,12 +158,12 @@ def proxy_img(
|
||||
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
|
||||
config and config.config and config.config.get("host")]
|
||||
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
|
||||
return fetch_image(url=imgurl, proxy=proxy, use_disk_cache=cache,
|
||||
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||
return await fetch_image(url=imgurl, proxy=proxy, use_disk_cache=cache,
|
||||
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||
|
||||
|
||||
@router.get("/cache/image", summary="图片缓存")
|
||||
def cache_img(
|
||||
async def cache_img(
|
||||
url: str,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||
@@ -170,7 +173,8 @@ def cache_img(
|
||||
"""
|
||||
# 如果没有启用全局图片缓存,则不使用磁盘缓存
|
||||
proxy = "doubanio.com" not in url
|
||||
return fetch_image(url=url, proxy=proxy, use_disk_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match)
|
||||
return await fetch_image(url=url, proxy=proxy, use_disk_cache=settings.GLOBAL_IMAGE_CACHE,
|
||||
if_none_match=if_none_match)
|
||||
|
||||
|
||||
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||
@@ -184,19 +188,22 @@ def get_global_setting(token: str):
|
||||
# FIXME: 新增敏感配置项时要在此处添加排除项
|
||||
info = settings.dict(
|
||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
||||
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN"}
|
||||
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN", "U115_APP_ID",
|
||||
"ALIPAN_APP_ID", "TVDB_V4_API_KEY", "TVDB_V4_API_PIN"}
|
||||
)
|
||||
# 追加用户唯一ID和订阅分享管理权限
|
||||
share_admin = SubscribeHelper().is_admin_user()
|
||||
info.update({
|
||||
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
|
||||
"SUBSCRIBE_SHARE_MANAGE": SubscribeHelper().is_admin_user(),
|
||||
"SUBSCRIBE_SHARE_MANAGE": share_admin,
|
||||
"WORKFLOW_SHARE_MANAGE": share_admin
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
|
||||
def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
||||
async def get_env_setting(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号(仅管理员)
|
||||
"""
|
||||
@@ -214,8 +221,8 @@ def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
|
||||
def set_env_setting(env: dict,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
async def set_env_setting(env: dict,
|
||||
_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
更新系统环境变量(仅管理员)
|
||||
"""
|
||||
@@ -237,7 +244,7 @@ def set_env_setting(env: dict,
|
||||
if success_updates:
|
||||
for key in success_updates.keys():
|
||||
# 发送配置变更事件
|
||||
eventmanager.send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=getattr(settings, key, None),
|
||||
change_type="update"
|
||||
@@ -266,7 +273,7 @@ async def get_progress(request: Request, process_type: str, _: schemas.TokenPayl
|
||||
break
|
||||
detail = progress.get(process_type)
|
||||
yield f"data: {json.dumps(detail)}\n\n"
|
||||
await asyncio.sleep(0.2)
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
@@ -274,8 +281,8 @@ async def get_progress(request: Request, process_type: str, _: schemas.TokenPayl
|
||||
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
def get_setting(key: str,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
async def get_setting(key: str,
|
||||
_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
查询系统设置(仅管理员)
|
||||
"""
|
||||
@@ -289,10 +296,10 @@ def get_setting(key: str,
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
):
|
||||
"""
|
||||
更新系统设置(仅管理员)
|
||||
@@ -301,7 +308,7 @@ def set_setting(
|
||||
success, message = settings.update_setting(key=key, value=value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
eventmanager.send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
@@ -313,10 +320,10 @@ def set_setting(
|
||||
if isinstance(value, list):
|
||||
value = list(filter(None, value))
|
||||
value = value if value else None
|
||||
success = SystemConfigOper().set(key, value)
|
||||
success = await SystemConfigOper().async_set(key, value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
eventmanager.send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
@@ -356,60 +363,106 @@ async def get_logging(request: Request, length: Optional[int] = 50, logfile: Opt
|
||||
length = -1 时, 返回text/plain
|
||||
否则 返回格式SSE
|
||||
"""
|
||||
log_path = settings.LOG_PATH / logfile
|
||||
base_path = AsyncPath(settings.LOG_PATH)
|
||||
log_path = base_path / logfile
|
||||
|
||||
if not SecurityUtils.is_safe_path(settings.LOG_PATH, log_path, allowed_suffixes={".log"}):
|
||||
if not await SecurityUtils.async_is_safe_path(base_path=base_path, user_path=log_path, allowed_suffixes={".log"}):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
if not await log_path.exists() or not await log_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
async def log_generator():
|
||||
try:
|
||||
# 使用固定大小的双向队列来限制内存使用
|
||||
lines_queue = deque(maxlen=max(length, 50))
|
||||
# 使用 aiofiles 异步读取文件
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8") as f:
|
||||
# 逐行读取文件,将每一行存入队列
|
||||
file_content = await f.read()
|
||||
for line in file_content.splitlines():
|
||||
# 获取文件大小
|
||||
file_stat = await log_path.stat()
|
||||
file_size = file_stat.st_size
|
||||
|
||||
# 读取历史日志
|
||||
async with log_path.open(mode="r", encoding="utf-8", errors="ignore") as f:
|
||||
# 优化大文件读取策略
|
||||
if file_size > 100 * 1024:
|
||||
# 只读取最后100KB的内容
|
||||
bytes_to_read = min(file_size, 100 * 1024)
|
||||
position = file_size - bytes_to_read
|
||||
await f.seek(position)
|
||||
content = await f.read()
|
||||
# 找到第一个完整的行
|
||||
first_newline = content.find('\n')
|
||||
if first_newline != -1:
|
||||
content = content[first_newline + 1:]
|
||||
else:
|
||||
# 小文件直接读取全部内容
|
||||
content = await f.read()
|
||||
|
||||
# 按行分割并添加到队列,只保留非空行
|
||||
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
||||
# 只取最后N行
|
||||
for line in lines[-max(length, 50):]:
|
||||
lines_queue.append(line)
|
||||
for line in lines_queue:
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
# 输出历史日志
|
||||
for line in lines_queue:
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
# 实时监听新日志
|
||||
async with log_path.open(mode="r", encoding="utf-8", errors="ignore") as f:
|
||||
# 移动文件指针到文件末尾,继续监听新增内容
|
||||
await f.seek(0, 2)
|
||||
# 记录初始文件大小
|
||||
initial_stat = await log_path.stat()
|
||||
initial_size = initial_stat.st_size
|
||||
# 实时监听新日志,使用更短的轮询间隔
|
||||
while not global_vars.is_system_stopped:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
line = await f.readline()
|
||||
if not line:
|
||||
# 检查文件是否有新内容
|
||||
current_stat = await log_path.stat()
|
||||
current_size = current_stat.st_size
|
||||
if current_size > initial_size:
|
||||
# 文件有新内容,读取新行
|
||||
line = await f.readline()
|
||||
if line:
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield f"data: {line}\n\n"
|
||||
initial_size = current_size
|
||||
else:
|
||||
# 没有新内容,短暂等待
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
yield f"data: {line}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as err:
|
||||
logger.error(f"日志读取异常: {err}")
|
||||
yield f"data: 日志读取异常: {err}\n\n"
|
||||
|
||||
# 根据length参数返回不同的响应
|
||||
if length == -1:
|
||||
# 返回全部日志作为文本响应
|
||||
if not log_path.exists():
|
||||
if not await log_path.exists():
|
||||
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||
with open(log_path, "r", encoding='utf-8') as file:
|
||||
text = file.read()
|
||||
# 倒序输出
|
||||
text = "\n".join(text.split("\n")[::-1])
|
||||
return Response(content=text, media_type="text/plain")
|
||||
try:
|
||||
# 使用 aiofiles 异步读取文件
|
||||
async with log_path.open(mode="r", encoding="utf-8", errors="ignore") as file:
|
||||
text = await file.read()
|
||||
# 倒序输出
|
||||
text = "\n".join(text.split("\n")[::-1])
|
||||
return Response(content=text, media_type="text/plain")
|
||||
except Exception as e:
|
||||
return Response(content=f"读取日志文件失败: {e}", media_type="text/plain")
|
||||
else:
|
||||
# 返回SSE流响应
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询Github所有Release版本
|
||||
"""
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
version_res = await AsyncRequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
@@ -451,11 +504,11 @@ def ruletest(title: str,
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
async def nettest(
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
):
|
||||
"""
|
||||
测试网络连通性
|
||||
@@ -463,43 +516,68 @@ def nettest(
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
headers = None
|
||||
if "github" in url or "{GITHUB_PROXY}" in url:
|
||||
# 当前使用的加速代理
|
||||
proxy_name = ""
|
||||
if "github" in url:
|
||||
# 这是github的连通性测试
|
||||
headers = settings.GITHUB_HEADERS
|
||||
if "{GITHUB_PROXY}" in url:
|
||||
url = url.replace(
|
||||
"{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "")
|
||||
)
|
||||
headers = settings.GITHUB_HEADERS
|
||||
if settings.GITHUB_PROXY:
|
||||
proxy_name = "Github加速代理"
|
||||
if "{PIP_PROXY}" in url:
|
||||
url = url.replace(
|
||||
"{PIP_PROXY}",
|
||||
UrlUtils.standardize_base_url(
|
||||
settings.PIP_PROXY or "https://pypi.org/simple/"
|
||||
),
|
||||
)
|
||||
if settings.PIP_PROXY:
|
||||
proxy_name = "PIP加速代理"
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
url = url.replace(
|
||||
"{PIP_PROXY}",
|
||||
UrlUtils.standardize_base_url(settings.PIP_PROXY or "https://pypi.org/simple/"),
|
||||
)
|
||||
result = RequestUtils(
|
||||
result = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
ua=settings.USER_AGENT,
|
||||
ua=settings.NORMAL_USER_AGENT,
|
||||
).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
time = round((end_time - start_time).total_seconds() * 1000)
|
||||
# 计算相关秒数
|
||||
if result is None:
|
||||
return schemas.Response(success=False, message="无法连接", data={"time": time})
|
||||
return schemas.Response(
|
||||
success=False, message=f"{proxy_name}无法连接", data={"time": time}
|
||||
)
|
||||
elif result.status_code == 200:
|
||||
if include and not re.search(r"%s" % include, result.text, re.IGNORECASE):
|
||||
# 通常是被加速代理跳转到其它页面了
|
||||
logger.error(f"{url} 的响应内容不匹配包含规则 {include}")
|
||||
if proxy_name:
|
||||
message = f"{proxy_name}已失效,请检查配置"
|
||||
else:
|
||||
message = f"无效响应,不匹配 {include}"
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=f"无效响应,不匹配 {include}",
|
||||
message=message,
|
||||
data={"time": time},
|
||||
)
|
||||
return schemas.Response(success=True, data={"time": time})
|
||||
else:
|
||||
return schemas.Response(
|
||||
success=False, message=f"错误码:{result.status_code}", data={"time": time}
|
||||
)
|
||||
if proxy_name:
|
||||
# 加速代理失败
|
||||
message = f"{proxy_name}已失效,错误码:{result.status_code}"
|
||||
else:
|
||||
message = f"错误码:{result.status_code}"
|
||||
if "github" in url:
|
||||
# 非加速代理访问github
|
||||
if result.status_code == 401:
|
||||
message = "Github Token已失效,请检查配置"
|
||||
elif result.status_code in {403, 429}:
|
||||
message = "触发限流,请配置Github Token"
|
||||
return schemas.Response(success=False, message=message, data={"time": time})
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
|
||||
@@ -11,28 +11,28 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
|
||||
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
"""
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
||||
if seasons_info:
|
||||
return seasons_info
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
medias = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_tv_similar(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if medias:
|
||||
@@ -41,17 +41,17 @@ def tmdb_similar(tmdbid: int,
|
||||
|
||||
|
||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
medias = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_tv_recommend(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if medias:
|
||||
@@ -60,63 +60,63 @@ def tmdb_recommend(tmdbid: int,
|
||||
|
||||
|
||||
@router.get("/collection/{collection_id}", summary="系列合集详情", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_collection(collection_id: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_collection(collection_id: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据合集ID查询合集详情
|
||||
"""
|
||||
medias = TmdbChain().tmdb_collection(collection_id=collection_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 []
|
||||
|
||||
|
||||
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
persons = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
persons = await TmdbChain().async_movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
persons = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
persons = await TmdbChain().async_tv_credits(tmdbid=tmdbid, page=page)
|
||||
else:
|
||||
return []
|
||||
return persons or []
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def tmdb_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return TmdbChain().person_detail(person_id=person_id)
|
||||
return await TmdbChain().async_person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
medias = await TmdbChain().async_person_credits(person_id=person_id, page=page)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
"""
|
||||
return TmdbChain().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,14 +9,14 @@ 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
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
|
||||
from app.utils.crypto import HashUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
|
||||
def torrents_cache(_: User = Depends(get_current_active_superuser)):
|
||||
async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取当前种子缓存数据
|
||||
"""
|
||||
@@ -24,9 +24,9 @@ def torrents_cache(_: User = Depends(get_current_active_superuser)):
|
||||
|
||||
# 获取spider和rss两种缓存
|
||||
if settings.SUBSCRIBE_MODE == "rss":
|
||||
cache_info = torrents_chain.get_torrents("rss")
|
||||
cache_info = await torrents_chain.async_get_torrents("rss")
|
||||
else:
|
||||
cache_info = torrents_chain.get_torrents("spider")
|
||||
cache_info = await torrents_chain.async_get_torrents("spider")
|
||||
|
||||
# 统计信息
|
||||
torrent_count = sum(len(torrents) for torrents in cache_info.values())
|
||||
@@ -62,9 +62,8 @@ def torrents_cache(_: User = Depends(get_current_active_superuser)):
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存",
|
||||
response_model=schemas.Response)
|
||||
def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser)):
|
||||
@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: 站点域名
|
||||
@@ -76,7 +75,7 @@ def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_a
|
||||
|
||||
try:
|
||||
# 获取当前缓存
|
||||
cache_data = torrents_chain.get_torrents()
|
||||
cache_data = await torrents_chain.async_get_torrents()
|
||||
|
||||
if domain not in cache_data:
|
||||
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
|
||||
@@ -92,7 +91,7 @@ def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_a
|
||||
return schemas.Response(success=False, message="未找到指定的种子")
|
||||
|
||||
# 保存更新后的缓存
|
||||
torrents_chain.save_cache(cache_data, torrents_chain.cache_file)
|
||||
await torrents_chain.async_save_cache(cache_data, torrents_chain.cache_file)
|
||||
|
||||
return schemas.Response(success=True, message="种子删除成功")
|
||||
except Exception as e:
|
||||
@@ -100,14 +99,14 @@ def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_a
|
||||
|
||||
|
||||
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
|
||||
def clear_cache(_: User = Depends(get_current_active_superuser)):
|
||||
async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
清理所有种子缓存
|
||||
"""
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
torrents_chain.clear_torrents()
|
||||
await torrents_chain.async_clear_torrents()
|
||||
return schemas.Response(success=True, message="种子缓存清理完成")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
|
||||
@@ -135,9 +134,9 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||
|
||||
|
||||
@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response)
|
||||
def reidentify_cache(domain: str, torrent_hash: str,
|
||||
async def reidentify_cache(domain: str, torrent_hash: str,
|
||||
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
重新识别指定的种子
|
||||
:param domain: 站点域名
|
||||
@@ -152,7 +151,7 @@ def reidentify_cache(domain: str, torrent_hash: str,
|
||||
|
||||
try:
|
||||
# 获取当前缓存
|
||||
cache_data = torrents_chain.get_torrents()
|
||||
cache_data = await torrents_chain.async_get_torrents()
|
||||
|
||||
if domain not in cache_data:
|
||||
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
|
||||
@@ -168,14 +167,13 @@ 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 = MediaChain().recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
|
||||
mediainfo = await media_chain.async_recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
|
||||
else:
|
||||
# 自动重新识别
|
||||
mediainfo = media_chain.recognize_by_meta(meta)
|
||||
mediainfo = await media_chain.async_recognize_by_meta(meta)
|
||||
|
||||
if not mediainfo:
|
||||
# 创建空的媒体信息
|
||||
@@ -188,7 +186,7 @@ def reidentify_cache(domain: str, torrent_hash: str,
|
||||
target_context.media_info = mediainfo
|
||||
|
||||
# 保存更新后的缓存
|
||||
torrents_chain.save_cache(cache_data, TorrentsChain().cache_file)
|
||||
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 "",
|
||||
|
||||
@@ -8,11 +8,14 @@ from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db.models import User
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.schemas import MediaType, FileItem, ManualTransferItem
|
||||
|
||||
router = APIRouter()
|
||||
@@ -35,11 +38,19 @@ def query_name(path: str, filetype: str,
|
||||
if not new_path:
|
||||
return schemas.Response(success=False, message="未识别到新名称")
|
||||
if filetype == "dir":
|
||||
parents = Path(new_path).parents
|
||||
if len(parents) > 2:
|
||||
new_name = parents[1].name
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format=settings.RENAME_FORMAT(mediainfo.type),
|
||||
rename_path=Path(new_path),
|
||||
)
|
||||
if media_path:
|
||||
new_name = media_path.name
|
||||
else:
|
||||
new_name = parents[0].name
|
||||
# fallback
|
||||
parents = Path(new_path).parents
|
||||
if len(parents) > 2:
|
||||
new_name = parents[1].name
|
||||
else:
|
||||
new_name = parents[0].name
|
||||
else:
|
||||
new_name = Path(new_path).name
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -48,7 +59,7 @@ def query_name(path: str, filetype: str,
|
||||
|
||||
|
||||
@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob])
|
||||
def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param _: Token校验
|
||||
@@ -57,7 +68,7 @@ def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response)
|
||||
def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param fileitem: 文件项
|
||||
@@ -71,7 +82,7 @@ def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(v
|
||||
def manual_transfer(transer_item: ManualTransferItem,
|
||||
background: Optional[bool] = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||
:param transer_item: 手工整理项
|
||||
|
||||
@@ -3,13 +3,14 @@ import re
|
||||
from typing import Annotated, Any, List, Union
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.core.security import get_password_hash
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db
|
||||
from app.db.models.user import User
|
||||
from app.db.user_oper import get_current_active_superuser, 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
|
||||
from app.utils.otp import OtpUtils
|
||||
|
||||
@@ -17,45 +18,43 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
查询用户列表
|
||||
"""
|
||||
users = current_user.list(db)
|
||||
return users
|
||||
return await current_user.async_list(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
||||
def create_user(
|
||||
async def create_user(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_in: schemas.UserCreate,
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
新增用户
|
||||
"""
|
||||
user = current_user.get_by_name(db, name=user_in.name)
|
||||
user = await current_user.async_get_by_name(db, name=user_in.name)
|
||||
if user:
|
||||
return schemas.Response(success=False, message="用户已存在")
|
||||
user_info = user_in.dict()
|
||||
if user_info.get("password"):
|
||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||
user_info.pop("password")
|
||||
user = User(**user_info)
|
||||
user.create(db)
|
||||
return schemas.Response(success=True)
|
||||
user = await User(**user_info).async_create(db)
|
||||
return schemas.Response(success=True if user else False)
|
||||
|
||||
|
||||
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
||||
def update_user(
|
||||
async def update_user(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_in: schemas.UserUpdate,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
更新用户
|
||||
@@ -69,24 +68,24 @@ def update_user(
|
||||
message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||
user_info.pop("password")
|
||||
user = User.get_by_id(db, user_id=user_info["id"])
|
||||
user = await current_user.async_get_by_id(db, user_id=user_info["id"])
|
||||
user_name = user_info.get("name")
|
||||
if not user_name:
|
||||
return schemas.Response(success=False, message="用户名不能为空")
|
||||
# 新用户名去重
|
||||
users = User.list(db)
|
||||
users = await current_user.async_list(db)
|
||||
for u in users:
|
||||
if u.name == user_name and u.id != user_info["id"]:
|
||||
return schemas.Response(success=False, message="用户名已被使用")
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.update(db, user_info)
|
||||
await user.async_update(db, user_info)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
|
||||
def read_current_user(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(get_current_active_user_async)
|
||||
) -> Any:
|
||||
"""
|
||||
当前登录用户信息
|
||||
@@ -95,18 +94,18 @@ def read_current_user(
|
||||
|
||||
|
||||
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
|
||||
def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
|
||||
_: User = Depends(get_current_active_user)):
|
||||
async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...),
|
||||
_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
上传用户头像
|
||||
"""
|
||||
# 将文件转换为Base64
|
||||
file_base64 = base64.b64encode(file.file.read())
|
||||
# 更新到用户表
|
||||
user = User.get(db, user_id)
|
||||
user = await User.async_get(db, user_id)
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.update(db, {
|
||||
await user.async_update(db, {
|
||||
"avatar": f"data:image/ico;base64,{file_base64}"
|
||||
})
|
||||
return schemas.Response(success=True, message=file.filename)
|
||||
@@ -121,31 +120,31 @@ def otp_generate(
|
||||
|
||||
|
||||
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
|
||||
def otp_judge(
|
||||
async def otp_judge(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
current_user: User = Depends(get_current_active_user_async)
|
||||
) -> Any:
|
||||
uri = data.get("uri")
|
||||
otp_password = data.get("otpPassword")
|
||||
if not OtpUtils.is_legal(uri, otp_password):
|
||||
return schemas.Response(success=False, message="验证码错误")
|
||||
current_user.update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
|
||||
await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post('/otp/disable', summary='关闭当前用户的otp验证', response_model=schemas.Response)
|
||||
def otp_disable(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
async def otp_disable(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
current_user: User = Depends(get_current_active_user_async)
|
||||
) -> Any:
|
||||
current_user.update_otp_by_name(db, current_user.name, False, "")
|
||||
await current_user.async_update_otp_by_name(db, current_user.name, False, "")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get('/otp/{userid}', summary='判断当前用户是否开启otp验证', response_model=schemas.Response)
|
||||
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
|
||||
user: User = User.get_by_name(db, userid)
|
||||
async def otp_enable(userid: str, db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
user: User = await User.async_get_by_name(db, userid)
|
||||
if not user:
|
||||
return schemas.Response(success=False)
|
||||
return schemas.Response(success=user.is_otp)
|
||||
@@ -165,9 +164,9 @@ def get_config(key: str,
|
||||
|
||||
@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),
|
||||
):
|
||||
"""
|
||||
更新用户配置
|
||||
@@ -177,49 +176,49 @@ def set_config(
|
||||
|
||||
|
||||
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
|
||||
def delete_user_by_id(
|
||||
async def delete_user_by_id(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
通过唯一ID删除用户
|
||||
"""
|
||||
user = current_user.get_by_id(db, user_id=user_id)
|
||||
user = await current_user.async_get_by_id(db, user_id=user_id)
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.delete_by_id(db, user_id)
|
||||
await current_user.async_delete(db, user_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response)
|
||||
def delete_user_by_name(
|
||||
async def delete_user_by_name(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_name: str,
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
通过用户名删除用户
|
||||
"""
|
||||
user = current_user.get_by_name(db, name=user_name)
|
||||
user = await current_user.async_get_by_name(db, name=user_name)
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.delete_by_name(db, user_name)
|
||||
await current_user.async_delete(db, user.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{username}", summary="用户详情", response_model=schemas.User)
|
||||
def read_user_by_name(
|
||||
async def read_user_by_name(
|
||||
username: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
"""
|
||||
查询用户详情
|
||||
"""
|
||||
user = current_user.get_by_name(db, name=username)
|
||||
user = await current_user.async_get_by_name(db, name=username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
||||
@@ -32,8 +32,8 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
||||
|
||||
|
||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
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=媒体服务器名
|
||||
"""
|
||||
|
||||
@@ -1,51 +1,59 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.core.config import global_vars
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.core.workflow import WorkFlowManager
|
||||
from app.db import get_db
|
||||
from app.db.models.workflow import Workflow
|
||||
from app.db import get_async_db, get_db
|
||||
from app.db.models import Workflow
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_user
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.helper.workflow import WorkflowHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import EventType, EVENT_TYPE_NAMES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow])
|
||||
def list_workflows(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
async def list_workflows(db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取工作流列表
|
||||
"""
|
||||
return Workflow.list(db)
|
||||
return await WorkflowOper(db).async_list()
|
||||
|
||||
|
||||
@router.post("/", summary="创建工作流", response_model=schemas.Response)
|
||||
def create_workflow(workflow: schemas.Workflow,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
async def create_workflow(workflow: schemas.Workflow,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
创建工作流
|
||||
"""
|
||||
if Workflow.get_by_name(db, workflow.name):
|
||||
if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name):
|
||||
return schemas.Response(success=False, message="已存在相同名称的工作流")
|
||||
if not workflow.add_time:
|
||||
workflow.add_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")
|
||||
if not workflow.state:
|
||||
workflow.state = "P"
|
||||
Workflow(**workflow.dict()).create(db)
|
||||
if not workflow.trigger_type:
|
||||
workflow.trigger_type = "timer"
|
||||
workflow_obj = Workflow(**workflow.dict())
|
||||
await workflow_obj.async_create(db)
|
||||
return schemas.Response(success=True, message="创建工作流成功")
|
||||
|
||||
|
||||
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
|
||||
def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
@@ -53,60 +61,124 @@ def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends
|
||||
|
||||
|
||||
@router.get("/actions", summary="所有动作", response_model=List[dict])
|
||||
def list_actions(_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
return WorkFlowManager().list_actions()
|
||||
|
||||
|
||||
@router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow)
|
||||
def get_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
|
||||
async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取工作流详情
|
||||
获取所有事件类型
|
||||
"""
|
||||
return Workflow.get(db, workflow_id)
|
||||
return [{
|
||||
"title": EVENT_TYPE_NAMES.get(event_type, event_type.name),
|
||||
"value": event_type.value
|
||||
} for event_type in EventType]
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response)
|
||||
def update_workflow(workflow: schemas.Workflow,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
|
||||
async def workflow_share(
|
||||
workflow: schemas.WorkflowShare,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
更新工作流
|
||||
分享工作流
|
||||
"""
|
||||
wf = Workflow.get(db, workflow.id)
|
||||
if not wf:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
wf.update(db, workflow.dict())
|
||||
return schemas.Response(success=True, message="更新成功")
|
||||
if not workflow.id or not workflow.share_title or not workflow.share_user:
|
||||
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 "")
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response)
|
||||
def delete_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
|
||||
async def workflow_share_delete(
|
||||
share_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除工作流
|
||||
删除分享
|
||||
"""
|
||||
workflow = Workflow.get(db, workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
# 删除定时任务
|
||||
Scheduler().remove_workflow_job(workflow)
|
||||
# 删除工作流
|
||||
Workflow.delete(db, workflow_id)
|
||||
# 删除缓存
|
||||
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
|
||||
return schemas.Response(success=True, message="删除成功")
|
||||
state, errmsg = await WorkflowHelper().async_share_delete(share_id=share_id)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
复用工作流
|
||||
"""
|
||||
if not workflow.name:
|
||||
return schemas.Response(success=False, message="工作流名称不能为空")
|
||||
|
||||
# 解析JSON数据,添加错误处理
|
||||
try:
|
||||
actions = json.loads(workflow.actions or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return schemas.Response(success=False, message="actions字段JSON格式错误")
|
||||
|
||||
try:
|
||||
flows = json.loads(workflow.flows or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return schemas.Response(success=False, message="flows字段JSON格式错误")
|
||||
|
||||
try:
|
||||
context = json.loads(workflow.context or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return schemas.Response(success=False, message="context字段JSON格式错误")
|
||||
|
||||
# 创建工作流
|
||||
workflow_dict = {
|
||||
"name": workflow.name,
|
||||
"description": workflow.description,
|
||||
"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 {},
|
||||
"actions": actions,
|
||||
"flows": flows,
|
||||
"context": context,
|
||||
"state": "P" # 默认暂停状态
|
||||
}
|
||||
|
||||
# 检查名称是否重复
|
||||
workflow_oper = WorkflowOper(db)
|
||||
if await workflow_oper.async_get_by_name(workflow_dict["name"]):
|
||||
return schemas.Response(success=False, message="已存在相同名称的工作流")
|
||||
|
||||
# 创建新工作流
|
||||
workflow = await Workflow(**workflow_dict).async_create(db)
|
||||
|
||||
# 更新复用次数
|
||||
if workflow:
|
||||
await WorkflowHelper().async_workflow_fork(share_id=workflow.id)
|
||||
|
||||
return schemas.Response(success=True, message="复用成功")
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
查询分享的工作流
|
||||
"""
|
||||
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(get_current_active_user)) -> Any:
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
执行工作流
|
||||
"""
|
||||
@@ -119,15 +191,19 @@ def run_workflow(workflow_id: int,
|
||||
@router.post("/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response)
|
||||
def start_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
启用工作流
|
||||
"""
|
||||
workflow = Workflow.get(db, workflow_id)
|
||||
workflow = WorkflowOper(db).get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
# 添加定时任务
|
||||
Scheduler().update_workflow_job(workflow)
|
||||
if not workflow.trigger_type or workflow.trigger_type == "timer":
|
||||
# 添加定时任务
|
||||
Scheduler().update_workflow_job(workflow)
|
||||
else:
|
||||
# 事件触发:添加到事件触发器
|
||||
WorkFlowManager().load_workflow_events(workflow_id)
|
||||
# 更新状态
|
||||
workflow.update_state(db, workflow_id, "W")
|
||||
return schemas.Response(success=True)
|
||||
@@ -136,15 +212,20 @@ def start_workflow(workflow_id: int,
|
||||
@router.post("/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response)
|
||||
def pause_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
停用工作流
|
||||
"""
|
||||
workflow = Workflow.get(db, workflow_id)
|
||||
workflow = WorkflowOper(db).get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
# 删除定时任务
|
||||
Scheduler().remove_workflow_job(workflow)
|
||||
# 根据触发类型进行不同处理
|
||||
if workflow.trigger_type == "timer":
|
||||
# 定时触发:移除定时任务
|
||||
Scheduler().remove_workflow_job(workflow)
|
||||
elif workflow.trigger_type == "event":
|
||||
# 事件触发:从事件触发器中移除
|
||||
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
|
||||
# 停止工作流
|
||||
global_vars.stop_workflow(workflow_id)
|
||||
# 更新状态
|
||||
@@ -153,19 +234,77 @@ def pause_workflow(workflow_id: int,
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response)
|
||||
def reset_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
async def reset_workflow(workflow_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
重置工作流
|
||||
"""
|
||||
workflow = Workflow.get(db, workflow_id)
|
||||
workflow = await WorkflowOper(db).async_get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
# 停止工作流
|
||||
global_vars.stop_workflow(workflow_id)
|
||||
# 重置工作流
|
||||
workflow.reset(db, workflow_id, reset_count=True)
|
||||
await Workflow.async_reset(db, workflow_id, reset_count=True)
|
||||
# 删除缓存
|
||||
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
获取工作流详情
|
||||
"""
|
||||
return await WorkflowOper(db).async_get(workflow_id)
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
更新工作流
|
||||
"""
|
||||
if not workflow.id:
|
||||
return schemas.Response(success=False, message="工作流ID不能为空")
|
||||
workflow_oper = WorkflowOper(db)
|
||||
wf = workflow_oper.get(workflow.id)
|
||||
if not wf:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
if not wf.trigger_type:
|
||||
workflow.trigger_type = "timer"
|
||||
wf.update(db, workflow.dict())
|
||||
# 更新后的工作流对象
|
||||
updated_workflow = workflow_oper.get(workflow.id)
|
||||
# 更新定时任务
|
||||
Scheduler().update_workflow_job(updated_workflow)
|
||||
# 更新事件注册
|
||||
WorkFlowManager().update_workflow_event(updated_workflow)
|
||||
return schemas.Response(success=True, message="更新成功")
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
删除工作流
|
||||
"""
|
||||
workflow = WorkflowOper(db).get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
if not workflow.trigger_type or workflow.trigger_type == "timer":
|
||||
# 定时触发:删除定时任务
|
||||
Scheduler().remove_workflow_job(workflow)
|
||||
else:
|
||||
# 事件触发:从事件触发器中移除
|
||||
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
|
||||
# 删除工作流
|
||||
Workflow.delete(db, workflow_id)
|
||||
# 删除缓存
|
||||
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
|
||||
return schemas.Response(success=True, message="删除成功")
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from typing import Any, List, Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tvdb import TvdbChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tvdb import TvdbChain
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_apikey
|
||||
from app.db import get_db
|
||||
from app.db import get_db, get_async_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.schemas import RadarrMovie, SonarrSeries
|
||||
from app.schemas.types import MediaType
|
||||
@@ -19,7 +20,7 @@ arr_router = APIRouter(tags=['servarr'])
|
||||
|
||||
|
||||
@arr_router.get("/system/status", summary="系统状态")
|
||||
def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr系统状态
|
||||
"""
|
||||
@@ -73,7 +74,7 @@ def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
||||
def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr质量配置
|
||||
"""
|
||||
@@ -114,7 +115,7 @@ def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/rootfolder", summary="根目录")
|
||||
def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr根目录
|
||||
"""
|
||||
@@ -130,7 +131,7 @@ def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/tag", summary="标签")
|
||||
def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr标签
|
||||
"""
|
||||
@@ -143,7 +144,7 @@ def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/languageprofile", summary="语言")
|
||||
def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr语言
|
||||
"""
|
||||
@@ -169,7 +170,7 @@ def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
|
||||
def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Rardar电影
|
||||
"""
|
||||
@@ -240,7 +241,7 @@ def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: Session = Depends(
|
||||
"""
|
||||
# 查询所有电影订阅
|
||||
result = []
|
||||
subscribes = Subscribe.list(db)
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
if subscribe.type != MediaType.MOVIE.value:
|
||||
continue
|
||||
@@ -306,11 +307,12 @@ def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: S
|
||||
|
||||
|
||||
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
|
||||
def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Rardar电影订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, mid)
|
||||
subscribe = await Subscribe.async_get(db, mid)
|
||||
if subscribe:
|
||||
return RadarrMovie(
|
||||
id=subscribe.id,
|
||||
@@ -332,25 +334,25 @@ def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], db: Session =
|
||||
|
||||
|
||||
@arr_router.post("/movie", summary="新增电影订阅")
|
||||
def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],
|
||||
movie: RadarrMovie,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
async def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],
|
||||
movie: RadarrMovie,
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
) -> Any:
|
||||
"""
|
||||
新增Rardar电影订阅
|
||||
"""
|
||||
# 检查订阅是否已存在
|
||||
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
|
||||
subscribe = await Subscribe.async_get_by_tmdbid(db, movie.tmdbId)
|
||||
if subscribe:
|
||||
return {
|
||||
"id": subscribe.id
|
||||
}
|
||||
# 添加订阅
|
||||
sid, message = SubscribeChain().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
|
||||
@@ -363,13 +365,14 @@ def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],
|
||||
|
||||
|
||||
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
|
||||
def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
删除Rardar电影订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, mid)
|
||||
subscribe = await Subscribe.async_get(db, mid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, mid)
|
||||
await subscribe.async_delete(db, mid)
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -379,7 +382,7 @@ def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], db: Se
|
||||
|
||||
|
||||
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
|
||||
def arr_series(_: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
@@ -487,7 +490,7 @@ def arr_series(_: Annotated[str, Depends(verify_apikey)], db: Session = Depends(
|
||||
"""
|
||||
# 查询所有电视剧订阅
|
||||
result = []
|
||||
subscribes = Subscribe.list(db)
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
continue
|
||||
@@ -605,11 +608,12 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db:
|
||||
|
||||
|
||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
||||
def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
subscribe = Subscribe.get(db, tid)
|
||||
subscribe = await Subscribe.async_get(db, tid)
|
||||
if subscribe:
|
||||
return SonarrSeries(
|
||||
id=subscribe.id,
|
||||
@@ -639,17 +643,17 @@ def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)], db: Session =
|
||||
|
||||
|
||||
@arr_router.post("/series", summary="新增剧集订阅")
|
||||
def arr_add_series(tv: schemas.SonarrSeries,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: Session = Depends(get_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 = Subscribe.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)
|
||||
@@ -664,12 +668,12 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
||||
for season in left_seasons:
|
||||
if not season.get("monitored"):
|
||||
continue
|
||||
sid, message = SubscribeChain().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 {
|
||||
@@ -683,21 +687,22 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
||||
|
||||
|
||||
@arr_router.put("/series", summary="更新剧集订阅")
|
||||
def arr_update_series(tv: schemas.SonarrSeries) -> Any:
|
||||
async def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
更新Sonarr剧集订阅
|
||||
"""
|
||||
return arr_add_series(tv)
|
||||
return await arr_add_series(tv)
|
||||
|
||||
|
||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||
def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
删除Sonarr剧集订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, tid)
|
||||
subscribe = await Subscribe.async_get(db, tid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, tid)
|
||||
await subscribe.async_delete(db, tid)
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -2,6 +2,7 @@ import gzip
|
||||
import json
|
||||
from typing import Annotated, Callable, Any, Dict, Optional
|
||||
|
||||
from aiopath import AsyncPath
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.routing import APIRoute
|
||||
@@ -19,7 +20,7 @@ class GzipRequest(Request):
|
||||
body = await super().body()
|
||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||
body = gzip.decompress(body)
|
||||
self._body = body # noqa
|
||||
self._body = body # noqa
|
||||
return self._body
|
||||
|
||||
|
||||
@@ -50,12 +51,12 @@ cookie_router = APIRouter(route_class=GzipRoute,
|
||||
|
||||
|
||||
@cookie_router.get("/", response_class=PlainTextResponse)
|
||||
def get_root():
|
||||
async def get_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
|
||||
|
||||
@cookie_router.post("/", response_class=PlainTextResponse)
|
||||
def post_root():
|
||||
async def post_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
|
||||
|
||||
@@ -64,31 +65,31 @@ async def update_cookie(req: schemas.CookieData):
|
||||
"""
|
||||
上传Cookie数据
|
||||
"""
|
||||
file_path = settings.COOKIE_PATH / f"{req.uuid}.json"
|
||||
file_path = AsyncPath(settings.COOKIE_PATH) / f"{req.uuid}.json"
|
||||
content = json.dumps({"encrypted": req.encrypted})
|
||||
with open(file_path, encoding="utf-8", mode="w") as file:
|
||||
file.write(content)
|
||||
with open(file_path, encoding="utf-8", mode="r") as file:
|
||||
read_content = file.read()
|
||||
async with file_path.open(encoding="utf-8", mode="w") as file:
|
||||
await file.write(content)
|
||||
async with file_path.open(encoding="utf-8", mode="r") as file:
|
||||
read_content = await file.read()
|
||||
if read_content == content:
|
||||
return {"action": "done"}
|
||||
else:
|
||||
return {"action": "error"}
|
||||
|
||||
|
||||
def load_encrypt_data(uuid: str) -> Dict[str, Any]:
|
||||
async def load_encrypt_data(uuid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
加载本地加密原始数据
|
||||
"""
|
||||
file_path = settings.COOKIE_PATH / f"{uuid}.json"
|
||||
file_path = AsyncPath(settings.COOKIE_PATH) / f"{uuid}.json"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# 读取文件
|
||||
with open(file_path, encoding="utf-8", mode="r") as file:
|
||||
read_content = file.read()
|
||||
async with file_path.open(encoding="utf-8", mode="r") as file:
|
||||
read_content = await file.read()
|
||||
data = json.loads(read_content.encode("utf-8"))
|
||||
return data
|
||||
|
||||
@@ -120,7 +121,7 @@ async def get_cookie(
|
||||
"""
|
||||
GET 下载加密数据
|
||||
"""
|
||||
return load_encrypt_data(uuid)
|
||||
return await load_encrypt_data(uuid)
|
||||
|
||||
|
||||
@cookie_router.post("/get/{uuid}")
|
||||
@@ -130,5 +131,5 @@ async def post_cookie(
|
||||
"""
|
||||
POST 下载加密数据
|
||||
"""
|
||||
data = load_encrypt_data(uuid)
|
||||
data = await load_encrypt_data(uuid)
|
||||
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import inspect
|
||||
import pickle
|
||||
import traceback
|
||||
from abc import ABCMeta
|
||||
@@ -6,6 +7,10 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Tuple, List, Set, Union, Dict
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
import aiofiles
|
||||
from aiopath import AsyncPath
|
||||
from qbittorrentapi import TorrentFilesList
|
||||
from transmission_rpc import File
|
||||
|
||||
@@ -58,6 +63,32 @@ class ChainBase(metaclass=ABCMeta):
|
||||
logger.error(f"加载缓存 {filename} 出错:{str(err)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def async_load_cache(filename: str) -> Any:
|
||||
"""
|
||||
异步从本地加载缓存
|
||||
"""
|
||||
cache_path = settings.TEMP_PATH / filename
|
||||
if cache_path.exists():
|
||||
try:
|
||||
async with aiofiles.open(cache_path, 'rb') as f:
|
||||
content = await f.read()
|
||||
return pickle.loads(content)
|
||||
except Exception as err:
|
||||
logger.error(f"加载缓存 {filename} 出错:{str(err)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def async_save_cache(cache: Any, filename: str) -> None:
|
||||
"""
|
||||
异步保存缓存到本地
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
await f.write(pickle.dumps(cache))
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
|
||||
@staticmethod
|
||||
def save_cache(cache: Any, filename: str) -> None:
|
||||
"""
|
||||
@@ -78,23 +109,78 @@ class ChainBase(metaclass=ABCMeta):
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
def run_module(self, method: str, *args, **kwargs) -> Any:
|
||||
@staticmethod
|
||||
async def async_remove_cache(filename: str) -> None:
|
||||
"""
|
||||
运行包含该方法的所有模块,然后返回结果
|
||||
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
|
||||
异步删除本地缓存
|
||||
"""
|
||||
cache_path = AsyncPath(settings.TEMP_PATH) / filename
|
||||
if await cache_path.exists():
|
||||
try:
|
||||
await cache_path.unlink()
|
||||
except Exception as err:
|
||||
logger.error(f"异步删除缓存 {filename} 出错:{str(err)}")
|
||||
|
||||
def is_result_empty(ret):
|
||||
"""
|
||||
判断结果是否为空
|
||||
"""
|
||||
if isinstance(ret, tuple):
|
||||
return all(value is None for value in ret)
|
||||
else:
|
||||
return ret is None
|
||||
@staticmethod
|
||||
def __is_valid_empty(ret):
|
||||
"""
|
||||
判断结果是否为空
|
||||
"""
|
||||
if isinstance(ret, tuple):
|
||||
return all(value is None for value in ret)
|
||||
else:
|
||||
return ret is None
|
||||
|
||||
result = None
|
||||
# 插件模块
|
||||
def __handle_plugin_error(self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs):
|
||||
"""
|
||||
处理插件模块执行错误
|
||||
"""
|
||||
if kwargs.get("raise_exception"):
|
||||
raise
|
||||
logger.error(
|
||||
f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{plugin_name} 发生了错误",
|
||||
message=str(err),
|
||||
role="plugin")
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
"type": "plugin",
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_name": plugin_name,
|
||||
"plugin_method": method,
|
||||
"error": str(err),
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
)
|
||||
|
||||
def __handle_system_error(self, err: Exception, module_id: str, module_name: str, method: str, **kwargs):
|
||||
"""
|
||||
处理系统模块执行错误
|
||||
"""
|
||||
if kwargs.get("raise_exception"):
|
||||
raise
|
||||
logger.error(
|
||||
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{module_name}发生了错误",
|
||||
message=str(err),
|
||||
role="system")
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
"type": "module",
|
||||
"module_id": module_id,
|
||||
"module_name": module_name,
|
||||
"module_method": method,
|
||||
"error": str(err),
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
)
|
||||
|
||||
def __execute_plugin_modules(self, method: str, result: Any, *args, **kwargs) -> Any:
|
||||
"""
|
||||
执行插件模块
|
||||
"""
|
||||
for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():
|
||||
plugin_id, plugin_name = plugin
|
||||
if method in module_dict:
|
||||
@@ -102,7 +188,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
if func:
|
||||
try:
|
||||
logger.info(f"请求插件 {plugin_name} 执行:{method} ...")
|
||||
if is_result_empty(result):
|
||||
if self.__is_valid_empty(result):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
result = func(*args, **kwargs)
|
||||
elif isinstance(result, list):
|
||||
@@ -113,29 +199,46 @@ class ChainBase(metaclass=ABCMeta):
|
||||
else:
|
||||
break
|
||||
except Exception as err:
|
||||
if kwargs.get("raise_exception"):
|
||||
raise
|
||||
logger.error(
|
||||
f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{plugin_name} 发生了错误",
|
||||
message=str(err),
|
||||
role="plugin")
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
"type": "plugin",
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_name": plugin_name,
|
||||
"plugin_method": method,
|
||||
"error": str(err),
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
)
|
||||
if not is_result_empty(result) and not isinstance(result, list):
|
||||
# 插件模块返回结果不为空且不是列表,直接返回
|
||||
return result
|
||||
self.__handle_plugin_error(err, plugin_id, plugin_name, method, **kwargs)
|
||||
return result
|
||||
|
||||
# 系统模块
|
||||
async def __async_execute_plugin_modules(self, method: str, result: Any, *args, **kwargs) -> Any:
|
||||
"""
|
||||
异步执行插件模块
|
||||
"""
|
||||
for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():
|
||||
plugin_id, plugin_name = plugin
|
||||
if method in module_dict:
|
||||
func = module_dict[method]
|
||||
if func:
|
||||
try:
|
||||
logger.info(f"请求插件 {plugin_name} 执行:{method} ...")
|
||||
if self.__is_valid_empty(result):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
if inspect.iscoroutinefunction(func):
|
||||
result = await func(*args, **kwargs)
|
||||
else:
|
||||
# 插件同步函数在异步环境中运行,避免阻塞
|
||||
result = await run_in_threadpool(func, *args, **kwargs)
|
||||
elif isinstance(result, list):
|
||||
# 返回为列表,有多个模块运行结果时进行合并
|
||||
if inspect.iscoroutinefunction(func):
|
||||
temp = await func(*args, **kwargs)
|
||||
else:
|
||||
# 插件同步函数在异步环境中运行,避免阻塞
|
||||
temp = await run_in_threadpool(func, *args, **kwargs)
|
||||
if isinstance(temp, list):
|
||||
result.extend(temp)
|
||||
else:
|
||||
break
|
||||
except Exception as err:
|
||||
self.__handle_plugin_error(err, plugin_id, plugin_name, method, **kwargs)
|
||||
return result
|
||||
|
||||
def __execute_system_modules(self, method: str, result: Any, *args, **kwargs) -> Any:
|
||||
"""
|
||||
执行系统模块
|
||||
"""
|
||||
logger.debug(f"请求系统模块执行:{method} ...")
|
||||
for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()):
|
||||
module_id = module.__class__.__name__
|
||||
@@ -146,7 +249,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
module_name = module_id
|
||||
try:
|
||||
func = getattr(module, method)
|
||||
if is_result_empty(result):
|
||||
if self.__is_valid_empty(result):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
result = func(*args, **kwargs)
|
||||
elif ObjectUtils.check_signature(func, result):
|
||||
@@ -161,26 +264,85 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 中止继续执行
|
||||
break
|
||||
except Exception as err:
|
||||
if kwargs.get("raise_exception"):
|
||||
raise
|
||||
logger.error(
|
||||
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{module_name}发生了错误",
|
||||
message=str(err),
|
||||
role="system")
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
"type": "module",
|
||||
"module_id": module_id,
|
||||
"module_name": module_name,
|
||||
"module_method": method,
|
||||
"error": str(err),
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
)
|
||||
self.__handle_system_error(err, module_id, module_name, method, **kwargs)
|
||||
return result
|
||||
|
||||
async def __async_execute_system_modules(self, method: str, result: Any, *args, **kwargs) -> Any:
|
||||
"""
|
||||
异步执行系统模块
|
||||
"""
|
||||
logger.debug(f"请求系统模块执行:{method} ...")
|
||||
for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()):
|
||||
module_id = module.__class__.__name__
|
||||
try:
|
||||
module_name = module.get_name()
|
||||
except Exception as err:
|
||||
logger.debug(f"获取模块名称出错:{str(err)}")
|
||||
module_name = module_id
|
||||
try:
|
||||
func = getattr(module, method)
|
||||
if self.__is_valid_empty(result):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
if inspect.iscoroutinefunction(func):
|
||||
result = await func(*args, **kwargs)
|
||||
else:
|
||||
result = func(*args, **kwargs)
|
||||
elif ObjectUtils.check_signature(func, result):
|
||||
# 返回结果与方法签名一致,将结果传入
|
||||
if inspect.iscoroutinefunction(func):
|
||||
result = await func(result)
|
||||
else:
|
||||
result = func(result)
|
||||
elif isinstance(result, list):
|
||||
# 返回为列表,有多个模块运行结果时进行合并
|
||||
if inspect.iscoroutinefunction(func):
|
||||
temp = await func(*args, **kwargs)
|
||||
else:
|
||||
temp = func(*args, **kwargs)
|
||||
if isinstance(temp, list):
|
||||
result.extend(temp)
|
||||
else:
|
||||
# 中止继续执行
|
||||
break
|
||||
except Exception as err:
|
||||
self.__handle_system_error(err, module_id, module_name, method, **kwargs)
|
||||
return result
|
||||
|
||||
def run_module(self, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行包含该方法的所有模块,然后返回结果
|
||||
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
|
||||
"""
|
||||
result = None
|
||||
|
||||
# 执行插件模块
|
||||
result = self.__execute_plugin_modules(method, result, *args, **kwargs)
|
||||
|
||||
if not self.__is_valid_empty(result) and not isinstance(result, list):
|
||||
# 插件模块返回结果不为空且不是列表,直接返回
|
||||
return result
|
||||
|
||||
# 执行系统模块
|
||||
return self.__execute_system_modules(method, result, *args, **kwargs)
|
||||
|
||||
async def async_run_module(self, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
异步运行包含该方法的所有模块,然后返回结果
|
||||
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
|
||||
支持异步和同步方法的混合调用
|
||||
"""
|
||||
result = None
|
||||
|
||||
# 执行插件模块
|
||||
result = await self.__async_execute_plugin_modules(method, result, *args, **kwargs)
|
||||
|
||||
if not self.__is_valid_empty(result) and not isinstance(result, list):
|
||||
# 插件模块返回结果不为空且不是列表,直接返回
|
||||
return result
|
||||
|
||||
# 执行系统模块
|
||||
return await self.__async_execute_system_modules(method, result, *args, **kwargs)
|
||||
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
@@ -214,6 +376,39 @@ class ChainBase(metaclass=ABCMeta):
|
||||
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,
|
||||
episode_group=episode_group, cache=cache)
|
||||
|
||||
async def async_recognize_media(self, meta: MetaBase = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息,不含Fanart图片(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||
:param tmdbid: tmdbid
|
||||
:param doubanid: 豆瓣ID
|
||||
:param bangumiid: BangumiID
|
||||
:param episode_group: 剧集组
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 识别用名中含指定信息情形
|
||||
if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||
mtype = meta.type
|
||||
if not tmdbid and hasattr(meta, "tmdbid"):
|
||||
tmdbid = meta.tmdbid
|
||||
if not doubanid and hasattr(meta, "doubanid"):
|
||||
doubanid = meta.doubanid
|
||||
# 有tmdbid时不使用其它ID
|
||||
if tmdbid:
|
||||
doubanid = None
|
||||
bangumiid = None
|
||||
return await self.async_run_module("async_recognize_media", meta=meta, mtype=mtype,
|
||||
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,
|
||||
episode_group=episode_group, cache=cache)
|
||||
|
||||
def match_doubaninfo(self, name: str, imdbid: Optional[str] = None,
|
||||
mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None,
|
||||
raise_exception: bool = False) -> Optional[dict]:
|
||||
@@ -229,6 +424,22 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
||||
mtype=mtype, year=year, season=season, raise_exception=raise_exception)
|
||||
|
||||
async def async_match_doubaninfo(self, name: str, imdbid: Optional[str] = None,
|
||||
mtype: Optional[MediaType] = None, year: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
raise_exception: bool = False) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配豆瓣信息(异步版本)
|
||||
:param name: 标题
|
||||
:param imdbid: imdbid
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
:param raise_exception: 触发速率限制时是否抛出异常
|
||||
"""
|
||||
return await self.async_run_module("async_match_doubaninfo", name=name, imdbid=imdbid,
|
||||
mtype=mtype, year=year, season=season, raise_exception=raise_exception)
|
||||
|
||||
def match_tmdbinfo(self, name: str, mtype: Optional[MediaType] = None,
|
||||
year: Optional[str] = None, season: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
@@ -241,6 +452,18 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("match_tmdbinfo", name=name,
|
||||
mtype=mtype, year=year, season=season)
|
||||
|
||||
async def async_match_tmdbinfo(self, name: str, mtype: Optional[MediaType] = None,
|
||||
year: Optional[str] = None, season: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配TMDB信息(异步版本)
|
||||
:param name: 标题
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
"""
|
||||
return await self.async_run_module("async_match_tmdbinfo", name=name,
|
||||
mtype=mtype, year=year, season=season)
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
补充抓取媒体信息图片
|
||||
@@ -249,6 +472,14 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("obtain_images", mediainfo=mediainfo)
|
||||
|
||||
async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
补充抓取媒体信息图片(异步版本)
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
return await self.async_run_module("async_obtain_images", mediainfo=mediainfo)
|
||||
|
||||
def obtain_specific_image(self, mediaid: Union[str, int], mtype: MediaType,
|
||||
image_type: MediaImageType, image_prefix: Optional[str] = None,
|
||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:
|
||||
@@ -276,6 +507,18 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception)
|
||||
|
||||
async def async_douban_info(self, doubanid: str, mtype: Optional[MediaType] = None,
|
||||
raise_exception: bool = False) -> Optional[dict]:
|
||||
"""
|
||||
获取豆瓣信息(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 媒体类型
|
||||
:return: 豆瓣信息
|
||||
:param raise_exception: 触发速率限制时是否抛出异常
|
||||
"""
|
||||
return await self.async_run_module("async_douban_info", doubanid=doubanid, mtype=mtype,
|
||||
raise_exception=raise_exception)
|
||||
|
||||
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取TVDB信息
|
||||
@@ -294,6 +537,16 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season)
|
||||
|
||||
async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
获取TMDB信息(异步版本)
|
||||
:param tmdbid: int
|
||||
:param mtype: 媒体类型
|
||||
:param season: 季
|
||||
:return: TVDB信息
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season)
|
||||
|
||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息
|
||||
@@ -302,6 +555,14 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
||||
|
||||
async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息(异步版本)
|
||||
:param bangumiid: int
|
||||
:return: Bangumi信息
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)
|
||||
|
||||
def message_parser(self, source: str, body: Any, form: Any,
|
||||
args: Any) -> Optional[CommingMessage]:
|
||||
"""
|
||||
@@ -335,6 +596,14 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("search_medias", meta=meta)
|
||||
|
||||
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_search_medias", meta=meta)
|
||||
|
||||
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息
|
||||
@@ -342,6 +611,13 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("search_persons", name=name)
|
||||
|
||||
async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息(异步版本)
|
||||
:param name: 人物名称
|
||||
"""
|
||||
return await self.async_run_module("async_search_persons", name=name)
|
||||
|
||||
def search_collections(self, name: str) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索集合信息
|
||||
@@ -349,21 +625,43 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("search_collections", name=name)
|
||||
|
||||
async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索集合信息(异步版本)
|
||||
:param name: 集合名称
|
||||
"""
|
||||
return await self.async_run_module("async_search_collections", name=name)
|
||||
|
||||
def search_torrents(self, site: dict,
|
||||
keywords: List[str],
|
||||
keyword: str,
|
||||
mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
搜索一个站点的种子资源
|
||||
:param site: 站点
|
||||
:param keywords: 搜索关键词列表
|
||||
:param keyword: 搜索关键词
|
||||
:param mtype: 媒体类型
|
||||
:param page: 页码
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
return self.run_module("search_torrents", site=site, keywords=keywords,
|
||||
return self.run_module("search_torrents", site=site, keyword=keyword,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
async def async_search_torrents(self, site: dict,
|
||||
keyword: str,
|
||||
mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
异步搜索一个站点的种子资源
|
||||
:param site: 站点
|
||||
:param keyword: 搜索关键词
|
||||
:param mtype: 媒体类型
|
||||
:param page: 页码
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
return await self.async_run_module("async_search_torrents", site=site, keyword=keyword,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
@@ -376,6 +674,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("refresh_torrents", site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
async def async_refresh_torrents(self, site: dict, keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
异步获取站点最新一页的种子,多个站点需要多线程处理
|
||||
:param site: 站点
|
||||
:param keyword: 标题
|
||||
:param cat: 分类
|
||||
:param page: 页码
|
||||
:reutrn: 种子资源列表
|
||||
"""
|
||||
return await self.async_run_module("async_refresh_torrents",
|
||||
site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
def filter_torrents(self, rule_groups: List[str],
|
||||
torrent_list: List[TorrentInfo],
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
@@ -611,6 +922,85 @@ class ChainBase(metaclass=ABCMeta):
|
||||
self.messagequeue.send_message("post_message", message=message,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
async def async_post_message(self,
|
||||
message: Optional[Notification] = None,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
torrentinfo: Optional[TorrentInfo] = None,
|
||||
transferinfo: Optional[TransferInfo] = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
异步发送消息
|
||||
:param message: Notification实例
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrentinfo: 种子信息
|
||||
:param transferinfo: 文件整理信息
|
||||
:param kwargs: 其他参数(覆盖业务对象属性值)
|
||||
:return: 成功或失败
|
||||
"""
|
||||
# 渲染消息
|
||||
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
|
||||
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
|
||||
# 保存消息
|
||||
self.messagehelper.put(message, role="user", title=message.title)
|
||||
await self.messageoper.async_add(**message.dict())
|
||||
# 发送消息按设置隔离
|
||||
if not message.userid and message.mtype:
|
||||
# 消息隔离设置
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action:
|
||||
# 'admin' 'user,admin' 'user' 'all'
|
||||
actions = notify_action.split(",")
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
useroper = UserOper()
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}")
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = useroper.get_settings(send_message.username)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
|
||||
continue
|
||||
elif send_message.username == settings.SUPERUSER:
|
||||
# 管理员同名已发送
|
||||
admin_sended = True
|
||||
else:
|
||||
# 按原消息发送全体
|
||||
if not admin_sended:
|
||||
send_orignal = True
|
||||
break
|
||||
# 按设定发送
|
||||
await self.eventmanager.async_send_event(etype=EventType.NoticeMessage,
|
||||
data={**send_message.dict(), "type": send_message.mtype})
|
||||
await self.messagequeue.async_send_message("post_message", message=send_message)
|
||||
if not send_orignal:
|
||||
return
|
||||
# 发送消息事件
|
||||
await self.eventmanager.async_send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 按原消息发送
|
||||
await self.messagequeue.async_send_message("post_message", message=message,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
"""
|
||||
发送媒体信息选择列表
|
||||
|
||||
@@ -57,3 +57,51 @@ class BangumiChain(ChainBase):
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("bangumi_person_credits", person_id=person_id)
|
||||
|
||||
async def async_calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_calendar")
|
||||
|
||||
async def async_discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现Bangumi番剧(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_discover", **kwargs)
|
||||
|
||||
async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
:return: Bangumi信息
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)
|
||||
|
||||
async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据BangumiID查询电影演职员表(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_credits", bangumiid=bangumiid)
|
||||
|
||||
async def async_bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据BangumiID查询推荐电影(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid)
|
||||
|
||||
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据人物ID查询Bangumi人物详情(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_person_detail", person_id=person_id)
|
||||
|
||||
async def async_person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_person_credits", person_id=person_id)
|
||||
|
||||
@@ -111,3 +111,111 @@ class DoubanChain(ChainBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_tv_recommend", doubanid=doubanid)
|
||||
|
||||
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据人物ID查询豆瓣人物详情(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_person_detail", person_id=person_id)
|
||||
|
||||
async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_douban_person_credits", person_id=person_id, page=page)
|
||||
|
||||
async def async_movie_top250(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取豆瓣电影TOP250(异步版本)
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
"""
|
||||
return await self.async_run_module("async_movie_top250", page=page, count=count)
|
||||
|
||||
async def async_movie_showing(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取正在上映的电影(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_movie_showing", page=page, count=count)
|
||||
|
||||
async def async_tv_weekly_chinese(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周中国剧集榜(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_weekly_chinese", page=page, count=count)
|
||||
|
||||
async def async_tv_weekly_global(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周全球剧集榜(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_weekly_global", page=page, count=count)
|
||||
|
||||
async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||
page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现豆瓣电影、剧集(异步版本)
|
||||
:param mtype: 媒体类型
|
||||
:param sort: 排序方式
|
||||
:param tags: 标签
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||
page=page, count=count)
|
||||
|
||||
async def async_tv_animation(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取动画剧集(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_animation", page=page, count=count)
|
||||
|
||||
async def async_movie_hot(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门电影(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_movie_hot", page=page, count=count)
|
||||
|
||||
async def async_tv_hot(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门剧集(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_hot", page=page, count=count)
|
||||
|
||||
async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid)
|
||||
|
||||
async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_tv_credits", doubanid=doubanid)
|
||||
|
||||
async def async_movie_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_movie_recommend", doubanid=doubanid)
|
||||
|
||||
async def async_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_tv_recommend", doubanid=doubanid)
|
||||
|
||||
@@ -60,6 +60,8 @@ class DownloadChain(ChainBase):
|
||||
# 是否使用cookie
|
||||
if not req_params.get('cookie'):
|
||||
cookie = None
|
||||
# 代理
|
||||
proxy = req_params.get('proxy')
|
||||
# 请求头
|
||||
if req_params.get('header'):
|
||||
headers = req_params.get('header')
|
||||
@@ -70,14 +72,16 @@ class DownloadChain(ChainBase):
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).get_res(url, params=req_params.get('params'))
|
||||
else:
|
||||
# POST请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).post_res(url, params=req_params.get('params'))
|
||||
if not res:
|
||||
return None
|
||||
@@ -188,6 +192,9 @@ class DownloadChain(ChainBase):
|
||||
f"Resource download canceled by event: {event_data.source},"
|
||||
f"Reason: {event_data.reason}")
|
||||
return None
|
||||
# 如果事件修改了下载路径,使用新路径
|
||||
if event_data.options and event_data.options.get("save_path"):
|
||||
save_path = event_data.options.get("save_path")
|
||||
|
||||
# 补充完整的media数据
|
||||
if not _media.genre_ids:
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.utils.string import StringUtils
|
||||
|
||||
recognize_lock = Lock()
|
||||
scraping_lock = Lock()
|
||||
scraping_files = []
|
||||
|
||||
|
||||
class MediaChain(ChainBase):
|
||||
@@ -35,25 +34,25 @@ class MediaChain(ChainBase):
|
||||
switchs = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {}
|
||||
# 默认配置
|
||||
default_switchs = {
|
||||
'movie_nfo': True, # 电影NFO
|
||||
'movie_poster': True, # 电影海报
|
||||
'movie_backdrop': True, # 电影背景图
|
||||
'movie_logo': True, # 电影Logo
|
||||
'movie_disc': True, # 电影光盘图
|
||||
'movie_banner': True, # 电影横幅图
|
||||
'movie_thumb': True, # 电影缩略图
|
||||
'tv_nfo': True, # 电视剧NFO
|
||||
'tv_poster': True, # 电视剧海报
|
||||
'tv_backdrop': True, # 电视剧背景图
|
||||
'tv_banner': True, # 电视剧横幅图
|
||||
'tv_logo': True, # 电视剧Logo
|
||||
'tv_thumb': True, # 电视剧缩略图
|
||||
'season_nfo': True, # 季NFO
|
||||
'season_poster': True, # 季海报
|
||||
'season_banner': True, # 季横幅图
|
||||
'season_thumb': True, # 季缩略图
|
||||
'episode_nfo': True, # 集NFO
|
||||
'episode_thumb': True # 集缩略图
|
||||
'movie_nfo': True, # 电影NFO
|
||||
'movie_poster': True, # 电影海报
|
||||
'movie_backdrop': True, # 电影背景图
|
||||
'movie_logo': True, # 电影Logo
|
||||
'movie_disc': True, # 电影光盘图
|
||||
'movie_banner': True, # 电影横幅图
|
||||
'movie_thumb': True, # 电影缩略图
|
||||
'tv_nfo': True, # 电视剧NFO
|
||||
'tv_poster': True, # 电视剧海报
|
||||
'tv_backdrop': True, # 电视剧背景图
|
||||
'tv_banner': True, # 电视剧横幅图
|
||||
'tv_logo': True, # 电视剧Logo
|
||||
'tv_thumb': True, # 电视剧缩略图
|
||||
'season_nfo': True, # 季NFO
|
||||
'season_poster': True, # 季海报
|
||||
'season_banner': True, # 季横幅图
|
||||
'season_thumb': True, # 季缩略图
|
||||
'episode_nfo': True, # 集NFO
|
||||
'episode_thumb': True # 集缩略图
|
||||
}
|
||||
# 合并用户配置和默认配置
|
||||
for key, default_value in default_switchs.items():
|
||||
@@ -231,17 +230,15 @@ class MediaChain(ChainBase):
|
||||
meta_names = list(dict.fromkeys([k for k in [meta_org.name,
|
||||
meta.cn_name,
|
||||
meta.en_name] if k]))
|
||||
for name in meta_names:
|
||||
tmdbinfo = self.match_tmdbinfo(
|
||||
name=name,
|
||||
year=meta.year,
|
||||
mtype=mtype or meta.type,
|
||||
season=meta.begin_season
|
||||
)
|
||||
if tmdbinfo:
|
||||
# 合季季后返回
|
||||
tmdbinfo['season'] = meta.begin_season
|
||||
break
|
||||
tmdbinfo = self._match_tmdb_with_names(
|
||||
meta_names=meta_names,
|
||||
year=meta.year,
|
||||
mtype=mtype or meta.type,
|
||||
season=meta.begin_season
|
||||
)
|
||||
if tmdbinfo:
|
||||
# 合季季后返回
|
||||
tmdbinfo['season'] = meta.begin_season
|
||||
return tmdbinfo
|
||||
|
||||
def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||||
@@ -257,23 +254,17 @@ class MediaChain(ChainBase):
|
||||
else:
|
||||
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||
# 年份
|
||||
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||||
if release_date:
|
||||
year = release_date[:4]
|
||||
else:
|
||||
year = None
|
||||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||||
# 识别TMDB媒体信息
|
||||
meta_names = list(dict.fromkeys([k for k in [meta_cn.name,
|
||||
meta.name] if k]))
|
||||
for name in meta_names:
|
||||
tmdbinfo = self.match_tmdbinfo(
|
||||
name=name,
|
||||
year=year,
|
||||
mtype=MediaType.TV,
|
||||
season=meta.begin_season
|
||||
)
|
||||
if tmdbinfo:
|
||||
return tmdbinfo
|
||||
tmdbinfo = self._match_tmdb_with_names(
|
||||
meta_names=meta_names,
|
||||
year=year,
|
||||
mtype=MediaType.TV,
|
||||
season=meta.begin_season
|
||||
)
|
||||
return tmdbinfo
|
||||
return None
|
||||
|
||||
def get_doubaninfo_by_tmdbid(self, tmdbid: int,
|
||||
@@ -286,19 +277,7 @@ class MediaChain(ChainBase):
|
||||
# 名称
|
||||
name = tmdbinfo.get("title") or tmdbinfo.get("name")
|
||||
# 年份
|
||||
year = None
|
||||
if tmdbinfo.get('release_date'):
|
||||
year = tmdbinfo['release_date'][:4]
|
||||
elif tmdbinfo.get('seasons') and season:
|
||||
for seainfo in tmdbinfo['seasons']:
|
||||
# 季
|
||||
season_number = seainfo.get("season_number")
|
||||
if not season_number:
|
||||
continue
|
||||
air_date = seainfo.get("air_date")
|
||||
if air_date and season_number == season:
|
||||
year = air_date[:4]
|
||||
break
|
||||
year = self._extract_year_from_tmdb(tmdbinfo, season)
|
||||
# IMDBID
|
||||
imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id")
|
||||
return self.match_doubaninfo(
|
||||
@@ -321,11 +300,7 @@ class MediaChain(ChainBase):
|
||||
else:
|
||||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||
# 年份
|
||||
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||||
if release_date:
|
||||
year = release_date[:4]
|
||||
else:
|
||||
year = None
|
||||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||||
# 使用名称识别豆瓣媒体信息
|
||||
return self.match_doubaninfo(
|
||||
name=meta.name,
|
||||
@@ -344,23 +319,49 @@ class MediaChain(ChainBase):
|
||||
return
|
||||
event_data = event.event_data or {}
|
||||
fileitem: FileItem = event_data.get("fileitem")
|
||||
file_list: List[str] = event_data.get("file_list", [])
|
||||
meta: MetaBase = event_data.get("meta")
|
||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||
overwrite = event_data.get("overwrite", False)
|
||||
if not fileitem:
|
||||
return
|
||||
|
||||
# 刮削锁
|
||||
with scraping_lock:
|
||||
if fileitem.path in scraping_files:
|
||||
# 检查文件项是否存在
|
||||
storagechain = StorageChain()
|
||||
if not storagechain.get_item(fileitem):
|
||||
logger.warn(f"文件项不存在:{fileitem.path}")
|
||||
return
|
||||
scraping_files.append(fileitem.path)
|
||||
try:
|
||||
# 执行刮削
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
|
||||
finally:
|
||||
# 释放锁
|
||||
with scraping_lock:
|
||||
scraping_files.remove(fileitem.path)
|
||||
# 检查是否为目录
|
||||
if fileitem.type == "file":
|
||||
# 单个文件刮削
|
||||
self.scrape_metadata(fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
parent=storagechain.get_parent_item(fileitem),
|
||||
overwrite=overwrite)
|
||||
else:
|
||||
# 检查目的目录下是否已经有nfo刮削文件
|
||||
has_nfo_file = storagechain.any_files(fileitem, extensions=['.nfo'])
|
||||
if has_nfo_file and file_list:
|
||||
logger.info(f"目录 {fileitem.path} 已有NFO文件,开始增量刮削...")
|
||||
for file_path in file_list:
|
||||
file_item = storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=Path(file_path))
|
||||
if file_item:
|
||||
# 对于电视剧文件,应该保存到与视频文件相同的目录
|
||||
# 而不是电视剧根目录
|
||||
self.scrape_metadata(fileitem=file_item,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
parent=None, # 让函数内部自动获取正确的父目录
|
||||
overwrite=overwrite)
|
||||
else:
|
||||
# 执行全量刮削
|
||||
logger.info(f"开始全量刮削目录 {fileitem.path} ...")
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, init_folder=True,
|
||||
mediainfo=mediainfo, overwrite=overwrite)
|
||||
|
||||
def scrape_metadata(self, fileitem: schemas.FileItem,
|
||||
meta: MetaBase = None, mediainfo: MediaInfo = None,
|
||||
@@ -407,8 +408,10 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
if not _fileitem or not _content or not _path:
|
||||
return
|
||||
# 保存文件到临时目录,文件名随机
|
||||
tmp_file = settings.TEMP_PATH / f"{_path.name}.{StringUtils.generate_random_str(10)}"
|
||||
# 保存文件到临时目录
|
||||
tmp_dir = settings.TEMP_PATH / StringUtils.generate_random_str(10)
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_file = tmp_dir / _path.name
|
||||
tmp_file.write_bytes(_content)
|
||||
# 获取文件的父目录
|
||||
try:
|
||||
@@ -427,7 +430,7 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
try:
|
||||
logger.info(f"正在下载图片:{_url} ...")
|
||||
r = RequestUtils(proxies=settings.PROXY, ua=settings.USER_AGENT).get_res(url=_url)
|
||||
r = RequestUtils(proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT).get_res(url=_url)
|
||||
if r:
|
||||
return r.content
|
||||
else:
|
||||
@@ -436,6 +439,9 @@ class MediaChain(ChainBase):
|
||||
logger.error(f"{_url} 图片下载失败:{str(err)}!")
|
||||
return None
|
||||
|
||||
if not fileitem:
|
||||
return
|
||||
|
||||
# 当前文件路径
|
||||
filepath = Path(fileitem.path)
|
||||
if fileitem.type == "file" \
|
||||
@@ -464,6 +470,8 @@ class MediaChain(ChainBase):
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
@@ -494,8 +502,9 @@ class MediaChain(ChainBase):
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
for file in files:
|
||||
self.scrape_metadata(fileitem=file,
|
||||
meta=meta, mediainfo=mediainfo,
|
||||
init_folder=False, parent=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
parent=fileitem,
|
||||
overwrite=overwrite)
|
||||
# 生成目录内图片文件
|
||||
if init_folder:
|
||||
@@ -587,11 +596,11 @@ class MediaChain(ChainBase):
|
||||
else:
|
||||
logger.info("集缩略图刮削已关闭,跳过")
|
||||
else:
|
||||
# 当前为目录,处理目录内的文件
|
||||
# 当前为电视剧目录,处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
for file in files:
|
||||
self.scrape_metadata(fileitem=file,
|
||||
meta=meta, mediainfo=mediainfo,
|
||||
mediainfo=mediainfo,
|
||||
parent=fileitem if file.type == "file" else None,
|
||||
init_folder=True if file.type == "dir" else False,
|
||||
overwrite=overwrite)
|
||||
@@ -659,7 +668,8 @@ class MediaChain(ChainBase):
|
||||
# 只下载当前刮削季的图片
|
||||
image_season = "00" if "specials" in image_name else image_name[6:8]
|
||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
logger.info(
|
||||
f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
continue
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
@@ -729,3 +739,295 @@ class MediaChain(ChainBase):
|
||||
else:
|
||||
logger.info(f"电视剧图片刮削已关闭,跳过:{image_name}")
|
||||
logger.info(f"{filepath.name} 刮削完成")
|
||||
|
||||
async def async_recognize_by_meta(self, metainfo: MetaBase,
|
||||
episode_group: Optional[str] = None) -> Optional[MediaInfo]:
|
||||
"""
|
||||
根据主副标题识别媒体信息(异步版本)
|
||||
"""
|
||||
title = metainfo.title
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(meta=metainfo, episode_group=episode_group)
|
||||
if not mediainfo:
|
||||
# 尝试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(ChainEventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{title} ...')
|
||||
mediainfo = await self.async_recognize_help(title=title, org_meta=metainfo)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{title} 未识别到媒体信息')
|
||||
return None
|
||||
# 识别成功
|
||||
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
await self.async_obtain_images(mediainfo=mediainfo)
|
||||
# 返回上下文
|
||||
return mediainfo
|
||||
|
||||
async def async_recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
请求辅助识别,返回媒体信息(异步版本)
|
||||
:param title: 标题
|
||||
:param org_meta: 原始元数据
|
||||
"""
|
||||
# 发送请求事件,等待结果
|
||||
result: Event = await eventmanager.async_send_event(
|
||||
ChainEventType.NameRecognize,
|
||||
{
|
||||
'title': title,
|
||||
}
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
# 获取返回事件数据
|
||||
event_data = result.event_data or {}
|
||||
logger.info(f'获取到辅助识别结果:{event_data}')
|
||||
# 处理数据格式
|
||||
title, year, season_number, episode_number = None, None, None, None
|
||||
if event_data.get("name"):
|
||||
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||||
if event_data.get("year"):
|
||||
year = str(event_data["year"]).split("/")[0].strip()
|
||||
if event_data.get("season") and str(event_data["season"]).isdigit():
|
||||
season_number = int(event_data["season"])
|
||||
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
||||
episode_number = int(event_data["episode"])
|
||||
if not title:
|
||||
return None
|
||||
if title == 'Unknown':
|
||||
return None
|
||||
if not str(year).isdigit():
|
||||
year = None
|
||||
# 结果赋值
|
||||
if title == org_meta.name and year == org_meta.year:
|
||||
logger.info(f'辅助识别与原始识别结果一致,无需重新识别媒体信息')
|
||||
return None
|
||||
logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...')
|
||||
org_meta.name = title
|
||||
org_meta.year = year
|
||||
org_meta.begin_season = season_number
|
||||
org_meta.begin_episode = episode_number
|
||||
if org_meta.begin_season or org_meta.begin_episode:
|
||||
org_meta.type = MediaType.TV
|
||||
# 重新识别
|
||||
return await self.async_recognize_media(meta=org_meta)
|
||||
|
||||
async def async_recognize_by_path(self, path: str, episode_group: Optional[str] = None) -> Optional[Context]:
|
||||
"""
|
||||
根据文件路径识别媒体信息(异步版本)
|
||||
"""
|
||||
logger.info(f'开始识别媒体信息,文件:{path} ...')
|
||||
file_path = Path(path)
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
# 识别媒体信息
|
||||
mediainfo = await self.async_recognize_media(meta=file_meta, episode_group=episode_group)
|
||||
if not mediainfo:
|
||||
# 尝试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(ChainEventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
|
||||
mediainfo = await self.async_recognize_help(title=path, org_meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{path} 未识别到媒体信息')
|
||||
return Context(meta_info=file_meta)
|
||||
logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
await self.async_obtain_images(mediainfo=mediainfo)
|
||||
# 返回上下文
|
||||
return Context(meta_info=file_meta, media_info=mediainfo)
|
||||
|
||||
async def async_search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体/人物信息(异步版本)
|
||||
:param title: 搜索内容
|
||||
:return: 识别元数据,媒体信息列表
|
||||
"""
|
||||
# 提取要素
|
||||
mtype, key_word, season_num, episode_num, year, content = StringUtils.get_keyword(title)
|
||||
# 识别
|
||||
meta = MetaInfo(content)
|
||||
if not meta.name:
|
||||
meta.cn_name = content
|
||||
# 合并信息
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if season_num:
|
||||
meta.begin_season = season_num
|
||||
if episode_num:
|
||||
meta.begin_episode = episode_num
|
||||
if year:
|
||||
meta.year = year
|
||||
# 开始搜索
|
||||
logger.info(f"开始搜索媒体信息:{meta.name}")
|
||||
medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta)
|
||||
if not medias:
|
||||
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
|
||||
return meta, []
|
||||
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
|
||||
# 识别的元数据,媒体信息列表
|
||||
return meta, medias
|
||||
|
||||
@staticmethod
|
||||
def _extract_year_from_bangumi(bangumiinfo: dict) -> Optional[str]:
|
||||
"""
|
||||
从Bangumi信息中提取年份
|
||||
"""
|
||||
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||||
if release_date:
|
||||
return release_date[:4]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_year_from_tmdb(tmdbinfo: dict, season: Optional[int] = None) -> Optional[str]:
|
||||
"""
|
||||
从TMDB信息中提取年份
|
||||
"""
|
||||
year = None
|
||||
if tmdbinfo.get('release_date'):
|
||||
year = tmdbinfo['release_date'][:4]
|
||||
elif tmdbinfo.get('seasons') and season:
|
||||
for seainfo in tmdbinfo['seasons']:
|
||||
season_number = seainfo.get("season_number")
|
||||
if not season_number:
|
||||
continue
|
||||
air_date = seainfo.get("air_date")
|
||||
if air_date and season_number == season:
|
||||
year = air_date[:4]
|
||||
break
|
||||
return year
|
||||
|
||||
def _match_tmdb_with_names(self, meta_names: list, year: Optional[str],
|
||||
mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
使用名称列表匹配TMDB信息
|
||||
"""
|
||||
for name in meta_names:
|
||||
tmdbinfo = self.match_tmdbinfo(
|
||||
name=name,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
season=season
|
||||
)
|
||||
if tmdbinfo:
|
||||
return tmdbinfo
|
||||
return None
|
||||
|
||||
async def _async_match_tmdb_with_names(self, meta_names: list, year: Optional[str],
|
||||
mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
使用名称列表匹配TMDB信息(异步版本)
|
||||
"""
|
||||
for name in meta_names:
|
||||
tmdbinfo = await self.async_match_tmdbinfo(
|
||||
name=name,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
season=season
|
||||
)
|
||||
if tmdbinfo:
|
||||
return tmdbinfo
|
||||
return None
|
||||
|
||||
async def async_get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
||||
"""
|
||||
根据豆瓣ID获取TMDB信息(异步版本)
|
||||
"""
|
||||
tmdbinfo = None
|
||||
doubaninfo = await self.async_douban_info(doubanid=doubanid, mtype=mtype)
|
||||
if doubaninfo:
|
||||
# 优先使用原标题匹配
|
||||
if doubaninfo.get("original_title"):
|
||||
meta = MetaInfo(title=doubaninfo.get("title"))
|
||||
meta_org = MetaInfo(title=doubaninfo.get("original_title"))
|
||||
else:
|
||||
meta_org = meta = MetaInfo(title=doubaninfo.get("title"))
|
||||
# 年份
|
||||
if doubaninfo.get("year"):
|
||||
meta.year = doubaninfo.get("year")
|
||||
# 处理类型
|
||||
if isinstance(doubaninfo.get('media_type'), MediaType):
|
||||
meta.type = doubaninfo.get('media_type')
|
||||
else:
|
||||
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
|
||||
# 匹配TMDB信息
|
||||
meta_names = list(dict.fromkeys([k for k in [meta_org.name,
|
||||
meta.cn_name,
|
||||
meta.en_name] if k]))
|
||||
tmdbinfo = await self._async_match_tmdb_with_names(
|
||||
meta_names=meta_names,
|
||||
year=meta.year,
|
||||
mtype=mtype or meta.type,
|
||||
season=meta.begin_season
|
||||
)
|
||||
if tmdbinfo:
|
||||
# 合季季后返回
|
||||
tmdbinfo['season'] = meta.begin_season
|
||||
return tmdbinfo
|
||||
|
||||
async def async_get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
根据BangumiID获取TMDB信息(异步版本)
|
||||
"""
|
||||
bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid)
|
||||
if bangumiinfo:
|
||||
# 优先使用原标题匹配
|
||||
if bangumiinfo.get("name_cn"):
|
||||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||
meta_cn = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||||
else:
|
||||
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||
# 年份
|
||||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||||
# 识别TMDB媒体信息
|
||||
meta_names = list(dict.fromkeys([k for k in [meta_cn.name,
|
||||
meta.name] if k]))
|
||||
tmdbinfo = await self._async_match_tmdb_with_names(
|
||||
meta_names=meta_names,
|
||||
year=year,
|
||||
mtype=MediaType.TV,
|
||||
season=meta.begin_season
|
||||
)
|
||||
return tmdbinfo
|
||||
return None
|
||||
|
||||
async def async_get_doubaninfo_by_tmdbid(self, tmdbid: int, mtype: MediaType = None,
|
||||
season: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
根据TMDBID获取豆瓣信息(异步版本)
|
||||
"""
|
||||
tmdbinfo = await self.async_tmdb_info(tmdbid=tmdbid, mtype=mtype)
|
||||
if tmdbinfo:
|
||||
# 名称
|
||||
name = tmdbinfo.get("title") or tmdbinfo.get("name")
|
||||
# 年份
|
||||
year = self._extract_year_from_tmdb(tmdbinfo, season)
|
||||
# IMDBID
|
||||
imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id")
|
||||
return await self.async_match_doubaninfo(
|
||||
name=name,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
imdbid=imdbid
|
||||
)
|
||||
return None
|
||||
|
||||
async def async_get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
根据BangumiID获取豆瓣信息(异步版本)
|
||||
"""
|
||||
bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid)
|
||||
if bangumiinfo:
|
||||
# 优先使用中文标题匹配
|
||||
if bangumiinfo.get("name_cn"):
|
||||
meta = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||||
else:
|
||||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||
# 年份
|
||||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||||
# 使用名称识别豆瓣媒体信息
|
||||
return await self.async_match_doubaninfo(
|
||||
name=meta.name,
|
||||
year=year,
|
||||
mtype=MediaType.TV,
|
||||
season=meta.begin_season
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
import io
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import aiofiles
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from PIL import Image
|
||||
from aiopath import AsyncPath
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.bangumi import BangumiChain
|
||||
@@ -14,8 +15,9 @@ from app.core.cache import cache_backend, cached
|
||||
from app.core.config import settings, global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.asyncio import AsyncUtils
|
||||
from app.utils.common import log_execution_time
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.http import AsyncRequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -34,127 +36,13 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
def refresh_recommend(self):
|
||||
"""
|
||||
刷新推荐
|
||||
刷新推荐数据 - 同步包装器
|
||||
"""
|
||||
logger.debug("Starting to refresh Recommend data.")
|
||||
cache_backend.clear(region=recommend_cache_region)
|
||||
logger.debug("Recommend Cache has been cleared.")
|
||||
|
||||
# 推荐来源方法
|
||||
recommend_methods = [
|
||||
self.tmdb_movies,
|
||||
self.tmdb_tvs,
|
||||
self.tmdb_trending,
|
||||
self.bangumi_calendar,
|
||||
self.douban_movie_showing,
|
||||
self.douban_movies,
|
||||
self.douban_tvs,
|
||||
self.douban_movie_top250,
|
||||
self.douban_tv_weekly_chinese,
|
||||
self.douban_tv_weekly_global,
|
||||
self.douban_tv_animation,
|
||||
self.douban_movie_hot,
|
||||
self.douban_tv_hot,
|
||||
]
|
||||
|
||||
# 缓存并刷新所有推荐数据
|
||||
recommends = []
|
||||
# 记录哪些方法已完成
|
||||
methods_finished = set()
|
||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||
for page in range(1, self.cache_max_pages + 1):
|
||||
for method in recommend_methods:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
if method in methods_finished:
|
||||
continue
|
||||
logger.debug(f"Fetch {method.__name__} data for page {page}.")
|
||||
data = method(page=page)
|
||||
if not data:
|
||||
logger.debug("All recommendation methods have finished fetching data. Ending pagination early.")
|
||||
methods_finished.add(method)
|
||||
continue
|
||||
recommends.extend(data)
|
||||
# 如果所有方法都已经完成,提前结束循环
|
||||
if len(methods_finished) == len(recommend_methods):
|
||||
break
|
||||
|
||||
# 缓存收集到的海报
|
||||
self.__cache_posters(recommends)
|
||||
logger.debug("Recommend data refresh completed.")
|
||||
|
||||
def __cache_posters(self, datas: List[dict]):
|
||||
"""
|
||||
提取 poster_path 并缓存图片
|
||||
:param datas: 数据列表
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE:
|
||||
return
|
||||
|
||||
for data in datas:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
poster_path = data.get("poster_path")
|
||||
if poster_path:
|
||||
poster_url = poster_path.replace("original", "w500")
|
||||
logger.debug(f"Caching poster image: {poster_url}")
|
||||
self.__fetch_and_save_image(poster_url)
|
||||
|
||||
@staticmethod
|
||||
def __fetch_and_save_image(url: str):
|
||||
"""
|
||||
请求并保存图片
|
||||
:param url: 图片路径
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE or not url:
|
||||
return
|
||||
|
||||
# 生成缓存路径
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = settings.CACHE_PATH / "images" / sanitized_path
|
||||
|
||||
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
|
||||
if not cache_path.suffix:
|
||||
cache_path = cache_path.with_suffix(".jpg")
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES):
|
||||
logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}")
|
||||
return
|
||||
|
||||
# 本地存在缓存图片,则直接跳过
|
||||
if cache_path.exists():
|
||||
logger.debug(f"Cache hit: Image already exists at {cache_path}")
|
||||
return
|
||||
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if not referer else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
if not response:
|
||||
logger.debug(f"Empty response for URL: {url}")
|
||||
return
|
||||
|
||||
# 验证下载的内容是否为有效图片
|
||||
try:
|
||||
Image.open(io.BytesIO(response.content)).verify()
|
||||
AsyncUtils.run_async(self.async_refresh_recommend())
|
||||
except Exception as e:
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
return
|
||||
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
try:
|
||||
if not cache_path.parent.exists():
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
tmp_file.write(response.content)
|
||||
temp_path = Path(tmp_file.name)
|
||||
temp_path.replace(cache_path)
|
||||
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||
logger.error(f"刷新推荐数据失败:{str(e)}")
|
||||
raise
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
@@ -310,3 +198,314 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
# 异步版本的方法
|
||||
async def async_refresh_recommend(self):
|
||||
"""
|
||||
异步刷新推荐
|
||||
"""
|
||||
logger.debug("Starting to async refresh Recommend data.")
|
||||
cache_backend.clear(region=recommend_cache_region)
|
||||
logger.debug("Recommend Cache has been cleared.")
|
||||
|
||||
# 推荐来源方法
|
||||
recommend_methods = [
|
||||
self.async_tmdb_movies,
|
||||
self.async_tmdb_tvs,
|
||||
self.async_tmdb_trending,
|
||||
self.async_bangumi_calendar,
|
||||
self.async_douban_movie_showing,
|
||||
self.async_douban_movies,
|
||||
self.async_douban_tvs,
|
||||
self.async_douban_movie_top250,
|
||||
self.async_douban_tv_weekly_chinese,
|
||||
self.async_douban_tv_weekly_global,
|
||||
self.async_douban_tv_animation,
|
||||
self.async_douban_movie_hot,
|
||||
self.async_douban_tv_hot,
|
||||
]
|
||||
|
||||
# 缓存并刷新所有推荐数据
|
||||
recommends = []
|
||||
# 记录哪些方法已完成
|
||||
methods_finished = set()
|
||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||
for page in range(1, self.cache_max_pages + 1):
|
||||
# 为每个页面并发执行所有方法
|
||||
tasks = []
|
||||
for method in recommend_methods:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
if method in methods_finished:
|
||||
continue
|
||||
tasks.append(self._async_fetch_method_data(method, page, methods_finished))
|
||||
|
||||
# 并发执行所有任务
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, list) and result:
|
||||
recommends.extend(result)
|
||||
|
||||
# 如果所有方法都已经完成,提前结束循环
|
||||
if len(methods_finished) == len(recommend_methods):
|
||||
break
|
||||
|
||||
# 缓存收集到的海报
|
||||
await self.__async_cache_posters(recommends)
|
||||
logger.debug("Async recommend data refresh completed.")
|
||||
|
||||
@staticmethod
|
||||
async def _async_fetch_method_data(method, page: int, methods_finished: set):
|
||||
"""
|
||||
异步获取方法数据的辅助函数
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Async fetch {method.__name__} data for page {page}.")
|
||||
data = await method(page=page)
|
||||
if not data:
|
||||
logger.debug(f"Method {method.__name__} finished fetching data. Ending pagination early.")
|
||||
methods_finished.add(method)
|
||||
return []
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching data from {method.__name__}: {e}")
|
||||
methods_finished.add(method)
|
||||
return []
|
||||
|
||||
async def __async_cache_posters(self, datas: List[dict]):
|
||||
"""
|
||||
异步提取 poster_path 并缓存图片
|
||||
:param datas: 数据列表
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE:
|
||||
return
|
||||
|
||||
tasks = []
|
||||
for data in datas:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
poster_path = data.get("poster_path")
|
||||
if poster_path:
|
||||
poster_url = poster_path.replace("original", "w500")
|
||||
logger.debug(f"Async caching poster image: {poster_url}")
|
||||
tasks.append(self.__async_fetch_and_save_image(poster_url))
|
||||
|
||||
# 并发缓存图片
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@staticmethod
|
||||
async def __async_fetch_and_save_image(url: str):
|
||||
"""
|
||||
异步请求并保存图片
|
||||
:param url: 图片路径
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE or not url:
|
||||
return
|
||||
|
||||
# 生成缓存路径
|
||||
base_path = AsyncPath(settings.CACHE_PATH)
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = base_path / "images" / sanitized_path
|
||||
|
||||
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
|
||||
if not cache_path.suffix:
|
||||
cache_path = cache_path.with_suffix(".jpg")
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not await SecurityUtils.async_is_safe_path(base_path=base_path,
|
||||
user_path=cache_path,
|
||||
allowed_suffixes=settings.SECURITY_IMAGE_SUFFIXES):
|
||||
logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}")
|
||||
return
|
||||
|
||||
# 本地存在缓存图片,则直接跳过
|
||||
if await cache_path.exists():
|
||||
logger.debug(f"Cache hit: Image already exists at {cache_path}")
|
||||
return
|
||||
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if not referer else None
|
||||
response = await AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT,
|
||||
proxies=proxies, referer=referer).get_res(url=url)
|
||||
if not response:
|
||||
logger.debug(f"Empty response for URL: {url}")
|
||||
return
|
||||
|
||||
# 验证下载的内容是否为有效图片
|
||||
try:
|
||||
Image.open(io.BytesIO(response.content)).verify()
|
||||
except Exception as e:
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
return
|
||||
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
try:
|
||||
if not await cache_path.parent.exists():
|
||||
await cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
await tmp_file.write(response.content)
|
||||
temp_path = AsyncPath(tmp_file.name)
|
||||
await temp_path.replace(cache_path)
|
||||
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_tmdb_movies(self, sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
异步TMDB热门电影
|
||||
"""
|
||||
movies = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "zh|en|ja|ko",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
异步TMDB热门电视剧
|
||||
"""
|
||||
tvs = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
异步TMDB流行趋势
|
||||
"""
|
||||
infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page)
|
||||
return [info.to_dict() for info in infos] if infos else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步Bangumi每日放送
|
||||
"""
|
||||
medias = await BangumiChain().async_run_module("async_bangumi_calendar")
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣正在热映
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣最新电影
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣最新电视剧
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣电影TOP250
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_movie_top250", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣国产剧集榜
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_weekly_chinese", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣全球剧集榜
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_weekly_global", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣热门动漫
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_animation", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣热门电影
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_movie_hot", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣热门电视剧
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_hot", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import asyncio
|
||||
import pickle
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from typing import Dict, Tuple
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo
|
||||
@@ -71,7 +76,7 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{sites} ...')
|
||||
# 搜索
|
||||
torrents = self.__search_all_sites(keywords=[title], sites=sites, page=page) or []
|
||||
torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or []
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 未搜索到资源')
|
||||
return []
|
||||
@@ -97,50 +102,84 @@ class SearchChain(ChainBase):
|
||||
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||
return []
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
rule_groups: List[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
async def async_last_search_results(self) -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
异步获取上次搜索结果
|
||||
"""
|
||||
# 读取本地文件缓存
|
||||
content = await self.async_load_cache(self.__result_temp_file)
|
||||
if not content:
|
||||
return []
|
||||
try:
|
||||
return pickle.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||
return []
|
||||
|
||||
async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
|
||||
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID异步搜索资源,精确匹配,不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param custom_words: 自定义识别词列表
|
||||
:param filter_params: 过滤参数
|
||||
:param season: 季数
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
return []
|
||||
no_exists = None
|
||||
if season:
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
results = await self.async_process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
await self.async_save_cache(pickle.dumps(results), self.__result_temp_file)
|
||||
return results
|
||||
|
||||
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||
"""
|
||||
执行优先级过滤
|
||||
"""
|
||||
return self.filter_torrents(rule_groups=rule_groups,
|
||||
torrent_list=torrent_list,
|
||||
mediainfo=mediainfo) or []
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
async def async_search_by_title(self, title: str, page: Optional[int] = 0,
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
|
||||
"""
|
||||
根据标题异步搜索资源,不识别不过滤,直接返回站点内容
|
||||
:param title: 标题,为空时返回所有站点首页内容
|
||||
:param page: 页码
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
if title:
|
||||
logger.info(f'开始搜索资源,关键词:{title} ...')
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{sites} ...')
|
||||
# 搜索
|
||||
torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or []
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 未搜索到资源')
|
||||
return []
|
||||
# 组装上下文
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
torrent_info=torrent) for torrent in torrents]
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
await self.async_save_cache(pickle.dumps(contexts), self.__result_temp_file)
|
||||
return contexts
|
||||
|
||||
@staticmethod
|
||||
def __prepare_params(mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None
|
||||
) -> Tuple[Dict[int, List[int]], List[str]]:
|
||||
"""
|
||||
准备搜索参数
|
||||
"""
|
||||
# 缺失的季集
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
@@ -164,14 +203,31 @@ class SearchChain(ChainBase):
|
||||
mediainfo.hk_title,
|
||||
mediainfo.tw_title,
|
||||
mediainfo.sg_title] if k]))
|
||||
# 限制搜索关键词数量
|
||||
if settings.MAX_SEARCH_NAME_LIMIT:
|
||||
keywords = keywords[:settings.MAX_SEARCH_NAME_LIMIT]
|
||||
|
||||
return season_episodes, keywords
|
||||
|
||||
def __parse_result(self, torrents: List[TorrentInfo],
|
||||
mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
rule_groups: List[str] = None,
|
||||
season_episodes: Dict[int, List[int]] = None,
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
"""
|
||||
处理搜索结果
|
||||
"""
|
||||
|
||||
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||
"""
|
||||
执行优先级过滤
|
||||
"""
|
||||
return self.filter_torrents(rule_groups=rule_groups,
|
||||
torrent_list=torrent_list,
|
||||
mediainfo=mediainfo) or []
|
||||
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keywords=keywords,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
@@ -281,10 +337,179 @@ class SearchChain(ChainBase):
|
||||
key=ProgressKey.Search)
|
||||
progress.end(ProgressKey.Search)
|
||||
|
||||
# 返回
|
||||
return contexts
|
||||
# 去重后返回
|
||||
return self.__remove_duplicate(contexts)
|
||||
|
||||
def __search_all_sites(self, keywords: List[str],
|
||||
@staticmethod
|
||||
def __remove_duplicate(_torrents: List[Context]) -> List[Context]:
|
||||
"""
|
||||
去除重复的种子
|
||||
:param _torrents: 种子列表
|
||||
:return: 去重后的种子列表
|
||||
"""
|
||||
if not settings.SEARCH_MULTIPLE_NAME:
|
||||
return _torrents
|
||||
# 通过encosure去重
|
||||
return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t
|
||||
for t in _torrents}.values())
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
rule_groups: List[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param custom_words: 自定义识别词列表
|
||||
:param filter_params: 过滤参数
|
||||
"""
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
|
||||
# 准备搜索参数
|
||||
season_episodes, keywords = self.__prepare_params(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
no_exists=no_exists
|
||||
)
|
||||
|
||||
# 站点搜索结果
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 站点搜索次数
|
||||
search_count = 0
|
||||
|
||||
# 多关键字执行搜索
|
||||
for search_word in keywords:
|
||||
# 强制休眠 1-10 秒
|
||||
if search_count > 0:
|
||||
logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
|
||||
time.sleep(random.randint(1, 10))
|
||||
# 搜索站点
|
||||
torrents.extend(
|
||||
self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=search_word,
|
||||
sites=sites,
|
||||
area=area
|
||||
) or []
|
||||
)
|
||||
search_count += 1
|
||||
|
||||
# 处理结果
|
||||
return self.__parse_result(
|
||||
torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
rule_groups=rule_groups,
|
||||
season_episodes=season_episodes,
|
||||
custom_words=custom_words,
|
||||
filter_params=filter_params
|
||||
)
|
||||
|
||||
async def async_process(self, mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
rule_groups: List[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
"""
|
||||
根据媒体信息异步搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param custom_words: 自定义识别词列表
|
||||
:param filter_params: 过滤参数
|
||||
"""
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
|
||||
# 准备搜索参数
|
||||
season_episodes, keywords = self.__prepare_params(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
no_exists=no_exists
|
||||
)
|
||||
|
||||
# 站点搜索结果
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 站点搜索次数
|
||||
search_count = 0
|
||||
|
||||
# 多关键字执行搜索
|
||||
for search_word in keywords:
|
||||
# 强制休眠 1-10 秒
|
||||
if search_count > 0:
|
||||
logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
|
||||
await asyncio.sleep(random.randint(1, 10))
|
||||
# 搜索站点
|
||||
torrents.extend(
|
||||
await self.__async_search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=search_word,
|
||||
sites=sites,
|
||||
area=area
|
||||
) or []
|
||||
)
|
||||
search_count += 1
|
||||
# 有结果则停止
|
||||
if torrents:
|
||||
logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索")
|
||||
break
|
||||
|
||||
# 处理结果
|
||||
return await run_in_threadpool(self.__parse_result,
|
||||
torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
rule_groups=rule_groups,
|
||||
season_episodes=season_episodes,
|
||||
custom_words=custom_words,
|
||||
filter_params=filter_params
|
||||
)
|
||||
|
||||
def __search_all_sites(self, keyword: str,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: Optional[int] = 0,
|
||||
@@ -292,7 +517,7 @@ class SearchChain(ChainBase):
|
||||
"""
|
||||
多线程搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keywords: 搜索关键词列表
|
||||
:param keyword: 搜索关键词
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
@@ -335,13 +560,13 @@ class SearchChain(ChainBase):
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
keyword=keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
@@ -354,7 +579,7 @@ class SearchChain(ChainBase):
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
@@ -369,6 +594,94 @@ class SearchChain(ChainBase):
|
||||
# 返回
|
||||
return results
|
||||
|
||||
async def __async_search_all_sites(self, keyword: str,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: Optional[int] = 0,
|
||||
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
异步搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
# 未开启的站点不搜索
|
||||
indexer_sites = []
|
||||
|
||||
# 配置的索引站点
|
||||
if not sites:
|
||||
sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
for indexer in await SitesHelper().async_get_indexers():
|
||||
# 检查站点索引开关
|
||||
if not sites or indexer.get("id") in sites:
|
||||
indexer_sites.append(indexer)
|
||||
if not indexer_sites:
|
||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
||||
return []
|
||||
|
||||
# 开始进度
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.Search)
|
||||
# 开始计时
|
||||
start_time = datetime.now()
|
||||
# 总数
|
||||
total_num = len(indexer_sites)
|
||||
# 完成数
|
||||
finish_count = 0
|
||||
# 更新进度
|
||||
progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 结果集
|
||||
results = []
|
||||
|
||||
# 创建异步任务列表
|
||||
tasks = []
|
||||
for site in indexer_sites:
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = self.async_search_torrents(site=site,
|
||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = self.async_search_torrents(site=site,
|
||||
keyword=keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
tasks.append(task)
|
||||
|
||||
# 使用asyncio.as_completed来处理并发任务
|
||||
for future in asyncio.as_completed(tasks):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
finish_count += 1
|
||||
result = await future
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
# 更新进度
|
||||
progress.update(value=100,
|
||||
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒",
|
||||
key=ProgressKey.Search)
|
||||
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
# 结束进度
|
||||
progress.end(ProgressKey.Search)
|
||||
|
||||
# 返回
|
||||
return results
|
||||
|
||||
@eventmanager.register(EventType.SiteDeleted)
|
||||
def remove_site(self, event: Event):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import base64
|
||||
import gc
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, Union, Dict
|
||||
@@ -9,7 +8,7 @@ from lxml import etree
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.event import Event, EventManager, eventmanager
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.db.models.site import Site
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -18,7 +17,7 @@ from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookie import CookieHelper
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.log import logger
|
||||
from app.schemas import MessageChannel, Notification, SiteUserData
|
||||
from app.schemas.types import EventType, NotificationType
|
||||
@@ -59,7 +58,7 @@ class SiteChain(ChainBase):
|
||||
name=site.get("name"),
|
||||
payload=userdata.dict())
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
eventmanager.send_event(EventType.SiteRefreshed, {
|
||||
"site_id": site.get("id")
|
||||
})
|
||||
# 发送站点消息
|
||||
@@ -104,14 +103,10 @@ class SiteChain(ChainBase):
|
||||
any_site_updated = True
|
||||
result[site.get("name")] = userdata
|
||||
if any_site_updated:
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
eventmanager.send_event(EventType.SiteRefreshed, {
|
||||
"site_id": "*"
|
||||
})
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
return result
|
||||
|
||||
def is_special_site(self, domain: str) -> bool:
|
||||
@@ -271,16 +266,20 @@ class SiteChain(ChainBase):
|
||||
logger.error(f"获取站点页面失败:{url}")
|
||||
return favicon_url, None
|
||||
html = etree.HTML(html_text)
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
try:
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
|
||||
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
return favicon_url, None
|
||||
|
||||
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
|
||||
@@ -416,7 +415,7 @@ class SiteChain(ChainBase):
|
||||
|
||||
# 通知站点更新
|
||||
if indexer:
|
||||
EventManager().send_event(EventType.SiteUpdated, {
|
||||
eventmanager.send_event(EventType.SiteUpdated, {
|
||||
"domain": domain,
|
||||
})
|
||||
# 处理完成
|
||||
|
||||
@@ -178,15 +178,14 @@ class StorageChain(ChainBase):
|
||||
|
||||
if mtype:
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
rename_format = settings.RENAME_FORMAT(mtype)
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format, rename_path=Path(fileitem.path)
|
||||
)
|
||||
if not media_path:
|
||||
return True
|
||||
# 处理媒体文件根目录
|
||||
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||
dir_item = self.get_file_item(storage=fileitem.storage, path=media_path)
|
||||
else:
|
||||
# 处理上级目录
|
||||
dir_item = self.get_parent_item(fileitem)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import copy
|
||||
import gc
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
@@ -16,7 +15,7 @@ from app.chain.tmdb import TmdbChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||
from app.core.event import eventmanager, Event, EventManager
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.meta.words import WordsMatcher
|
||||
from app.core.metainfo import MetaInfo
|
||||
@@ -39,6 +38,84 @@ class SubscribeChain(ChainBase):
|
||||
"""
|
||||
|
||||
_rlock = threading.RLock()
|
||||
# 避免莫名原因导致长时间持有锁
|
||||
_LOCK_TIMOUT = 3600 * 2
|
||||
|
||||
@staticmethod
|
||||
def __get_event_meida(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
广播事件解析媒体信息
|
||||
"""
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=_mediaid,
|
||||
convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.MediaRecognizeConvert, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
if event_data.media_dict:
|
||||
mediachain = MediaChain()
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
return mediachain.recognize_media(meta=_meta, tmdbid=new_id)
|
||||
elif event_data.convert_type == "douban":
|
||||
return mediachain.recognize_media(meta=_meta, doubanid=new_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def __async_get_event_meida(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
广播事件解析媒体信息
|
||||
"""
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=_mediaid,
|
||||
convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
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:
|
||||
mediachain = MediaChain()
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
return await mediachain.async_recognize_media(meta=_meta, tmdbid=new_id)
|
||||
elif event_data.convert_type == "douban":
|
||||
return await mediachain.async_recognize_media(meta=_meta, doubanid=new_id)
|
||||
return None
|
||||
|
||||
def __get_default_kwargs(self, mtype: MediaType, **kwargs) -> dict:
|
||||
"""
|
||||
获取订阅默认配置
|
||||
:param mtype: 媒体类型
|
||||
:param key: 配置键
|
||||
:return: 配置值
|
||||
"""
|
||||
return {
|
||||
'quality': self.__get_default_subscribe_config(mtype, "quality") if not kwargs.get(
|
||||
"quality") else kwargs.get("quality"),
|
||||
'resolution': self.__get_default_subscribe_config(mtype, "resolution") if not kwargs.get(
|
||||
"resolution") else kwargs.get("resolution"),
|
||||
'effect': self.__get_default_subscribe_config(mtype, "effect") if not kwargs.get(
|
||||
"effect") else kwargs.get("effect"),
|
||||
'include': self.__get_default_subscribe_config(mtype, "include") if not kwargs.get(
|
||||
"include") else kwargs.get("include"),
|
||||
'exclude': self.__get_default_subscribe_config(mtype, "exclude") if not kwargs.get(
|
||||
"exclude") else kwargs.get("exclude"),
|
||||
'best_version': self.__get_default_subscribe_config(mtype, "best_version") if not kwargs.get(
|
||||
"best_version") else kwargs.get("best_version"),
|
||||
'search_imdbid': self.__get_default_subscribe_config(mtype, "search_imdbid") if not kwargs.get(
|
||||
"search_imdbid") else kwargs.get("search_imdbid"),
|
||||
'sites': self.__get_default_subscribe_config(mtype, "sites") or None if not kwargs.get(
|
||||
"sites") else kwargs.get("sites"),
|
||||
'downloader': self.__get_default_subscribe_config(mtype, "downloader") if not kwargs.get(
|
||||
"downloader") else kwargs.get("downloader"),
|
||||
'save_path': self.__get_default_subscribe_config(mtype, "save_path") if not kwargs.get(
|
||||
"save_path") else kwargs.get("save_path"),
|
||||
'filter_groups': self.__get_default_subscribe_config(mtype, "filter_groups") if not kwargs.get(
|
||||
"filter_groups") else kwargs.get("filter_groups")
|
||||
}
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -59,27 +136,6 @@ class SubscribeChain(ChainBase):
|
||||
识别媒体信息并添加订阅
|
||||
"""
|
||||
|
||||
def __get_event_meida(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
广播事件解析媒体信息
|
||||
"""
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=_mediaid,
|
||||
convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.MediaRecognizeConvert, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
if event_data.media_dict:
|
||||
mediachain = MediaChain()
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
return mediachain.recognize_media(meta=_meta, tmdbid=new_id)
|
||||
elif event_data.convert_type == "douban":
|
||||
return mediachain.recognize_media(meta=_meta, doubanid=new_id)
|
||||
return None
|
||||
|
||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||
|
||||
mediainfo = None
|
||||
@@ -102,7 +158,7 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
|
||||
elif mediaid:
|
||||
# 未知前缀,广播事件解析媒体信息
|
||||
mediainfo = __get_event_meida(mediaid, metainfo)
|
||||
mediainfo = self.__get_event_meida(mediaid, metainfo)
|
||||
else:
|
||||
# 使用TMDBID识别
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
|
||||
@@ -113,7 +169,7 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
|
||||
elif mediaid:
|
||||
# 未知前缀,广播事件解析媒体信息
|
||||
mediainfo = __get_event_meida(mediaid, metainfo)
|
||||
mediainfo = self.__get_event_meida(mediaid, metainfo)
|
||||
if mediainfo:
|
||||
# 豆瓣标题处理
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
@@ -175,30 +231,8 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo.bangumi_id = bangumiid
|
||||
|
||||
# 添加订阅
|
||||
kwargs.update({
|
||||
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality") if not kwargs.get(
|
||||
"quality") else kwargs.get("quality"),
|
||||
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution") if not kwargs.get(
|
||||
"resolution") else kwargs.get("resolution"),
|
||||
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect") if not kwargs.get(
|
||||
"effect") else kwargs.get("effect"),
|
||||
'include': self.__get_default_subscribe_config(mediainfo.type, "include") if not kwargs.get(
|
||||
"include") else kwargs.get("include"),
|
||||
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude") if not kwargs.get(
|
||||
"exclude") else kwargs.get("exclude"),
|
||||
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version") if not kwargs.get(
|
||||
"best_version") else kwargs.get("best_version"),
|
||||
'search_imdbid': self.__get_default_subscribe_config(mediainfo.type, "search_imdbid") if not kwargs.get(
|
||||
"search_imdbid") else kwargs.get("search_imdbid"),
|
||||
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None if not kwargs.get(
|
||||
"sites") else kwargs.get("sites"),
|
||||
'downloader': self.__get_default_subscribe_config(mediainfo.type, "downloader") if not kwargs.get(
|
||||
"downloader") else kwargs.get("downloader"),
|
||||
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
|
||||
"save_path") else kwargs.get("save_path"),
|
||||
'filter_groups': self.__get_default_subscribe_config(mediainfo.type, "filter_groups") if not kwargs.get(
|
||||
"filter_groups") else kwargs.get("filter_groups")
|
||||
})
|
||||
kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))
|
||||
|
||||
# 操作数据库
|
||||
sid, err_msg = SubscribeOper().add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
||||
if not sid:
|
||||
@@ -236,7 +270,7 @@ class SubscribeChain(ChainBase):
|
||||
username=username
|
||||
)
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeAdded, {
|
||||
eventmanager.send_event(EventType.SubscribeAdded, {
|
||||
"subscribe_id": sid,
|
||||
"username": username,
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
@@ -260,6 +294,183 @@ class SubscribeChain(ChainBase):
|
||||
# 返回结果
|
||||
return sid, ""
|
||||
|
||||
async def async_add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
userid: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
message: Optional[bool] = True,
|
||||
exist_ok: Optional[bool] = False,
|
||||
**kwargs) -> Tuple[Optional[int], str]:
|
||||
"""
|
||||
异步识别媒体信息并添加订阅
|
||||
"""
|
||||
|
||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||
|
||||
mediainfo = None
|
||||
metainfo = MetaInfo(title)
|
||||
if year:
|
||||
metainfo.year = year
|
||||
if mtype:
|
||||
metainfo.type = mtype
|
||||
if season:
|
||||
metainfo.type = MediaType.TV
|
||||
metainfo.begin_season = season
|
||||
# 识别媒体信息
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
# TMDB识别模式
|
||||
if not tmdbid:
|
||||
if doubanid:
|
||||
# 将豆瓣信息转换为TMDB信息
|
||||
tmdbinfo = await MediaChain().async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||
if tmdbinfo:
|
||||
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
|
||||
elif mediaid:
|
||||
# 未知前缀,广播事件解析媒体信息
|
||||
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
|
||||
else:
|
||||
# 使用TMDBID识别
|
||||
mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
|
||||
episode_group=episode_group, cache=False)
|
||||
else:
|
||||
if doubanid:
|
||||
# 豆瓣识别模式,不使用缓存
|
||||
mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
|
||||
elif mediaid:
|
||||
# 未知前缀,广播事件解析媒体信息
|
||||
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
|
||||
if mediainfo:
|
||||
# 豆瓣标题处理
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
if not season:
|
||||
season = meta.begin_season
|
||||
|
||||
# 使用名称识别兜底
|
||||
if not mediainfo:
|
||||
mediainfo = await self.async_recognize_media(meta=metainfo, episode_group=episode_group)
|
||||
|
||||
# 识别失败
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}')
|
||||
return None, "未识别到媒体信息"
|
||||
|
||||
# 总集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
if not season:
|
||||
season = 1
|
||||
# 总集数
|
||||
if not kwargs.get('total_episode'):
|
||||
if not mediainfo.seasons or episode_group:
|
||||
# 补充媒体信息
|
||||
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
bangumiid=mediainfo.bangumi_id,
|
||||
episode_group=episode_group,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.error(f"媒体信息识别失败!")
|
||||
return None, "媒体信息识别失败"
|
||||
if not mediainfo.seasons:
|
||||
logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}")
|
||||
return None, "媒体信息中没有季集信息"
|
||||
total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
if not total_episode:
|
||||
logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}')
|
||||
return None, f"未获取到第 {season} 季的总集数"
|
||||
kwargs.update({
|
||||
'total_episode': total_episode
|
||||
})
|
||||
# 缺失集
|
||||
if not kwargs.get('lack_episode'):
|
||||
kwargs.update({
|
||||
'lack_episode': kwargs.get('total_episode')
|
||||
})
|
||||
else:
|
||||
# 避免season为0的问题
|
||||
season = None
|
||||
|
||||
# 更新媒体图片
|
||||
await self.async_obtain_images(mediainfo=mediainfo)
|
||||
# 合并信息
|
||||
if doubanid:
|
||||
mediainfo.douban_id = doubanid
|
||||
if bangumiid:
|
||||
mediainfo.bangumi_id = bangumiid
|
||||
|
||||
# 列新默认参数
|
||||
kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))
|
||||
|
||||
# 操作数据库
|
||||
sid, err_msg = await SubscribeOper().async_add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
||||
if not sid:
|
||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||
if not exist_ok and message:
|
||||
# 失败发回原用户
|
||||
await self.async_post_message(schemas.Notification(channel=channel,
|
||||
source=source,
|
||||
mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year} {metainfo.season} "
|
||||
f"添加订阅失败!",
|
||||
text=f"{err_msg}",
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
return None, err_msg
|
||||
elif message:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 订阅成功按规则发送消息
|
||||
await self.async_post_message(
|
||||
schemas.Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
mtype=NotificationType.Subscribe,
|
||||
ctype=ContentType.SubscribeAdded,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
userid=userid,
|
||||
username=username
|
||||
),
|
||||
meta=metainfo,
|
||||
mediainfo=mediainfo,
|
||||
username=username
|
||||
)
|
||||
# 发送事件
|
||||
await eventmanager.async_send_event(EventType.SubscribeAdded, {
|
||||
"subscribe_id": sid,
|
||||
"username": username,
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 统计订阅
|
||||
await SubscribeHelper().async_sub_reg({
|
||||
"name": title,
|
||||
"year": year,
|
||||
"type": metainfo.type.value,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
"tvdbid": mediainfo.tvdb_id,
|
||||
"doubanid": mediainfo.douban_id,
|
||||
"bangumiid": mediainfo.bangumi_id,
|
||||
"season": metainfo.begin_season,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
"vote": mediainfo.vote_average,
|
||||
"description": mediainfo.overview
|
||||
})
|
||||
# 返回结果
|
||||
return sid, ""
|
||||
|
||||
@staticmethod
|
||||
def exists(mediainfo: MediaInfo, meta: MetaBase = None):
|
||||
"""
|
||||
@@ -279,8 +490,15 @@ class SubscribeChain(ChainBase):
|
||||
:param manual: 是否手动搜索
|
||||
:return: 更新订阅状态为R或删除订阅
|
||||
"""
|
||||
with self._rlock:
|
||||
logger.debug(f"search lock acquired at {datetime.now()}")
|
||||
lock_acquired = False
|
||||
try:
|
||||
if lock_acquired := self._rlock.acquire(
|
||||
blocking=True, timeout=self._LOCK_TIMOUT
|
||||
):
|
||||
logger.debug(f"search lock acquired at {datetime.now()}")
|
||||
else:
|
||||
logger.warn("search上锁超时")
|
||||
|
||||
subscribeoper = SubscribeOper()
|
||||
if sid:
|
||||
subscribe = subscribeoper.get(sid)
|
||||
@@ -437,12 +655,10 @@ class SubscribeChain(ChainBase):
|
||||
finally:
|
||||
subscribes.clear()
|
||||
del subscribes
|
||||
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
finally:
|
||||
if lock_acquired:
|
||||
self._rlock.release()
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
|
||||
mediainfo: MediaInfo, downloads: Optional[List[Context]]):
|
||||
@@ -515,9 +731,6 @@ class SubscribeChain(ChainBase):
|
||||
self.match(
|
||||
TorrentsChain().refresh(sites=sites)
|
||||
)
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
@staticmethod
|
||||
def get_sub_sites(subscribe: Subscribe) -> List[int]:
|
||||
@@ -547,10 +760,15 @@ class SubscribeChain(ChainBase):
|
||||
:return: 返回[]代表所有站点命中,返回None代表没有订阅
|
||||
"""
|
||||
ret_sites = []
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
# 没有订阅
|
||||
return None
|
||||
# 刷新订阅选中的Rss站点
|
||||
for subscribe in SubscribeOper().list(self.get_states_for_search('R')):
|
||||
for subscribe in subscribes:
|
||||
# 刷新选中的站点
|
||||
ret_sites.extend(self.get_sub_sites(subscribe))
|
||||
if subscribe.state in self.get_states_for_search('R'):
|
||||
ret_sites.extend(self.get_sub_sites(subscribe))
|
||||
# 去重
|
||||
if ret_sites:
|
||||
ret_sites = list(set(ret_sites))
|
||||
@@ -565,8 +783,14 @@ class SubscribeChain(ChainBase):
|
||||
logger.warn('没有缓存资源,无法匹配订阅')
|
||||
return
|
||||
|
||||
with self._rlock:
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
lock_acquired = False
|
||||
try:
|
||||
if lock_acquired := self._rlock.acquire(
|
||||
blocking=True, timeout=self._LOCK_TIMOUT
|
||||
):
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
else:
|
||||
logger.warn("match上锁超时")
|
||||
|
||||
# 预识别所有未识别的种子
|
||||
processed_torrents: Dict[str, List[Context]] = {}
|
||||
@@ -577,15 +801,27 @@ class SubscribeChain(ChainBase):
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 如果种子未识别,尝试识别
|
||||
if not context.media_info or (not context.media_info.tmdb_id
|
||||
and not context.media_info.douban_id):
|
||||
# 如果种子未识别且失败次数未超过3次,尝试识别
|
||||
if (not context.media_info or (not context.media_info.tmdb_id
|
||||
and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3:
|
||||
logger.debug(
|
||||
f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3')
|
||||
re_mediainfo = self.recognize_media(meta=context.meta_info)
|
||||
if re_mediainfo:
|
||||
# 清理多余信息
|
||||
re_mediainfo.clear()
|
||||
# 更新种子缓存
|
||||
context.media_info = re_mediainfo
|
||||
# 重置失败次数
|
||||
context.media_recognize_fail_count = 0
|
||||
logger.debug(f'种子 {context.torrent_info.title} 重新识别成功')
|
||||
else:
|
||||
# 识别失败,增加失败次数
|
||||
context.media_recognize_fail_count += 1
|
||||
logger.debug(
|
||||
f'种子 {context.torrent_info.title} 媒体识别失败,失败次数:{context.media_recognize_fail_count}/3')
|
||||
elif context.media_recognize_fail_count >= 3:
|
||||
logger.debug(f'种子 {context.torrent_info.title} 已达到最大识别失败次数(3次),跳过识别')
|
||||
# 添加已预处理
|
||||
processed_torrents[domain].append(context)
|
||||
|
||||
@@ -688,7 +924,7 @@ class SubscribeChain(ChainBase):
|
||||
# 如果仍然没有识别到媒体信息,尝试标题匹配
|
||||
if not torrent_mediainfo or (
|
||||
not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
@@ -807,7 +1043,8 @@ class SubscribeChain(ChainBase):
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
source=self.get_subscribe_source_keyword(
|
||||
subscribe)
|
||||
)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
@@ -822,8 +1059,10 @@ class SubscribeChain(ChainBase):
|
||||
del processed_torrents
|
||||
subscribes.clear()
|
||||
del subscribes
|
||||
|
||||
logger.debug(f"match Lock released at {datetime.now()}")
|
||||
finally:
|
||||
if lock_acquired:
|
||||
self._rlock.release()
|
||||
logger.debug(f"match Lock released at {datetime.now()}")
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
@@ -1073,7 +1312,7 @@ class SubscribeChain(ChainBase):
|
||||
username=subscribe.username
|
||||
)
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeComplete, {
|
||||
eventmanager.send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe.to_dict(),
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
|
||||
@@ -6,12 +6,12 @@ from typing import Union, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.helper.system import SystemHelper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from version import FRONTEND_VERSION, APP_VERSION
|
||||
|
||||
|
||||
@@ -136,13 +136,6 @@ class SystemChain(ChainBase):
|
||||
shutil.rmtree(target_path)
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已恢复插件目录: {item.name}")
|
||||
# 安装依赖
|
||||
requirements_file = target_path / "requirements.txt"
|
||||
if requirements_file.exists():
|
||||
logger.info(f"正在安装插件 {item.name} 的依赖...")
|
||||
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
|
||||
if not success:
|
||||
logger.warn(f"插件 {item.name} 依赖安装失败: {message}")
|
||||
restored_count += 1
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
@@ -155,6 +148,9 @@ class SystemChain(ChainBase):
|
||||
|
||||
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
|
||||
|
||||
# 安装缺少的依赖
|
||||
PluginManager.install_plugin_missing_dependencies()
|
||||
|
||||
# 删除备份目录
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
@@ -164,3 +164,159 @@ class TmdbChain(ChainBase):
|
||||
if infos:
|
||||
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||
return []
|
||||
|
||||
async def async_tmdb_discover(self, mtype: MediaType,
|
||||
sort_by: str,
|
||||
with_genres: str,
|
||||
with_original_language: str,
|
||||
with_keywords: str,
|
||||
with_watch_providers: str,
|
||||
vote_average: float,
|
||||
vote_count: int,
|
||||
release_date: str,
|
||||
page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现TMDB电影、剧集(异步版本)
|
||||
:param mtype: 媒体类型
|
||||
:param sort_by: 排序方式
|
||||
:param with_genres: 类型
|
||||
:param with_original_language: 语言
|
||||
:param with_keywords: 关键字
|
||||
:param with_watch_providers: 提供商
|
||||
:param vote_average: 评分
|
||||
:param vote_count: 评分人数
|
||||
:param release_date: 上映日期
|
||||
:param page: 页码
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_discover", mtype=mtype,
|
||||
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)
|
||||
|
||||
async def async_tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
TMDB流行趋势(异步版本)
|
||||
:param page: 第几页
|
||||
:return: TMDB信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_trending", page=page)
|
||||
|
||||
async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据合集ID查询集合(异步版本)
|
||||
:param collection_id: 合集ID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_collection", collection_id=collection_id)
|
||||
|
||||
async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid)
|
||||
|
||||
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据剧集组ID查询themoviedb所有季集信息(异步版本)
|
||||
:param group_id: 剧集组ID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id)
|
||||
|
||||
async def async_tmdb_episodes(self, tmdbid: int, season: int,
|
||||
episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
:param season: 季
|
||||
:param episode_group: 剧集组
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_episodes", tmdbid=tmdbid, season=season,
|
||||
episode_group=episode_group)
|
||||
|
||||
async def async_movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询类似电影(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_movie_similar", tmdbid=tmdbid)
|
||||
|
||||
async def async_tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_tv_similar", tmdbid=tmdbid)
|
||||
|
||||
async def async_movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_movie_recommend", tmdbid=tmdbid)
|
||||
|
||||
async def async_tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid)
|
||||
|
||||
async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询演职员详情(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_person_detail", person_id=person_id)
|
||||
|
||||
async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_person_credits", person_id=person_id, page=page)
|
||||
|
||||
async def async_get_random_wallpager(self) -> Optional[str]:
|
||||
"""
|
||||
获取随机壁纸(异步版本),缓存1个小时
|
||||
"""
|
||||
infos = await self.async_tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.backdrop_path:
|
||||
return info.backdrop_path
|
||||
return None
|
||||
|
||||
async def async_get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:
|
||||
"""
|
||||
获取所有流行壁纸(异步版本)
|
||||
"""
|
||||
infos = await self.async_tmdb_trending()
|
||||
if infos:
|
||||
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||
return []
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
@@ -56,9 +56,34 @@ class TorrentsChain(ChainBase):
|
||||
|
||||
# 读取缓存
|
||||
if stype == 'spider':
|
||||
return self.load_cache(self._spider_file) or {}
|
||||
torrents_cache = self.load_cache(self._spider_file) or {}
|
||||
else:
|
||||
return self.load_cache(self._rss_file) or {}
|
||||
torrents_cache = self.load_cache(self._rss_file) or {}
|
||||
|
||||
# 兼容性处理:为旧版本的Context对象添加失败次数字段
|
||||
self._ensure_context_compatibility(torrents_cache)
|
||||
|
||||
return torrents_cache
|
||||
|
||||
async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
异步获取当前缓存的种子
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
"""
|
||||
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
|
||||
# 异步读取缓存
|
||||
if stype == 'spider':
|
||||
torrents_cache = await self.async_load_cache(self._spider_file) or {}
|
||||
else:
|
||||
torrents_cache = await self.async_load_cache(self._rss_file) or {}
|
||||
|
||||
# 兼容性处理:为旧版本的Context对象添加失败次数字段
|
||||
self._ensure_context_compatibility(torrents_cache)
|
||||
|
||||
return torrents_cache
|
||||
|
||||
def clear_torrents(self):
|
||||
"""
|
||||
@@ -69,6 +94,15 @@ class TorrentsChain(ChainBase):
|
||||
self.remove_cache(self._rss_file)
|
||||
logger.info(f'种子缓存数据清理完成')
|
||||
|
||||
async def async_clear_torrents(self):
|
||||
"""
|
||||
异步清理种子缓存数据
|
||||
"""
|
||||
logger.info(f'开始异步清理种子缓存数据 ...')
|
||||
await self.async_remove_cache(self._spider_file)
|
||||
await self.async_remove_cache(self._rss_file)
|
||||
logger.info(f'异步种子缓存数据清理完成')
|
||||
|
||||
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
@@ -85,6 +119,22 @@ class TorrentsChain(ChainBase):
|
||||
return []
|
||||
return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
异步浏览站点首页内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
:param keyword: 搜索标题
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = await SitesHelper().async_get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存3分钟
|
||||
@@ -140,6 +190,16 @@ class TorrentsChain(ChainBase):
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
:param sites: 强制指定站点ID列表,为空则读取设置的订阅站点
|
||||
"""
|
||||
|
||||
def __is_no_cache_site(_domain: str) -> bool:
|
||||
"""
|
||||
判断站点是否不需要缓存
|
||||
"""
|
||||
for url_key in settings.NO_CACHE_SITE_KEY.split(','):
|
||||
if url_key in _domain:
|
||||
return True
|
||||
return False
|
||||
|
||||
# 刷新类型
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
@@ -169,7 +229,15 @@ class TorrentsChain(ChainBase):
|
||||
domains.append(domain)
|
||||
if stype == "spider":
|
||||
# 刷新首页种子
|
||||
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 读取第0页和第1页
|
||||
for page in range(2):
|
||||
page_torrents = self.browse(domain=domain, page=page)
|
||||
if page_torrents:
|
||||
torrents.extend(page_torrents)
|
||||
else:
|
||||
# 如果某一页没有数据,说明已经到最后一页,停止获取
|
||||
break
|
||||
else:
|
||||
# 刷新RSS种子
|
||||
torrents: List[TorrentInfo] = self.rss(domain=domain)
|
||||
@@ -178,11 +246,16 @@ class TorrentsChain(ChainBase):
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CONF.refresh]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []}
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}' not in cached_signatures]
|
||||
if __is_no_cache_site(domain):
|
||||
# 不需要缓存的站点,直接处理
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个种子 (不缓存)')
|
||||
torrents_cache[domain] = []
|
||||
else:
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []}
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}' not in cached_signatures]
|
||||
if torrents:
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子')
|
||||
else:
|
||||
@@ -211,6 +284,9 @@ class TorrentsChain(ChainBase):
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 如果未识别到媒体信息,设置初始失败次数为1
|
||||
if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id):
|
||||
context.media_recognize_fail_count = 1
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
@@ -237,6 +313,21 @@ class TorrentsChain(ChainBase):
|
||||
|
||||
return torrents_cache
|
||||
|
||||
@staticmethod
|
||||
def _ensure_context_compatibility(torrents_cache: Dict[str, List[Context]]):
|
||||
"""
|
||||
确保Context对象的兼容性,为旧版本添加缺失的字段
|
||||
"""
|
||||
for domain, contexts in torrents_cache.items():
|
||||
for context in contexts:
|
||||
# 如果Context对象没有media_recognize_fail_count字段,添加默认值
|
||||
if not hasattr(context, 'media_recognize_fail_count'):
|
||||
context.media_recognize_fail_count = 0
|
||||
# 如果媒体信息未识别,设置初始失败次数
|
||||
if (not context.media_info or
|
||||
(not context.media_info.tmdb_id and not context.media_info.douban_id)):
|
||||
context.media_recognize_fail_count = 1
|
||||
|
||||
def __renew_rss_url(self, domain: str, site: dict):
|
||||
"""
|
||||
保留原配置生成新的rss地址
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import gc
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from time import sleep
|
||||
from typing import List, Optional, Tuple, Union, Dict, Callable
|
||||
|
||||
@@ -16,9 +14,9 @@ from app.chain.storage import StorageChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
@@ -28,11 +26,11 @@ from app.helper.directory import DirectoryHelper
|
||||
from app.helper.format import FormatParser
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.log import logger
|
||||
from app.schemas import StorageOperSelectionEventData
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
||||
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
||||
SystemConfigKey, ChainEventType, ContentType
|
||||
from app.schemas import StorageOperSelectionEventData
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -213,6 +211,7 @@ class JobManager:
|
||||
set(self._season_episodes[mediaid]) - set(task.meta.episode_list)
|
||||
)
|
||||
return task
|
||||
return None
|
||||
|
||||
def remove_job(self, task: TransferTask) -> Optional[TransferJob]:
|
||||
"""
|
||||
@@ -226,6 +225,7 @@ class JobManager:
|
||||
if __mediaid__ in self._season_episodes:
|
||||
self._season_episodes.pop(__mediaid__)
|
||||
return self._job_view.pop(__mediaid__)
|
||||
return None
|
||||
|
||||
def is_done(self, task: TransferTask) -> bool:
|
||||
"""
|
||||
@@ -311,7 +311,7 @@ class JobManager:
|
||||
|
||||
def count(self, media: MediaInfo, season: Optional[int] = None) -> int:
|
||||
"""
|
||||
获取某项任务总数
|
||||
获取某项任务成功总数
|
||||
"""
|
||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||
with job_lock:
|
||||
@@ -322,7 +322,7 @@ class JobManager:
|
||||
|
||||
def size(self, media: MediaInfo, season: Optional[int] = None) -> int:
|
||||
"""
|
||||
获取某项任务总大小
|
||||
获取某项任务成功文件总大小
|
||||
"""
|
||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||
with job_lock:
|
||||
@@ -359,22 +359,20 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
文件整理处理链
|
||||
"""
|
||||
|
||||
# 可处理的文件后缀
|
||||
all_exts = settings.RMT_MEDIAEXT
|
||||
|
||||
# 待整理任务队列
|
||||
_queue = Queue()
|
||||
|
||||
# 文件整理线程
|
||||
_transfer_thread = None
|
||||
|
||||
# 队列间隔时间(秒)
|
||||
_transfer_interval = 15
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 可处理的文件后缀
|
||||
self.all_exts = settings.RMT_MEDIAEXT
|
||||
# 待整理任务队列
|
||||
self._queue = queue.Queue()
|
||||
# 文件整理线程
|
||||
self._transfer_thread = None
|
||||
# 队列间隔时间(秒)
|
||||
self._transfer_interval = 15
|
||||
# 事件管理器
|
||||
self.jobview = JobManager()
|
||||
|
||||
# 车移成功的文件清单
|
||||
self._success_target_files: Dict[str, List[str]] = {}
|
||||
# 启动整理任务
|
||||
self.__init()
|
||||
|
||||
@@ -391,6 +389,44 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
整理完成后处理
|
||||
"""
|
||||
|
||||
def __do_finished():
|
||||
"""
|
||||
完成时发送消息、刮削事件、移除任务等
|
||||
"""
|
||||
# 更新文件数量
|
||||
transferinfo.file_count = self.jobview.count(task.mediainfo, task.meta.begin_season) or 1
|
||||
# 更新文件大小
|
||||
transferinfo.total_size = self.jobview.size(task.mediainfo,
|
||||
task.meta.begin_season) or task.fileitem.size
|
||||
# 更新文件清单
|
||||
transferinfo.file_list_new = self._success_target_files.pop(transferinfo.target_diritem.path, [])
|
||||
# 发送通知,实时手动整理时不发
|
||||
if transferinfo.need_notify and (task.background or not task.manual):
|
||||
se_str = None
|
||||
if task.mediainfo.type == MediaType.TV:
|
||||
season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)
|
||||
if season_episodes:
|
||||
se_str = f"{task.meta.season} {StringUtils.format_ep(season_episodes)}"
|
||||
else:
|
||||
se_str = f"{task.meta.season}"
|
||||
self.send_transfer_message(meta=task.meta,
|
||||
mediainfo=task.mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
season_episode=se_str,
|
||||
username=task.username)
|
||||
# 刮削事件
|
||||
if transferinfo.need_scrape:
|
||||
self.eventmanager.send_event(EventType.MetadataScrape, {
|
||||
'meta': task.meta,
|
||||
'mediainfo': task.mediainfo,
|
||||
'fileitem': transferinfo.target_diritem,
|
||||
'file_list': transferinfo.file_list_new,
|
||||
'overwrite': False
|
||||
})
|
||||
# 移除已完成的任务
|
||||
self.jobview.remove_job(task)
|
||||
|
||||
transferhis = TransferHistoryOper()
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
@@ -416,6 +452,10 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
))
|
||||
# 整理失败
|
||||
self.jobview.fail_task(task)
|
||||
with task_lock:
|
||||
# 整理完成且有成功的任务时
|
||||
if self.jobview.is_finished(task):
|
||||
__do_finished()
|
||||
return False, transferinfo.message
|
||||
|
||||
# 转移成功
|
||||
@@ -444,55 +484,31 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
})
|
||||
|
||||
with task_lock:
|
||||
# 登记转移成功文件清单
|
||||
target_dir_path = transferinfo.target_diritem.path
|
||||
target_files = transferinfo.file_list_new
|
||||
if self._success_target_files.get(target_dir_path):
|
||||
self._success_target_files[target_dir_path].extend(target_files)
|
||||
else:
|
||||
self._success_target_files[target_dir_path] = target_files
|
||||
# 全部整理成功时
|
||||
if self.jobview.is_success(task):
|
||||
# 移动模式删除空目录
|
||||
if transferinfo.transfer_type in ["move"]:
|
||||
# 所有成功的业务
|
||||
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
||||
# 记录已处理的种子hash
|
||||
processed_hashes = set()
|
||||
storagechain = StorageChain()
|
||||
# 获取整理屏蔽词
|
||||
transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords)
|
||||
for t in tasks:
|
||||
# 下载器hash
|
||||
if t.download_hash and t.download_hash not in processed_hashes:
|
||||
processed_hashes.add(t.download_hash)
|
||||
if t.download_hash and self._can_delete_torrent(t.download_hash, t.downloader, transfer_exclude_words):
|
||||
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
||||
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
||||
# 删除残留目录
|
||||
logger.info(f"移动模式删除种子成功:{t.download_hash}")
|
||||
if t.fileitem:
|
||||
storagechain.delete_media_file(t.fileitem, delete_self=False)
|
||||
# 整理完成且有成功的任务时
|
||||
if self.jobview.is_finished(task):
|
||||
# 发送通知,实时手动整理时不发
|
||||
if transferinfo.need_notify and (task.background or not task.manual):
|
||||
se_str = None
|
||||
if task.mediainfo.type == MediaType.TV:
|
||||
season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)
|
||||
if season_episodes:
|
||||
se_str = f"{task.meta.season} {StringUtils.format_ep(season_episodes)}"
|
||||
else:
|
||||
se_str = f"{task.meta.season}"
|
||||
# 更新文件数量
|
||||
transferinfo.file_count = self.jobview.count(task.mediainfo, task.meta.begin_season) or 1
|
||||
# 更新文件大小
|
||||
transferinfo.total_size = self.jobview.size(task.mediainfo,
|
||||
task.meta.begin_season) or task.fileitem.size
|
||||
self.send_transfer_message(meta=task.meta,
|
||||
mediainfo=task.mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
season_episode=se_str,
|
||||
username=task.username)
|
||||
# 刮削事件
|
||||
if transferinfo.need_scrape:
|
||||
self.eventmanager.send_event(EventType.MetadataScrape, {
|
||||
'meta': task.meta,
|
||||
'mediainfo': task.mediainfo,
|
||||
'fileitem': transferinfo.target_diritem
|
||||
})
|
||||
|
||||
# 移除已完成的任务
|
||||
self.jobview.remove_job(task)
|
||||
__do_finished()
|
||||
|
||||
return True, ""
|
||||
|
||||
@@ -847,7 +863,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
state, errmsg = self.do_transfer(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path=str(file_path).replace("\\", "/"),
|
||||
path=file_path.as_posix(),
|
||||
type="dir" if not file_path.is_file() else "file",
|
||||
name=file_path.name,
|
||||
size=file_path.stat().st_size,
|
||||
@@ -867,10 +883,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
torrents.clear()
|
||||
del torrents
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
# 结束
|
||||
logger.info("所有下载器中下载完成的文件已整理完成")
|
||||
return True
|
||||
@@ -1058,16 +1070,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
is_blocked = False
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, file_item.path, re.IGNORECASE):
|
||||
logger.info(f"{file_item.path} 命中整理屏蔽词 {keyword},不处理")
|
||||
is_blocked = True
|
||||
break
|
||||
if is_blocked:
|
||||
if self._is_blocked_by_exclude_words(file_item.path, transfer_exclude_words):
|
||||
continue
|
||||
|
||||
# 整理成功的不再处理
|
||||
@@ -1099,7 +1102,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name, file_meta=file_meta)
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name,
|
||||
file_meta=file_meta)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
@@ -1342,7 +1346,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
mediainfo: MediaInfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid,
|
||||
mtype=mtype, episode_group=episode_group)
|
||||
if not mediainfo:
|
||||
return False, f"媒体信息识别失败,tmdbid:{tmdbid},doubanid:{doubanid},type: {mtype.value}"
|
||||
return (False,
|
||||
f"媒体信息识别失败,tmdbid:{tmdbid},doubanid:{doubanid},type: {mtype.value if mtype else None}")
|
||||
else:
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
@@ -1412,3 +1417,68 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
season_episode=season_episode,
|
||||
username=username
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_blocked_by_exclude_words(file_path: str, exclude_words: list) -> bool:
|
||||
"""
|
||||
检查文件是否被整理屏蔽词阻止处理
|
||||
:param file_path: 文件路径
|
||||
:param exclude_words: 整理屏蔽词列表
|
||||
:return: 如果被屏蔽返回True,否则返回False
|
||||
"""
|
||||
if not exclude_words:
|
||||
return False
|
||||
|
||||
for keyword in exclude_words:
|
||||
if keyword and re.search(r"%s" % keyword, file_path, re.IGNORECASE):
|
||||
logger.debug(f"{file_path} 命中屏蔽词 {keyword}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _can_delete_torrent(self, download_hash: str, downloader: str, transfer_exclude_words) -> bool:
|
||||
"""
|
||||
检查是否可以删除种子文件
|
||||
:param download_hash: 种子Hash
|
||||
:param downloader: 下载器名称
|
||||
:param transfer_exclude_words: 整理屏蔽词
|
||||
:return: 如果可以删除返回True,否则返回False
|
||||
"""
|
||||
try:
|
||||
# 获取种子信息
|
||||
torrents = self.list_torrents(hashs=download_hash, downloader=downloader)
|
||||
if not torrents:
|
||||
return False
|
||||
|
||||
# 未下载完成
|
||||
if torrents[0].progress < 100:
|
||||
return False
|
||||
|
||||
# 获取种子文件列表
|
||||
torrent_files = self.torrent_files(download_hash, downloader)
|
||||
if not torrent_files:
|
||||
return False
|
||||
|
||||
if not isinstance(torrent_files, list):
|
||||
torrent_files = torrent_files.data
|
||||
|
||||
# 检查是否有媒体文件未被屏蔽且存在
|
||||
save_path = torrents[0].path.parent
|
||||
for file in torrent_files:
|
||||
file_path = save_path / file.name
|
||||
# 如果存在未被屏蔽的媒体文件,则不删除种子
|
||||
if (
|
||||
file_path.suffix in self.all_exts
|
||||
and not self._is_blocked_by_exclude_words(
|
||||
str(file_path), transfer_exclude_words
|
||||
)
|
||||
and file_path.exists()
|
||||
):
|
||||
return False
|
||||
|
||||
# 所有媒体文件都被屏蔽或不存在,可以删除种子
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查种子 {download_hash} 是否需要删除失败:{e}")
|
||||
return False
|
||||
|
||||
@@ -10,11 +10,13 @@ from pydantic.fields import Callable
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.core.workflow import WorkFlowManager
|
||||
from app.db.models import Workflow
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
from app.schemas import ActionContext, ActionFlow, Action, ActionExecution
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class WorkflowExecutor:
|
||||
@@ -188,6 +190,16 @@ class WorkflowChain(ChainBase):
|
||||
工作流链
|
||||
"""
|
||||
|
||||
@eventmanager.register(EventType.WorkflowExecute)
|
||||
def event_process(self, event: Event):
|
||||
"""
|
||||
事件触发工作流执行
|
||||
"""
|
||||
workflow_id = event.event_data.get('workflow_id')
|
||||
if not workflow_id:
|
||||
return
|
||||
self.process(workflow_id, from_begin=False)
|
||||
|
||||
@staticmethod
|
||||
def process(workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -225,7 +237,7 @@ class WorkflowChain(ChainBase):
|
||||
logger.warn(f"工作流 {workflow.name} 无流程")
|
||||
return False, "工作流无流程"
|
||||
|
||||
logger.info(f"开始处理 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
|
||||
logger.info(f"开始执行工作流 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
|
||||
workflowoper.start(workflow_id)
|
||||
|
||||
# 执行工作流
|
||||
@@ -247,3 +259,17 @@ class WorkflowChain(ChainBase):
|
||||
获取工作流列表
|
||||
"""
|
||||
return WorkflowOper().list_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_timer_workflows() -> List[Workflow]:
|
||||
"""
|
||||
获取定时触发的工作流列表
|
||||
"""
|
||||
return WorkflowOper().get_timer_triggered_workflows()
|
||||
|
||||
@staticmethod
|
||||
def get_event_workflows() -> List[Workflow]:
|
||||
"""
|
||||
获取事件触发的工作流列表
|
||||
"""
|
||||
return WorkflowOper().get_event_triggered_workflows()
|
||||
|
||||
@@ -529,33 +529,65 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 512, ttl: Opti
|
||||
# 获取缓存区
|
||||
cache_region = region if region is not None else f"{func.__module__}.{func.__name__}"
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 获取缓存键
|
||||
cache_key = cache_backend.get_cache_key(func, args, kwargs)
|
||||
# 尝试获取缓存
|
||||
cached_value = cache_backend.get(cache_key, region=cache_region)
|
||||
if should_cache(cached_value) and is_valid_cache_value(cache_key, cached_value, cache_region):
|
||||
return cached_value
|
||||
# 执行函数并缓存结果
|
||||
result = func(*args, **kwargs)
|
||||
# 判断是否需要缓存
|
||||
if not should_cache(result):
|
||||
# 检查是否为异步函数
|
||||
is_async = inspect.iscoroutinefunction(func)
|
||||
|
||||
if is_async:
|
||||
# 异步函数的缓存装饰器
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 获取缓存键
|
||||
cache_key = cache_backend.get_cache_key(func, args, kwargs)
|
||||
# 尝试获取缓存
|
||||
cached_value = cache_backend.get(cache_key, region=cache_region)
|
||||
if should_cache(cached_value) and is_valid_cache_value(cache_key, cached_value, cache_region):
|
||||
return cached_value
|
||||
# 执行异步函数并缓存结果
|
||||
result = await func(*args, **kwargs)
|
||||
# 判断是否需要缓存
|
||||
if not should_cache(result):
|
||||
return result
|
||||
# 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值)
|
||||
cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)
|
||||
return result
|
||||
# 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值)
|
||||
cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)
|
||||
return result
|
||||
|
||||
def cache_clear():
|
||||
"""
|
||||
清理缓存区
|
||||
"""
|
||||
# 清理缓存区
|
||||
cache_backend.clear(region=cache_region)
|
||||
def cache_clear():
|
||||
"""
|
||||
清理缓存区
|
||||
"""
|
||||
cache_backend.clear(region=cache_region)
|
||||
|
||||
wrapper.cache_region = cache_region
|
||||
wrapper.cache_clear = cache_clear
|
||||
return wrapper
|
||||
async_wrapper.cache_region = cache_region
|
||||
async_wrapper.cache_clear = cache_clear
|
||||
return async_wrapper
|
||||
else:
|
||||
# 同步函数的缓存装饰器
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 获取缓存键
|
||||
cache_key = cache_backend.get_cache_key(func, args, kwargs)
|
||||
# 尝试获取缓存
|
||||
cached_value = cache_backend.get(cache_key, region=cache_region)
|
||||
if should_cache(cached_value) and is_valid_cache_value(cache_key, cached_value, cache_region):
|
||||
return cached_value
|
||||
# 执行函数并缓存结果
|
||||
result = func(*args, **kwargs)
|
||||
# 判断是否需要缓存
|
||||
if not should_cache(result):
|
||||
return result
|
||||
# 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值)
|
||||
cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)
|
||||
return result
|
||||
|
||||
def cache_clear():
|
||||
"""
|
||||
清理缓存区
|
||||
"""
|
||||
cache_backend.clear(region=cache_region)
|
||||
|
||||
wrapper.cache_region = cache_region
|
||||
wrapper.cache_clear = cache_clear
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
@@ -11,8 +13,10 @@ from dotenv import set_key
|
||||
from pydantic import BaseModel, BaseSettings, validator, Field
|
||||
|
||||
from app.log import logger, log_settings, LogConfigModel
|
||||
from app.schemas import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
@@ -207,10 +211,14 @@ class ConfigModel(BaseModel):
|
||||
LOCAL_EXISTS_SEARCH: bool = False
|
||||
# 搜索多个名称
|
||||
SEARCH_MULTIPLE_NAME: bool = False
|
||||
# 最大搜索名称数量
|
||||
MAX_SEARCH_NAME_LIMIT: int = 2
|
||||
# 站点数据刷新间隔(小时)
|
||||
SITEDATA_REFRESH_INTERVAL: int = 6
|
||||
# 读取和发送站点消息
|
||||
SITE_MESSAGE: bool = True
|
||||
# 不能缓存站点资源的站点域名,多个使用,分隔
|
||||
NO_CACHE_SITE_KEY: str = "m-team"
|
||||
# 种子标签
|
||||
TORRENT_TAG: str = "MOVIEPILOT"
|
||||
# 下载站点字幕
|
||||
@@ -229,8 +237,6 @@ class ConfigModel(BaseModel):
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# CookieCloud同步黑名单,多个域名,分割
|
||||
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
||||
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
||||
@@ -274,12 +280,8 @@ class ConfigModel(BaseModel):
|
||||
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 是否启用内存监控
|
||||
MEMORY_ANALYSIS: bool = False
|
||||
# 内存快照间隔(分钟)
|
||||
MEMORY_SNAPSHOT_INTERVAL: int = 30
|
||||
# 保留的内存快照文件数量
|
||||
MEMORY_SNAPSHOT_KEEP_COUNT: int = 20
|
||||
# FastApi性能监控
|
||||
PERFORMANCE_MONITOR_ENABLE: bool = False
|
||||
# 全局图片缓存,将媒体图片缓存到本地
|
||||
GLOBAL_IMAGE_CACHE: bool = False
|
||||
# 是否启用编码探测的性能模式
|
||||
@@ -311,6 +313,12 @@ class ConfigModel(BaseModel):
|
||||
DEFAULT_SUB: Optional[str] = "zh-cn"
|
||||
# Docker Client API地址
|
||||
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
|
||||
# 工作流数据共享
|
||||
WORKFLOW_STATISTIC_SHARE: bool = True
|
||||
# 对rclone进行快照对比时,是否检查文件夹的修改时间
|
||||
RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME = True
|
||||
# 对OpenList进行快照对比时,是否检查文件夹的修改时间
|
||||
OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME = True
|
||||
|
||||
|
||||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
@@ -510,6 +518,20 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
return "v2"
|
||||
|
||||
@property
|
||||
def USER_AGENT(self) -> str:
|
||||
"""
|
||||
全局用户代理字符串
|
||||
"""
|
||||
return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})"
|
||||
|
||||
@property
|
||||
def NORMAL_USER_AGENT(self) -> str:
|
||||
"""
|
||||
默认浏览器用户代理字符串
|
||||
"""
|
||||
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||
|
||||
@property
|
||||
def INNER_CONFIG_PATH(self):
|
||||
return self.ROOT_PATH / "config"
|
||||
@@ -606,7 +628,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if self.GITHUB_TOKEN:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
"User-Agent": self.NORMAL_USER_AGENT,
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -635,7 +657,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
continue
|
||||
headers[repo_info] = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
"User-Agent": self.NORMAL_USER_AGENT,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||
@@ -655,6 +677,23 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return None
|
||||
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||||
|
||||
def RENAME_FORMAT(self, media_type: MediaType):
|
||||
"""
|
||||
获取指定类型的重命名格式
|
||||
|
||||
:param media_type: MediaType.TV 或 MediaType.Movie
|
||||
:return: 重命名格式
|
||||
"""
|
||||
rename_format = (
|
||||
self.TV_RENAME_FORMAT
|
||||
if media_type == MediaType.TV
|
||||
else self.MOVIE_RENAME_FORMAT
|
||||
)
|
||||
# 规范重命名格式
|
||||
rename_format = rename_format.replace("\\", "/")
|
||||
rename_format = re.sub(r'/+', '/', rename_format)
|
||||
return rename_format.strip("/")
|
||||
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings()
|
||||
|
||||
@@ -193,7 +193,7 @@ class MediaInfo:
|
||||
# LOGO
|
||||
logo_path: str = None
|
||||
# 评分
|
||||
vote_average: float = 0.0
|
||||
vote_average: float = None
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
@@ -237,9 +237,9 @@ class MediaInfo:
|
||||
# 流媒体平台
|
||||
networks: list = field(default_factory=list)
|
||||
# 集数
|
||||
number_of_episodes: int = 0
|
||||
number_of_episodes: int = None
|
||||
# 季数
|
||||
number_of_seasons: int = 0
|
||||
number_of_seasons: int = None
|
||||
# 原产国
|
||||
origin_country: list = field(default_factory=list)
|
||||
# 原名
|
||||
@@ -255,9 +255,9 @@ class MediaInfo:
|
||||
# 标签
|
||||
tagline: str = None
|
||||
# 评价数量
|
||||
vote_count: int = 0
|
||||
vote_count: int = None
|
||||
# 流行度
|
||||
popularity: int = 0
|
||||
popularity: int = None
|
||||
# 时长
|
||||
runtime: int = None
|
||||
# 下一集
|
||||
@@ -474,7 +474,16 @@ class MediaInfo:
|
||||
self.names = info.get('names') or []
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if hasattr(self, key) and not getattr(self, key):
|
||||
if not value:
|
||||
continue
|
||||
if not hasattr(self, key):
|
||||
continue
|
||||
current_value = getattr(self, key)
|
||||
if current_value:
|
||||
continue
|
||||
if current_value is None:
|
||||
setattr(self, key, value)
|
||||
elif type(current_value) == type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_douban_info(self, info: dict):
|
||||
@@ -606,7 +615,16 @@ class MediaInfo:
|
||||
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if not value:
|
||||
continue
|
||||
if not hasattr(self, key):
|
||||
continue
|
||||
current_value = getattr(self, key)
|
||||
if current_value:
|
||||
continue
|
||||
if current_value is None:
|
||||
setattr(self, key, value)
|
||||
elif type(current_value) == type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_bangumi_info(self, info: dict):
|
||||
@@ -796,6 +814,8 @@ class Context:
|
||||
media_info: MediaInfo = None
|
||||
# 种子信息
|
||||
torrent_info: TorrentInfo = None
|
||||
# 媒体识别失败次数
|
||||
media_recognize_fail_count: int = 0
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
@@ -804,5 +824,6 @@ class Context:
|
||||
return {
|
||||
"meta_info": self.meta_info.to_dict() if self.meta_info else None,
|
||||
"torrent_info": self.torrent_info.to_dict() if self.torrent_info else None,
|
||||
"media_info": self.media_info.to_dict() if self.media_info else None
|
||||
"media_info": self.media_info.to_dict() if self.media_info else None,
|
||||
"media_recognize_fail_count": self.media_recognize_fail_count
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import importlib
|
||||
import inspect
|
||||
import random
|
||||
@@ -7,7 +6,9 @@ import time
|
||||
import traceback
|
||||
import uuid
|
||||
from queue import Empty, PriorityQueue
|
||||
from typing import Callable, Dict, List, Optional, Union
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, Any
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
@@ -69,9 +70,6 @@ class EventManager(metaclass=Singleton):
|
||||
EventManager 负责管理和调度广播事件和链式事件,包括订阅、发送和处理事件
|
||||
"""
|
||||
|
||||
# 退出事件
|
||||
__event = threading.Event()
|
||||
|
||||
def __init__(self):
|
||||
self.__executor = ThreadHelper() # 动态线程池,用于消费事件
|
||||
self.__consumer_threads = [] # 用于保存启动的事件消费者线程
|
||||
@@ -81,6 +79,7 @@ class EventManager(metaclass=Singleton):
|
||||
self.__disabled_handlers = set() # 禁用的事件处理器集合
|
||||
self.__disabled_classes = set() # 禁用的事件处理器类集合
|
||||
self.__lock = threading.Lock() # 线程锁
|
||||
self.__event = threading.Event() # 退出事件
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
@@ -144,6 +143,25 @@ class EventManager(metaclass=Singleton):
|
||||
logger.error(f"Unknown event type: {etype}")
|
||||
return None
|
||||
|
||||
async def async_send_event(self, etype: Union[EventType, ChainEventType],
|
||||
data: Optional[Union[Dict, ChainEventData]] = None,
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
|
||||
"""
|
||||
异步发送事件,根据事件类型决定是广播事件还是链式事件
|
||||
:param etype: 事件类型 (EventType 或 ChainEventType)
|
||||
:param data: 可选,事件数据
|
||||
:param priority: 广播事件的优先级,默认为 10
|
||||
:return: 如果是链式事件,返回处理后的事件数据;否则返回 None
|
||||
"""
|
||||
event = Event(etype, data, priority)
|
||||
if isinstance(etype, EventType):
|
||||
return self.__trigger_broadcast_event(event)
|
||||
elif isinstance(etype, ChainEventType):
|
||||
return await self.__trigger_chain_event_async(event)
|
||||
else:
|
||||
logger.error(f"Unknown event type: {etype}")
|
||||
return None
|
||||
|
||||
def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable,
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
||||
"""
|
||||
@@ -327,6 +345,14 @@ class EventManager(metaclass=Singleton):
|
||||
dispatch = self.__dispatch_chain_event(event)
|
||||
return event if dispatch else None
|
||||
|
||||
async def __trigger_chain_event_async(self, event: Event) -> Optional[Event]:
|
||||
"""
|
||||
异步触发链式事件,按顺序调用订阅的处理器,并记录处理耗时
|
||||
"""
|
||||
logger.debug(f"Triggering asynchronous chain event: {event}")
|
||||
dispatch = await self.__dispatch_chain_event_async(event)
|
||||
return event if dispatch else None
|
||||
|
||||
def __trigger_broadcast_event(self, event: Event):
|
||||
"""
|
||||
触发广播事件,将事件插入到优先级队列中
|
||||
@@ -364,6 +390,35 @@ class EventManager(metaclass=Singleton):
|
||||
self.__log_event_lifecycle(event, "Completed")
|
||||
return True
|
||||
|
||||
async def __dispatch_chain_event_async(self, event: Event) -> bool:
|
||||
"""
|
||||
异步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间
|
||||
:param event: 要调度的事件对象
|
||||
"""
|
||||
handlers = self.__chain_subscribers.get(event.event_type, {})
|
||||
if not handlers:
|
||||
logger.debug(f"No handlers found for chain event: {event}")
|
||||
return False
|
||||
|
||||
# 过滤出启用的处理器
|
||||
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
|
||||
if self.__is_handler_enabled(handler)}
|
||||
|
||||
if not enabled_handlers:
|
||||
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
|
||||
return False
|
||||
|
||||
self.__log_event_lifecycle(event, "Started")
|
||||
for handler_id, (priority, handler) in enabled_handlers.items():
|
||||
start_time = time.time()
|
||||
await self.__safe_invoke_handler_async(handler, event)
|
||||
logger.debug(
|
||||
f"{self.__get_handler_identifier(handler)} (Priority: {priority}), "
|
||||
f"completed in {time.time() - start_time:.3f}s for event: {event}"
|
||||
)
|
||||
self.__log_event_lifecycle(event, "Completed")
|
||||
return True
|
||||
|
||||
def __dispatch_broadcast_event(self, event: Event):
|
||||
"""
|
||||
异步方式调度广播事件,通过线程池逐个调用事件处理器
|
||||
@@ -373,8 +428,17 @@ class EventManager(metaclass=Singleton):
|
||||
if not handlers:
|
||||
logger.debug(f"No handlers found for broadcast event: {event}")
|
||||
return
|
||||
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
|
||||
for handler_id, handler in handlers.items():
|
||||
self.__executor.submit(self.__safe_invoke_handler, handler, event)
|
||||
# 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值
|
||||
if isinstance(event.event_data, dict):
|
||||
event_data_copy = event.event_data.copy()
|
||||
else:
|
||||
event_data_copy = event.event_data
|
||||
isolated_event = Event(event_type=event.event_type,
|
||||
event_data=event_data_copy,
|
||||
priority=event.priority)
|
||||
self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)
|
||||
|
||||
def __safe_invoke_handler(self, handler: Callable, event: Event):
|
||||
"""
|
||||
@@ -386,49 +450,140 @@ class EventManager(metaclass=Singleton):
|
||||
logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution")
|
||||
return
|
||||
|
||||
# 根据事件类型判断是否需要深复制
|
||||
is_broadcast_event = isinstance(event.event_type, EventType)
|
||||
event_to_process = copy.deepcopy(event) if is_broadcast_event else event
|
||||
|
||||
names = handler.__qualname__.split(".")
|
||||
class_name, method_name = names[0], names[1]
|
||||
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
if class_name in PluginManager().get_plugin_ids():
|
||||
def plugin_callable():
|
||||
"""
|
||||
插件调用函数
|
||||
"""
|
||||
PluginManager().run_plugin_method(class_name, method_name, event_to_process)
|
||||
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(plugin_callable)
|
||||
else:
|
||||
plugin_callable()
|
||||
elif class_name in ModuleManager().get_module_ids():
|
||||
module = ModuleManager().get_running_module(class_name)
|
||||
if module:
|
||||
method = getattr(module, method_name, None)
|
||||
if method:
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(method, event_to_process)
|
||||
else:
|
||||
method(event_to_process)
|
||||
else:
|
||||
# 获取全局对象或模块类的实例
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
if class_obj and hasattr(class_obj, method_name):
|
||||
method = getattr(class_obj, method_name)
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(method, event_to_process)
|
||||
else:
|
||||
method(event_to_process)
|
||||
self.__invoke_handler_by_type_sync(handler, event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event, handler, e)
|
||||
|
||||
async def __safe_invoke_handler_async(self, handler: Callable, event: Event):
|
||||
"""
|
||||
异步调用处理器,处理链式事件
|
||||
:param handler: 处理器
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not self.__is_handler_enabled(handler):
|
||||
logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution")
|
||||
return
|
||||
|
||||
try:
|
||||
await self.__invoke_handler_by_type_async(handler, event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event, handler, e)
|
||||
|
||||
def __invoke_handler_by_type_sync(self, handler: Callable, event: Event):
|
||||
"""
|
||||
同步方式根据处理器类型调用相应的方法
|
||||
:param handler: 处理器
|
||||
:param event: 要处理的事件对象
|
||||
"""
|
||||
class_name, method_name = self.__parse_handler_names(handler)
|
||||
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
module_manager = ModuleManager()
|
||||
|
||||
if class_name in plugin_manager.get_plugin_ids():
|
||||
# 插件处理器
|
||||
plugin_manager.run_plugin_method(class_name, method_name, event)
|
||||
elif class_name in module_manager.get_module_ids():
|
||||
# 模块处理器
|
||||
module = module_manager.get_running_module(class_name)
|
||||
if not module:
|
||||
return
|
||||
method = getattr(module, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
method(event)
|
||||
else:
|
||||
# 全局处理器
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
if not class_obj or not hasattr(class_obj, method_name):
|
||||
return
|
||||
method = getattr(class_obj, method_name)
|
||||
if not method:
|
||||
return
|
||||
method(event)
|
||||
|
||||
async def __invoke_handler_by_type_async(self, handler: Callable, event: Event):
|
||||
"""
|
||||
异步方式根据处理器类型调用相应的方法
|
||||
:param handler: 处理器
|
||||
:param event: 要处理的事件对象
|
||||
"""
|
||||
class_name, method_name = self.__parse_handler_names(handler)
|
||||
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
module_manager = ModuleManager()
|
||||
|
||||
if class_name in plugin_manager.get_plugin_ids():
|
||||
await self.__invoke_plugin_method_async(plugin_manager, class_name, method_name, event)
|
||||
elif class_name in module_manager.get_module_ids():
|
||||
await self.__invoke_module_method_async(module_manager, class_name, method_name, event)
|
||||
else:
|
||||
await self.__invoke_global_method_async(class_name, method_name, event)
|
||||
|
||||
@staticmethod
|
||||
def __parse_handler_names(handler: Callable) -> Tuple[str, str]:
|
||||
"""
|
||||
解析处理器的类名和方法名
|
||||
:param handler: 处理器
|
||||
:return: (class_name, method_name)
|
||||
"""
|
||||
names = handler.__qualname__.split(".")
|
||||
return names[0], names[1]
|
||||
|
||||
@staticmethod
|
||||
async def __invoke_plugin_method_async(handler: Any, class_name: str, method_name: str, event: Event):
|
||||
"""
|
||||
异步调用插件方法
|
||||
"""
|
||||
plugin = handler.running_plugins.get(class_name)
|
||||
if plugin and hasattr(plugin, method_name):
|
||||
method = getattr(plugin, method_name)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
else:
|
||||
# 插件同步函数在异步环境中运行,避免阻塞
|
||||
await run_in_threadpool(method, event)
|
||||
|
||||
@staticmethod
|
||||
async def __invoke_module_method_async(handler: Any, class_name: str, method_name: str, event: Event):
|
||||
"""
|
||||
异步调用模块方法
|
||||
"""
|
||||
module = handler.get_running_module(class_name)
|
||||
if not module:
|
||||
return
|
||||
|
||||
method = getattr(module, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
else:
|
||||
method(event)
|
||||
|
||||
async def __invoke_global_method_async(self, class_name: str, method_name: str, event: Event):
|
||||
"""
|
||||
异步调用全局对象方法
|
||||
"""
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
if not class_obj or not hasattr(class_obj, method_name):
|
||||
return
|
||||
|
||||
method = getattr(class_obj, method_name)
|
||||
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
else:
|
||||
method(event)
|
||||
|
||||
@staticmethod
|
||||
def __get_class_instance(class_name: str):
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,6 @@ class CustomizationMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别自定义占位符
|
||||
"""
|
||||
customization = None
|
||||
custom_separator = None
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
@@ -200,7 +200,7 @@ class MetaVideo(MetaBase):
|
||||
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
||||
flags=re.IGNORECASE).strip()
|
||||
name = re.sub(r'\s+', ' ', name)
|
||||
if name.isdigit() \
|
||||
if name.isdecimal() \
|
||||
and int(name) < 1800 \
|
||||
and not self.year \
|
||||
and not self.begin_season \
|
||||
|
||||
@@ -9,7 +9,6 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别制作组、字幕组
|
||||
"""
|
||||
__release_groups: str = None
|
||||
# 内置组
|
||||
RELEASE_GROUPS: dict = {
|
||||
"0ff": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'],
|
||||
@@ -48,7 +47,7 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
"joyhd": [],
|
||||
"keepfrds": ['FRDS', 'Yumi', 'cXcY'],
|
||||
"lemonhd": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'],
|
||||
"mteam": ['MTeam(?:TV|)', 'MPAD'],
|
||||
"mteam": ['MTeam(?:TV|)', 'MPAD', 'MWeb'],
|
||||
"nanyangpt": [],
|
||||
"nicept": [],
|
||||
"oshen": [],
|
||||
@@ -70,7 +69,7 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
"U2": [],
|
||||
"ultrahd": [],
|
||||
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:yG|)',
|
||||
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )',],
|
||||
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
|
||||
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
|
||||
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
|
||||
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社',
|
||||
|
||||
@@ -312,4 +312,3 @@ class StreamingPlatforms(metaclass=Singleton):
|
||||
if name is None:
|
||||
return False
|
||||
return name.upper() in self._lookup_cache
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ class ModuleManager(metaclass=Singleton):
|
||||
模块管理器
|
||||
"""
|
||||
|
||||
# 模块列表
|
||||
_modules: dict = {}
|
||||
# 运行态模块列表
|
||||
_running_modules: dict = {}
|
||||
# 子模块类型集合
|
||||
SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]
|
||||
|
||||
def __init__(self):
|
||||
# 模块列表
|
||||
self._modules: dict = {}
|
||||
# 运行态模块列表
|
||||
self._running_modules: dict = {}
|
||||
self.load_modules()
|
||||
|
||||
def load_modules(self):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import concurrent
|
||||
import concurrent.futures
|
||||
import importlib.util
|
||||
@@ -21,7 +22,7 @@ from app.core.event import eventmanager, Event
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
from app.utils.crypto import RSAUtils
|
||||
@@ -88,16 +89,15 @@ class PluginManager(metaclass=Singleton):
|
||||
插件管理器
|
||||
"""
|
||||
|
||||
# 插件列表
|
||||
_plugins: dict = {}
|
||||
# 运行态插件列表
|
||||
_running_plugins: dict = {}
|
||||
# 配置Key
|
||||
_config_key: str = "plugin.%s"
|
||||
# 监听器
|
||||
_observer: Observer = None
|
||||
|
||||
def __init__(self):
|
||||
# 插件列表
|
||||
self._plugins: dict = {}
|
||||
# 运行态插件列表
|
||||
self._running_plugins: dict = {}
|
||||
# 配置Key
|
||||
self._config_key: str = "plugin.%s"
|
||||
# 监听器
|
||||
self._observer: Observer = None
|
||||
# 开发者模式监测插件修改
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
self.__start_monitor()
|
||||
@@ -832,6 +832,25 @@ class PluginManager(metaclass=Singleton):
|
||||
return None
|
||||
return getattr(plugin, method)(*args, **kwargs)
|
||||
|
||||
async def async_run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
异步运行插件方法
|
||||
:param pid: 插件ID
|
||||
:param method: 方法名
|
||||
:param args: 参数
|
||||
:param kwargs: 关键字参数
|
||||
"""
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return None
|
||||
if not hasattr(plugin, method):
|
||||
return None
|
||||
method_func = getattr(plugin, method)
|
||||
if asyncio.iscoroutinefunction(method_func):
|
||||
return await method_func(*args, **kwargs)
|
||||
else:
|
||||
return method_func(*args, **kwargs)
|
||||
|
||||
def get_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有插件ID
|
||||
@@ -851,8 +870,6 @@ class PluginManager(metaclass=Singleton):
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return []
|
||||
|
||||
# 返回值
|
||||
all_plugins = []
|
||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||
higher_version_plugins = []
|
||||
# 用于存储 v1 版本插件
|
||||
@@ -885,25 +902,7 @@ class PluginManager(metaclass=Singleton):
|
||||
else:
|
||||
base_version_plugins.extend(plugins) # 收集 v1 版本插件
|
||||
|
||||
# 优先处理高版本插件
|
||||
all_plugins.extend(higher_version_plugins)
|
||||
# 将未出现在高版本插件列表中的 v1 插件加入 all_plugins
|
||||
higher_plugin_ids = {f"{p.id}{p.plugin_version}" for p in higher_version_plugins}
|
||||
all_plugins.extend([p for p in base_version_plugins if f"{p.id}{p.plugin_version}" not in higher_plugin_ids])
|
||||
# 去重
|
||||
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
|
||||
# 所有插件按 repo 在设置中的顺序排序
|
||||
all_plugins.sort(
|
||||
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
|
||||
)
|
||||
# 相同 ID 的插件保留版本号最大的版本
|
||||
max_versions = {}
|
||||
for p in all_plugins:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||
max_versions[p.id] = p.plugin_version
|
||||
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||
return result
|
||||
return self._process_plugins_list(higher_version_plugins, base_version_plugins)
|
||||
|
||||
def get_local_plugins(self) -> List[schemas.Plugin]:
|
||||
"""
|
||||
@@ -1033,81 +1032,215 @@ class PluginManager(metaclass=Singleton):
|
||||
ret_plugins = []
|
||||
add_time = len(online_plugins)
|
||||
for pid, plugin_info in online_plugins.items():
|
||||
# 如 package_version 为空,则需要判断插件是否兼容当前版本
|
||||
if not package_version:
|
||||
if plugin_info.get(settings.VERSION_FLAG) is not True:
|
||||
# 插件当前版本不兼容
|
||||
continue
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
# 非运行态插件
|
||||
plugin_static = self._plugins.get(pid)
|
||||
# 基本属性
|
||||
plugin = schemas.Plugin()
|
||||
# ID
|
||||
plugin.id = pid
|
||||
# 安装状态
|
||||
if pid in installed_apps and plugin_static:
|
||||
plugin.installed = True
|
||||
else:
|
||||
plugin.installed = False
|
||||
# 是否有新版本
|
||||
plugin.has_update = False
|
||||
if plugin_static:
|
||||
installed_version = getattr(plugin_static, "plugin_version")
|
||||
if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
|
||||
# 需要更新
|
||||
plugin.has_update = True
|
||||
# 运行状态
|
||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||
try:
|
||||
state = plugin_obj.get_state()
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||
state = False
|
||||
plugin.state = state
|
||||
else:
|
||||
plugin.state = False
|
||||
# 是否有详情页面
|
||||
plugin.has_page = False
|
||||
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
||||
if ObjectUtils.check_method(plugin_obj.get_page):
|
||||
plugin.has_page = True
|
||||
# 公钥
|
||||
if plugin_info.get("key"):
|
||||
plugin.plugin_public_key = plugin_info.get("key")
|
||||
# 权限
|
||||
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info):
|
||||
plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version)
|
||||
if plugin:
|
||||
ret_plugins.append(plugin)
|
||||
add_time -= 1
|
||||
|
||||
return ret_plugins
|
||||
|
||||
@staticmethod
|
||||
def _process_plugins_list(higher_version_plugins: List[schemas.Plugin],
|
||||
base_version_plugins: List[schemas.Plugin]) -> List[schemas.Plugin]:
|
||||
"""
|
||||
处理插件列表:合并、去重、排序、保留最高版本
|
||||
:param higher_version_plugins: 高版本插件列表
|
||||
:param base_version_plugins: 基础版本插件列表
|
||||
:return: 处理后的插件列表
|
||||
"""
|
||||
# 优先处理高版本插件
|
||||
all_plugins = []
|
||||
all_plugins.extend(higher_version_plugins)
|
||||
# 将未出现在高版本插件列表中的 v1 插件加入 all_plugins
|
||||
higher_plugin_ids = {f"{p.id}{p.plugin_version}" for p in higher_version_plugins}
|
||||
all_plugins.extend([p for p in base_version_plugins if f"{p.id}{p.plugin_version}" not in higher_plugin_ids])
|
||||
# 去重
|
||||
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
|
||||
# 所有插件按 repo 在设置中的顺序排序
|
||||
all_plugins.sort(
|
||||
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
|
||||
)
|
||||
# 相同 ID 的插件保留版本号最大的版本
|
||||
max_versions = {}
|
||||
for p in all_plugins:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||
max_versions[p.id] = p.plugin_version
|
||||
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||
return result
|
||||
|
||||
def _process_plugin_info(self, pid: str, plugin_info: dict, market: str,
|
||||
installed_apps: List[str], add_time: int,
|
||||
package_version: Optional[str] = None) -> Optional[schemas.Plugin]:
|
||||
"""
|
||||
处理单个插件信息,创建 schemas.Plugin 对象
|
||||
:param pid: 插件ID
|
||||
:param plugin_info: 插件信息字典
|
||||
:param market: 市场URL
|
||||
:param installed_apps: 已安装插件列表
|
||||
:param add_time: 添加顺序
|
||||
:param package_version: 包版本
|
||||
:return: 创建的插件对象,如果验证失败返回None
|
||||
"""
|
||||
if not isinstance(plugin_info, dict):
|
||||
return None
|
||||
|
||||
# 如 package_version 为空,则需要判断插件是否兼容当前版本
|
||||
if not package_version:
|
||||
if plugin_info.get(settings.VERSION_FLAG) is not True:
|
||||
# 插件当前版本不兼容
|
||||
return None
|
||||
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
# 非运行态插件
|
||||
plugin_static = self._plugins.get(pid)
|
||||
# 基本属性
|
||||
plugin = schemas.Plugin()
|
||||
# ID
|
||||
plugin.id = pid
|
||||
# 安装状态
|
||||
if pid in installed_apps and plugin_static:
|
||||
plugin.installed = True
|
||||
else:
|
||||
plugin.installed = False
|
||||
# 是否有新版本
|
||||
plugin.has_update = False
|
||||
if plugin_static:
|
||||
installed_version = getattr(plugin_static, "plugin_version")
|
||||
if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
|
||||
# 需要更新
|
||||
plugin.has_update = True
|
||||
# 运行状态
|
||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||
try:
|
||||
state = plugin_obj.get_state()
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||
state = False
|
||||
plugin.state = state
|
||||
else:
|
||||
plugin.state = False
|
||||
# 是否有详情页面
|
||||
plugin.has_page = False
|
||||
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
||||
if ObjectUtils.check_method(plugin_obj.get_page):
|
||||
plugin.has_page = True
|
||||
# 公钥
|
||||
if plugin_info.get("key"):
|
||||
plugin.plugin_public_key = plugin_info.get("key")
|
||||
# 权限
|
||||
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info):
|
||||
return None
|
||||
# 名称
|
||||
if plugin_info.get("name"):
|
||||
plugin.plugin_name = plugin_info.get("name")
|
||||
# 描述
|
||||
if plugin_info.get("description"):
|
||||
plugin.plugin_desc = plugin_info.get("description")
|
||||
# 版本
|
||||
if plugin_info.get("version"):
|
||||
plugin.plugin_version = plugin_info.get("version")
|
||||
# 图标
|
||||
if plugin_info.get("icon"):
|
||||
plugin.plugin_icon = plugin_info.get("icon")
|
||||
# 标签
|
||||
if plugin_info.get("labels"):
|
||||
plugin.plugin_label = plugin_info.get("labels")
|
||||
# 作者
|
||||
if plugin_info.get("author"):
|
||||
plugin.plugin_author = plugin_info.get("author")
|
||||
# 更新历史
|
||||
if plugin_info.get("history"):
|
||||
plugin.history = plugin_info.get("history")
|
||||
# 仓库链接
|
||||
plugin.repo_url = market
|
||||
# 本地标志
|
||||
plugin.is_local = False
|
||||
# 添加顺序
|
||||
plugin.add_time = add_time
|
||||
|
||||
return plugin
|
||||
|
||||
async def async_get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]:
|
||||
"""
|
||||
异步获取所有在线插件信息
|
||||
"""
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return []
|
||||
|
||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||
higher_version_plugins = []
|
||||
# 用于存储 v1 版本插件
|
||||
base_version_plugins = []
|
||||
|
||||
# 使用异步并发获取线上插件
|
||||
import asyncio
|
||||
tasks = []
|
||||
task_to_version = {}
|
||||
|
||||
for m in settings.PLUGIN_MARKET.split(","):
|
||||
if not m:
|
||||
continue
|
||||
# 名称
|
||||
if plugin_info.get("name"):
|
||||
plugin.plugin_name = plugin_info.get("name")
|
||||
# 描述
|
||||
if plugin_info.get("description"):
|
||||
plugin.plugin_desc = plugin_info.get("description")
|
||||
# 版本
|
||||
if plugin_info.get("version"):
|
||||
plugin.plugin_version = plugin_info.get("version")
|
||||
# 图标
|
||||
if plugin_info.get("icon"):
|
||||
plugin.plugin_icon = plugin_info.get("icon")
|
||||
# 标签
|
||||
if plugin_info.get("labels"):
|
||||
plugin.plugin_label = plugin_info.get("labels")
|
||||
# 作者
|
||||
if plugin_info.get("author"):
|
||||
plugin.plugin_author = plugin_info.get("author")
|
||||
# 更新历史
|
||||
if plugin_info.get("history"):
|
||||
plugin.history = plugin_info.get("history")
|
||||
# 仓库链接
|
||||
plugin.repo_url = market
|
||||
# 本地标志
|
||||
plugin.is_local = False
|
||||
# 添加顺序
|
||||
plugin.add_time = add_time
|
||||
# 汇总
|
||||
ret_plugins.append(plugin)
|
||||
# 创建任务获取 v1 版本插件
|
||||
base_task = asyncio.create_task(self.async_get_plugins_from_market(m, None, force))
|
||||
tasks.append(base_task)
|
||||
task_to_version[base_task] = "base_version"
|
||||
|
||||
# 创建任务获取高版本插件(如 v2、v3)
|
||||
if settings.VERSION_FLAG:
|
||||
higher_version_task = asyncio.create_task(
|
||||
self.async_get_plugins_from_market(m, settings.VERSION_FLAG, force))
|
||||
tasks.append(higher_version_task)
|
||||
task_to_version[higher_version_task] = "higher_version"
|
||||
|
||||
# 并发执行所有任务
|
||||
if tasks:
|
||||
completed_tasks = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, result in enumerate(completed_tasks):
|
||||
task = tasks[i]
|
||||
version = task_to_version[task]
|
||||
|
||||
# 检查是否有异常
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"获取插件市场数据失败:{str(result)}")
|
||||
continue
|
||||
|
||||
plugins = result
|
||||
if plugins:
|
||||
if version == "higher_version":
|
||||
higher_version_plugins.extend(plugins) # 收集高版本插件
|
||||
else:
|
||||
base_version_plugins.extend(plugins) # 收集 v1 版本插件
|
||||
|
||||
return self._process_plugins_list(higher_version_plugins, base_version_plugins)
|
||||
|
||||
async def async_get_plugins_from_market(self, market: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False) -> Optional[List[schemas.Plugin]]:
|
||||
"""
|
||||
异步从指定的市场获取插件信息
|
||||
:param market: 市场的 URL 或标识
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
:param force: 是否强制刷新(忽略缓存)
|
||||
:return: 返回插件的列表,若获取失败返回 []
|
||||
"""
|
||||
if not market:
|
||||
return []
|
||||
# 已安装插件
|
||||
installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 获取在线插件
|
||||
online_plugins = await PluginHelper().async_get_plugins(market, package_version, force)
|
||||
if online_plugins is None:
|
||||
logger.warning(
|
||||
f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接")
|
||||
return []
|
||||
ret_plugins = []
|
||||
add_time = len(online_plugins)
|
||||
for pid, plugin_info in online_plugins.items():
|
||||
plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version)
|
||||
if plugin:
|
||||
ret_plugins.append(plugin)
|
||||
add_time -= 1
|
||||
|
||||
return ret_plugins
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import threading
|
||||
from time import sleep
|
||||
from typing import Dict, Any, Tuple, List
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import List, Tuple
|
||||
|
||||
from app.core.config import global_vars
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.db.models import Workflow
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Action, ActionContext
|
||||
from app.schemas import ActionContext, Action
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
@@ -13,10 +19,11 @@ class WorkFlowManager(metaclass=Singleton):
|
||||
工作流管理器
|
||||
"""
|
||||
|
||||
# 所有动作定义
|
||||
_actions: Dict[str, Any] = {}
|
||||
|
||||
def __init__(self):
|
||||
# 所有动作定义
|
||||
self._lock = threading.Lock()
|
||||
self._actions: Dict[str, Any] = {}
|
||||
self._event_workflows: Dict[str, List[int]] = {}
|
||||
self.init()
|
||||
|
||||
def init(self):
|
||||
@@ -49,11 +56,15 @@ class WorkFlowManager(metaclass=Singleton):
|
||||
except Exception as err:
|
||||
logger.error(f"加载动作失败: {action.__name__} - {err}")
|
||||
|
||||
# 加载工作流事件触发器
|
||||
self.load_workflow_events()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止
|
||||
"""
|
||||
pass
|
||||
self._actions = {}
|
||||
self._event_workflows = {}
|
||||
|
||||
def excute(self, workflow_id: int, action: Action,
|
||||
context: ActionContext = None) -> Tuple[bool, str, ActionContext]:
|
||||
@@ -110,3 +121,180 @@ class WorkFlowManager(metaclass=Singleton):
|
||||
}
|
||||
} for key, action in self._actions.items()
|
||||
]
|
||||
|
||||
def update_workflow_event(self, workflow: Workflow):
|
||||
"""
|
||||
更新工作流事件触发器
|
||||
"""
|
||||
# 确保先移除旧的事件监听器
|
||||
self.remove_workflow_event(workflow_id=workflow.id, event_type_str=workflow.event_type)
|
||||
# 如果工作流是事件触发类型且未被禁用
|
||||
if workflow.trigger_type == "event" and workflow.state != 'P':
|
||||
# 注册事件触发器
|
||||
self.register_workflow_event(workflow.id, workflow.event_type)
|
||||
|
||||
def load_workflow_events(self, workflow_id: Optional[int] = None):
|
||||
"""
|
||||
加载工作流触发事件
|
||||
"""
|
||||
workflows = []
|
||||
if workflow_id:
|
||||
workflow = WorkflowOper().get(workflow_id)
|
||||
if workflow:
|
||||
workflows = [workflow]
|
||||
else:
|
||||
workflows = WorkflowOper().get_event_triggered_workflows()
|
||||
try:
|
||||
for workflow in workflows:
|
||||
self.update_workflow_event(workflow)
|
||||
except Exception as e:
|
||||
logger.error(f"加载事件触发工作流失败: {e}")
|
||||
|
||||
def register_workflow_event(self, workflow_id: int, event_type_str: str):
|
||||
"""
|
||||
注册工作流事件触发器
|
||||
"""
|
||||
try:
|
||||
event_type = EventType(event_type_str)
|
||||
except ValueError:
|
||||
logger.error(f"无效的事件类型: {event_type_str}")
|
||||
return
|
||||
if event_type in EventType:
|
||||
# 确保先移除旧的事件监听器
|
||||
self.remove_workflow_event(workflow_id, event_type.value)
|
||||
with self._lock:
|
||||
# 添加新的事件监听器
|
||||
eventmanager.add_event_listener(event_type, self._handle_event)
|
||||
# 记录工作流事件触发器
|
||||
if event_type.value not in self._event_workflows:
|
||||
self._event_workflows[event_type.value] = []
|
||||
self._event_workflows[event_type.value].append(workflow_id)
|
||||
logger.info(f"已注册工作流 {workflow_id} 事件触发器: {event_type.value}")
|
||||
|
||||
def remove_workflow_event(self, workflow_id: int, event_type_str: str):
|
||||
"""
|
||||
移除工作流事件触发器
|
||||
"""
|
||||
try:
|
||||
event_type = EventType(event_type_str)
|
||||
except ValueError:
|
||||
logger.error(f"无效的事件类型: {event_type_str}")
|
||||
return
|
||||
if event_type in EventType:
|
||||
with self._lock:
|
||||
eventmanager.remove_event_listener(event_type, self._handle_event)
|
||||
if event_type.value in self._event_workflows:
|
||||
if workflow_id in self._event_workflows[event_type.value]:
|
||||
self._event_workflows[event_type.value].remove(workflow_id)
|
||||
if not self._event_workflows[event_type.value]:
|
||||
del self._event_workflows[event_type.value]
|
||||
logger.info(f"已移除工作流 {workflow_id} 事件触发器")
|
||||
|
||||
def _handle_event(self, event: Event):
|
||||
"""
|
||||
处理事件,触发相应的工作流
|
||||
"""
|
||||
try:
|
||||
event_type_str = str(event.event_type.value)
|
||||
with self._lock:
|
||||
if event_type_str not in self._event_workflows:
|
||||
return
|
||||
workflow_ids = self._event_workflows[event_type_str].copy()
|
||||
for workflow_id in workflow_ids:
|
||||
self._trigger_workflow(workflow_id, event)
|
||||
except Exception as e:
|
||||
logger.error(f"处理工作流事件失败: {e}")
|
||||
|
||||
def _trigger_workflow(self, workflow_id: int, event: Event):
|
||||
"""
|
||||
触发工作流执行
|
||||
"""
|
||||
try:
|
||||
# 检查工作流是否存在且启用
|
||||
workflow = WorkflowOper().get(workflow_id)
|
||||
if not workflow or workflow.state == 'P':
|
||||
return
|
||||
|
||||
# 检查事件条件
|
||||
if not self._check_event_conditions(workflow, event):
|
||||
logger.debug(f"工作流 {workflow.name} 事件条件不匹配,跳过执行")
|
||||
return
|
||||
|
||||
# 检查工作流是否正在运行
|
||||
if workflow.state == 'R':
|
||||
logger.warning(f"工作流 {workflow.name} 正在运行中,跳过重复触发")
|
||||
return
|
||||
|
||||
logger.info(f"事件 {event.event_type.value} 触发工作流: {workflow.name}")
|
||||
|
||||
# 发送工作流执行事件以启动工作流
|
||||
eventmanager.send_event(EventType.WorkflowExecute, {
|
||||
"workflow_id": workflow_id,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"触发工作流 {workflow_id} 失败: {e}")
|
||||
|
||||
def _check_event_conditions(self, workflow, event: Event) -> bool:
|
||||
"""
|
||||
检查事件是否满足工作流的触发条件
|
||||
"""
|
||||
if not workflow.event_conditions:
|
||||
return True
|
||||
|
||||
conditions = workflow.event_conditions
|
||||
event_data = event.event_data or {}
|
||||
|
||||
# 检查字段匹配条件
|
||||
for field, expected_value in conditions.items():
|
||||
if field not in event_data:
|
||||
return False
|
||||
actual_value = event_data[field]
|
||||
# 支持多种条件匹配方式
|
||||
if isinstance(expected_value, dict):
|
||||
# 复杂条件匹配
|
||||
if not self._check_complex_condition(actual_value, expected_value):
|
||||
return False
|
||||
else:
|
||||
# 简单值匹配
|
||||
if actual_value != expected_value:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _check_complex_condition(actual_value: any, condition: dict) -> bool:
|
||||
"""
|
||||
检查复杂条件匹配
|
||||
支持的操作符:equals, not_equals, contains, not_contains, in, not_in, regex
|
||||
"""
|
||||
for operator, expected_value in condition.items():
|
||||
if operator == "equals":
|
||||
if actual_value != expected_value:
|
||||
return False
|
||||
elif operator == "not_equals":
|
||||
if actual_value == expected_value:
|
||||
return False
|
||||
elif operator == "contains":
|
||||
if expected_value not in str(actual_value):
|
||||
return False
|
||||
elif operator == "not_contains":
|
||||
if expected_value in str(actual_value):
|
||||
return False
|
||||
elif operator == "in":
|
||||
if actual_value not in expected_value:
|
||||
return False
|
||||
elif operator == "not_in":
|
||||
if actual_value in expected_value:
|
||||
return False
|
||||
elif operator == "regex":
|
||||
import re
|
||||
if not re.search(expected_value, str(actual_value)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_event_workflows(self) -> dict:
|
||||
"""
|
||||
获取所有事件触发的工作流
|
||||
"""
|
||||
with self._lock:
|
||||
return self._event_workflows.copy()
|
||||
|
||||
@@ -1,45 +1,106 @@
|
||||
from typing import Any, Generator, List, Optional, Self, Tuple
|
||||
import asyncio
|
||||
from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Sequence, Union
|
||||
|
||||
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text
|
||||
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
|
||||
connect_args = {
|
||||
"timeout": settings.DB_TIMEOUT
|
||||
}
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if settings.DB_WAL_ENABLE:
|
||||
connect_args["check_same_thread"] = False
|
||||
db_kwargs = {
|
||||
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": pool_class,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": connect_args
|
||||
}
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if pool_class == QueuePool:
|
||||
db_kwargs.update({
|
||||
"pool_size": settings.CONF.dbpool,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.CONF.dbpooloverflow
|
||||
})
|
||||
# 创建数据库引擎
|
||||
Engine = create_engine(**db_kwargs)
|
||||
# 根据配置设置日志模式
|
||||
journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
with Engine.connect() as connection:
|
||||
current_mode = connection.execute(text(f"PRAGMA journal_mode={journal_mode};")).scalar()
|
||||
print(f"Database journal mode set to: {current_mode}")
|
||||
|
||||
# 会话工厂
|
||||
def _get_database_engine(is_async: bool = False):
|
||||
"""
|
||||
获取数据库连接参数并设置WAL模式
|
||||
:param is_async: 是否创建异步引擎,True - 异步引擎, False - 同步引擎
|
||||
:return: 返回对应的数据库引擎
|
||||
"""
|
||||
# 连接参数
|
||||
_connect_args = {
|
||||
"timeout": settings.DB_TIMEOUT,
|
||||
}
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if settings.DB_WAL_ENABLE:
|
||||
_connect_args["check_same_thread"] = False
|
||||
|
||||
# 创建同步引擎
|
||||
if not is_async:
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
_pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
|
||||
|
||||
# 数据库参数
|
||||
_db_kwargs = {
|
||||
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": _pool_class,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": _connect_args
|
||||
}
|
||||
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if _pool_class == QueuePool:
|
||||
_db_kwargs.update({
|
||||
"pool_size": settings.CONF.dbpool,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.CONF.dbpooloverflow
|
||||
})
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(**_db_kwargs)
|
||||
|
||||
# 设置WAL模式
|
||||
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
with engine.connect() as connection:
|
||||
current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar()
|
||||
print(f"Database journal mode set to: {current_mode}")
|
||||
|
||||
return engine
|
||||
else:
|
||||
# 数据库参数,只能使用 NullPool
|
||||
_db_kwargs = {
|
||||
"url": f"sqlite+aiosqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": NullPool,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": _connect_args
|
||||
}
|
||||
# 创建异步数据库引擎
|
||||
async_engine = create_async_engine(**_db_kwargs)
|
||||
|
||||
# 设置WAL模式
|
||||
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
|
||||
async def set_async_wal_mode():
|
||||
"""
|
||||
设置异步引擎的WAL模式
|
||||
"""
|
||||
async with async_engine.connect() as _connection:
|
||||
result = await _connection.execute(text(f"PRAGMA journal_mode={_journal_mode};"))
|
||||
_current_mode = result.scalar()
|
||||
print(f"Async database journal mode set to: {_current_mode}")
|
||||
|
||||
try:
|
||||
asyncio.run(set_async_wal_mode())
|
||||
except Exception as e:
|
||||
print(f"Failed to set async WAL mode: {e}")
|
||||
|
||||
return async_engine
|
||||
|
||||
|
||||
# 同步数据库引擎
|
||||
Engine = _get_database_engine(is_async=False)
|
||||
|
||||
# 异步数据库引擎
|
||||
AsyncEngine = _get_database_engine(is_async=True)
|
||||
|
||||
# 同步会话工厂
|
||||
SessionFactory = sessionmaker(bind=Engine)
|
||||
|
||||
# 多线程全局使用的数据库会话
|
||||
# 异步会话工厂
|
||||
AsyncSessionFactory = async_sessionmaker(bind=AsyncEngine, class_=AsyncSession)
|
||||
|
||||
# 同步多线程全局使用的数据库会话
|
||||
ScopedSession = scoped_session(SessionFactory)
|
||||
|
||||
|
||||
@@ -57,37 +118,32 @@ def get_db() -> Generator:
|
||||
db.close()
|
||||
|
||||
|
||||
def perform_checkpoint(mode: str = "PASSIVE"):
|
||||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
执行 SQLite 的 checkpoint 操作,将 WAL 文件内容写回主数据库
|
||||
:param mode: checkpoint 模式,可选值包括 "PASSIVE"、"FULL"、"RESTART"、"TRUNCATE"
|
||||
默认为 "PASSIVE",即不锁定 WAL 文件的轻量级同步
|
||||
获取异步数据库会话,用于WEB请求
|
||||
:return: AsyncSession
|
||||
"""
|
||||
if not settings.DB_WAL_ENABLE:
|
||||
return
|
||||
valid_modes = {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}
|
||||
if mode.upper() not in valid_modes:
|
||||
raise ValueError(f"Invalid checkpoint mode '{mode}'. Must be one of {valid_modes}")
|
||||
try:
|
||||
# 使用指定的 checkpoint 模式,确保 WAL 文件数据被正确写回主数据库
|
||||
with Engine.connect() as conn:
|
||||
conn.execute(text(f"PRAGMA wal_checkpoint({mode.upper()});"))
|
||||
except Exception as e:
|
||||
print(f"Error during WAL checkpoint: {e}")
|
||||
async with AsyncSessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def close_database():
|
||||
async def close_database():
|
||||
"""
|
||||
关闭所有数据库连接并清理资源
|
||||
"""
|
||||
try:
|
||||
# 释放连接池,SQLite 会自动清空 WAL 文件,这里不单独再调用 checkpoint
|
||||
Engine.dispose()
|
||||
except Exception as e:
|
||||
print(f"Error while disposing database connections: {e}")
|
||||
# 释放同步连接池
|
||||
Engine.dispose() # noqa
|
||||
# 释放异步连接池
|
||||
await AsyncEngine.dispose()
|
||||
except Exception as err:
|
||||
print(f"Error while disposing database connections: {err}")
|
||||
|
||||
|
||||
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
"""
|
||||
从参数中获取数据库Session对象
|
||||
"""
|
||||
@@ -105,7 +161,25 @@ def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
return db
|
||||
|
||||
|
||||
def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
|
||||
def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]:
|
||||
"""
|
||||
从参数中获取异步数据库AsyncSession对象
|
||||
"""
|
||||
db = None
|
||||
if args:
|
||||
for arg in args:
|
||||
if isinstance(arg, AsyncSession):
|
||||
db = arg
|
||||
break
|
||||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
if isinstance(value, AsyncSession):
|
||||
db = value
|
||||
break
|
||||
return db
|
||||
|
||||
|
||||
def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
|
||||
"""
|
||||
更新参数中的数据库Session对象,关键字传参时更新db的值,否则更新第1或第2个参数
|
||||
"""
|
||||
@@ -119,6 +193,20 @@ def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]:
|
||||
"""
|
||||
更新参数中的异步数据库AsyncSession对象,关键字传参时更新db的值,否则更新第1或第2个参数
|
||||
"""
|
||||
if kwargs and 'db' in kwargs:
|
||||
kwargs['db'] = db
|
||||
elif args:
|
||||
if args[0] is None:
|
||||
args = (db, *args[1:])
|
||||
else:
|
||||
args = (args[0], db, *args[2:])
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def db_update(func):
|
||||
"""
|
||||
数据库更新类操作装饰器,第一个参数必须是数据库会话或存在db参数
|
||||
@@ -128,14 +216,14 @@ def db_update(func):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取数据库会话
|
||||
db = get_args_db(args, kwargs)
|
||||
db = _get_args_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到数据库会话,创建一个
|
||||
db = ScopedSession()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的数据库会话
|
||||
args, kwargs = update_args_db(args, kwargs, db)
|
||||
args, kwargs = _update_args_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = func(*args, **kwargs)
|
||||
@@ -154,6 +242,41 @@ def db_update(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def async_db_update(func):
|
||||
"""
|
||||
异步数据库更新类操作装饰器,第一个参数必须是异步数据库会话或存在db参数
|
||||
"""
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取异步数据库会话
|
||||
db = _get_args_async_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到异步数据库会话,创建一个
|
||||
db = AsyncSessionFactory()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的异步数据库会话
|
||||
args, kwargs = _update_args_async_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
# 提交事务
|
||||
await db.commit()
|
||||
except Exception as err:
|
||||
# 回滚事务
|
||||
await db.rollback()
|
||||
raise err
|
||||
finally:
|
||||
# 关闭数据库会话
|
||||
if _close_db:
|
||||
await db.close()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def db_query(func):
|
||||
"""
|
||||
数据库查询操作装饰器,第一个参数必须是数据库会话或存在db参数
|
||||
@@ -164,14 +287,14 @@ def db_query(func):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取数据库会话
|
||||
db = get_args_db(args, kwargs)
|
||||
db = _get_args_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到数据库会话,创建一个
|
||||
db = ScopedSession()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的数据库会话
|
||||
args, kwargs = update_args_db(args, kwargs, db)
|
||||
args, kwargs = _update_args_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = func(*args, **kwargs)
|
||||
@@ -186,6 +309,38 @@ def db_query(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def async_db_query(func):
|
||||
"""
|
||||
异步数据库查询操作装饰器,第一个参数必须是异步数据库会话或存在db参数
|
||||
注意:db.query列表数据时,需要转换为list返回
|
||||
"""
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取异步数据库会话
|
||||
db = _get_args_async_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到异步数据库会话,创建一个
|
||||
db = AsyncSessionFactory()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的异步数据库会话
|
||||
args, kwargs = _update_args_async_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
except Exception as err:
|
||||
raise err
|
||||
finally:
|
||||
# 关闭数据库会话
|
||||
if _close_db:
|
||||
await db.close()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
id: Any
|
||||
@@ -195,11 +350,23 @@ class Base:
|
||||
def create(self, db: Session):
|
||||
db.add(self)
|
||||
|
||||
@async_db_update
|
||||
async def async_create(self, db: AsyncSession):
|
||||
db.add(self)
|
||||
await db.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(and_(cls.id == rid)).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get(cls, db: AsyncSession, rid: int) -> Self:
|
||||
result = await db.execute(select(cls).where(and_(cls.id == rid)))
|
||||
return result.scalars().first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
@@ -208,23 +375,50 @@ class Base:
|
||||
if inspect(self).detached:
|
||||
db.add(self)
|
||||
|
||||
@async_db_update
|
||||
async def async_update(self, db: AsyncSession, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
for key, value in payload.items():
|
||||
setattr(self, key, value)
|
||||
if inspect(self).detached:
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete(cls, db: Session, rid):
|
||||
db.query(cls).filter(and_(cls.id == rid)).delete()
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_delete(cls, db: AsyncSession, rid):
|
||||
result = await db.execute(select(cls).where(and_(cls.id == rid)))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
await db.delete(user)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_truncate(cls, db: AsyncSession):
|
||||
await db.execute(delete(cls))
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
return db.query(cls).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list(cls, db: AsyncSession) -> Sequence[Self]:
|
||||
result = await db.execute(select(cls))
|
||||
return result.scalars().all()
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
@@ -236,5 +430,5 @@ class DbOper:
|
||||
数据库操作基类
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
def __init__(self, db: Union[Session, AsyncSession] = None):
|
||||
self._db = db
|
||||
|
||||
@@ -58,6 +58,32 @@ class MediaServerOper(DbOper):
|
||||
return None
|
||||
return item
|
||||
|
||||
async def async_exists(self, **kwargs) -> Optional[MediaServerItem]:
|
||||
"""
|
||||
异步判断媒体服务器数据是否存在
|
||||
"""
|
||||
if kwargs.get("tmdbid"):
|
||||
# 优先按TMDBID查
|
||||
item = await MediaServerItem.async_exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
|
||||
mtype=kwargs.get("mtype"))
|
||||
elif kwargs.get("title"):
|
||||
# 按标题、类型、年份查
|
||||
item = await MediaServerItem.async_exists_by_title(self._db, title=kwargs.get("title"),
|
||||
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
|
||||
else:
|
||||
return None
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if kwargs.get("season"):
|
||||
# 判断季是否存在
|
||||
if not item.seasoninfo:
|
||||
return None
|
||||
seasoninfo = item.seasoninfo or {}
|
||||
if kwargs.get("season") not in seasoninfo.keys():
|
||||
return None
|
||||
return item
|
||||
|
||||
def get_item_id(self, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
获取媒体服务器数据ID
|
||||
@@ -66,3 +92,12 @@ class MediaServerOper(DbOper):
|
||||
if not item:
|
||||
return None
|
||||
return str(item.item_id)
|
||||
|
||||
async def async_get_item_id(self, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
异步获取媒体服务器数据ID
|
||||
"""
|
||||
item = await self.async_exists(**kwargs)
|
||||
if not item:
|
||||
return None
|
||||
return str(item.item_id)
|
||||
|
||||
@@ -29,7 +29,7 @@ class MessageOper(DbOper):
|
||||
note: Union[list, dict] = None,
|
||||
**kwargs):
|
||||
"""
|
||||
新增媒体服务器数据
|
||||
新增消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 来源
|
||||
:param mtype: 消息类型
|
||||
@@ -57,11 +57,47 @@ class MessageOper(DbOper):
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
Message(**kwargs).create(self._db)
|
||||
|
||||
async def async_add(self,
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
mtype: NotificationType = None,
|
||||
title: Optional[str] = None,
|
||||
text: Optional[str] = None,
|
||||
image: Optional[str] = None,
|
||||
link: Optional[str] = None,
|
||||
userid: Optional[str] = None,
|
||||
action: Optional[int] = 1,
|
||||
note: Union[list, dict] = None,
|
||||
**kwargs):
|
||||
"""
|
||||
异步新增消息
|
||||
"""
|
||||
kwargs.update({
|
||||
"channel": channel.value if channel else '',
|
||||
"source": source,
|
||||
"mtype": mtype.value if mtype else '',
|
||||
"title": title,
|
||||
"text": text,
|
||||
"image": image,
|
||||
"link": link,
|
||||
"userid": userid,
|
||||
"action": action,
|
||||
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"note": note or {}
|
||||
})
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
await Message(**kwargs).async_create(self._db)
|
||||
|
||||
def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[str]:
|
||||
"""
|
||||
获取媒体服务器数据ID
|
||||
|
||||
@@ -9,4 +9,3 @@ from .transferhistory import TransferHistory
|
||||
from .user import User
|
||||
from .userconfig import UserConfig
|
||||
from .workflow import Workflow
|
||||
from .userrequest import UserRequest
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query
|
||||
|
||||
|
||||
class DownloadHistory(Base):
|
||||
@@ -55,35 +56,43 @@ class DownloadHistory(Base):
|
||||
# 剧集组
|
||||
episode_group = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str):
|
||||
def get_by_hash(cls, db: Session, download_hash: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by(
|
||||
DownloadHistory.date.desc()
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
||||
def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str):
|
||||
if tmdbid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
|
||||
elif doubanid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = await db.execute(
|
||||
select(cls).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_path(db: Session, path: str):
|
||||
def get_by_path(cls, db: Session, path: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.path == path).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_last_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
def get_last_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
year: Optional[str] = None, season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
"""
|
||||
@@ -133,9 +142,9 @@ class DownloadHistory(Base):
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_user_date(db: Session, date: str, username: Optional[str] = None):
|
||||
def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None):
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
@@ -147,9 +156,9 @@ class DownloadHistory(Base):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_date(db: Session, date: str, type: str, tmdbid: str, seasons: Optional[str] = None):
|
||||
def list_by_date(cls, db: Session, date: str, type: str, tmdbid: str, seasons: Optional[str] = None):
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
@@ -165,9 +174,9 @@ class DownloadHistory(Base):
|
||||
DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
def list_by_type(cls, db: Session, mtype: str, days: int):
|
||||
return db.query(DownloadHistory) \
|
||||
.filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
@@ -195,35 +204,35 @@ class DownloadFiles(Base):
|
||||
# 状态 0-已删除 1-正常
|
||||
state = Column(Integer, nullable=False, default=1)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str, state: Optional[int] = None):
|
||||
def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None):
|
||||
if state:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
return db.query(cls).filter(cls.download_hash == download_hash,
|
||||
cls.state == state).all()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
return db.query(cls).filter(cls.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_fullpath(db: Session, fullpath: str, all_files: bool = False):
|
||||
def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False):
|
||||
if not all_files:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
return db.query(cls).filter(cls.fullpath == fullpath).order_by(
|
||||
cls.id.desc()).first()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).all()
|
||||
return db.query(cls).filter(cls.fullpath == fullpath).order_by(
|
||||
cls.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_savepath(db: Session, savepath: str):
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
def get_by_savepath(cls, db: Session, savepath: str):
|
||||
return db.query(cls).filter(cls.savepath == savepath).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete_by_fullpath(db: Session, fullpath: str):
|
||||
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
|
||||
DownloadFiles.state == 1).update(
|
||||
def delete_by_fullpath(cls, db: Session, fullpath: str):
|
||||
db.query(cls).filter(cls.fullpath == fullpath,
|
||||
cls.state == 1).update(
|
||||
{
|
||||
"state": 0
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, async_db_query, Base
|
||||
|
||||
|
||||
class MediaServerItem(Base):
|
||||
@@ -41,28 +43,49 @@ class MediaServerItem(Base):
|
||||
# 同步时间
|
||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_itemid(db: Session, item_id: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.item_id == item_id).first()
|
||||
def get_by_itemid(cls, db: Session, item_id: str):
|
||||
return db.query(cls).filter(cls.item_id == item_id).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def empty(db: Session, server: Optional[str] = None):
|
||||
def empty(cls, db: Session, server: Optional[str] = None):
|
||||
if server is None:
|
||||
db.query(MediaServerItem).delete()
|
||||
db.query(cls).delete()
|
||||
else:
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
db.query(cls).filter(cls.server == server).delete()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def exist_by_tmdbid(db: Session, tmdbid: int, mtype: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.tmdbid == tmdbid,
|
||||
MediaServerItem.item_type == mtype).first()
|
||||
def exist_by_tmdbid(cls, db: Session, tmdbid: int, mtype: str):
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.item_type == mtype).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def exists_by_title(db: Session, title: str, mtype: str, year: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.title == title,
|
||||
MediaServerItem.item_type == mtype,
|
||||
MediaServerItem.year == str(year)).first()
|
||||
def exists_by_title(cls, db: Session, title: str, mtype: str, year: str):
|
||||
return db.query(cls).filter(cls.title == title,
|
||||
cls.item_type == mtype,
|
||||
cls.year == str(year)).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_itemid(cls, db: AsyncSession, item_id: str):
|
||||
result = await db.execute(select(cls).filter(cls.item_id == item_id))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_exist_by_tmdbid(cls, db: AsyncSession, tmdbid: int, mtype: str):
|
||||
result = await db.execute(select(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.item_type == mtype))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_exists_by_title(cls, db: AsyncSession, title: str, mtype: str, year: str):
|
||||
result = await db.execute(select(cls).filter(cls.title == title,
|
||||
cls.item_type == mtype,
|
||||
cls.year == str(year)))
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
from app.db import db_query, Base, async_db_query
|
||||
|
||||
|
||||
class Message(Base):
|
||||
@@ -34,7 +35,15 @@ class Message(Base):
|
||||
# 附件json
|
||||
note = Column(JSON)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(Message).order_by(Message.reg_time.desc()).offset((page - 1) * count).limit(count).all()
|
||||
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = await db.execute(
|
||||
select(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -13,27 +13,27 @@ class PluginData(Base):
|
||||
key = Column(String, index=True, nullable=False)
|
||||
value = Column(JSON)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_plugin_data(db: Session, plugin_id: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
def get_plugin_data(cls, db: Session, plugin_id: str):
|
||||
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).first()
|
||||
def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||
return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
|
||||
def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||
db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).delete()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def del_plugin_data(db: Session, plugin_id: str):
|
||||
db.query(PluginData).filter(PluginData.plugin_id == plugin_id).delete()
|
||||
def del_plugin_data(cls, db: Session, plugin_id: str):
|
||||
db.query(cls).filter(cls.plugin_id == plugin_id).delete()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str):
|
||||
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence, JSON, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query, async_db_update
|
||||
|
||||
|
||||
class Site(Base):
|
||||
@@ -54,27 +55,50 @@ class Site(Base):
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str):
|
||||
return db.query(Site).filter(Site.domain == domain).first()
|
||||
def get_by_domain(cls, db: Session, domain: str):
|
||||
return db.query(cls).filter(cls.domain == domain).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_domain(cls, db: AsyncSession, domain: str):
|
||||
result = await db.execute(select(cls).where(cls.domain == domain))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_actives(db: Session):
|
||||
return db.query(Site).filter(Site.is_active == 1).all()
|
||||
def get_actives(cls, db: Session):
|
||||
return db.query(cls).filter(cls.is_active == 1).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_actives(cls, db: AsyncSession):
|
||||
result = await db.execute(select(cls).where(cls.is_active == 1))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_order_by_pri(db: Session):
|
||||
return db.query(Site).order_by(Site.pri).all()
|
||||
def list_order_by_pri(cls, db: Session):
|
||||
return db.query(cls).order_by(cls.pri).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_order_by_pri(cls, db: AsyncSession):
|
||||
result = await db.execute(select(cls).order_by(cls.pri))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_domains_by_ids(db: Session, ids: list):
|
||||
return [r[0] for r in db.query(Site.domain).filter(Site.id.in_(ids)).all()]
|
||||
def get_domains_by_ids(cls, db: Session, ids: list):
|
||||
return [r[0] for r in db.query(cls.domain).filter(cls.id.in_(ids)).all()]
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def reset(db: Session):
|
||||
db.query(Site).delete()
|
||||
def reset(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_reset(cls, db: AsyncSession):
|
||||
await db.execute(delete(cls))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy import Column, Integer, String, Sequence, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
from app.db import db_query, Base, async_db_query
|
||||
|
||||
|
||||
class SiteIcon(Base):
|
||||
@@ -18,7 +19,13 @@ class SiteIcon(Base):
|
||||
# 图标Base64
|
||||
base64 = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str):
|
||||
return db.query(SiteIcon).filter(SiteIcon.domain == domain).first()
|
||||
def get_by_domain(cls, db: Session, domain: str):
|
||||
return db.query(cls).filter(cls.domain == domain).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_domain(cls, db: AsyncSession, domain: str):
|
||||
result = await db.execute(select(cls).where(cls.domain == domain))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query
|
||||
|
||||
|
||||
class SiteStatistic(Base):
|
||||
@@ -26,12 +27,18 @@ class SiteStatistic(Base):
|
||||
# 耗时记录 Json
|
||||
note = Column(JSON)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str):
|
||||
return db.query(SiteStatistic).filter(SiteStatistic.domain == domain).first()
|
||||
def get_by_domain(cls, db: Session, domain: str):
|
||||
return db.query(cls).filter(cls.domain == domain).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_domain(cls, db: AsyncSession, domain: str):
|
||||
result = await db.execute(select(cls).where(cls.domain == domain))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def reset(db: Session):
|
||||
db.query(SiteStatistic).delete()
|
||||
def reset(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func, or_
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
from app.db import db_query, Base, async_db_query
|
||||
|
||||
|
||||
class SiteUserData(Base):
|
||||
@@ -53,42 +54,78 @@ class SiteUserData(Base):
|
||||
# 更新时间
|
||||
updated_time = Column(String, default=datetime.now().strftime('%H:%M:%S'))
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):
|
||||
def get_by_domain(cls, db: Session, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):
|
||||
if workdate and worktime:
|
||||
return db.query(SiteUserData).filter(SiteUserData.domain == domain,
|
||||
SiteUserData.updated_day == workdate,
|
||||
SiteUserData.updated_time == worktime).all()
|
||||
return db.query(cls).filter(cls.domain == domain,
|
||||
cls.updated_day == workdate,
|
||||
cls.updated_time == worktime).all()
|
||||
elif workdate:
|
||||
return db.query(SiteUserData).filter(SiteUserData.domain == domain,
|
||||
SiteUserData.updated_day == workdate).all()
|
||||
return db.query(SiteUserData).filter(SiteUserData.domain == domain).all()
|
||||
return db.query(cls).filter(cls.domain == domain,
|
||||
cls.updated_day == workdate).all()
|
||||
return db.query(cls).filter(cls.domain == domain).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_date(db: Session, date: str):
|
||||
return db.query(SiteUserData).filter(SiteUserData.updated_day == date).all()
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_domain(cls, db: AsyncSession, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):
|
||||
query = select(cls).filter(cls.domain == domain)
|
||||
if workdate and worktime:
|
||||
query = query.filter(cls.updated_day == workdate, cls.updated_time == worktime)
|
||||
elif workdate:
|
||||
query = query.filter(cls.updated_day == workdate)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_latest(db: Session):
|
||||
def get_by_date(cls, db: Session, date: str):
|
||||
return db.query(cls).filter(cls.updated_day == date).all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_latest(cls, db: Session):
|
||||
"""
|
||||
获取各站点最新一天的数据
|
||||
"""
|
||||
subquery = (
|
||||
db.query(
|
||||
SiteUserData.domain,
|
||||
func.max(SiteUserData.updated_day).label('latest_update_day')
|
||||
cls.domain,
|
||||
func.max(cls.updated_day).label('latest_update_day')
|
||||
)
|
||||
.group_by(SiteUserData.domain)
|
||||
.filter(or_(SiteUserData.err_msg.is_(None), SiteUserData.err_msg == ""))
|
||||
.group_by(cls.domain)
|
||||
.filter(or_(cls.err_msg.is_(None), cls.err_msg == ""))
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# 主查询:按 domain 和 updated_day 获取最新的记录
|
||||
return db.query(SiteUserData).join(
|
||||
return db.query(cls).join(
|
||||
subquery,
|
||||
(SiteUserData.domain == subquery.c.domain) &
|
||||
(SiteUserData.updated_day == subquery.c.latest_update_day)
|
||||
).order_by(SiteUserData.updated_time.desc()).all()
|
||||
(cls.domain == subquery.c.domain) &
|
||||
(cls.updated_day == subquery.c.latest_update_day)
|
||||
).order_by(cls.updated_time.desc()).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_latest(cls, db: AsyncSession):
|
||||
"""
|
||||
异步获取各站点最新一天的数据
|
||||
"""
|
||||
subquery = (
|
||||
select(
|
||||
cls.domain,
|
||||
func.max(cls.updated_day).label('latest_update_day')
|
||||
)
|
||||
.group_by(cls.domain)
|
||||
.filter(or_(cls.err_msg.is_(None), cls.err_msg == ""))
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# 主查询:按 domain 和 updated_day 获取最新的记录
|
||||
result = await db.execute(
|
||||
select(cls).join(
|
||||
subquery,
|
||||
(cls.domain == subquery.c.domain) &
|
||||
(cls.updated_day == subquery.c.latest_update_day)
|
||||
).order_by(cls.updated_time.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query, async_db_update
|
||||
|
||||
|
||||
class Subscribe(Base):
|
||||
@@ -87,59 +88,144 @@ class Subscribe(Base):
|
||||
# 选择的剧集组
|
||||
episode_group = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def exists(db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None):
|
||||
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
season: Optional[int] = None):
|
||||
if tmdbid:
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).first()
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.season == season).first()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
|
||||
elif doubanid:
|
||||
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
|
||||
return db.query(cls).filter(cls.doubanid == doubanid).first()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
season: Optional[int] = None):
|
||||
if tmdbid:
|
||||
if season:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.tmdbid == tmdbid)
|
||||
)
|
||||
elif doubanid:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.doubanid == doubanid)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_state(db: Session, state: str):
|
||||
def get_by_state(cls, db: Session, state: str):
|
||||
# 如果 state 为空或 None,返回所有订阅
|
||||
if not state:
|
||||
return db.query(Subscribe).all()
|
||||
return db.query(cls).all()
|
||||
else:
|
||||
# 如果传入的状态不为空,拆分成多个状态
|
||||
return db.query(Subscribe).filter(Subscribe.state.in_(state.split(','))).all()
|
||||
return db.query(cls).filter(cls.state.in_(state.split(','))).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_title(db: Session, title: str, season: Optional[int] = None):
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.name == title,
|
||||
Subscribe.season == season).first()
|
||||
return db.query(Subscribe).filter(Subscribe.name == title).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_tmdbid(db: Session, tmdbid: int, season: Optional[int] = None):
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).all()
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_state(cls, db: AsyncSession, state: str):
|
||||
# 如果 state 为空或 None,返回所有订阅
|
||||
if not state:
|
||||
result = await db.execute(select(cls))
|
||||
else:
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
|
||||
# 如果传入的状态不为空,拆分成多个状态
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.state.in_(state.split(',')))
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_doubanid(db: Session, doubanid: str):
|
||||
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
|
||||
def get_by_title(cls, db: Session, title: str, season: Optional[int] = None):
|
||||
if season:
|
||||
return db.query(cls).filter(cls.name == title,
|
||||
cls.season == season).first()
|
||||
return db.query(cls).filter(cls.name == title).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_bangumiid(db: Session, bangumiid: int):
|
||||
return db.query(Subscribe).filter(Subscribe.bangumiid == bangumiid).first()
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
|
||||
if season:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.name == title, cls.season == season)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.name == title)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_mediaid(db: Session, mediaid: str):
|
||||
return db.query(Subscribe).filter(Subscribe.mediaid == mediaid).first()
|
||||
def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None):
|
||||
if season:
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.season == season).all()
|
||||
else:
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_tmdbid(cls, db: AsyncSession, tmdbid: int, season: Optional[int] = None):
|
||||
if season:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.tmdbid == tmdbid)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_doubanid(cls, db: Session, doubanid: str):
|
||||
return db.query(cls).filter(cls.doubanid == doubanid).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_doubanid(cls, db: AsyncSession, doubanid: str):
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.doubanid == doubanid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_bangumiid(cls, db: Session, bangumiid: int):
|
||||
return db.query(cls).filter(cls.bangumiid == bangumiid).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_bangumiid(cls, db: AsyncSession, bangumiid: int):
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.bangumiid == bangumiid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_mediaid(cls, db: Session, mediaid: str):
|
||||
return db.query(cls).filter(cls.mediaid == mediaid).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_mediaid(cls, db: AsyncSession, mediaid: str):
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.mediaid == mediaid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@db_update
|
||||
def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):
|
||||
@@ -148,6 +234,13 @@ class Subscribe(Base):
|
||||
subscrbie.delete(db, subscrbie.id)
|
||||
return True
|
||||
|
||||
@async_db_update
|
||||
async def async_delete_by_tmdbid(self, db: AsyncSession, tmdbid: int, season: int):
|
||||
subscrbies = await self.async_get_by_tmdbid(db, tmdbid, season)
|
||||
for subscrbie in subscrbies:
|
||||
await subscrbie.async_delete(db, subscrbie.id)
|
||||
return True
|
||||
|
||||
@db_update
|
||||
def delete_by_doubanid(self, db: Session, doubanid: str):
|
||||
subscribe = self.get_by_doubanid(db, doubanid)
|
||||
@@ -155,6 +248,13 @@ class Subscribe(Base):
|
||||
subscribe.delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@async_db_update
|
||||
async def async_delete_by_doubanid(self, db: AsyncSession, doubanid: str):
|
||||
subscribe = await self.async_get_by_doubanid(db, doubanid)
|
||||
if subscribe:
|
||||
await subscribe.async_delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@db_update
|
||||
def delete_by_mediaid(self, db: Session, mediaid: str):
|
||||
subscribe = self.get_by_mediaid(db, mediaid)
|
||||
@@ -162,29 +262,72 @@ class Subscribe(Base):
|
||||
subscribe.delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@async_db_update
|
||||
async def async_delete_by_mediaid(self, db: AsyncSession, mediaid: str):
|
||||
subscribe = await self.async_get_by_mediaid(db, mediaid)
|
||||
if subscribe:
|
||||
await subscribe.async_delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_username(db: Session, username: str, state: Optional[str] = None, mtype: Optional[str] = None):
|
||||
def list_by_username(cls, db: Session, username: str, state: Optional[str] = None, mtype: Optional[str] = None):
|
||||
if mtype:
|
||||
if state:
|
||||
return db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
return db.query(cls).filter(cls.state == state,
|
||||
cls.username == username,
|
||||
cls.type == mtype).all()
|
||||
else:
|
||||
return db.query(Subscribe).filter(Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
return db.query(cls).filter(cls.username == username,
|
||||
cls.type == mtype).all()
|
||||
else:
|
||||
if state:
|
||||
return db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username).all()
|
||||
return db.query(cls).filter(cls.state == state,
|
||||
cls.username == username).all()
|
||||
else:
|
||||
return db.query(Subscribe).filter(Subscribe.username == username).all()
|
||||
return db.query(cls).filter(cls.username == username).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_username(cls, db: AsyncSession, username: str, state: Optional[str] = None,
|
||||
mtype: Optional[str] = None):
|
||||
if mtype:
|
||||
if state:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.state == state, cls.username == username, cls.type == mtype)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.username == username, cls.type == mtype)
|
||||
)
|
||||
else:
|
||||
if state:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.state == state, cls.username == username)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.username == username)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
return db.query(Subscribe) \
|
||||
.filter(Subscribe.type == mtype,
|
||||
Subscribe.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
def list_by_type(cls, db: Session, mtype: str, days: int):
|
||||
return db.query(cls) \
|
||||
.filter(cls.type == mtype,
|
||||
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_type(cls, db: AsyncSession, mtype: str, days: int):
|
||||
result = await db.execute(
|
||||
select(cls).filter(
|
||||
cls.type == mtype,
|
||||
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
from app.db import db_query, Base, async_db_query
|
||||
|
||||
|
||||
class SubscribeHistory(Base):
|
||||
@@ -72,23 +73,57 @@ class SubscribeHistory(Base):
|
||||
# 剧集组
|
||||
episode_group = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(SubscribeHistory).filter(
|
||||
SubscribeHistory.type == mtype
|
||||
def list_by_type(cls, db: Session, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(cls).filter(
|
||||
cls.type == mtype
|
||||
).order_by(
|
||||
SubscribeHistory.date.desc()
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = await db.execute(
|
||||
select(cls).filter(
|
||||
cls.type == mtype
|
||||
).order_by(
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def exists(db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None):
|
||||
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
season: Optional[int] = None):
|
||||
if tmdbid:
|
||||
if season:
|
||||
return db.query(SubscribeHistory).filter(SubscribeHistory.tmdbid == tmdbid,
|
||||
SubscribeHistory.season == season).first()
|
||||
return db.query(SubscribeHistory).filter(SubscribeHistory.tmdbid == tmdbid).first()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.season == season).first()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
|
||||
elif doubanid:
|
||||
return db.query(SubscribeHistory).filter(SubscribeHistory.doubanid == doubanid).first()
|
||||
return db.query(cls).filter(cls.doubanid == doubanid).first()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
season: Optional[int] = None):
|
||||
if tmdbid:
|
||||
if season:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.tmdbid == tmdbid)
|
||||
)
|
||||
elif doubanid:
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.doubanid == doubanid)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
@@ -14,10 +15,16 @@ class SystemConfig(Base):
|
||||
# 值
|
||||
value = Column(JSON)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_key(db: Session, key: str):
|
||||
return db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||
def get_by_key(cls, db: Session, key: str):
|
||||
return db.query(cls).filter(cls.key == key).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_key(cls, db: AsyncSession, key: str):
|
||||
result = await db.execute(select(cls).where(cls.key == key))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@db_update
|
||||
def delete_by_key(self, db: Session, key: str):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func, or_, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func, or_, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query
|
||||
|
||||
|
||||
class TransferHistory(Base):
|
||||
@@ -59,97 +60,187 @@ class TransferHistory(Base):
|
||||
# 剧集组
|
||||
episode_group = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_title(db: Session, title: str, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):
|
||||
def list_by_title(cls, db: Session, title: str, page: Optional[int] = 1, count: Optional[int] = 30,
|
||||
status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(TransferHistory).filter(
|
||||
TransferHistory.status == status
|
||||
return db.query(cls).filter(
|
||||
cls.status == status
|
||||
).order_by(
|
||||
TransferHistory.date.desc()
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
else:
|
||||
return db.query(TransferHistory).filter(or_(
|
||||
TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.src.like(f'%{title}%'),
|
||||
TransferHistory.dest.like(f'%{title}%'),
|
||||
return db.query(cls).filter(or_(
|
||||
cls.title.like(f'%{title}%'),
|
||||
cls.src.like(f'%{title}%'),
|
||||
cls.dest.like(f'%{title}%'),
|
||||
)).order_by(
|
||||
TransferHistory.date.desc()
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_title(cls, db: AsyncSession, title: str, page: Optional[int] = 1, count: Optional[int] = 30,
|
||||
status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(TransferHistory).filter(
|
||||
TransferHistory.status == status
|
||||
result = await db.execute(
|
||||
select(cls).filter(
|
||||
cls.status == status
|
||||
).order_by(
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).filter(or_(
|
||||
cls.title.like(f'%{title}%'),
|
||||
cls.src.like(f'%{title}%'),
|
||||
cls.dest.like(f'%{title}%'),
|
||||
)).order_by(
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(cls).filter(
|
||||
cls.status == status
|
||||
).order_by(
|
||||
TransferHistory.date.desc()
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
else:
|
||||
return db.query(TransferHistory).order_by(
|
||||
TransferHistory.date.desc()
|
||||
return db.query(cls).order_by(
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).first()
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30,
|
||||
status: bool = None):
|
||||
if status is not None:
|
||||
result = await db.execute(
|
||||
select(cls).filter(
|
||||
cls.status == status
|
||||
).order_by(
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(cls).order_by(
|
||||
cls.date.desc()
|
||||
).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_src(db: Session, src: str, storage: Optional[str] = None):
|
||||
def get_by_hash(cls, db: Session, download_hash: str):
|
||||
return db.query(cls).filter(cls.download_hash == download_hash).first()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_src(cls, db: Session, src: str, storage: Optional[str] = None):
|
||||
if storage:
|
||||
return db.query(TransferHistory).filter(TransferHistory.src == src,
|
||||
TransferHistory.src_storage == storage).first()
|
||||
return db.query(cls).filter(cls.src == src,
|
||||
cls.src_storage == storage).first()
|
||||
else:
|
||||
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
||||
return db.query(cls).filter(cls.src == src).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_dest(db: Session, dest: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.dest == dest).first()
|
||||
def get_by_dest(cls, db: Session, dest: str):
|
||||
return db.query(cls).filter(cls.dest == dest).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_hash(db: Session, download_hash: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
def list_by_hash(cls, db: Session, download_hash: str):
|
||||
return db.query(cls).filter(cls.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def statistic(db: Session, days: Optional[int] = 7):
|
||||
def statistic(cls, db: Session, days: Optional[int] = 7):
|
||||
"""
|
||||
统计最近days天的下载历史数量,按日期分组返回每日数量
|
||||
"""
|
||||
sub_query = db.query(func.substr(TransferHistory.date, 1, 10).label('date'),
|
||||
TransferHistory.id.label('id')).filter(
|
||||
TransferHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * days))).subquery()
|
||||
sub_query = db.query(func.substr(cls.date, 1, 10).label('date'),
|
||||
cls.id.label('id')).filter(
|
||||
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * days))).subquery()
|
||||
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def count(db: Session, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).first()[0]
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7):
|
||||
"""
|
||||
统计最近days天的下载历史数量,按日期分组返回每日数量
|
||||
"""
|
||||
sub_query = select(func.substr(cls.date, 1, 10).label('date'),
|
||||
cls.id.label('id')).filter(
|
||||
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * days))).subquery()
|
||||
result = await db.execute(
|
||||
select(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def count_by_title(db: Session, title: str, status: bool = None):
|
||||
def count(cls, db: Session, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||
return db.query(func.count(cls.id)).filter(cls.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).filter(or_(
|
||||
TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.src.like(f'%{title}%'),
|
||||
TransferHistory.dest.like(f'%{title}%')
|
||||
return db.query(func.count(cls.id)).first()[0]
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_count(cls, db: AsyncSession, status: bool = None):
|
||||
if status is not None:
|
||||
result = await db.execute(
|
||||
select(func.count(cls.id)).filter(cls.status == status)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(func.count(cls.id))
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def count_by_title(cls, db: Session, title: str, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(cls.id)).filter(cls.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(cls.id)).filter(or_(
|
||||
cls.title.like(f'%{title}%'),
|
||||
cls.src.like(f'%{title}%'),
|
||||
cls.dest.like(f'%{title}%')
|
||||
)).first()[0]
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_count_by_title(cls, db: AsyncSession, title: str, status: bool = None):
|
||||
if status is not None:
|
||||
result = await db.execute(
|
||||
select(func.count(cls.id)).filter(cls.status == status)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(func.count(cls.id)).filter(or_(
|
||||
cls.title.like(f'%{title}%'),
|
||||
cls.src.like(f'%{title}%'),
|
||||
cls.dest.like(f'%{title}%')
|
||||
))
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None,
|
||||
def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None, dest: Optional[str] = None):
|
||||
"""
|
||||
@@ -160,80 +251,80 @@ class TransferHistory(Base):
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.type == mtype,
|
||||
cls.seasons == season,
|
||||
cls.episodes == episode,
|
||||
cls.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season).all()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.type == mtype,
|
||||
cls.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.type == mtype,
|
||||
cls.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).all()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.type == mtype).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(cls).filter(cls.title == title,
|
||||
cls.year == year,
|
||||
cls.seasons == season,
|
||||
cls.episodes == episode,
|
||||
cls.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
return db.query(cls).filter(cls.title == title,
|
||||
cls.year == year,
|
||||
cls.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(cls).filter(cls.title == title,
|
||||
cls.year == year,
|
||||
cls.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
return db.query(cls).filter(cls.title == title,
|
||||
cls.year == year).all()
|
||||
# 类型 + 转移路径(emby webhook season无tmdbid场景)
|
||||
elif mtype and season and dest:
|
||||
# 电视剧某季
|
||||
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.dest.like(f"{dest}%")).all()
|
||||
return db.query(cls).filter(cls.type == mtype,
|
||||
cls.seasons == season,
|
||||
cls.dest.like(f"{dest}%")).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_type_tmdbid(db: Session, mtype: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
def get_by_type_tmdbid(cls, db: Session, mtype: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
"""
|
||||
据tmdbid、type查询转移记录
|
||||
"""
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).first()
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.type == mtype).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def update_download_hash(db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None):
|
||||
db.query(TransferHistory).filter(TransferHistory.id == historyid).update(
|
||||
def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None):
|
||||
db.query(cls).filter(cls.id == historyid).update(
|
||||
{
|
||||
"download_hash": download_hash
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_date(db: Session, date: str):
|
||||
def list_by_date(cls, db: Session, date: str):
|
||||
"""
|
||||
查询某时间之后的转移历史
|
||||
"""
|
||||
return db.query(TransferHistory).filter(TransferHistory.date > date).order_by(TransferHistory.id.desc()).all()
|
||||
return db.query(cls).filter(cls.date > date).order_by(cls.id.desc()).all()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, JSON, Sequence, String
|
||||
from sqlalchemy import Boolean, Column, Integer, JSON, Sequence, String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base, db_query, db_update
|
||||
from app.db import Base, db_query, db_update, async_db_query, async_db_update
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -31,15 +32,31 @@ class User(Base):
|
||||
# 用户个性化设置 json
|
||||
settings = Column(JSON, default=dict)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_name(db: Session, name: str):
|
||||
return db.query(User).filter(User.name == name).first()
|
||||
def get_by_name(cls, db: Session, name: str):
|
||||
return db.query(cls).filter(cls.name == name).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_name(cls, db: AsyncSession, name: str):
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.name == name)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_id(db: Session, user_id: int):
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
def get_by_id(cls, db: Session, user_id: int):
|
||||
return db.query(cls).filter(cls.id == user_id).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_id(cls, db: AsyncSession, user_id: int):
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.id == user_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@db_update
|
||||
def delete_by_name(self, db: Session, name: str):
|
||||
@@ -48,6 +65,13 @@ class User(Base):
|
||||
user.delete(db, user.id)
|
||||
return True
|
||||
|
||||
@async_db_update
|
||||
async def async_delete_by_name(self, db: AsyncSession, name: str):
|
||||
user = await self.async_get_by_name(db, name)
|
||||
if user:
|
||||
await user.async_delete(db, user.id)
|
||||
return True
|
||||
|
||||
@db_update
|
||||
def delete_by_id(self, db: Session, user_id: int):
|
||||
user = self.get_by_id(db, user_id)
|
||||
@@ -55,6 +79,13 @@ class User(Base):
|
||||
user.delete(db, user.id)
|
||||
return True
|
||||
|
||||
@async_db_update
|
||||
async def async_delete_by_id(self, db: AsyncSession, user_id: int):
|
||||
user = await self.async_get_by_id(db, user_id)
|
||||
if user:
|
||||
await user.async_delete(db, user.id)
|
||||
return True
|
||||
|
||||
@db_update
|
||||
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
|
||||
user = self.get_by_name(db, name)
|
||||
@@ -65,3 +96,14 @@ class User(Base):
|
||||
})
|
||||
return True
|
||||
return False
|
||||
|
||||
@async_db_update
|
||||
async def async_update_otp_by_name(self, db: AsyncSession, name: str, otp: bool, secret: str):
|
||||
user = await self.async_get_by_name(db, name)
|
||||
if user:
|
||||
await user.async_update(db, {
|
||||
'is_otp': otp,
|
||||
'otp_secret': secret
|
||||
})
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -22,12 +22,12 @@ class UserConfig(Base):
|
||||
Index('ix_userconfig_username_key', 'username', 'key'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_key(db: Session, username: str, key: str):
|
||||
return db.query(UserConfig) \
|
||||
.filter(UserConfig.username == username) \
|
||||
.filter(UserConfig.key == key) \
|
||||
def get_by_key(cls, db: Session, username: str, key: str):
|
||||
return db.query(cls) \
|
||||
.filter(cls.username == username) \
|
||||
.filter(cls.key == key) \
|
||||
.first()
|
||||
|
||||
@db_update
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
|
||||
|
||||
class UserRequest(Base):
|
||||
"""
|
||||
用户请求表
|
||||
"""
|
||||
# ID
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 申请用户
|
||||
req_user = Column(String, index=True, nullable=False)
|
||||
# 申请时间
|
||||
req_time = Column(String)
|
||||
# 申请备注
|
||||
req_remark = Column(String)
|
||||
# 审批用户
|
||||
app_user = Column(String, index=True, nullable=False)
|
||||
# 审批时间
|
||||
app_time = Column(String)
|
||||
# 审批状态 0-待审批 1-通过 2-拒绝
|
||||
app_status = Column(Integer, default=0)
|
||||
# 类型
|
||||
type = Column(String)
|
||||
# 标题
|
||||
title = Column(String)
|
||||
# 年份
|
||||
year = Column(String)
|
||||
# 媒体ID
|
||||
tmdbid = Column(Integer)
|
||||
imdbid = Column(String)
|
||||
tvdbid = Column(Integer)
|
||||
doubanid = Column(String)
|
||||
bangumiid = Column(Integer)
|
||||
# 季号
|
||||
season = Column(Integer)
|
||||
# 海报
|
||||
poster = Column(String)
|
||||
# 背景图
|
||||
backdrop = Column(String)
|
||||
# 评分,float
|
||||
vote = Column(Float)
|
||||
# 简介
|
||||
description = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_req_user(db: Session, req_user: str, status: int = None):
|
||||
if status:
|
||||
return db.query(UserRequest).filter(UserRequest.req_user == req_user,
|
||||
UserRequest.app_status == status).all()
|
||||
else:
|
||||
return db.query(UserRequest).filter(UserRequest.req_user == req_user).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_app_user(db: Session, app_user: str, status: int = None):
|
||||
if status:
|
||||
return db.query(UserRequest).filter(UserRequest.app_user == app_user,
|
||||
UserRequest.app_status == status).all()
|
||||
else:
|
||||
return db.query(UserRequest).filter(UserRequest.app_user == app_user).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_status(db: Session, status: int):
|
||||
return db.query(UserRequest).filter(UserRequest.app_status == status).all()
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, JSON, Sequence, String, and_
|
||||
from sqlalchemy import Column, Integer, JSON, Sequence, String, and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Base, db_query, db_update
|
||||
from app.db import Base, db_query, db_update, async_db_query, async_db_update
|
||||
|
||||
|
||||
class Workflow(Base):
|
||||
@@ -18,6 +19,12 @@ class Workflow(Base):
|
||||
description = Column(String)
|
||||
# 定时器
|
||||
timer = Column(String)
|
||||
# 触发类型:timer-定时触发 event-事件触发 manual-手动触发
|
||||
trigger_type = Column(String, default='timer')
|
||||
# 事件类型(当trigger_type为event时使用)
|
||||
event_type = Column(String)
|
||||
# 事件条件(JSON格式,用于过滤事件)
|
||||
event_conditions = Column(JSON, default=dict)
|
||||
# 状态:W-等待 R-运行中 P-暂停 S-成功 F-失败
|
||||
state = Column(String, nullable=False, index=True, default='W')
|
||||
# 已执行动作(,分隔)
|
||||
@@ -37,67 +44,210 @@ class Workflow(Base):
|
||||
# 最后执行时间
|
||||
last_time = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_enabled_workflows(db):
|
||||
return db.query(Workflow).filter(Workflow.state != 'P').all()
|
||||
def list(cls, db):
|
||||
return db.query(cls).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list(cls, db: AsyncSession):
|
||||
result = await db.execute(select(cls))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_name(db, name: str):
|
||||
return db.query(Workflow).filter(Workflow.name == name).first()
|
||||
def get_enabled_workflows(cls, db):
|
||||
return db.query(cls).filter(cls.state != 'P').all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_enabled_workflows(cls, db: AsyncSession):
|
||||
result = await db.execute(select(cls).where(cls.state != 'P'))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_timer_triggered_workflows(cls, db):
|
||||
"""获取定时触发的工作流"""
|
||||
return db.query(cls).filter(
|
||||
and_(
|
||||
or_(
|
||||
cls.trigger_type == 'timer',
|
||||
not cls.trigger_type
|
||||
),
|
||||
cls.state != 'P'
|
||||
)
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_timer_triggered_workflows(cls, db: AsyncSession):
|
||||
"""异步获取定时触发的工作流"""
|
||||
result = await db.execute(select(cls).where(
|
||||
and_(
|
||||
or_(
|
||||
cls.trigger_type == 'timer',
|
||||
not cls.trigger_type
|
||||
),
|
||||
cls.state != 'P'
|
||||
)
|
||||
))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_event_triggered_workflows(cls, db):
|
||||
"""获取事件触发的工作流"""
|
||||
return db.query(cls).filter(
|
||||
and_(
|
||||
cls.trigger_type == 'event',
|
||||
cls.state != 'P'
|
||||
)
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_event_triggered_workflows(cls, db: AsyncSession):
|
||||
"""异步获取事件触发的工作流"""
|
||||
result = await db.execute(select(cls).where(
|
||||
and_(
|
||||
cls.trigger_type == 'event',
|
||||
cls.state != 'P'
|
||||
)
|
||||
))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_name(cls, db, name: str):
|
||||
return db.query(cls).filter(cls.name == name).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_name(cls, db: AsyncSession, name: str):
|
||||
result = await db.execute(select(cls).where(cls.name == name))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def update_state(db, wid: int, state: str):
|
||||
db.query(Workflow).filter(Workflow.id == wid).update({"state": state})
|
||||
def update_state(cls, db, wid: int, state: str):
|
||||
db.query(cls).filter(cls.id == wid).update({"state": state})
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_update_state(cls, db: AsyncSession, wid: int, state: str):
|
||||
from sqlalchemy import update
|
||||
await db.execute(update(cls).where(cls.id == wid).values(state=state))
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def start(db, wid: int):
|
||||
db.query(Workflow).filter(Workflow.id == wid).update({
|
||||
def start(cls, db, wid: int):
|
||||
db.query(cls).filter(cls.id == wid).update({
|
||||
"state": 'R'
|
||||
})
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_start(cls, db: AsyncSession, wid: int):
|
||||
from sqlalchemy import update
|
||||
await db.execute(update(cls).where(cls.id == wid).values(state='R'))
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def fail(db, wid: int, result: str):
|
||||
db.query(Workflow).filter(and_(Workflow.id == wid, Workflow.state != "P")).update({
|
||||
def fail(cls, db, wid: int, result: str):
|
||||
db.query(cls).filter(and_(cls.id == wid, cls.state != "P")).update({
|
||||
"state": 'F',
|
||||
"result": result,
|
||||
"last_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_fail(cls, db: AsyncSession, wid: int, result: str):
|
||||
from sqlalchemy import update
|
||||
await db.execute(update(cls).where(
|
||||
and_(cls.id == wid, cls.state != "P")
|
||||
).values(
|
||||
state='F',
|
||||
result=result,
|
||||
last_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
))
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def success(db, wid: int, result: Optional[str] = None):
|
||||
db.query(Workflow).filter(and_(Workflow.id == wid, Workflow.state != "P")).update({
|
||||
def success(cls, db, wid: int, result: Optional[str] = None):
|
||||
db.query(cls).filter(and_(cls.id == wid, cls.state != "P")).update({
|
||||
"state": 'S',
|
||||
"result": result,
|
||||
"run_count": Workflow.run_count + 1,
|
||||
"run_count": cls.run_count + 1,
|
||||
"last_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_success(cls, db: AsyncSession, wid: int, result: Optional[str] = None):
|
||||
from sqlalchemy import update
|
||||
await db.execute(update(cls).where(
|
||||
and_(cls.id == wid, cls.state != "P")
|
||||
).values(
|
||||
state='S',
|
||||
result=result,
|
||||
run_count=cls.run_count + 1,
|
||||
last_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
))
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def reset(db, wid: int, reset_count: Optional[bool] = False):
|
||||
db.query(Workflow).filter(Workflow.id == wid).update({
|
||||
def reset(cls, db, wid: int, reset_count: Optional[bool] = False):
|
||||
db.query(cls).filter(cls.id == wid).update({
|
||||
"state": 'W',
|
||||
"result": None,
|
||||
"current_action": None,
|
||||
"run_count": 0 if reset_count else Workflow.run_count,
|
||||
"run_count": 0 if reset_count else cls.run_count,
|
||||
})
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_reset(cls, db: AsyncSession, wid: int, reset_count: Optional[bool] = False):
|
||||
from sqlalchemy import update
|
||||
await db.execute(update(cls).where(cls.id == wid).values(
|
||||
state='W',
|
||||
result=None,
|
||||
current_action=None,
|
||||
run_count=0 if reset_count else cls.run_count,
|
||||
))
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def update_current_action(db, wid: int, action_id: str, context: dict):
|
||||
db.query(Workflow).filter(Workflow.id == wid).update({
|
||||
"current_action": Workflow.current_action + f",{action_id}" if Workflow.current_action else action_id,
|
||||
def update_current_action(cls, db, wid: int, action_id: str, context: dict):
|
||||
db.query(cls).filter(cls.id == wid).update({
|
||||
"current_action": cls.current_action + f",{action_id}" if cls.current_action else action_id,
|
||||
"context": context
|
||||
})
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_update_current_action(cls, db: AsyncSession, wid: int, action_id: str, context: dict):
|
||||
from sqlalchemy import update
|
||||
# 先获取当前current_action
|
||||
result = await db.execute(select(cls.current_action).where(cls.id == wid))
|
||||
current_action = result.scalar()
|
||||
new_current_action = current_action + f",{action_id}" if current_action else action_id
|
||||
|
||||
await db.execute(update(cls).where(cls.id == wid).values(
|
||||
current_action=new_current_action,
|
||||
context=context
|
||||
))
|
||||
return True
|
||||
|
||||
@@ -35,6 +35,12 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return Site.list(self._db)
|
||||
|
||||
async def async_list(self) -> List[Site]:
|
||||
"""
|
||||
异步获取站点列表
|
||||
"""
|
||||
return await Site.async_list(self._db)
|
||||
|
||||
def list_order_by_pri(self) -> List[Site]:
|
||||
"""
|
||||
获取站点列表
|
||||
@@ -47,6 +53,12 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return Site.get_actives(self._db)
|
||||
|
||||
async def async_list_active(self) -> List[Site]:
|
||||
"""
|
||||
异步按状态获取站点列表
|
||||
"""
|
||||
return await Site.async_get_actives(self._db)
|
||||
|
||||
def delete(self, sid: int):
|
||||
"""
|
||||
删除站点
|
||||
@@ -67,6 +79,12 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return Site.get_by_domain(self._db, domain)
|
||||
|
||||
async def async_get_by_domain(self, domain: str) -> Site:
|
||||
"""
|
||||
异步按域名获取站点
|
||||
"""
|
||||
return await Site.async_get_by_domain(self._db, domain)
|
||||
|
||||
def get_domains_by_ids(self, ids: List[int]) -> List[str]:
|
||||
"""
|
||||
按ID获取站点域名
|
||||
@@ -180,20 +198,23 @@ class SiteOper(DbOper):
|
||||
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
sta = SiteStatistic.get_by_domain(self._db, domain)
|
||||
if sta:
|
||||
avg_seconds, note = None, {}
|
||||
# 使用深复制确保 note 是全新的字典对象
|
||||
note = dict(sta.note) if sta.note else {}
|
||||
avg_seconds = None
|
||||
|
||||
if seconds is not None:
|
||||
note: dict = sta.note or {}
|
||||
note[lst_date] = seconds or 1
|
||||
avg_times = len(note.keys())
|
||||
if avg_times > 10:
|
||||
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
|
||||
avg_seconds = sum([v for v in note.values()]) // avg_times
|
||||
|
||||
sta.update(self._db, {
|
||||
"success": sta.success + 1,
|
||||
"seconds": avg_seconds or sta.seconds,
|
||||
"lst_state": 0,
|
||||
"lst_mod_date": lst_date,
|
||||
"note": note or sta.note
|
||||
"note": note
|
||||
})
|
||||
else:
|
||||
note = {}
|
||||
@@ -231,3 +252,65 @@ class SiteOper(DbOper):
|
||||
lst_state=1,
|
||||
lst_mod_date=lst_date
|
||||
).create(self._db)
|
||||
|
||||
async def async_success(self, domain: str, seconds: Optional[int] = None):
|
||||
"""
|
||||
异步站点访问成功
|
||||
"""
|
||||
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
sta = await SiteStatistic.async_get_by_domain(self._db, domain)
|
||||
if sta:
|
||||
# 使用深复制确保 note 是全新的字典对象
|
||||
note = dict(sta.note) if sta.note else {}
|
||||
avg_seconds = None
|
||||
|
||||
if seconds is not None:
|
||||
note[lst_date] = seconds or 1
|
||||
avg_times = len(note.keys())
|
||||
if avg_times > 10:
|
||||
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
|
||||
avg_seconds = sum([v for v in note.values()]) // avg_times
|
||||
|
||||
await sta.async_update(self._db, {
|
||||
"success": sta.success + 1,
|
||||
"seconds": avg_seconds or sta.seconds,
|
||||
"lst_state": 0,
|
||||
"lst_mod_date": lst_date,
|
||||
"note": note
|
||||
})
|
||||
else:
|
||||
note = {}
|
||||
if seconds is not None:
|
||||
note = {
|
||||
lst_date: seconds or 1
|
||||
}
|
||||
await SiteStatistic(
|
||||
domain=domain,
|
||||
success=1,
|
||||
fail=0,
|
||||
seconds=seconds or 1,
|
||||
lst_state=0,
|
||||
lst_mod_date=lst_date,
|
||||
note=note
|
||||
).async_create(self._db)
|
||||
|
||||
async def async_fail(self, domain: str):
|
||||
"""
|
||||
异步站点访问失败
|
||||
"""
|
||||
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
sta = await SiteStatistic.async_get_by_domain(self._db, domain)
|
||||
if sta:
|
||||
await sta.async_update(self._db, {
|
||||
"fail": sta.fail + 1,
|
||||
"lst_state": 1,
|
||||
"lst_mod_date": lst_date
|
||||
})
|
||||
else:
|
||||
await SiteStatistic(
|
||||
domain=domain,
|
||||
success=0,
|
||||
fail=1,
|
||||
lst_state=1,
|
||||
lst_mod_date=lst_date
|
||||
).async_create(self._db)
|
||||
|
||||
@@ -48,7 +48,44 @@ class SubscribeOper(DbOper):
|
||||
else:
|
||||
return subscribe.id, "订阅已存在"
|
||||
|
||||
def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None) -> bool:
|
||||
async def async_add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]:
|
||||
"""
|
||||
异步新增订阅
|
||||
"""
|
||||
subscribe = await Subscribe.async_exists(self._db,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
season=kwargs.get('season'))
|
||||
kwargs.update({
|
||||
"name": mediainfo.title,
|
||||
"year": mediainfo.year,
|
||||
"type": mediainfo.type.value,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
"tvdbid": mediainfo.tvdb_id,
|
||||
"doubanid": mediainfo.douban_id,
|
||||
"bangumiid": mediainfo.bangumi_id,
|
||||
"episode_group": mediainfo.episode_group,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
"vote": mediainfo.vote_average,
|
||||
"description": mediainfo.overview,
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
})
|
||||
if not subscribe:
|
||||
subscribe = Subscribe(**kwargs)
|
||||
await subscribe.async_create(self._db)
|
||||
# 查询订阅
|
||||
subscribe = await Subscribe.async_exists(self._db,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
season=kwargs.get('season'))
|
||||
return subscribe.id, "新增订阅成功"
|
||||
else:
|
||||
return subscribe.id, "订阅已存在"
|
||||
|
||||
def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
season: Optional[int] = None) -> bool:
|
||||
"""
|
||||
判断是否存在
|
||||
"""
|
||||
@@ -67,6 +104,12 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
return Subscribe.get(self._db, rid=sid)
|
||||
|
||||
async def async_get(self, sid: int) -> Subscribe:
|
||||
"""
|
||||
获取订阅
|
||||
"""
|
||||
return await Subscribe.async_get(self._db, id=sid)
|
||||
|
||||
def list(self, state: Optional[str] = None) -> List[Subscribe]:
|
||||
"""
|
||||
获取订阅列表
|
||||
@@ -96,7 +139,8 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
return Subscribe.get_by_tmdbid(self._db, tmdbid=tmdbid, season=season)
|
||||
|
||||
def list_by_username(self, username: str, state: Optional[str] = None, mtype: Optional[str] = None) -> List[Subscribe]:
|
||||
def list_by_username(self, username: str, state: Optional[str] = None,
|
||||
mtype: Optional[str] = None) -> List[Subscribe]:
|
||||
"""
|
||||
获取指定用户的订阅
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,33 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
conf.create(self._db)
|
||||
return True
|
||||
|
||||
async def async_set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:
|
||||
"""
|
||||
异步设置系统设置
|
||||
:param key: 配置键
|
||||
:param value: 配置值
|
||||
:return: 是否设置成功(True 成功/False 失败/None 无需更新)
|
||||
"""
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
# 旧值
|
||||
old_value = self.__SYSTEMCONF.get(key)
|
||||
# 更新内存(deepcopy避免内存共享)
|
||||
self.__SYSTEMCONF[key] = copy.deepcopy(value)
|
||||
conf = await SystemConfig.async_get_by_key(self._db, key)
|
||||
if conf:
|
||||
if old_value != value:
|
||||
if value:
|
||||
conf.update(self._db, {"value": value})
|
||||
else:
|
||||
conf.delete(self._db, conf.id)
|
||||
return True
|
||||
return None
|
||||
else:
|
||||
conf = SystemConfig(key=key, value=value)
|
||||
await conf.async_create(self._db)
|
||||
return True
|
||||
|
||||
def get(self, key: Union[str, SystemConfigKey] = None) -> Any:
|
||||
"""
|
||||
获取系统设置
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.security import verify_token
|
||||
from app.db import DbOper, get_db
|
||||
from app.db import DbOper, get_db, get_async_db
|
||||
from app.db.models.user import User
|
||||
|
||||
|
||||
@@ -22,6 +23,19 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_async(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
token_data: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> User:
|
||||
"""
|
||||
异步获取当前用户
|
||||
"""
|
||||
user = await User.async_get(db, rid=token_data.sub)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
@@ -33,6 +47,17 @@ def get_current_active_user(
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_active_user_async(
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
) -> User:
|
||||
"""
|
||||
异步获取当前激活用户
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户未激活")
|
||||
return current_user
|
||||
|
||||
|
||||
def get_current_active_superuser(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
@@ -46,6 +71,19 @@ def get_current_active_superuser(
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_active_superuser_async(
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
) -> User:
|
||||
"""
|
||||
异步获取当前激活超级管理员
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="用户权限不足"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
class UserOper(DbOper):
|
||||
"""
|
||||
用户管理
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.userrequest import UserRequest
|
||||
|
||||
|
||||
class UserRequestOper(DbOper):
|
||||
"""
|
||||
用户请求管理
|
||||
"""
|
||||
|
||||
def get_need_approve(self) -> Optional[UserRequest]:
|
||||
"""
|
||||
获取待审批申请
|
||||
"""
|
||||
return UserRequest.get_by_status(self._db, 0)
|
||||
|
||||
def get_my_requests(self, username: str) -> Optional[UserRequest]:
|
||||
"""
|
||||
获取我的申请
|
||||
"""
|
||||
return UserRequest.get_by_req_user(self._db, username)
|
||||
|
||||
def approve(self, rid: int) -> bool:
|
||||
"""
|
||||
审批申请
|
||||
"""
|
||||
user_request = UserRequest.get(self._db, rid)
|
||||
if user_request:
|
||||
user_request.update(self._db, {"status": 1})
|
||||
return True
|
||||
return False
|
||||
|
||||
def deny(self, rid: int) -> bool:
|
||||
"""
|
||||
拒绝申请
|
||||
"""
|
||||
user_request = UserRequest.get(self._db, rid)
|
||||
if user_request:
|
||||
user_request.update(self._db, {"status": 2})
|
||||
return True
|
||||
return False
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Tuple, Optional
|
||||
from typing import List, Tuple, Optional, Any, Coroutine, Sequence
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.workflow import Workflow
|
||||
@@ -25,18 +25,54 @@ class WorkflowOper(DbOper):
|
||||
"""
|
||||
return Workflow.get(self._db, wid)
|
||||
|
||||
async def async_get(self, wid: int) -> Workflow:
|
||||
"""
|
||||
异步查询单个工作流
|
||||
"""
|
||||
return await Workflow.async_get(self._db, wid)
|
||||
|
||||
def list(self) -> List[Workflow]:
|
||||
"""
|
||||
获取所有工作流列表
|
||||
"""
|
||||
return Workflow.list(self._db)
|
||||
|
||||
async def async_list(self) -> Coroutine[Any, Any, Sequence[Any]]:
|
||||
"""
|
||||
异步获取所有工作流列表
|
||||
"""
|
||||
return await Workflow.async_list(self._db)
|
||||
|
||||
def list_enabled(self) -> List[Workflow]:
|
||||
"""
|
||||
获取启用的工作流列表
|
||||
"""
|
||||
return Workflow.get_enabled_workflows(self._db)
|
||||
|
||||
def get_timer_triggered_workflows(self) -> List[Workflow]:
|
||||
"""
|
||||
获取定时触发的工作流列表
|
||||
"""
|
||||
return Workflow.get_timer_triggered_workflows(self._db)
|
||||
|
||||
def get_event_triggered_workflows(self) -> List[Workflow]:
|
||||
"""
|
||||
获取事件触发的工作流列表
|
||||
"""
|
||||
return Workflow.get_event_triggered_workflows(self._db)
|
||||
|
||||
def get_by_name(self, name: str) -> Workflow:
|
||||
"""
|
||||
按名称获取工作流
|
||||
"""
|
||||
return Workflow.get_by_name(self._db, name)
|
||||
|
||||
async def async_get_by_name(self, name: str) -> Workflow:
|
||||
"""
|
||||
异步按名称获取工作流
|
||||
"""
|
||||
return await Workflow.async_get_by_name(self._db, name)
|
||||
|
||||
def start(self, wid: int) -> bool:
|
||||
"""
|
||||
启动
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.monitoring import setup_prometheus_metrics
|
||||
from app.startup.lifecycle import lifespan
|
||||
|
||||
|
||||
@@ -17,13 +18,16 @@ def create_app() -> FastAPI:
|
||||
|
||||
# 配置 CORS 中间件
|
||||
_app.add_middleware(
|
||||
CORSMiddleware,
|
||||
CORSMiddleware, # noqa
|
||||
allow_origins=settings.ALLOWED_HOSTS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 设置性能监控
|
||||
setup_prometheus_metrics(_app)
|
||||
|
||||
return _app
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ class PlaywrightHelper:
|
||||
except Exception as e:
|
||||
logger.error(f"网页操作失败: {str(e)}")
|
||||
finally:
|
||||
# 确保资源被正确清理
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
|
||||
@@ -96,127 +96,134 @@ class CookieHelper:
|
||||
return None, None, "获取源码失败"
|
||||
# 查找用户名输入框
|
||||
html = etree.HTML(html_text)
|
||||
username_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("username"):
|
||||
if html.xpath(xpath):
|
||||
username_xpath = xpath
|
||||
break
|
||||
if not username_xpath:
|
||||
return None, None, "未找到用户名输入框"
|
||||
# 查找密码输入框
|
||||
password_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("password"):
|
||||
if html.xpath(xpath):
|
||||
password_xpath = xpath
|
||||
break
|
||||
if not password_xpath:
|
||||
return None, None, "未找到密码输入框"
|
||||
# 处理二步验证码
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
# 查找二步验证码输入框
|
||||
twostep_xpath = None
|
||||
if otp_code:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
twostep_xpath = xpath
|
||||
break
|
||||
# 查找验证码输入框
|
||||
captcha_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
||||
if html.xpath(xpath):
|
||||
captcha_xpath = xpath
|
||||
break
|
||||
# 查找验证码图片
|
||||
captcha_img_url = None
|
||||
if captcha_xpath:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha_img"):
|
||||
if html.xpath(xpath):
|
||||
captcha_img_url = html.xpath(xpath)[0]
|
||||
break
|
||||
if not captcha_img_url:
|
||||
return None, None, "未找到验证码图片"
|
||||
# 查找登录按钮
|
||||
submit_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("submit"):
|
||||
if html.xpath(xpath):
|
||||
submit_xpath = xpath
|
||||
break
|
||||
if not submit_xpath:
|
||||
return None, None, "未找到登录按钮"
|
||||
# 点击登录按钮
|
||||
try:
|
||||
# 等待登录按钮准备好
|
||||
page.wait_for_selector(submit_xpath)
|
||||
# 输入用户名
|
||||
page.fill(username_xpath, username)
|
||||
# 输入密码
|
||||
page.fill(password_xpath, password)
|
||||
# 输入二步验证码
|
||||
if twostep_xpath:
|
||||
page.fill(twostep_xpath, otp_code)
|
||||
# 识别验证码
|
||||
if captcha_xpath and captcha_img_url:
|
||||
captcha_element = page.query_selector(captcha_xpath)
|
||||
if captcha_element.is_visible():
|
||||
# 验证码图片地址
|
||||
code_url = self.__get_captcha_url(url, captcha_img_url)
|
||||
# 获取当前的cookie和ua
|
||||
cookie = self.parse_cookies(page.context.cookies())
|
||||
ua = page.evaluate("() => window.navigator.userAgent")
|
||||
# 自动OCR识别验证码
|
||||
captcha = self.__get_captcha_text(cookie=cookie, ua=ua, code_url=code_url)
|
||||
if captcha:
|
||||
logger.info("验证码地址为:%s,识别结果:%s" % (code_url, captcha))
|
||||
else:
|
||||
return None, None, "验证码识别失败"
|
||||
# 输入验证码
|
||||
captcha_element.fill(captcha)
|
||||
else:
|
||||
# 不可见元素不处理
|
||||
pass
|
||||
username_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("username"):
|
||||
if html.xpath(xpath):
|
||||
username_xpath = xpath
|
||||
break
|
||||
if not username_xpath:
|
||||
return None, None, "未找到用户名输入框"
|
||||
# 查找密码输入框
|
||||
password_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("password"):
|
||||
if html.xpath(xpath):
|
||||
password_xpath = xpath
|
||||
break
|
||||
if not password_xpath:
|
||||
return None, None, "未找到密码输入框"
|
||||
# 处理二步验证码
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
# 查找二步验证码输入框
|
||||
twostep_xpath = None
|
||||
if otp_code:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
twostep_xpath = xpath
|
||||
break
|
||||
# 查找验证码输入框
|
||||
captcha_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
||||
if html.xpath(xpath):
|
||||
captcha_xpath = xpath
|
||||
break
|
||||
# 查找验证码图片
|
||||
captcha_img_url = None
|
||||
if captcha_xpath:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha_img"):
|
||||
if html.xpath(xpath):
|
||||
captcha_img_url = html.xpath(xpath)[0]
|
||||
break
|
||||
if not captcha_img_url:
|
||||
return None, None, "未找到验证码图片"
|
||||
# 查找登录按钮
|
||||
submit_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("submit"):
|
||||
if html.xpath(xpath):
|
||||
submit_xpath = xpath
|
||||
break
|
||||
if not submit_xpath:
|
||||
return None, None, "未找到登录按钮"
|
||||
|
||||
# 点击登录按钮
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"仿真登录失败:{str(e)}")
|
||||
return None, None, f"仿真登录失败:{str(e)}"
|
||||
# 对于某二次验证码为单页面的站点,输入二次验证码
|
||||
if "verify" in page.url:
|
||||
if not otp_code:
|
||||
return None, None, "需要二次验证码"
|
||||
html = etree.HTML(page.content())
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
try:
|
||||
# 刷新一下 2fa code
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
page.fill(xpath, otp_code)
|
||||
# 登录按钮 xpath 理论上相同,不再重复查找
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"二次验证码输入失败:{str(e)}")
|
||||
return None, None, f"二次验证码输入失败:{str(e)}"
|
||||
break
|
||||
# 登录后的源码
|
||||
html_text = page.content()
|
||||
if not html_text:
|
||||
return None, None, "获取网页源码失败"
|
||||
if SiteUtils.is_logged_in(html_text):
|
||||
return self.parse_cookies(page.context.cookies()), \
|
||||
page.evaluate("() => window.navigator.userAgent"), ""
|
||||
else:
|
||||
# 读取错误信息
|
||||
error_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("error"):
|
||||
if html.xpath(xpath):
|
||||
error_xpath = xpath
|
||||
break
|
||||
if not error_xpath:
|
||||
return None, None, "登录失败"
|
||||
try:
|
||||
# 等待登录按钮准备好
|
||||
page.wait_for_selector(submit_xpath)
|
||||
# 输入用户名
|
||||
page.fill(username_xpath, username)
|
||||
# 输入密码
|
||||
page.fill(password_xpath, password)
|
||||
# 输入二步验证码
|
||||
if twostep_xpath:
|
||||
page.fill(twostep_xpath, otp_code)
|
||||
# 识别验证码
|
||||
if captcha_xpath and captcha_img_url:
|
||||
captcha_element = page.query_selector(captcha_xpath)
|
||||
if captcha_element.is_visible():
|
||||
# 验证码图片地址
|
||||
code_url = self.__get_captcha_url(url, captcha_img_url)
|
||||
# 获取当前的cookie和ua
|
||||
cookie = self.parse_cookies(page.context.cookies())
|
||||
ua = page.evaluate("() => window.navigator.userAgent")
|
||||
# 自动OCR识别验证码
|
||||
captcha = self.__get_captcha_text(cookie=cookie, ua=ua, code_url=code_url)
|
||||
if captcha:
|
||||
logger.info("验证码地址为:%s,识别结果:%s" % (code_url, captcha))
|
||||
else:
|
||||
return None, None, "验证码识别失败"
|
||||
# 输入验证码
|
||||
captcha_element.fill(captcha)
|
||||
else:
|
||||
# 不可见元素不处理
|
||||
pass
|
||||
# 点击登录按钮
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"仿真登录失败:{str(e)}")
|
||||
return None, None, f"仿真登录失败:{str(e)}"
|
||||
|
||||
# 对于某二次验证码为单页面的站点,输入二次验证码
|
||||
if "verify" in page.url:
|
||||
if not otp_code:
|
||||
return None, None, "需要二次验证码"
|
||||
html = etree.HTML(page.content())
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
try:
|
||||
# 刷新一下 2fa code
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
page.fill(xpath, otp_code)
|
||||
# 登录按钮 xpath 理论上相同,不再重复查找
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"二次验证码输入失败:{str(e)}")
|
||||
return None, None, f"二次验证码输入失败:{str(e)}"
|
||||
break
|
||||
|
||||
# 登录后的源码
|
||||
html_text = page.content()
|
||||
if not html_text:
|
||||
return None, None, "获取网页源码失败"
|
||||
if SiteUtils.is_logged_in(html_text):
|
||||
return self.parse_cookies(page.context.cookies()), \
|
||||
page.evaluate("() => window.navigator.userAgent"), ""
|
||||
else:
|
||||
error_msg = html.xpath(error_xpath)[0]
|
||||
return None, None, error_msg
|
||||
# 读取错误信息
|
||||
error_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("error"):
|
||||
if html.xpath(xpath):
|
||||
error_xpath = xpath
|
||||
break
|
||||
if not error_xpath:
|
||||
return None, None, "登录失败"
|
||||
else:
|
||||
error_msg = html.xpath(error_xpath)[0]
|
||||
return None, None, error_msg
|
||||
finally:
|
||||
if html:
|
||||
del html
|
||||
|
||||
if not url or not username or not password:
|
||||
return None, None, "参数错误"
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from app import schemas
|
||||
from app.core.context import MediaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?\}\}", re.DOTALL)
|
||||
|
||||
|
||||
class DirectoryHelper:
|
||||
"""
|
||||
@@ -109,3 +113,42 @@ class DirectoryHelper:
|
||||
return matched_dir
|
||||
return matched_dirs[0]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_media_root_path(rename_format: str, rename_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
获取重命名后的媒体文件根路径
|
||||
|
||||
:param rename_format: 重命名格式
|
||||
:param rename_path: 重命名后的路径
|
||||
:return: 媒体文件根路径
|
||||
"""
|
||||
if not rename_format:
|
||||
logger.error("重命名格式不能为空")
|
||||
return None
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_list = rename_format.split("/")
|
||||
rename_format_level = len(rename_list) - 1
|
||||
# 查找标题参数所在层
|
||||
for level, name in enumerate(rename_list):
|
||||
matchs = JINJA2_VAR_PATTERN.findall(name)
|
||||
if not matchs:
|
||||
continue
|
||||
# 处理特例,有的人重命名的第一层是年份、分辨率
|
||||
if any("title" in m for m in matchs):
|
||||
# 找出含标题的这一层作为媒体根目录
|
||||
rename_format_level -= level
|
||||
break
|
||||
else:
|
||||
# 假定第一层目录是媒体根目录
|
||||
logger.warn(f"重命名格式 {rename_format} 缺少标题参数")
|
||||
if rename_format_level > len(rename_path.parents):
|
||||
# 通常因为路径以/结尾,被Path规范化删除了
|
||||
logger.error(f"路径 {rename_path} 不匹配重命名格式 {rename_format}")
|
||||
return None
|
||||
if rename_format_level <= 0:
|
||||
# 所有媒体文件都存在一个目录内的特殊需求
|
||||
rename_format_level = 1
|
||||
# 媒体根路径
|
||||
media_root = rename_path.parents[rename_format_level - 1]
|
||||
return media_root
|
||||
|
||||
@@ -8,9 +8,9 @@ import os
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
_display: Display = None
|
||||
|
||||
def __init__(self):
|
||||
self._display = None
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -70,6 +70,9 @@ def enable_doh(enable: bool):
|
||||
|
||||
|
||||
class DohHelper(metaclass=Singleton):
|
||||
"""
|
||||
DoH帮助类,用于处理DNS over HTTPS解析。
|
||||
"""
|
||||
def __init__(self):
|
||||
enable_doh(settings.DOH_ENABLE)
|
||||
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
import gc
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
from pympler import muppy, summary, asizeof
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class MemoryHelper(metaclass=Singleton):
|
||||
"""
|
||||
内存管理工具类,用于监控和优化内存使用
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 检查间隔(秒) - 从配置获取,默认5分钟
|
||||
self._check_interval = settings.MEMORY_SNAPSHOT_INTERVAL * 60
|
||||
self._monitoring = False
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
# 内存快照保存目录
|
||||
self._memory_snapshot_dir = settings.LOG_PATH / "memory_snapshots"
|
||||
# 保留的快照文件数量
|
||||
self._keep_count = settings.MEMORY_SNAPSHOT_KEEP_COUNT
|
||||
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
"""
|
||||
处理配置变更事件,更新内存监控设置
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ['MEMORY_ANALYSIS', 'MEMORY_SNAPSHOT_INTERVAL', 'MEMORY_SNAPSHOT_KEEP_COUNT']:
|
||||
return
|
||||
|
||||
# 更新配置
|
||||
if event_data.key == 'MEMORY_SNAPSHOT_INTERVAL':
|
||||
self._check_interval = settings.MEMORY_SNAPSHOT_INTERVAL * 60
|
||||
elif event_data.key == 'MEMORY_SNAPSHOT_KEEP_COUNT':
|
||||
self._keep_count = settings.MEMORY_SNAPSHOT_KEEP_COUNT
|
||||
self.stop_monitoring()
|
||||
self.start_monitoring()
|
||||
|
||||
def start_monitoring(self):
|
||||
"""
|
||||
开始内存监控
|
||||
"""
|
||||
if not settings.MEMORY_ANALYSIS:
|
||||
return
|
||||
if self._monitoring:
|
||||
return
|
||||
|
||||
# 创建内存快照目录
|
||||
self._memory_snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 初始化内存分析器
|
||||
self._monitoring = True
|
||||
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
logger.info("内存监控已启动")
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""
|
||||
停止内存监控
|
||||
"""
|
||||
self._monitoring = False
|
||||
if self._monitor_thread:
|
||||
self._monitor_thread.join(timeout=5)
|
||||
logger.info("内存监控已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""
|
||||
内存监控循环
|
||||
"""
|
||||
logger.info("内存监控循环开始")
|
||||
while self._monitoring:
|
||||
try:
|
||||
# 生成内存快照
|
||||
self._create_memory_snapshot()
|
||||
time.sleep(self._check_interval)
|
||||
except Exception as e:
|
||||
logger.error(f"内存监控出错: {e}")
|
||||
# 出错后等待1分钟再继续
|
||||
time.sleep(60)
|
||||
logger.info("内存监控循环结束")
|
||||
|
||||
def _create_memory_snapshot(self):
|
||||
"""
|
||||
创建内存快照并保存到文件
|
||||
"""
|
||||
try:
|
||||
# 获取当前时间戳
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
snapshot_file = self._memory_snapshot_dir / f"memory_snapshot_{timestamp}.txt"
|
||||
|
||||
# 获取系统内存使用情况
|
||||
memory_usage = psutil.Process().memory_info().rss
|
||||
|
||||
logger.info(f"开始创建内存快照: {snapshot_file}")
|
||||
|
||||
# 第一步:写入基本信息和对象类型统计
|
||||
self._write_basic_info(snapshot_file, memory_usage)
|
||||
|
||||
# 第二步:分析并写入类实例内存使用情况
|
||||
self._append_class_analysis(snapshot_file)
|
||||
|
||||
# 第三步:分析并写入大内存变量详情
|
||||
self._append_variable_analysis(snapshot_file)
|
||||
|
||||
logger.info(f"内存快照已保存: {snapshot_file}, 当前内存使用: {memory_usage / 1024 / 1024:.2f} MB")
|
||||
|
||||
# 清理过期的快照文件(保留最近30个)
|
||||
self._cleanup_old_snapshots()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建内存快照失败: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _write_basic_info(snapshot_file, memory_usage):
|
||||
"""
|
||||
写入基本信息和对象类型统计
|
||||
"""
|
||||
# 获取当前进程的内存使用情况
|
||||
all_objects = muppy.get_objects()
|
||||
sum1 = summary.summarize(all_objects)
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"内存快照时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"当前进程内存使用: {memory_usage / 1024 / 1024:.2f} MB\n")
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write("对象类型统计:\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
|
||||
# 写入对象统计信息
|
||||
for line in summary.format_(sum1):
|
||||
f.write(line + "\n")
|
||||
|
||||
# 立即刷新到磁盘
|
||||
f.flush()
|
||||
|
||||
logger.debug("基本信息已写入快照文件")
|
||||
|
||||
def _append_class_analysis(self, snapshot_file):
|
||||
"""
|
||||
分析并追加类实例内存使用情况
|
||||
"""
|
||||
with open(snapshot_file, 'a', encoding='utf-8') as f:
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
f.write("类实例内存使用情况 (按内存大小排序):\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
f.write("正在分析中...\n")
|
||||
# 立即刷新,让用户知道这部分开始了
|
||||
f.flush()
|
||||
|
||||
try:
|
||||
logger.debug("开始分析类实例内存使用情况")
|
||||
class_objects = self._get_class_memory_usage()
|
||||
|
||||
# 重新打开文件,移除"正在分析中..."并写入实际结果
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换"正在分析中..."
|
||||
content = content.replace("正在分析中...\n", "")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
if class_objects:
|
||||
# 只显示前100个类
|
||||
for i, class_info in enumerate(class_objects[:100], 1):
|
||||
f.write(f"{i:3d}. {class_info['name']:<50} "
|
||||
f"{class_info['size_mb']:>8.2f} MB ({class_info['count']} 个实例)\n")
|
||||
else:
|
||||
f.write("未找到有效的类实例信息\n")
|
||||
|
||||
f.flush()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取类实例信息失败: {e}")
|
||||
|
||||
# 即使出错也要更新文件
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace("正在分析中...\n", f"获取类实例信息失败: {e}\n")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
|
||||
logger.debug("类实例分析已完成并写入")
|
||||
|
||||
def _append_variable_analysis(self, snapshot_file):
|
||||
"""
|
||||
分析并追加大内存变量详情
|
||||
"""
|
||||
with open(snapshot_file, 'a', encoding='utf-8') as f:
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
f.write("大内存变量详情 (前100个):\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
f.write("正在分析中...\n")
|
||||
# 立即刷新,让用户知道这部分开始了
|
||||
f.flush()
|
||||
|
||||
try:
|
||||
logger.debug("开始分析大内存变量")
|
||||
large_variables = self._get_large_variables(100)
|
||||
|
||||
# 重新打开文件,移除"正在分析中..."并写入实际结果
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换最后的"正在分析中..."
|
||||
content = content.replace("正在分析中...\n", "")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
if large_variables:
|
||||
for i, var_info in enumerate(large_variables, 1):
|
||||
f.write(
|
||||
f"{i:3d}. {var_info['name']:<30} {var_info['type']:<15} {var_info['size_mb']:>8.2f} MB\n")
|
||||
else:
|
||||
f.write("未找到大内存变量\n")
|
||||
|
||||
f.flush()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取大内存变量信息失败: {e}")
|
||||
|
||||
# 即使出错也要更新文件
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace("正在分析中...\n", f"获取变量信息失败: {e}\n")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
|
||||
logger.debug("大内存变量分析已完成并写入")
|
||||
|
||||
def _cleanup_old_snapshots(self):
|
||||
"""
|
||||
清理过期的内存快照文件,只保留最近的指定数量文件
|
||||
"""
|
||||
try:
|
||||
snapshot_files = list(self._memory_snapshot_dir.glob("memory_snapshot_*.txt"))
|
||||
if len(snapshot_files) > self._keep_count:
|
||||
# 按修改时间排序,删除最旧的文件
|
||||
snapshot_files.sort(key=lambda x: x.stat().st_mtime)
|
||||
for old_file in snapshot_files[:-self._keep_count]:
|
||||
old_file.unlink()
|
||||
logger.debug(f"已删除过期内存快照: {old_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"清理过期快照失败: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _get_class_memory_usage():
|
||||
"""
|
||||
获取所有类实例的内存使用情况,按内存大小排序
|
||||
"""
|
||||
class_info = {}
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
|
||||
# 获取所有对象
|
||||
all_objects = muppy.get_objects()
|
||||
logger.debug(f"开始分析 {len(all_objects)} 个对象的类实例内存使用情况")
|
||||
|
||||
for obj in all_objects:
|
||||
try:
|
||||
# 跳过类对象本身,统计类的实例
|
||||
if isinstance(obj, type):
|
||||
continue
|
||||
|
||||
# 获取对象的类名 - 这里可能会出错
|
||||
obj_class = type(obj)
|
||||
|
||||
# 安全地获取类名
|
||||
try:
|
||||
if hasattr(obj_class, '__module__') and hasattr(obj_class, '__name__'):
|
||||
class_name = f"{obj_class.__module__}.{obj_class.__name__}"
|
||||
else:
|
||||
class_name = str(obj_class)
|
||||
except Exception as e:
|
||||
# 如果获取类名失败,使用简单的类型描述
|
||||
class_name = f"<unknown_class_{id(obj_class)}>"
|
||||
logger.debug(f"获取类名失败: {e}")
|
||||
|
||||
# 计算对象本身的内存使用(不包括引用对象,避免重复计算)
|
||||
size_bytes = sys.getsizeof(obj)
|
||||
if size_bytes < 100: # 跳过太小的对象
|
||||
continue
|
||||
|
||||
size_mb = size_bytes / 1024 / 1024
|
||||
processed_count += 1
|
||||
|
||||
if class_name in class_info:
|
||||
class_info[class_name]['size_mb'] += size_mb
|
||||
class_info[class_name]['count'] += 1
|
||||
else:
|
||||
class_info[class_name] = {
|
||||
'name': class_name,
|
||||
'size_mb': size_mb,
|
||||
'count': 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 捕获所有可能的异常,包括SQLAlchemy、ORM等框架的异常
|
||||
error_count += 1
|
||||
if error_count <= 5: # 只记录前5个错误,避免日志过多
|
||||
logger.debug(f"分析对象时出错: {e}")
|
||||
continue
|
||||
|
||||
logger.debug(f"类实例分析完成: 处理了 {processed_count} 个对象, 遇到 {error_count} 个错误")
|
||||
|
||||
# 按内存大小排序
|
||||
sorted_classes = sorted(class_info.values(), key=lambda x: x['size_mb'], reverse=True)
|
||||
return sorted_classes
|
||||
|
||||
def _get_large_variables(self, limit=100):
|
||||
"""
|
||||
获取大内存变量信息,按内存大小排序
|
||||
使用已计算对象集合避免重复计算
|
||||
"""
|
||||
large_vars = []
|
||||
processed_count = 0
|
||||
calculated_objects = set() # 避免重复计算
|
||||
|
||||
# 获取所有对象
|
||||
all_objects = muppy.get_objects()
|
||||
logger.debug(f"开始分析 {len(all_objects)} 个对象的内存使用情况")
|
||||
|
||||
for obj in all_objects:
|
||||
# 跳过类对象
|
||||
if isinstance(obj, type):
|
||||
continue
|
||||
|
||||
# 跳过已经计算过的对象
|
||||
obj_id = id(obj)
|
||||
if obj_id in calculated_objects:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 首先使用 sys.getsizeof 快速筛选
|
||||
shallow_size = sys.getsizeof(obj)
|
||||
if shallow_size < 1024: # 只处理大于1KB的对象
|
||||
continue
|
||||
|
||||
# 对于较大的对象,使用 asizeof 进行深度计算
|
||||
size_bytes = asizeof.asizeof(obj)
|
||||
|
||||
# 只处理大于10KB的对象,提高分析效率
|
||||
if size_bytes < 10240:
|
||||
continue
|
||||
|
||||
size_mb = size_bytes / 1024 / 1024
|
||||
processed_count += 1
|
||||
calculated_objects.add(obj_id)
|
||||
|
||||
# 获取对象信息
|
||||
var_info = self._get_variable_info(obj, size_mb)
|
||||
if var_info:
|
||||
large_vars.append(var_info)
|
||||
|
||||
# 如果已经找到足够多的大对象,可以提前结束
|
||||
if len(large_vars) >= limit * 2: # 多收集一些,后面排序筛选
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# 更广泛的异常捕获
|
||||
logger.debug(f"分析对象失败: {e}")
|
||||
continue
|
||||
|
||||
logger.debug(f"处理了 {processed_count} 个大对象,找到 {len(large_vars)} 个有效变量")
|
||||
|
||||
# 按内存大小排序并返回前N个
|
||||
large_vars.sort(key=lambda x: x['size_mb'], reverse=True)
|
||||
return large_vars[:limit]
|
||||
|
||||
def _get_variable_info(self, obj, size_mb):
|
||||
"""
|
||||
获取变量的描述信息
|
||||
"""
|
||||
try:
|
||||
obj_type = type(obj).__name__
|
||||
|
||||
# 尝试获取变量名
|
||||
var_name = self._get_variable_name(obj)
|
||||
|
||||
# 生成描述性信息
|
||||
if isinstance(obj, dict):
|
||||
key_count = len(obj)
|
||||
if key_count > 0:
|
||||
sample_keys = list(obj.keys())[:3]
|
||||
var_name += f" ({key_count}项, 键: {sample_keys})"
|
||||
elif isinstance(obj, (list, tuple, set)):
|
||||
var_name += f" ({len(obj)}个元素)"
|
||||
elif isinstance(obj, str):
|
||||
if len(obj) > 50:
|
||||
var_name += f" (长度: {len(obj)}, 内容: '{obj[:50]}...')"
|
||||
else:
|
||||
var_name += f" ('{obj}')"
|
||||
elif hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'):
|
||||
if hasattr(obj, '__dict__'):
|
||||
attr_count = len(obj.__dict__)
|
||||
var_name += f" ({attr_count}个属性)"
|
||||
|
||||
return {
|
||||
'name': var_name,
|
||||
'type': obj_type,
|
||||
'size_mb': size_mb
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取变量信息失败: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_variable_name(obj):
|
||||
"""
|
||||
尝试获取变量名
|
||||
"""
|
||||
try:
|
||||
# 尝试通过gc获取引用该对象的变量名
|
||||
referrers = gc.get_referrers(obj)
|
||||
|
||||
for referrer in referrers:
|
||||
if isinstance(referrer, dict):
|
||||
# 检查是否在某个模块的全局变量中
|
||||
for name, value in referrer.items():
|
||||
if value is obj and isinstance(name, str):
|
||||
return name
|
||||
elif hasattr(referrer, '__dict__'):
|
||||
# 检查是否在某个实例的属性中
|
||||
for name, value in referrer.__dict__.items():
|
||||
if value is obj and isinstance(name, str):
|
||||
return f"{type(referrer).__name__}.{name}"
|
||||
|
||||
# 如果找不到变量名,返回对象类型和id
|
||||
return f"{type(obj).__name__}_{id(obj)}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取变量名失败: {e}")
|
||||
return f"{type(obj).__name__}_{id(obj)}"
|
||||
@@ -541,8 +541,6 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
消息发送队列管理器
|
||||
"""
|
||||
|
||||
schedule_periods: List[tuple[int, int, int, int]] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
send_callback: Optional[Callable] = None,
|
||||
@@ -554,6 +552,8 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
:param send_callback: 实际发送消息的回调函数
|
||||
:param check_interval: 时间检查间隔(秒)
|
||||
"""
|
||||
self.schedule_periods: List[tuple[int, int, int, int]] = []
|
||||
|
||||
self.init_config()
|
||||
|
||||
self.queue: queue.Queue[Any] = queue.Queue()
|
||||
@@ -657,6 +657,17 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
})
|
||||
logger.info(f"消息已加入队列,当前队列长度:{self.queue.qsize()}")
|
||||
|
||||
async def async_send_message(self, *args, **kwargs) -> None:
|
||||
"""
|
||||
异步发送消息(直接加入队列)
|
||||
"""
|
||||
kwargs.pop("immediately", False)
|
||||
self.queue.put({
|
||||
"args": args,
|
||||
"kwargs": kwargs
|
||||
})
|
||||
logger.info(f"消息已加入队列,当前队列长度:{self.queue.qsize()}")
|
||||
|
||||
def _send(self, *args, **kwargs) -> None:
|
||||
"""
|
||||
实际发送消息(可通过回调函数自定义)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,11 @@
|
||||
from enum import Enum
|
||||
from typing import Union, Dict, Optional
|
||||
from typing import Union, Optional
|
||||
|
||||
from app.schemas.types import ProgressKey
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.singleton import WeakSingleton
|
||||
|
||||
|
||||
class ProgressHelper(metaclass=Singleton):
|
||||
_process_detail: Dict[str, dict] = {}
|
||||
class ProgressHelper(metaclass=WeakSingleton):
|
||||
|
||||
def __init__(self):
|
||||
self._process_detail = {}
|
||||
|
||||
@@ -2,12 +2,13 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class ResourceHelper:
|
||||
@@ -58,15 +59,15 @@ class ResourceHelper:
|
||||
if rtype == "auth":
|
||||
# 站点认证资源
|
||||
local_version = SitesHelper().auth_version
|
||||
# 阻断v2.3.0以下的版本直接更新,避免无限重启
|
||||
# 阻断站点认证资源v2.3.0以下的版本直接更新,避免无限重启
|
||||
if StringUtils.compare_version(local_version, "<", "2.3.0"):
|
||||
continue
|
||||
# 阻断主程序版本v2.6.3以下的版本直接更新,避免搜索异常
|
||||
if StringUtils.compare_version(APP_VERSION, "<", "2.6.3"):
|
||||
continue
|
||||
elif rtype == "sites":
|
||||
# 站点索引资源
|
||||
local_version = SitesHelper().indexer_version
|
||||
# 阻断v2.0.0以下的版本直接更新,避免无限重启
|
||||
if StringUtils.compare_version(local_version, "<", "2.0.0"):
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, ">", local_version):
|
||||
@@ -84,6 +85,8 @@ class ResourceHelper:
|
||||
elif not r:
|
||||
return None, "连接仓库失败"
|
||||
files_info = r.json()
|
||||
# 下载资源文件
|
||||
success = True
|
||||
for item in files_info:
|
||||
save_path = need_updates.get(item.get("name"))
|
||||
if not save_path:
|
||||
@@ -96,16 +99,23 @@ class ResourceHelper:
|
||||
timeout=180).get_res(download_url)
|
||||
if not res:
|
||||
logger.error(f"文件 {item.get('name')} 下载失败!")
|
||||
success = False
|
||||
break
|
||||
elif res.status_code != 200:
|
||||
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
|
||||
success = False
|
||||
break
|
||||
# 创建插件文件夹
|
||||
file_path = self._base_dir / save_path / item.get("name")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 写入文件
|
||||
file_path.write_bytes(res.content)
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemHelper.restart()
|
||||
if success:
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemHelper.restart()
|
||||
else:
|
||||
logger.warn("资源包更新失败,跳过升级!")
|
||||
else:
|
||||
logger.info("所有资源已最新,无需更新")
|
||||
except json.JSONDecodeError:
|
||||
|
||||
@@ -7,12 +7,12 @@ from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class SubscribeHelper(metaclass=Singleton):
|
||||
class SubscribeHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
订阅数据统计/订阅分享等
|
||||
"""
|
||||
@@ -29,6 +29,8 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
|
||||
_sub_shares = f"{settings.MP_SERVER_HOST}/subscribe/shares"
|
||||
|
||||
_sub_share_statistic = f"{settings.MP_SERVER_HOST}/subscribe/share/statistics"
|
||||
|
||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||
|
||||
_shares_cache_region = "subscribe_share"
|
||||
@@ -58,27 +60,111 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
self.get_user_uuid()
|
||||
self.get_github_user()
|
||||
|
||||
@cached(maxsize=5, ttl=1800)
|
||||
@staticmethod
|
||||
def _check_subscribe_share_enabled() -> Tuple[bool, str]:
|
||||
"""
|
||||
检查订阅分享功能是否开启
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _validate_subscribe(subscribe) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证订阅是否存在
|
||||
"""
|
||||
if not subscribe:
|
||||
return False, "订阅不存在"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _prepare_subscribe_data(subscribe) -> dict:
|
||||
"""
|
||||
准备订阅分享数据
|
||||
"""
|
||||
subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict.pop("id", None)
|
||||
return subscribe_dict
|
||||
|
||||
def _build_share_payload(self, share_title: str, share_comment: str,
|
||||
share_user: str, subscribe_dict: dict) -> dict:
|
||||
"""
|
||||
构建分享请求载荷
|
||||
"""
|
||||
return {
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._share_user_id,
|
||||
**subscribe_dict
|
||||
}
|
||||
|
||||
def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理HTTP响应
|
||||
"""
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
|
||||
# 检查响应状态
|
||||
if res and res.status_code == 200:
|
||||
# 清除缓存
|
||||
if clear_cache:
|
||||
cache_backend.clear(region=self._shares_cache_region)
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@staticmethod
|
||||
def _handle_list_response(res) -> List[dict]:
|
||||
"""
|
||||
处理返回List的HTTP响应
|
||||
"""
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True)
|
||||
def get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅统计数据
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params={
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True)
|
||||
async def async_get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步获取订阅统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params={
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
def sub_reg(self, sub: dict) -> bool:
|
||||
"""
|
||||
新增订阅统计
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
@@ -87,11 +173,26 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_sub_reg(self, sub: dict) -> bool:
|
||||
"""
|
||||
异步新增订阅统计
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_reg, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sub_done(self, sub: dict) -> bool:
|
||||
"""
|
||||
完成订阅统计
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
@@ -120,7 +221,8 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
上报存量订阅统计
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
@@ -139,81 +241,177 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
分享订阅
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取订阅信息
|
||||
subscribe = SubscribeOper().get(subscribe_id)
|
||||
if not subscribe:
|
||||
return False, "订阅不存在"
|
||||
subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict.pop("id")
|
||||
cache_backend.clear(region=self._shares_cache_region)
|
||||
|
||||
# 验证订阅
|
||||
valid, message = self._validate_subscribe(subscribe)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
subscribe_dict = self._prepare_subscribe_data(subscribe)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_share,
|
||||
json={
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._share_user_id,
|
||||
**subscribe_dict
|
||||
})
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
# 清除 get_shares 的缓存,以便实时看到结果
|
||||
cache_backend.clear(region=self._shares_cache_region)
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
timeout=10).post(self._sub_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_sub_share(self, subscribe_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步分享订阅
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取订阅信息
|
||||
subscribe = await SubscribeOper().async_get(subscribe_id)
|
||||
|
||||
# 验证订阅
|
||||
valid, message = self._validate_subscribe(subscribe)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
subscribe_dict = self._prepare_subscribe_data(subscribe)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
timeout=5).delete_res(f"{self._sub_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
# 清除 get_shares 的缓存,以便实时看到结果
|
||||
cache_backend.clear(region=self._shares_cache_region)
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY,
|
||||
timeout=5).delete_res(f"{self._sub_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def sub_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
复用分享的订阅
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._sub_fork % share_id)
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@cached(region=_shares_cache_region)
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
async def async_sub_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步复用分享的订阅
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._sub_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享数据
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> \
|
||||
List[dict]:
|
||||
"""
|
||||
异步获取订阅分享数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
def get_share_statistics(self) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
async def async_get_share_statistics(self) -> List[dict]:
|
||||
"""
|
||||
异步获取订阅分享统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
def get_user_uuid(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List, Union, Dict
|
||||
from typing import Tuple, Optional, List, Union, Dict, Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from requests import Response
|
||||
from torrentool.api import Torrent
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -16,17 +15,17 @@ from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentHelper(metaclass=Singleton):
|
||||
class TorrentHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
种子帮助类
|
||||
"""
|
||||
|
||||
# 失败的种子:站点链接
|
||||
_invalid_torrents = []
|
||||
def __init__(self):
|
||||
self._invalid_torrents = []
|
||||
|
||||
def download_torrent(self, url: str,
|
||||
cookie: Optional[str] = None,
|
||||
@@ -40,6 +39,22 @@ class TorrentHelper(metaclass=Singleton):
|
||||
"""
|
||||
if url.startswith("magnet:"):
|
||||
return None, url, "", [], f"磁力链接"
|
||||
# 构建 torrent 种子文件的存储路径
|
||||
file_path = (Path(settings.TEMP_PATH) / StringUtils.md5_hash(url)).with_suffix(".torrent")
|
||||
if file_path.exists():
|
||||
try:
|
||||
# 获取种子目录和文件清单
|
||||
folder_name, file_list = self.get_torrent_info(file_path)
|
||||
# 无法获取信息,则认为缓存文件无效
|
||||
if not folder_name and not file_list:
|
||||
raise ValueError("无效的缓存种子文件")
|
||||
# 获取种子数据
|
||||
content = file_path.read_bytes()
|
||||
# 成功拿到种子数据
|
||||
return file_path, content, folder_name, file_list, ""
|
||||
except Exception as err:
|
||||
logger.error(f"处理缓存的种子文件 {file_path} 时出错: {err},将重新下载")
|
||||
file_path.unlink(missing_ok=True)
|
||||
# 请求种子文件
|
||||
req = RequestUtils(
|
||||
ua=ua,
|
||||
@@ -106,10 +121,6 @@ class TorrentHelper(metaclass=Singleton):
|
||||
if req.content:
|
||||
# 检查是不是种子文件,如果不是仍然抛出异常
|
||||
try:
|
||||
# 读取种子文件名
|
||||
file_name = self.get_url_filename(req, url)
|
||||
# 种子文件路径
|
||||
file_path = Path(settings.TEMP_PATH) / file_name
|
||||
# 保存到文件
|
||||
file_path.write_bytes(req.content)
|
||||
# 获取种子目录和文件清单
|
||||
@@ -170,7 +181,7 @@ class TorrentHelper(metaclass=Singleton):
|
||||
return "", []
|
||||
|
||||
@staticmethod
|
||||
def get_url_filename(req: Response, url: str) -> str:
|
||||
def get_url_filename(req: Any, url: str) -> str:
|
||||
"""
|
||||
从下载请求中获取种子文件名
|
||||
"""
|
||||
@@ -308,7 +319,7 @@ class TorrentHelper(metaclass=Singleton):
|
||||
self._invalid_torrents.append(url)
|
||||
|
||||
@staticmethod
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo) -> bool:
|
||||
"""
|
||||
检查种子是否匹配媒体信息
|
||||
:param mediainfo: 需要匹配的媒体信息
|
||||
|
||||
275
app/helper/workflow.py
Normal file
275
app/helper/workflow.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import json
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
from app.core.cache import cached, cache_backend
|
||||
from app.core.config import settings
|
||||
from app.db.models import Workflow
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class WorkflowHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
工作流分享等
|
||||
"""
|
||||
|
||||
_workflow_share = f"{settings.MP_SERVER_HOST}/workflow/share"
|
||||
|
||||
_workflow_shares = f"{settings.MP_SERVER_HOST}/workflow/shares"
|
||||
|
||||
_workflow_fork = f"{settings.MP_SERVER_HOST}/workflow/fork/%s"
|
||||
|
||||
_shares_cache_region = "workflow_share"
|
||||
|
||||
_share_user_id = None
|
||||
|
||||
def __init__(self):
|
||||
self.get_user_uuid()
|
||||
|
||||
@staticmethod
|
||||
def _check_workflow_share_enabled() -> Tuple[bool, str]:
|
||||
"""
|
||||
检查工作流分享功能是否开启
|
||||
"""
|
||||
if not settings.WORKFLOW_STATISTIC_SHARE:
|
||||
return False, "当前没有开启工作流数据共享功能"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _validate_workflow(workflow: Workflow) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证工作流是否可以分享
|
||||
"""
|
||||
if not workflow:
|
||||
return False, "工作流不存在"
|
||||
|
||||
if not workflow.actions or not workflow.flows:
|
||||
return False, "请分享有动作和流程的工作流"
|
||||
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _prepare_workflow_data(workflow: Workflow) -> dict:
|
||||
"""
|
||||
准备工作流分享数据
|
||||
"""
|
||||
workflow_dict = workflow.to_dict()
|
||||
workflow_dict.pop("id", None)
|
||||
workflow_dict.pop("context", None)
|
||||
workflow_dict['actions'] = json.dumps(workflow_dict['actions'] or [])
|
||||
workflow_dict['flows'] = json.dumps(workflow_dict['flows'] or [])
|
||||
return workflow_dict
|
||||
|
||||
def _build_share_payload(self, share_title: str, share_comment: str,
|
||||
share_user: str, workflow_dict: dict) -> dict:
|
||||
"""
|
||||
构建分享请求载荷
|
||||
"""
|
||||
return {
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._share_user_id,
|
||||
**workflow_dict
|
||||
}
|
||||
|
||||
def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理HTTP响应
|
||||
"""
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
|
||||
# 检查响应状态
|
||||
success = True if res.status_code == 200 else False
|
||||
|
||||
if success:
|
||||
# 清除缓存
|
||||
if clear_cache:
|
||||
cache_backend.clear(region=self._shares_cache_region)
|
||||
return True, ""
|
||||
else:
|
||||
try:
|
||||
error_msg = res.json().get("message", "未知错误")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error(f"工作流响应JSON解析失败: {e}")
|
||||
error_msg = f"响应解析失败: {res.text[:100]}..."
|
||||
return False, error_msg
|
||||
|
||||
@staticmethod
|
||||
def _handle_list_response(res) -> List[dict]:
|
||||
"""
|
||||
处理返回List的HTTP响应
|
||||
"""
|
||||
if res and res.status_code == 200:
|
||||
try:
|
||||
return res.json()
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error(f"工作流列表响应JSON解析失败: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
def workflow_share(self, workflow_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
分享工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取工作流信息
|
||||
workflow = WorkflowOper().get(workflow_id)
|
||||
|
||||
# 验证工作流
|
||||
valid, message = self._validate_workflow(workflow)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
workflow_dict = self._prepare_workflow_data(workflow)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = RequestUtils(proxies=settings.PROXY or {},
|
||||
content_type="application/json",
|
||||
timeout=10).post(self._workflow_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_workflow_share(self, workflow_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步分享工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取工作流信息
|
||||
workflow = await WorkflowOper().async_get(workflow_id)
|
||||
|
||||
# 验证工作流
|
||||
valid, message = self._validate_workflow(workflow)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
workflow_dict = self._prepare_workflow_data(workflow)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {},
|
||||
content_type="application/json",
|
||||
timeout=10).post(self._workflow_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY or {},
|
||||
timeout=5).delete_res(f"{self._workflow_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {},
|
||||
timeout=5).delete_res(f"{self._workflow_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def workflow_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
复用分享的工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY or {}, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._workflow_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
async def async_workflow_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步复用分享的工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {},
|
||||
timeout=5,
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._workflow_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, skip_empty=True)
|
||||
def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取工作流分享数据
|
||||
"""
|
||||
enabled, _ = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, skip_empty=True)
|
||||
async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> \
|
||||
List[dict]:
|
||||
"""
|
||||
异步获取工作流分享数据
|
||||
"""
|
||||
enabled, _ = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
return self._handle_list_response(res)
|
||||
|
||||
def get_user_uuid(self) -> str:
|
||||
"""
|
||||
获取用户uuid
|
||||
"""
|
||||
if not self._share_user_id:
|
||||
self._share_user_id = SystemUtils.generate_user_unique_id()
|
||||
logger.info(f"当前用户UUID: {self._share_user_id}")
|
||||
return self._share_user_id or ""
|
||||
299
app/log.py
299
app/log.py
@@ -1,6 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
@@ -33,6 +38,14 @@ class LogConfigModel(BaseModel):
|
||||
LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s"
|
||||
# 文件日志格式
|
||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
||||
# 异步文件写入队列大小
|
||||
ASYNC_FILE_QUEUE_SIZE: int = 1000
|
||||
# 异步文件写入线程数
|
||||
ASYNC_FILE_WORKERS: int = 2
|
||||
# 批量写入大小
|
||||
BATCH_WRITE_SIZE: int = 50
|
||||
# 写入超时时间(秒)
|
||||
WRITE_TIMEOUT: float = 3.0
|
||||
|
||||
|
||||
class LogSettings(BaseSettings, LogConfigModel):
|
||||
@@ -64,6 +77,9 @@ class LogSettings(BaseSettings, LogConfigModel):
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
# 实例化日志设置
|
||||
log_settings = LogSettings()
|
||||
|
||||
# 日志级别颜色映射
|
||||
level_name_colors = {
|
||||
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||
@@ -88,6 +104,206 @@ class CustomFormatter(logging.Formatter):
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class LogEntry:
|
||||
"""
|
||||
日志条目
|
||||
"""
|
||||
|
||||
def __init__(self, level: str, message: str, file_path: Path, timestamp: datetime = None):
|
||||
self.level = level
|
||||
self.message = message
|
||||
self.file_path = file_path
|
||||
self.timestamp = timestamp or datetime.now()
|
||||
|
||||
|
||||
class NonBlockingFileHandler:
|
||||
"""
|
||||
非阻塞文件处理器 - 使用RotatingFileHandler实现日志滚动
|
||||
"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
_rotating_handlers = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
|
||||
self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
|
||||
thread_name_prefix="LogWriter")
|
||||
self._running = True
|
||||
|
||||
# 启动后台写入线程
|
||||
self._write_thread = threading.Thread(target=self._batch_writer, daemon=True)
|
||||
self._write_thread.start()
|
||||
|
||||
def _get_rotating_handler(self, file_path: Path) -> RotatingFileHandler:
|
||||
"""
|
||||
获取或创建RotatingFileHandler实例
|
||||
"""
|
||||
if file_path not in self._rotating_handlers:
|
||||
# 确保目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建RotatingFileHandler
|
||||
handler = RotatingFileHandler(
|
||||
filename=str(file_path),
|
||||
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
|
||||
backupCount=log_settings.LOG_BACKUP_COUNT,
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
# 设置格式化器
|
||||
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
self._rotating_handlers[file_path] = handler
|
||||
|
||||
return self._rotating_handlers[file_path]
|
||||
|
||||
def write_log(self, level: str, message: str, file_path: Path):
|
||||
"""
|
||||
写入日志 - 自动检测协程环境并使用合适的方式
|
||||
"""
|
||||
entry = LogEntry(level, message, file_path)
|
||||
|
||||
# 检测是否在协程环境中
|
||||
if self._is_in_event_loop():
|
||||
# 在协程环境中,使用非阻塞方式
|
||||
self._write_non_blocking(entry)
|
||||
else:
|
||||
# 不在协程环境中,直接同步写入
|
||||
self._write_sync(entry)
|
||||
|
||||
@staticmethod
|
||||
def _is_in_event_loop() -> bool:
|
||||
"""
|
||||
检测当前是否在事件循环中
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
return loop is not None
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
def _write_non_blocking(self, entry: LogEntry):
|
||||
"""
|
||||
非阻塞写入(用于协程环境)
|
||||
"""
|
||||
try:
|
||||
self._write_queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
# 队列满时,使用线程池处理
|
||||
self._executor.submit(self._write_sync, entry)
|
||||
|
||||
@staticmethod
|
||||
def _write_sync(entry: LogEntry):
|
||||
"""
|
||||
同步写入日志
|
||||
"""
|
||||
try:
|
||||
# 获取RotatingFileHandler实例
|
||||
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
|
||||
|
||||
# 使用RotatingFileHandler的emit方法,只传递原始消息
|
||||
handler.emit(logging.LogRecord(
|
||||
name='',
|
||||
level=getattr(logging, entry.level.upper(), logging.INFO),
|
||||
pathname='',
|
||||
lineno=0,
|
||||
msg=entry.message,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
created=entry.timestamp.timestamp()
|
||||
))
|
||||
except Exception as e:
|
||||
# 如果文件写入失败,至少输出到控制台
|
||||
print(f"日志写入失败 {entry.file_path}: {e}")
|
||||
print(f"【{entry.level.upper()}】{entry.timestamp} - {entry.message}")
|
||||
|
||||
def _batch_writer(self):
|
||||
"""
|
||||
后台批量写入线程
|
||||
"""
|
||||
while self._running:
|
||||
try:
|
||||
# 收集一批日志条目
|
||||
batch = []
|
||||
end_time = time.time() + log_settings.WRITE_TIMEOUT
|
||||
|
||||
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
|
||||
try:
|
||||
remaining_time = max(0, end_time - time.time())
|
||||
entry = self._write_queue.get(timeout=remaining_time)
|
||||
batch.append(entry)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
if batch:
|
||||
self._write_batch(batch)
|
||||
|
||||
except Exception as e:
|
||||
print(f"批量写入线程错误: {e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
def _write_batch(self, batch: list):
|
||||
"""
|
||||
批量写入日志
|
||||
"""
|
||||
# 按文件分组
|
||||
file_groups = {}
|
||||
for entry in batch:
|
||||
if entry.file_path not in file_groups:
|
||||
file_groups[entry.file_path] = []
|
||||
file_groups[entry.file_path].append(entry)
|
||||
|
||||
# 批量写入每个文件
|
||||
for file_path, entries in file_groups.items():
|
||||
try:
|
||||
# 获取RotatingFileHandler
|
||||
handler = self._get_rotating_handler(file_path)
|
||||
|
||||
# 批量写入
|
||||
for entry in entries:
|
||||
# 使用RotatingFileHandler的emit方法,只传递原始消息
|
||||
handler.emit(logging.LogRecord(
|
||||
name='',
|
||||
level=getattr(logging, entry.level.upper(), logging.INFO),
|
||||
pathname='',
|
||||
lineno=0,
|
||||
msg=entry.message,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
created=entry.timestamp.timestamp()
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"批量写入失败 {file_path}: {e}")
|
||||
# 回退到逐个写入
|
||||
for entry in entries:
|
||||
self._write_sync(entry)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
关闭文件处理器
|
||||
"""
|
||||
self._running = False
|
||||
if hasattr(self, '_write_thread'):
|
||||
self._write_thread.join(timeout=5)
|
||||
if self._executor:
|
||||
self._executor.shutdown(wait=True)
|
||||
|
||||
# 清理缓存
|
||||
self._rotating_handlers.clear()
|
||||
|
||||
|
||||
class LoggerManager:
|
||||
"""
|
||||
日志管理
|
||||
@@ -98,6 +314,8 @@ class LoggerManager:
|
||||
_default_log_file = "moviepilot.log"
|
||||
# 线程锁
|
||||
_lock = threading.Lock()
|
||||
# 非阻塞文件处理器
|
||||
_file_handler = NonBlockingFileHandler()
|
||||
|
||||
def get_logger(self, name: str) -> logging.Logger:
|
||||
"""
|
||||
@@ -112,8 +330,8 @@ class LoggerManager:
|
||||
# 检查是否已经创建过这个 logger
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
# 如果没有,就使用现有的 __setup_logger 来创建一个新的
|
||||
_logger = self.__setup_logger(log_file=logfile)
|
||||
# 如果没有,就使用现有的 __setup_console_logger 来创建一个新的
|
||||
_logger = self.__setup_console_logger(log_file=logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
return _logger
|
||||
|
||||
@@ -171,13 +389,12 @@ class LoggerManager:
|
||||
return caller_name or "log.py", plugin_name
|
||||
|
||||
@staticmethod
|
||||
def __setup_logger(log_file: str):
|
||||
def __setup_console_logger(log_file: str):
|
||||
"""
|
||||
初始化日志实例
|
||||
初始化控制台日志实例(文件输出由 NonBlockingFileHandler 处理)
|
||||
:param log_file:日志文件相对路径
|
||||
"""
|
||||
log_file_path = log_settings.LOG_PATH / log_file
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建新实例
|
||||
_logger = logging.getLogger(log_file_path.stem)
|
||||
@@ -189,24 +406,12 @@ class LoggerManager:
|
||||
for handler in _logger.handlers:
|
||||
_logger.removeHandler(handler)
|
||||
|
||||
# 终端日志
|
||||
# 只设置终端日志(文件日志由 NonBlockingFileHandler 处理)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
_logger.addHandler(console_handler)
|
||||
|
||||
# 文件日志
|
||||
file_handler = RotatingFileHandler(
|
||||
filename=log_file_path,
|
||||
mode="a",
|
||||
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
|
||||
backupCount=log_settings.LOG_BACKUP_COUNT,
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_formatter = CustomFormatter(log_settings.LOG_FILE_FORMAT)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
_logger.addHandler(file_handler)
|
||||
|
||||
# 禁止向父级log传递
|
||||
_logger.propagate = False
|
||||
|
||||
@@ -226,22 +431,15 @@ class LoggerManager:
|
||||
更新 Logger 的 handler 配置
|
||||
:param _logger: 需要更新的 Logger 实例
|
||||
"""
|
||||
# 更新现有 handler
|
||||
# 更新现有 handler(只有控制台 handler)
|
||||
for handler in _logger.handlers:
|
||||
try:
|
||||
if isinstance(handler, RotatingFileHandler):
|
||||
# 更新最大文件大小和备份数量
|
||||
handler.maxBytes = log_settings.LOG_MAX_FILE_SIZE_BYTES
|
||||
handler.backupCount = log_settings.LOG_BACKUP_COUNT
|
||||
# 更新日志文件输出格式
|
||||
file_formatter = CustomFormatter(log_settings.LOG_FILE_FORMAT)
|
||||
handler.setFormatter(file_formatter)
|
||||
elif isinstance(handler, logging.StreamHandler):
|
||||
if isinstance(handler, logging.StreamHandler):
|
||||
# 更新控制台输出格式
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
handler.setFormatter(console_formatter)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update handler: {handler}. Error: {e}")
|
||||
print(f"更新日志处理器失败: {handler}. 错误: {e}")
|
||||
# 更新日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
@@ -258,8 +456,26 @@ class LoggerManager:
|
||||
:param method: 日志方法
|
||||
:param msg: 日志信息
|
||||
"""
|
||||
# 获取当前日志级别
|
||||
current_level = self.__get_log_level()
|
||||
method_level = getattr(logging, method.upper(), logging.INFO)
|
||||
|
||||
# 如果当前方法的级别低于设定的日志级别,则不处理
|
||||
if method_level < current_level:
|
||||
return
|
||||
|
||||
# 获取调用者文件名和插件名
|
||||
caller_name, plugin_name = self.__get_caller()
|
||||
|
||||
# 格式化消息
|
||||
formatted_msg = f"{caller_name} - {msg}"
|
||||
if args:
|
||||
try:
|
||||
formatted_msg = formatted_msg % args
|
||||
except (TypeError, ValueError):
|
||||
# 如果格式化失败,直接拼接
|
||||
formatted_msg = f"{formatted_msg} {' '.join(str(arg) for arg in args)}"
|
||||
|
||||
# 区分插件日志
|
||||
if plugin_name:
|
||||
# 使用插件日志文件
|
||||
@@ -267,16 +483,24 @@ class LoggerManager:
|
||||
else:
|
||||
# 使用默认日志文件
|
||||
logfile = self._default_log_file
|
||||
with LoggerManager._lock: # 添加锁
|
||||
# 获取调用者的模块的logger
|
||||
|
||||
# 构建完整的日志文件路径
|
||||
log_file_path = log_settings.LOG_PATH / logfile
|
||||
|
||||
# 使用非阻塞文件处理器写入文件日志
|
||||
self._file_handler.write_log(method.upper(), formatted_msg, log_file_path)
|
||||
|
||||
# 同时保持控制台输出(使用标准 logging)
|
||||
with LoggerManager._lock:
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
_logger = self.__setup_logger(log_file=logfile)
|
||||
_logger = self.__setup_console_logger(log_file=logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
# 调用logger的方法打印日志
|
||||
|
||||
# 只在控制台输出,文件写入已由 _file_handler 处理
|
||||
if hasattr(_logger, method):
|
||||
log_method = getattr(_logger, method)
|
||||
log_method(f"{caller_name} - {msg}", *args, **kwargs)
|
||||
log_method(formatted_msg)
|
||||
|
||||
def info(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
@@ -314,9 +538,14 @@ class LoggerManager:
|
||||
"""
|
||||
self.logger("critical", msg, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls):
|
||||
"""
|
||||
关闭日志管理器,清理资源
|
||||
"""
|
||||
if cls._file_handler:
|
||||
cls._file_handler.shutdown()
|
||||
|
||||
# 实例化日志设置
|
||||
log_settings = LogSettings()
|
||||
|
||||
# 初始化日志管理
|
||||
logger = LoggerManager()
|
||||
|
||||
@@ -25,7 +25,7 @@ setproctitle.setproctitle(settings.PROJECT_NAME)
|
||||
|
||||
# uvicorn服务
|
||||
Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,
|
||||
reload=settings.DEV, workers=multiprocessing.cpu_count(),
|
||||
reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1,
|
||||
timeout_graceful_shutdown=60))
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,29 @@ class BangumiModule(_ModuleBase):
|
||||
|
||||
return None
|
||||
|
||||
async def async_recognize_media(self, bangumiid: int = None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息(异步版本)
|
||||
:param bangumiid: 识别的Bangumi ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
if not bangumiid:
|
||||
return None
|
||||
|
||||
# 直接查询详情
|
||||
info = await self.async_bangumi_info(bangumiid=bangumiid)
|
||||
if info:
|
||||
# 赋值TMDB信息并返回
|
||||
mediainfo = MediaInfo(bangumi_info=info)
|
||||
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}")
|
||||
return mediainfo
|
||||
else:
|
||||
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
|
||||
|
||||
return None
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
@@ -99,6 +122,23 @@ class BangumiModule(_ModuleBase):
|
||||
or meta.name.lower() in str(info.get("name_cn")).lower()]
|
||||
return []
|
||||
|
||||
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
infos = await self.bangumiapi.async_search(meta.name)
|
||||
if infos:
|
||||
return [MediaInfo(bangumi_info=info) for info in infos
|
||||
if meta.name.lower() in str(info.get("name")).lower()
|
||||
or meta.name.lower() in str(info.get("name_cn")).lower()]
|
||||
return []
|
||||
|
||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息
|
||||
@@ -110,6 +150,17 @@ class BangumiModule(_ModuleBase):
|
||||
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
|
||||
return self.bangumiapi.detail(bangumiid)
|
||||
|
||||
async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
:return: Bangumi信息
|
||||
"""
|
||||
if not bangumiid:
|
||||
return None
|
||||
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
|
||||
return await self.bangumiapi.async_detail(bangumiid)
|
||||
|
||||
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送
|
||||
@@ -119,6 +170,15 @@ class BangumiModule(_ModuleBase):
|
||||
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
async def async_bangumi_calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送(异步版本)
|
||||
"""
|
||||
infos = await self.bangumiapi.async_calendar()
|
||||
if infos:
|
||||
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表
|
||||
@@ -129,6 +189,16 @@ class BangumiModule(_ModuleBase):
|
||||
return [schemas.MediaPerson(source='bangumi', **person) for person in persons]
|
||||
return []
|
||||
|
||||
async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
persons = await self.bangumiapi.async_credits(bangumiid)
|
||||
if persons:
|
||||
return [schemas.MediaPerson(source='bangumi', **person) for person in persons]
|
||||
return []
|
||||
|
||||
def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据BangumiID查询推荐电影
|
||||
@@ -139,6 +209,16 @@ class BangumiModule(_ModuleBase):
|
||||
return [MediaInfo(bangumi_info=subject) for subject in subjects]
|
||||
return []
|
||||
|
||||
async def async_bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据BangumiID查询推荐电影(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
subjects = await self.bangumiapi.async_subjects(bangumiid)
|
||||
if subjects:
|
||||
return [MediaInfo(bangumi_info=subject) for subject in subjects]
|
||||
return []
|
||||
|
||||
def bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
获取人物详细信息
|
||||
@@ -156,6 +236,23 @@ class BangumiModule(_ModuleBase):
|
||||
})
|
||||
return None
|
||||
|
||||
async def async_bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
获取人物详细信息(异步版本)
|
||||
:param person_id: 豆瓣人物ID
|
||||
"""
|
||||
personinfo = await self.bangumiapi.async_person_detail(person_id)
|
||||
if personinfo:
|
||||
return schemas.MediaPerson(source='bangumi', **{
|
||||
"id": personinfo.get("id"),
|
||||
"name": personinfo.get("name"),
|
||||
"images": personinfo.get("images"),
|
||||
"biography": personinfo.get("summary"),
|
||||
"birthday": personinfo.get("birth_day"),
|
||||
"gender": personinfo.get("gender")
|
||||
})
|
||||
return None
|
||||
|
||||
def bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询人物参演作品
|
||||
@@ -166,6 +263,16 @@ class BangumiModule(_ModuleBase):
|
||||
return [MediaInfo(bangumi_info=credit) for credit in credits_info]
|
||||
return []
|
||||
|
||||
async def async_bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
credits_info = await self.bangumiapi.async_person_credits(person_id=person_id)
|
||||
if credits_info:
|
||||
return [MediaInfo(bangumi_info=credit) for credit in credits_info]
|
||||
return []
|
||||
|
||||
def bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现Bangumi番剧
|
||||
@@ -174,3 +281,12 @@ class BangumiModule(_ModuleBase):
|
||||
if infos:
|
||||
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
async def async_bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现Bangumi番剧(异步版本)
|
||||
"""
|
||||
infos = await self.bangumiapi.async_discover(**kwargs)
|
||||
if infos:
|
||||
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
@@ -5,7 +5,7 @@ import requests
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
|
||||
|
||||
class BangumiApi(object):
|
||||
@@ -28,7 +28,8 @@ class BangumiApi(object):
|
||||
|
||||
def __init__(self):
|
||||
self._session = requests.Session()
|
||||
self._req = RequestUtils(session=self._session)
|
||||
self._req = RequestUtils(ua=settings.NORMAL_USER_AGENT, session=self._session)
|
||||
self._async_req = AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT)
|
||||
|
||||
@cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta)
|
||||
def __invoke(self, url, key: Optional[str] = None, **kwargs):
|
||||
@@ -46,6 +47,22 @@ class BangumiApi(object):
|
||||
print(e)
|
||||
return None
|
||||
|
||||
@cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta)
|
||||
async def __async_invoke(self, url, key: Optional[str] = None, **kwargs):
|
||||
req_url = self._base_url + url
|
||||
params = {}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
resp = await self._async_req.get_res(url=req_url, params=params)
|
||||
try:
|
||||
if not resp:
|
||||
return None
|
||||
result = resp.json()
|
||||
return result.get(key) if key else result
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
|
||||
def search(self, name):
|
||||
"""
|
||||
搜索媒体信息
|
||||
@@ -55,6 +72,15 @@ class BangumiApi(object):
|
||||
return result.get("list")
|
||||
return []
|
||||
|
||||
async def async_search(self, name):
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
"""
|
||||
result = await self.__async_invoke("search/subject/%s" % name)
|
||||
if result:
|
||||
return result.get("list")
|
||||
return []
|
||||
|
||||
def calendar(self):
|
||||
"""
|
||||
获取每日放送,返回items
|
||||
@@ -153,12 +179,29 @@ class BangumiApi(object):
|
||||
ret_list.extend(item.get("items") or [])
|
||||
return ret_list
|
||||
|
||||
async def async_calendar(self):
|
||||
"""
|
||||
获取每日放送,返回items(异步版本)
|
||||
"""
|
||||
ret_list = []
|
||||
result = await self.__async_invoke(self._urls["calendar"], _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
if result:
|
||||
for item in result:
|
||||
ret_list.extend(item.get("items") or [])
|
||||
return ret_list
|
||||
|
||||
def detail(self, bid: int):
|
||||
"""
|
||||
获取番剧详情
|
||||
"""
|
||||
return self.__invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
async def async_detail(self, bid: int):
|
||||
"""
|
||||
获取番剧详情(异步版本)
|
||||
"""
|
||||
return await self.__async_invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
def credits(self, bid: int):
|
||||
"""
|
||||
获取番剧人物
|
||||
@@ -175,18 +218,48 @@ class BangumiApi(object):
|
||||
ret_list.append(actor_info)
|
||||
return ret_list
|
||||
|
||||
async def async_credits(self, bid: int):
|
||||
"""
|
||||
获取番剧人物(异步版本)
|
||||
"""
|
||||
ret_list = []
|
||||
result = await self.__async_invoke(self._urls["characters"] % bid,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
if result:
|
||||
for item in result:
|
||||
character_id = item.get("id")
|
||||
actors = item.get("actors")
|
||||
if character_id and actors and actors[0]:
|
||||
actor_info = actors[0]
|
||||
actor_info.update({'career': [item.get('name')]})
|
||||
ret_list.append(actor_info)
|
||||
return ret_list
|
||||
|
||||
def subjects(self, bid: int):
|
||||
"""
|
||||
获取关联条目信息
|
||||
"""
|
||||
return self.__invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
async def async_subjects(self, bid: int):
|
||||
"""
|
||||
获取关联条目信息(异步版本)
|
||||
"""
|
||||
return await self.__async_invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
def person_detail(self, person_id: int):
|
||||
"""
|
||||
获取人物详细信息
|
||||
"""
|
||||
return self.__invoke(self._urls["person_detail"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
async def async_person_detail(self, person_id: int):
|
||||
"""
|
||||
获取人物详细信息(异步版本)
|
||||
"""
|
||||
return await self.__async_invoke(self._urls["person_detail"] % person_id,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
def person_credits(self, person_id: int):
|
||||
"""
|
||||
获取人物参演作品
|
||||
@@ -199,6 +272,18 @@ class BangumiApi(object):
|
||||
ret_list.append(item)
|
||||
return ret_list
|
||||
|
||||
async def async_person_credits(self, person_id: int):
|
||||
"""
|
||||
获取人物参演作品(异步版本)
|
||||
"""
|
||||
ret_list = []
|
||||
result = await self.__async_invoke(self._urls["person_credits"] % person_id,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
if result:
|
||||
for item in result:
|
||||
ret_list.append(item)
|
||||
return ret_list
|
||||
|
||||
def discover(self, **kwargs):
|
||||
"""
|
||||
发现
|
||||
@@ -207,6 +292,14 @@ class BangumiApi(object):
|
||||
key="data",
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs)
|
||||
|
||||
async def async_discover(self, **kwargs):
|
||||
"""
|
||||
发现(异步版本)
|
||||
"""
|
||||
return await self.__async_invoke(self._urls["discover"],
|
||||
key="data",
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs)
|
||||
|
||||
def close(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user