mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-08 22:15:04 +08:00
Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe25f8f48f | ||
|
|
7f59572d8b | ||
|
|
90fc4c6bad | ||
|
|
bcbfe2ccd5 | ||
|
|
bd9a1d7ec7 | ||
|
|
9331ba64d6 | ||
|
|
21e5cb0a03 | ||
|
|
1a8e0c9ecb | ||
|
|
16fc0d31cd | ||
|
|
a622ada58b | ||
|
|
ee9c4948d3 | ||
|
|
cf28e1d963 | ||
|
|
089ec36160 | ||
|
|
04ce774c22 | ||
|
|
99c1422f37 | ||
|
|
b583a60f23 | ||
|
|
7be2910809 | ||
|
|
30de524319 | ||
|
|
c431d5e759 | ||
|
|
184b62b024 | ||
|
|
2751770350 | ||
|
|
75d98aee8e | ||
|
|
48120b9406 | ||
|
|
0e302d7959 | ||
|
|
59cd176f44 | ||
|
|
619f728f09 | ||
|
|
6e8002acc4 | ||
|
|
8a4a6174f7 | ||
|
|
ee6c4823d3 | ||
|
|
14dcb73d06 | ||
|
|
e15107e5ec | ||
|
|
0167a9462e | ||
|
|
7fa1d342ab | ||
|
|
05b9988e1d | ||
|
|
1c09e61219 | ||
|
|
35f0ad7a83 | ||
|
|
7ae1d6763a | ||
|
|
460e859795 | ||
|
|
4b88ec6460 | ||
|
|
27ee13bb7e | ||
|
|
e6cdd337c3 | ||
|
|
7d8dd12131 | ||
|
|
0800e3a136 | ||
|
|
9b0f1a2a04 | ||
|
|
9de3cb0f92 | ||
|
|
c053a8291c | ||
|
|
a0ddfe173b | ||
|
|
17843a7c71 | ||
|
|
324ae5c883 | ||
|
|
ef03989c3f | ||
|
|
63412ddd42 | ||
|
|
30ce32608a | ||
|
|
74799ad096 | ||
|
|
31176f99c8 | ||
|
|
b9439c05ec | ||
|
|
435a04da0c | ||
|
|
0040b266a5 | ||
|
|
645de137f2 | ||
|
|
1883607118 | ||
|
|
4ccae1dac7 | ||
|
|
ff75db310f | ||
|
|
5788520401 | ||
|
|
570dddc120 | ||
|
|
ea31072ae5 | ||
|
|
5eca5a6011 | ||
|
|
67d5357227 | ||
|
|
a0d04ff488 | ||
|
|
f83787508f | ||
|
|
20aba7eb17 | ||
|
|
0cdea3318c | ||
|
|
4dc2c18075 | ||
|
|
74e97abac4 | ||
|
|
b1db95a925 | ||
|
|
9dac9850b6 | ||
|
|
abe091254a | ||
|
|
d2e5367dc6 | ||
|
|
8ccd1f5fe4 | ||
|
|
50bc865dd2 | ||
|
|
74a6ee7066 | ||
|
|
89e76bcb48 | ||
|
|
c55f6baf67 | ||
|
|
ae154489e1 | ||
|
|
fdc79033ce | ||
|
|
9a8aa5e632 | ||
|
|
6b81f3ce5f | ||
|
|
aeaddfe36b | ||
|
|
20c1f30877 | ||
|
|
52ce6ff38e | ||
|
|
c692a3c80e | ||
|
|
491009636a | ||
|
|
ed16ee14ea | ||
|
|
7f2ed09267 | ||
|
|
c0976897ef | ||
|
|
85b55aa924 | ||
|
|
91d0f76783 | ||
|
|
741badf9e6 | ||
|
|
ca1f3ac377 | ||
|
|
e13e1c9ca3 | ||
|
|
06ad042443 | ||
|
|
9d333b855c | ||
|
|
f46e2acd56 | ||
|
|
5ac4d3f4ae | ||
|
|
1614eebc47 | ||
|
|
b50599b71f | ||
|
|
0459025bf8 | ||
|
|
0bd37da8c7 | ||
|
|
da969dde53 | ||
|
|
33fdd6cafa | ||
|
|
2fe68766eb | ||
|
|
205348697c | ||
|
|
9b3533c1da | ||
|
|
c3584e838e | ||
|
|
16d8b3fb58 | ||
|
|
686bbdc16b |
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -56,10 +56,22 @@ jobs:
|
|||||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||||
|
|
||||||
|
- name: Get existing release body
|
||||||
|
id: get_release_body
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
|
||||||
|
jq -r '.body // ""')
|
||||||
|
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$release_body" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Delete Release
|
- name: Delete Release
|
||||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ env.app_version }}
|
tag_name: v${{ env.app_version }}
|
||||||
delete_release: true
|
delete_release: true
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -68,6 +80,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: v${{ env.app_version }}
|
tag_name: v${{ env.app_version }}
|
||||||
name: v${{ env.app_version }}
|
name: v${{ env.app_version }}
|
||||||
|
body: ${{ env.RELEASE_BODY }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
make_latest: false
|
make_latest: false
|
||||||
|
|||||||
@@ -77,5 +77,7 @@ def wallpapers() -> Any:
|
|||||||
return WebUtils.get_bing_wallpapers()
|
return WebUtils.get_bing_wallpapers()
|
||||||
elif settings.WALLPAPER == "mediaserver":
|
elif settings.WALLPAPER == "mediaserver":
|
||||||
return MediaServerChain().get_latest_wallpapers()
|
return MediaServerChain().get_latest_wallpapers()
|
||||||
else:
|
elif settings.WALLPAPER == "tmdb":
|
||||||
return TmdbChain().get_trending_wallpapers()
|
return TmdbChain().get_trending_wallpapers()
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ def seasons(mediaid: Optional[str] = None,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||||
def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: int = None,
|
def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import mimetypes
|
||||||
from typing import Annotated, Any, List, Optional
|
from typing import Annotated, Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from starlette import status
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.command import Command
|
from app.command import Command
|
||||||
@@ -16,7 +19,6 @@ from app.scheduler import Scheduler
|
|||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||||||
|
|
||||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -66,9 +68,13 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
|||||||
try:
|
try:
|
||||||
api["path"] = api_path
|
api["path"] = api_path
|
||||||
allow_anonymous = api.pop("allow_anonymous", False)
|
allow_anonymous = api.pop("allow_anonymous", False)
|
||||||
|
auth_mode = api.pop("auth", "apikey")
|
||||||
dependencies = api.setdefault("dependencies", [])
|
dependencies = api.setdefault("dependencies", [])
|
||||||
if not allow_anonymous and Depends(verify_apikey) not in dependencies:
|
if not allow_anonymous:
|
||||||
dependencies.append(Depends(verify_apikey))
|
if auth_mode == "bear" and Depends(verify_token) not in dependencies:
|
||||||
|
dependencies.append(Depends(verify_token))
|
||||||
|
elif Depends(verify_apikey) not in dependencies:
|
||||||
|
dependencies.append(Depends(verify_apikey))
|
||||||
app.add_api_route(**api, tags=["plugin"])
|
app.add_api_route(**api, tags=["plugin"])
|
||||||
is_modified = True
|
is_modified = True
|
||||||
logger.debug(f"Added plugin route: {api_path}")
|
logger.debug(f"Added plugin route: {api_path}")
|
||||||
@@ -116,6 +122,18 @@ def _clean_protected_routes(existing_paths: dict):
|
|||||||
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def register_plugin(plugin_id: str):
|
||||||
|
"""
|
||||||
|
注册一个插件相关的服务
|
||||||
|
"""
|
||||||
|
# 注册插件服务
|
||||||
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
# 注册菜单命令
|
||||||
|
Command().init_commands(plugin_id)
|
||||||
|
# 注册插件API
|
||||||
|
register_plugin_api(plugin_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||||
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||||
state: Optional[str] = "all") -> List[schemas.Plugin]:
|
state: Optional[str] = "all") -> List[schemas.Plugin]:
|
||||||
@@ -179,6 +197,18 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return PluginHelper().get_statistic()
|
return PluginHelper().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:
|
||||||
|
"""
|
||||||
|
重新加载插件
|
||||||
|
"""
|
||||||
|
# 重新加载插件
|
||||||
|
PluginManager().reload_plugin(plugin_id)
|
||||||
|
# 注册插件服务
|
||||||
|
register_plugin(plugin_id)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||||
def install(plugin_id: str,
|
def install(plugin_id: str,
|
||||||
repo_url: Optional[str] = "",
|
repo_url: Optional[str] = "",
|
||||||
@@ -207,36 +237,65 @@ def install(plugin_id: str,
|
|||||||
install_plugins.append(plugin_id)
|
install_plugins.append(plugin_id)
|
||||||
# 保存设置
|
# 保存设置
|
||||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||||
# 加载插件到内存
|
# 重新加载插件
|
||||||
PluginManager().reload_plugin(plugin_id)
|
reload_plugin(plugin_id)
|
||||||
# 注册插件服务
|
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
|
||||||
# 注册菜单命令
|
|
||||||
Command().init_commands(plugin_id)
|
|
||||||
# 注册插件API
|
|
||||||
register_plugin_api(plugin_id)
|
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
|
||||||
|
def remotes(token: str) -> Any:
|
||||||
|
"""
|
||||||
|
获取插件联邦组件列表
|
||||||
|
"""
|
||||||
|
if token != "moviepilot":
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
return PluginManager().get_plugin_remotes()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||||
def plugin_form(plugin_id: str,
|
def plugin_form(plugin_id: str,
|
||||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件配置表单
|
根据插件ID获取插件配置表单或Vue组件URL
|
||||||
"""
|
"""
|
||||||
conf, model = PluginManager().get_plugin_form(plugin_id)
|
plugin_instance = PluginManager().running_plugins.get(plugin_id)
|
||||||
return {
|
if not plugin_instance:
|
||||||
"conf": conf,
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
|
||||||
"model": model
|
|
||||||
}
|
# 渲染模式
|
||||||
|
render_mode, _ = plugin_instance.get_render_mode()
|
||||||
|
try:
|
||||||
|
conf, model = plugin_instance.get_form()
|
||||||
|
return {
|
||||||
|
"render_mode": render_mode,
|
||||||
|
"conf": conf,
|
||||||
|
"model": PluginManager().get_plugin_config(plugin_id) or model
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
|
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件数据页面
|
根据插件ID获取插件数据页面
|
||||||
"""
|
"""
|
||||||
return PluginManager().get_plugin_page(plugin_id)
|
plugin_instance = PluginManager().running_plugins.get(plugin_id)
|
||||||
|
if not plugin_instance:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
|
||||||
|
|
||||||
|
# 渲染模式
|
||||||
|
render_mode, _ = plugin_instance.get_render_mode()
|
||||||
|
try:
|
||||||
|
page = plugin_instance.get_page()
|
||||||
|
return {
|
||||||
|
"render_mode": render_mode,
|
||||||
|
"page": page or []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||||
@@ -247,22 +306,22 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li
|
|||||||
return PluginManager().get_plugin_dashboard_meta()
|
return PluginManager().get_plugin_dashboard_meta()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||||
|
def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]:
|
||||||
|
"""
|
||||||
|
根据插件ID获取插件仪表板
|
||||||
|
"""
|
||||||
|
return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
||||||
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件仪表板
|
根据插件ID获取插件仪表板
|
||||||
"""
|
"""
|
||||||
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
|
return plugin_dashboard_by_key(plugin_id, "", user_agent)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
|
||||||
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
|
||||||
"""
|
|
||||||
根据插件ID获取插件仪表板
|
|
||||||
"""
|
|
||||||
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||||
@@ -275,17 +334,46 @@ def reset_plugin(plugin_id: str,
|
|||||||
PluginManager().delete_plugin_config(plugin_id)
|
PluginManager().delete_plugin_config(plugin_id)
|
||||||
# 删除插件所有数据
|
# 删除插件所有数据
|
||||||
PluginManager().delete_plugin_data(plugin_id)
|
PluginManager().delete_plugin_data(plugin_id)
|
||||||
# 重新生效插件
|
# 重新加载插件
|
||||||
PluginManager().reload_plugin(plugin_id)
|
reload_plugin(plugin_id)
|
||||||
# 注册插件服务
|
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
|
||||||
# 注册菜单命令
|
|
||||||
Command().init_commands(plugin_id)
|
|
||||||
# 注册插件API
|
|
||||||
register_plugin_api(plugin_id)
|
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
|
||||||
|
def plugin_static_file(plugin_id: str, filepath: str):
|
||||||
|
"""
|
||||||
|
获取插件静态文件
|
||||||
|
"""
|
||||||
|
# 基础安全检查
|
||||||
|
if ".." in filepath or ".." in filepath:
|
||||||
|
logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||||
|
|
||||||
|
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||||
|
plugin_file_path = plugin_base_dir / filepath
|
||||||
|
if not 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():
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件")
|
||||||
|
|
||||||
|
# 判断 MIME 类型
|
||||||
|
response_type, _ = mimetypes.guess_type(str(plugin_file_path))
|
||||||
|
suffix = plugin_file_path.suffix.lower()
|
||||||
|
# 强制修正 .mjs 和 .js 的 MIME 类型
|
||||||
|
if suffix in ['.js', '.mjs']:
|
||||||
|
response_type = 'application/javascript'
|
||||||
|
elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正
|
||||||
|
response_type = 'text/css'
|
||||||
|
elif not response_type: # 对于其他猜不出的类型
|
||||||
|
response_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
try:
|
||||||
|
return FileResponse(plugin_file_path, media_type=response_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating/sending FileResponse for {plugin_file_path}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||||
def plugin_config(plugin_id: str,
|
def plugin_config(plugin_id: str,
|
||||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
@@ -306,11 +394,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
|||||||
# 重新生效插件
|
# 重新生效插件
|
||||||
PluginManager().init_plugin(plugin_id, conf)
|
PluginManager().init_plugin(plugin_id, conf)
|
||||||
# 注册插件服务
|
# 注册插件服务
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
register_plugin(plugin_id)
|
||||||
# 注册菜单命令
|
|
||||||
Command().init_commands(plugin_id)
|
|
||||||
# 注册插件API
|
|
||||||
register_plugin_api(plugin_id)
|
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -335,7 +419,3 @@ def uninstall_plugin(plugin_id: str,
|
|||||||
# 移除插件
|
# 移除插件
|
||||||
PluginManager().remove_plugin(plugin_id)
|
PluginManager().remove_plugin(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
# 注册全部插件API
|
|
||||||
register_plugin_api()
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from starlette.background import BackgroundTasks
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.site import SiteChain
|
from app.chain.site import SiteChain
|
||||||
from app.chain.torrents import TorrentsChain
|
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.plugin import PluginManager
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
@@ -22,6 +23,7 @@ from app.helper.sites import SitesHelper
|
|||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import SystemConfigKey, EventType
|
from app.schemas.types import SystemConfigKey, EventType
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
from startup.plugins_initializer import register_plugin_api
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -385,8 +387,11 @@ def auth_site(
|
|||||||
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
||||||
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
||||||
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
||||||
|
# 认证成功后,重新初始化插件
|
||||||
PluginManager().init_config()
|
PluginManager().init_config()
|
||||||
Scheduler().init_plugin_jobs()
|
Scheduler().init_plugin_jobs()
|
||||||
|
Command().init_commands()
|
||||||
|
register_plugin_api()
|
||||||
return schemas.Response(success=status, message=msg)
|
return schemas.Response(success=status, message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ def save(name: str,
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response)
|
||||||
|
def reset(name: str,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
重置存储配置
|
||||||
|
"""
|
||||||
|
StorageChain().reset_config(name)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
||||||
def list_files(fileitem: schemas.FileItem,
|
def list_files(fileitem: schemas.FileItem,
|
||||||
sort: Optional[str] = 'updated_at',
|
sort: Optional[str] = 'updated_at',
|
||||||
|
|||||||
@@ -171,10 +171,13 @@ def cache_img(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||||
def get_global_setting():
|
def get_global_setting(token: str):
|
||||||
"""
|
"""
|
||||||
查询非敏感系统设置(无需鉴权)
|
查询非敏感系统设置(默认鉴权)
|
||||||
"""
|
"""
|
||||||
|
if token != "moviepilot":
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
# FIXME: 新增敏感配置项时要在此处添加排除项
|
# FIXME: 新增敏感配置项时要在此处添加排除项
|
||||||
info = settings.dict(
|
info = settings.dict(
|
||||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
||||||
|
|||||||
@@ -518,32 +518,33 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db:
|
|||||||
"""
|
"""
|
||||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||||
"""
|
"""
|
||||||
|
# 季信息
|
||||||
|
seas: List[int] = []
|
||||||
|
|
||||||
# 获取TVDBID
|
# 获取TVDBID
|
||||||
if not term.startswith("tvdb:"):
|
if not term.startswith("tvdb:"):
|
||||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||||
mtype=MediaType.TV)
|
mtype=MediaType.TV)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
return [SonarrSeries()]
|
return [SonarrSeries()]
|
||||||
tvdbid = mediainfo.tvdb_id
|
|
||||||
if not tvdbid:
|
# 季信息
|
||||||
return [SonarrSeries()]
|
if mediainfo.seasons:
|
||||||
|
seas = list(mediainfo.seasons)
|
||||||
else:
|
else:
|
||||||
mediainfo = None
|
|
||||||
tvdbid = int(term.replace("tvdb:", ""))
|
tvdbid = int(term.replace("tvdb:", ""))
|
||||||
|
|
||||||
# 查询TVDB信息
|
# 查询TVDB信息
|
||||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||||
if not tvdbinfo:
|
if not tvdbinfo:
|
||||||
return [SonarrSeries()]
|
return [SonarrSeries()]
|
||||||
|
|
||||||
# 季信息
|
# 季信息
|
||||||
seas: List[int] = []
|
sea_num = tvdbinfo.get('season')
|
||||||
sea_num = tvdbinfo.get('season')
|
if sea_num:
|
||||||
if sea_num:
|
seas = list(range(1, int(sea_num) + 1))
|
||||||
seas = list(range(1, int(sea_num) + 1))
|
|
||||||
|
|
||||||
# 根据TVDB查询媒体信息
|
# 根据TVDB查询媒体信息
|
||||||
if not mediainfo:
|
|
||||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||||
mtype=MediaType.TV)
|
mtype=MediaType.TV)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import gc
|
|||||||
import pickle
|
import pickle
|
||||||
import traceback
|
import traceback
|
||||||
from abc import ABCMeta
|
from abc import ABCMeta
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Any, Tuple, List, Set, Union, Dict
|
from typing import Optional, Any, Tuple, List, Set, Union, Dict
|
||||||
|
|
||||||
@@ -14,9 +15,10 @@ from app.core.context import Context, MediaInfo, TorrentInfo
|
|||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
from app.db.message_oper import MessageOper
|
from app.db.message_oper import MessageOper
|
||||||
from app.db.user_oper import UserOper
|
from app.db.user_oper import UserOper
|
||||||
from app.helper.message import MessageHelper, MessageQueueManager
|
from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper
|
||||||
from app.helper.service import ServiceConfigHelper
|
from app.helper.service import ServiceConfigHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||||
@@ -42,6 +44,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
send_callback=self.run_module
|
send_callback=self.run_module
|
||||||
)
|
)
|
||||||
self.useroper = UserOper()
|
self.useroper = UserOper()
|
||||||
|
self.pluginmanager = PluginManager()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_cache(filename: str) -> Any:
|
def load_cache(filename: str) -> Any:
|
||||||
@@ -97,7 +100,50 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
return ret is None
|
return ret is None
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
logger.debug(f"请求模块执行:{method} ...")
|
plugin_modules = self.pluginmanager.get_plugin_modules()
|
||||||
|
# 插件模块
|
||||||
|
for plugin, module_dict in 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 is_result_empty(result):
|
||||||
|
# 返回None,第一次执行或者需继续执行下一模块
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
elif isinstance(result, list):
|
||||||
|
# 返回为列表,有多个模块运行结果时进行合并
|
||||||
|
temp = func(*args, **kwargs)
|
||||||
|
if isinstance(temp, list):
|
||||||
|
result.extend(temp)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 系统模块
|
||||||
|
logger.debug(f"请求系统模块执行:{method} ...")
|
||||||
modules = self.modulemanager.get_running_modules(method)
|
modules = self.modulemanager.get_running_modules(method)
|
||||||
# 按优先级排序
|
# 按优先级排序
|
||||||
modules = sorted(modules, key=lambda x: x.get_priority())
|
modules = sorted(modules, key=lambda x: x.get_priority())
|
||||||
@@ -114,10 +160,10 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 返回None,第一次执行或者需继续执行下一模块
|
# 返回None,第一次执行或者需继续执行下一模块
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
elif ObjectUtils.check_signature(func, result):
|
elif ObjectUtils.check_signature(func, result):
|
||||||
# 返回结果与方法签名一致,将结果传入(不能多个模块同时运行的需要通过开关控制)
|
# 返回结果与方法签名一致,将结果传入
|
||||||
result = func(result)
|
result = func(result)
|
||||||
elif isinstance(result, list):
|
elif isinstance(result, list):
|
||||||
# 返回为列表,有多个模块运行结果时进行合并(不能多个模块同时运行的需要通过开关控制)
|
# 返回为列表,有多个模块运行结果时进行合并
|
||||||
temp = func(*args, **kwargs)
|
temp = func(*args, **kwargs)
|
||||||
if isinstance(temp, list):
|
if isinstance(temp, list):
|
||||||
result.extend(temp)
|
result.extend(temp)
|
||||||
@@ -401,7 +447,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
target_storage: Optional[str] = None, target_path: Path = None,
|
target_storage: Optional[str] = None, target_path: Path = None,
|
||||||
transfer_type: Optional[str] = None, scrape: bool = None,
|
transfer_type: Optional[str] = None, scrape: bool = None,
|
||||||
library_type_folder: bool = None, library_category_folder: bool = None,
|
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
episodes_info: List[TmdbEpisode] = None,
|
||||||
|
source_oper: Callable = None, target_oper: Callable = None) -> Optional[TransferInfo]:
|
||||||
"""
|
"""
|
||||||
文件转移
|
文件转移
|
||||||
:param fileitem: 文件信息
|
:param fileitem: 文件信息
|
||||||
@@ -415,6 +462,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param library_type_folder: 是否按类型创建目录
|
:param library_type_folder: 是否按类型创建目录
|
||||||
:param library_category_folder: 是否按类别创建目录
|
:param library_category_folder: 是否按类别创建目录
|
||||||
:param episodes_info: 当前季的全部集信息
|
:param episodes_info: 当前季的全部集信息
|
||||||
|
:param source_oper: 源存储操作类
|
||||||
|
:param target_oper: 目标存储操作类
|
||||||
:return: {path, target_path, message}
|
:return: {path, target_path, message}
|
||||||
"""
|
"""
|
||||||
return self.run_module("transfer",
|
return self.run_module("transfer",
|
||||||
@@ -424,7 +473,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
transfer_type=transfer_type, scrape=scrape,
|
transfer_type=transfer_type, scrape=scrape,
|
||||||
library_type_folder=library_type_folder,
|
library_type_folder=library_type_folder,
|
||||||
library_category_folder=library_category_folder,
|
library_category_folder=library_category_folder,
|
||||||
episodes_info=episodes_info)
|
episodes_info=episodes_info,
|
||||||
|
source_oper=source_oper, target_oper=target_oper)
|
||||||
|
|
||||||
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
|
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -492,13 +542,27 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("media_files", mediainfo=mediainfo)
|
return self.run_module("media_files", mediainfo=mediainfo)
|
||||||
|
|
||||||
def post_message(self, message: Notification) -> None:
|
def 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: 消息体
|
:param message: Notification实例
|
||||||
|
:param meta: 元数据
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
|
:param torrentinfo: 种子信息
|
||||||
|
:param transferinfo: 文件整理信息
|
||||||
|
:param kwargs: 其他参数(覆盖业务对象属性值)
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
# 保存原消息
|
# 渲染消息
|
||||||
|
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
|
||||||
|
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
|
||||||
|
# 保存消息
|
||||||
self.messagehelper.put(message, role="user", title=message.title)
|
self.messagehelper.put(message, role="user", title=message.title)
|
||||||
self.messageoper.add(**message.dict())
|
self.messageoper.add(**message.dict())
|
||||||
# 发送消息按设置隔离
|
# 发送消息按设置隔离
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.helper.message import MessageHelper
|
|||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData
|
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData
|
||||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType
|
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, ChainEventType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
@@ -38,63 +38,6 @@ class DownloadChain(ChainBase):
|
|||||||
self.directoryhelper = DirectoryHelper()
|
self.directoryhelper = DirectoryHelper()
|
||||||
self.messagehelper = MessageHelper()
|
self.messagehelper = MessageHelper()
|
||||||
|
|
||||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
|
||||||
channel: MessageChannel = None, username: Optional[str] = None,
|
|
||||||
download_episodes: Optional[str] = None):
|
|
||||||
"""
|
|
||||||
发送添加下载的消息,根据消息场景开关决定发给谁
|
|
||||||
:param meta: 元数据
|
|
||||||
:param mediainfo: 媒体信息
|
|
||||||
:param torrent: 种子信息
|
|
||||||
:param channel: 通知渠道
|
|
||||||
:param username: 通知显示的下载用户信息
|
|
||||||
:param download_episodes: 下载的集数
|
|
||||||
"""
|
|
||||||
# 拼装消息内容
|
|
||||||
msg_text = ""
|
|
||||||
if username:
|
|
||||||
msg_text = f"用户:{username}"
|
|
||||||
if torrent.site_name:
|
|
||||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
|
||||||
if meta.resource_term:
|
|
||||||
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
|
|
||||||
if torrent.size:
|
|
||||||
if str(torrent.size).replace(".", "").isdigit():
|
|
||||||
size = StringUtils.str_filesize(torrent.size)
|
|
||||||
else:
|
|
||||||
size = torrent.size
|
|
||||||
msg_text = f"{msg_text}\n大小:{size}"
|
|
||||||
if torrent.title:
|
|
||||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
|
||||||
if torrent.pubdate:
|
|
||||||
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
|
|
||||||
if torrent.freedate:
|
|
||||||
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
|
|
||||||
if torrent.seeders:
|
|
||||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
|
||||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
|
||||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
|
||||||
if torrent.hit_and_run:
|
|
||||||
msg_text = f"{msg_text}\nHit&Run:是"
|
|
||||||
if torrent.labels:
|
|
||||||
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
|
|
||||||
if torrent.description:
|
|
||||||
html_re = re.compile(r'<[^>]+>', re.S)
|
|
||||||
description = html_re.sub('', torrent.description)
|
|
||||||
torrent.description = re.sub(r'<[^>]+>', '', description)
|
|
||||||
msg_text = f"{msg_text}\n描述:{torrent.description}"
|
|
||||||
|
|
||||||
# 下载成功按规则发送消息
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel,
|
|
||||||
mtype=NotificationType.Download,
|
|
||||||
title=f"{mediainfo.title_year} "
|
|
||||||
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
|
|
||||||
text=msg_text,
|
|
||||||
image=mediainfo.get_message_image(),
|
|
||||||
link=settings.MP_DOMAIN('/#/downloading'),
|
|
||||||
username=username))
|
|
||||||
|
|
||||||
def download_torrent(self, torrent: TorrentInfo,
|
def download_torrent(self, torrent: TorrentInfo,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
@@ -384,8 +327,21 @@ class DownloadChain(ChainBase):
|
|||||||
self.downloadhis.add_files(files_to_add)
|
self.downloadhis.add_files(files_to_add)
|
||||||
|
|
||||||
# 下载成功发送消息
|
# 下载成功发送消息
|
||||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
self.post_message(
|
||||||
username=username, download_episodes=download_episodes)
|
Notification(
|
||||||
|
channel=channel,
|
||||||
|
mtype=NotificationType.Download,
|
||||||
|
ctype=ContentType.DownloadAdded,
|
||||||
|
image=_media.get_message_image(),
|
||||||
|
link=settings.MP_DOMAIN('/#/downloading'),
|
||||||
|
username=username
|
||||||
|
),
|
||||||
|
meta=_meta,
|
||||||
|
mediainfo=_media,
|
||||||
|
torrentinfo=_torrent,
|
||||||
|
download_episodes=download_episodes,
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
# 下载成功后处理
|
# 下载成功后处理
|
||||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||||
# 广播事件
|
# 广播事件
|
||||||
|
|||||||
@@ -449,23 +449,19 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
# 生成目录内图片文件
|
# 生成目录内图片文件
|
||||||
if init_folder:
|
if init_folder:
|
||||||
# 图片
|
# 图片
|
||||||
for attr_name, attr_value in vars(mediainfo).items():
|
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||||
if attr_value \
|
if image_dict:
|
||||||
and attr_name.endswith("_path") \
|
for image_name, image_url in image_dict.items():
|
||||||
and attr_value \
|
image_path = filepath.with_name(image_name)
|
||||||
and isinstance(attr_value, str) \
|
|
||||||
and attr_value.startswith("http"):
|
|
||||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
|
||||||
image_path = filepath / image_name
|
|
||||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
path=image_path):
|
path=image_path):
|
||||||
# 下载图片
|
# 下载图片
|
||||||
content = __download_image(_url=attr_value)
|
content = __download_image(image_url)
|
||||||
# 写入图片到当前目录
|
# 写入图片到当前目录
|
||||||
if content:
|
if content:
|
||||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
else:
|
else:
|
||||||
logger.info(f"已存在图片文件:{image_path}")
|
logger.info(f"已存在图片文件:{image_path}")
|
||||||
else:
|
else:
|
||||||
# 电视剧
|
# 电视剧
|
||||||
if fileitem.type == "file":
|
if fileitem.type == "file":
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class MessageChain(ChainBase):
|
|||||||
userid = info.userid
|
userid = info.userid
|
||||||
# 用户名
|
# 用户名
|
||||||
username = info.username or userid
|
username = info.username or userid
|
||||||
if not userid:
|
if userid is None or userid == '':
|
||||||
logger.debug(f'未识别到用户ID:{body}{form}{args}')
|
logger.debug(f'未识别到用户ID:{body}{form}{args}')
|
||||||
return
|
return
|
||||||
# 消息内容
|
# 消息内容
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
self.run_module("save_config", storage=storage, conf=conf)
|
self.run_module("save_config", storage=storage, conf=conf)
|
||||||
|
|
||||||
|
def reset_config(self, storage: str) -> None:
|
||||||
|
"""
|
||||||
|
重置存储配置
|
||||||
|
"""
|
||||||
|
self.run_module("reset_config", storage=storage)
|
||||||
|
|
||||||
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||||
"""
|
"""
|
||||||
生成二维码
|
生成二维码
|
||||||
@@ -131,28 +137,43 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
删除媒体文件,以及不含媒体文件的目录
|
删除媒体文件,以及不含媒体文件的目录
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __is_bluray_dir(_fileitem: schemas.FileItem) -> bool:
|
||||||
|
"""
|
||||||
|
检查是否蓝光目录
|
||||||
|
"""
|
||||||
|
_dir_files = self.list_files(fileitem=_fileitem, recursion=False)
|
||||||
|
if _dir_files:
|
||||||
|
for _f in _dir_files:
|
||||||
|
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
|
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
|
||||||
if fileitem.path == "/" or len(Path(fileitem.path).parts) <= 2:
|
if fileitem.path == "/" or len(Path(fileitem.path).parts) <= 2:
|
||||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
||||||
return False
|
return False
|
||||||
if fileitem.type == "dir":
|
if fileitem.type == "dir":
|
||||||
# 本身是目录
|
# 本身是目录
|
||||||
if _blue_dir := self.list_files(fileitem=fileitem, recursion=False):
|
if __is_bluray_dir(fileitem):
|
||||||
# 删除蓝光目录
|
logger.warn(f"正在删除蓝光原盘目录:【{fileitem.storage}】{fileitem.path}")
|
||||||
for _f in _blue_dir:
|
if not self.delete_file(fileitem):
|
||||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||||
logger.warn(f"【{fileitem.storage}】{_f.path} 删除蓝光目录")
|
return False
|
||||||
self.delete_file(_f)
|
elif self.any_files(fileitem, extensions=media_exts) is False:
|
||||||
if self.any_files(fileitem, extensions=media_exts) is False:
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,正在删除空目录")
|
||||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
if not self.delete_file(fileitem):
|
||||||
return self.delete_file(fileitem)
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||||
return False
|
return False
|
||||||
|
# 不处理父目录
|
||||||
|
return True
|
||||||
elif delete_self:
|
elif delete_self:
|
||||||
# 本身是文件
|
# 本身是文件,需要删除文件
|
||||||
logger.warn(f"正在删除【{fileitem.storage}】{fileitem.path}")
|
logger.warn(f"正在删除文件【{fileitem.storage}】{fileitem.path}")
|
||||||
if not self.delete_file(fileitem):
|
if not self.delete_file(fileitem):
|
||||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if mtype:
|
if mtype:
|
||||||
# 重命名格式
|
# 重命名格式
|
||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
@@ -161,11 +182,14 @@ class StorageChain(ChainBase):
|
|||||||
rename_format_level = len(rename_format.split("/")) - 1
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
if rename_format_level < 1:
|
if rename_format_level < 1:
|
||||||
return True
|
return True
|
||||||
# 处理上级目录
|
# 处理媒体文件根目录
|
||||||
dir_item = self.get_file_item(storage=fileitem.storage,
|
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||||
path=Path(fileitem.path).parents[rename_format_level - 1])
|
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||||
else:
|
else:
|
||||||
|
# 处理上级目录
|
||||||
dir_item = self.get_parent_item(fileitem)
|
dir_item = self.get_parent_item(fileitem)
|
||||||
|
|
||||||
|
# 检查和删除上级目录
|
||||||
if dir_item and len(Path(dir_item.path).parts) > 2:
|
if dir_item and len(Path(dir_item.path).parts) > 2:
|
||||||
# 如何目录是所有下载目录、媒体库目录的上级,则不处理
|
# 如何目录是所有下载目录、媒体库目录的上级,则不处理
|
||||||
for d in self.directoryhelper.get_dirs():
|
for d in self.directoryhelper.get_dirs():
|
||||||
@@ -177,7 +201,9 @@ class StorageChain(ChainBase):
|
|||||||
return True
|
return True
|
||||||
# 不存在其他媒体文件,删除空目录
|
# 不存在其他媒体文件,删除空目录
|
||||||
if self.any_files(dir_item, extensions=media_exts) is False:
|
if self.any_files(dir_item, extensions=media_exts) is False:
|
||||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,删除空目录")
|
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,正在删除空目录")
|
||||||
return self.delete_file(dir_item)
|
if not self.delete_file(dir_item):
|
||||||
|
logger.warn(f"【{dir_item.storage}】{dir_item.path} 删除失败")
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from app.helper.subscribe import SubscribeHelper
|
|||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MediaRecognizeConvertEventData
|
from app.schemas import MediaRecognizeConvertEventData
|
||||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType
|
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, ContentType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
@@ -228,22 +228,23 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
userid=userid))
|
userid=userid))
|
||||||
return None, err_msg
|
return None, err_msg
|
||||||
elif message:
|
elif message:
|
||||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
|
||||||
if username:
|
|
||||||
text = f"评分:{mediainfo.vote_average},来自用户:{username}"
|
|
||||||
else:
|
|
||||||
text = f"评分:{mediainfo.vote_average}"
|
|
||||||
if mediainfo.type == MediaType.TV:
|
if mediainfo.type == MediaType.TV:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
# 订阅成功按规则发送消息
|
# 订阅成功按规则发送消息
|
||||||
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
self.post_message(
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
schemas.Notification(
|
||||||
text=text,
|
mtype=NotificationType.Subscribe,
|
||||||
image=mediainfo.get_message_image(),
|
ctype=ContentType.SubscribeAdded,
|
||||||
link=link,
|
image=mediainfo.get_message_image(),
|
||||||
username=username))
|
link=link,
|
||||||
|
username=username
|
||||||
|
),
|
||||||
|
meta=metainfo,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
username=username
|
||||||
|
)
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeAdded, {
|
EventManager().send_event(EventType.SubscribeAdded, {
|
||||||
"subscribe_id": sid,
|
"subscribe_id": sid,
|
||||||
@@ -1013,11 +1014,19 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
# 完成订阅按规则发送消息
|
# 完成订阅按规则发送消息
|
||||||
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
self.post_message(
|
||||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
schemas.Notification(
|
||||||
image=mediainfo.get_message_image(),
|
mtype=NotificationType.Subscribe,
|
||||||
link=link,
|
ctype=ContentType.SubscribeComplete,
|
||||||
username=subscribe.username))
|
image=mediainfo.get_message_image(),
|
||||||
|
link=link,
|
||||||
|
username=subscribe.username
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
msgstr=msgstr,
|
||||||
|
username=subscribe.username
|
||||||
|
)
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeComplete, {
|
EventManager().send_event(EventType.SubscribeComplete, {
|
||||||
"subscribe_id": subscribe.id,
|
"subscribe_id": subscribe.id,
|
||||||
|
|||||||
69
app/chain/transfer.py
Normal file → Executable file
69
app/chain/transfer.py
Normal file → Executable file
@@ -17,6 +17,7 @@ from app.core.config import settings, global_vars
|
|||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfoPath
|
from app.core.metainfo import MetaInfoPath
|
||||||
|
from app.core.event import eventmanager
|
||||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||||
from app.db.models.downloadhistory import DownloadHistory
|
from app.db.models.downloadhistory import DownloadHistory
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
@@ -29,7 +30,8 @@ from app.log import logger
|
|||||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
||||||
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
||||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
||||||
SystemConfigKey
|
SystemConfigKey, ChainEventType, ContentType
|
||||||
|
from app.schemas import StorageOperSelectionEventData
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
@@ -699,10 +701,36 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
storage=task.fileitem.storage,
|
storage=task.fileitem.storage,
|
||||||
src_path=Path(task.fileitem.path),
|
src_path=Path(task.fileitem.path),
|
||||||
target_storage=task.target_storage)
|
target_storage=task.target_storage)
|
||||||
|
if not task.target_storage and task.target_directory:
|
||||||
|
task.target_storage = task.target_directory.library_storage
|
||||||
|
|
||||||
# 正在处理
|
# 正在处理
|
||||||
self.jobview.running_task(task)
|
self.jobview.running_task(task)
|
||||||
|
|
||||||
|
# 广播事件,请示额外的源存储支持
|
||||||
|
source_oper = None
|
||||||
|
source_event_data = StorageOperSelectionEventData(
|
||||||
|
storage=task.fileitem.storage,
|
||||||
|
)
|
||||||
|
source_event = eventmanager.send_event(ChainEventType.StorageOperSelection, source_event_data)
|
||||||
|
# 使用事件返回的上下文数据
|
||||||
|
if source_event and source_event.event_data:
|
||||||
|
source_event_data: StorageOperSelectionEventData = source_event.event_data
|
||||||
|
if source_event_data.storage_oper:
|
||||||
|
source_oper = source_event_data.storage_oper
|
||||||
|
|
||||||
|
# 广播事件,请示额外的目标存储支持
|
||||||
|
target_oper = None
|
||||||
|
target_event_data = StorageOperSelectionEventData(
|
||||||
|
storage=task.target_storage,
|
||||||
|
)
|
||||||
|
target_event = eventmanager.send_event(ChainEventType.StorageOperSelection, target_event_data)
|
||||||
|
# 使用事件返回的上下文数据
|
||||||
|
if target_event and target_event.event_data:
|
||||||
|
target_event_data: StorageOperSelectionEventData = target_event.event_data
|
||||||
|
if target_event_data.storage_oper:
|
||||||
|
target_oper = target_event_data.storage_oper
|
||||||
|
|
||||||
# 执行整理
|
# 执行整理
|
||||||
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
||||||
meta=task.meta,
|
meta=task.meta,
|
||||||
@@ -714,7 +742,9 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
episodes_info=task.episodes_info,
|
episodes_info=task.episodes_info,
|
||||||
scrape=task.scrape,
|
scrape=task.scrape,
|
||||||
library_type_folder=task.library_type_folder,
|
library_type_folder=task.library_type_folder,
|
||||||
library_category_folder=task.library_category_folder)
|
library_category_folder=task.library_category_folder,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper)
|
||||||
if not transferinfo:
|
if not transferinfo:
|
||||||
logger.error("文件整理模块运行失败")
|
logger.error("文件整理模块运行失败")
|
||||||
return False, "文件整理模块运行失败"
|
return False, "文件整理模块运行失败"
|
||||||
@@ -830,7 +860,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
# 设置下载任务状态
|
# 设置下载任务状态
|
||||||
if state:
|
if state:
|
||||||
self.transfer_completed(hashs=torrent.hash)
|
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
|
||||||
|
|
||||||
# 结束
|
# 结束
|
||||||
logger.info("所有下载器中下载完成的文件已整理完成")
|
logger.info("所有下载器中下载完成的文件已整理完成")
|
||||||
@@ -1344,22 +1374,17 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
发送入库成功的消息
|
发送入库成功的消息
|
||||||
"""
|
"""
|
||||||
msg_title = f"{mediainfo.title_year} {meta.season_episode if not season_episode else season_episode} 已入库"
|
self.post_message(
|
||||||
if mediainfo.vote_average:
|
Notification(
|
||||||
msg_str = f"评分:{mediainfo.vote_average},类型:{mediainfo.type.value}"
|
mtype=NotificationType.Organize,
|
||||||
else:
|
ctype=ContentType.OrganizeSuccess,
|
||||||
msg_str = f"类型:{mediainfo.type.value}"
|
image=mediainfo.get_message_image(),
|
||||||
if mediainfo.category:
|
username=username,
|
||||||
msg_str = f"{msg_str},类别:{mediainfo.category}"
|
link=settings.MP_DOMAIN('#/history')
|
||||||
if meta.resource_term:
|
),
|
||||||
msg_str = f"{msg_str},质量:{meta.resource_term}"
|
meta=meta,
|
||||||
msg_str = f"{msg_str},共{transferinfo.file_count}个文件," \
|
mediainfo=mediainfo,
|
||||||
f"大小:{StringUtils.str_filesize(transferinfo.total_size)}"
|
transferinfo=transferinfo,
|
||||||
if transferinfo.message:
|
season_episode=season_episode,
|
||||||
msg_str = f"{msg_str},以下文件处理失败:\n{transferinfo.message}"
|
username=username
|
||||||
# 发送
|
)
|
||||||
self.post_message(Notification(
|
|
||||||
mtype=NotificationType.Organize,
|
|
||||||
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
|
|
||||||
username=username,
|
|
||||||
link=settings.MP_DOMAIN('#/history')))
|
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class ConfigModel(BaseModel):
|
|||||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||||
# TMDB API地址
|
# TMDB API地址
|
||||||
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
||||||
|
# TMDB元数据语言
|
||||||
|
TMDB_LOCALE: str = "zh"
|
||||||
|
# 刮削使用TMDB原始语种图片
|
||||||
|
TMDB_SCRAP_ORIGINAL_IMAGE: bool = False
|
||||||
# TMDB API Key
|
# TMDB API Key
|
||||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||||
# TVDB API Key
|
# TVDB API Key
|
||||||
@@ -213,7 +217,8 @@ class ConfigModel(BaseModel):
|
|||||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||||
"https://github.com/honue/MoviePilot-Plugins,"
|
"https://github.com/honue/MoviePilot-Plugins,"
|
||||||
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
||||||
"https://github.com/DDS-Derek/MoviePilot-Plugins")
|
"https://github.com/DDS-Derek/MoviePilot-Plugins,"
|
||||||
|
"https://github.com/madrays/MoviePilot-Plugins")
|
||||||
# 插件安装数据共享
|
# 插件安装数据共享
|
||||||
PLUGIN_STATISTIC_SHARE: bool = True
|
PLUGIN_STATISTIC_SHARE: bool = True
|
||||||
# 是否开启插件热加载
|
# 是否开启插件热加载
|
||||||
@@ -238,6 +243,7 @@ class ConfigModel(BaseModel):
|
|||||||
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
||||||
default_factory=lambda: ["image.tmdb.org",
|
default_factory=lambda: ["image.tmdb.org",
|
||||||
"static-mdb.v.geilijiasu.com",
|
"static-mdb.v.geilijiasu.com",
|
||||||
|
"bing.com",
|
||||||
"doubanio.com",
|
"doubanio.com",
|
||||||
"lain.bgm.tv",
|
"lain.bgm.tv",
|
||||||
"raw.githubusercontent.com",
|
"raw.githubusercontent.com",
|
||||||
@@ -547,6 +553,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
|||||||
return {
|
return {
|
||||||
"server": self.PROXY_HOST
|
"server": self.PROXY_HOST
|
||||||
}
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def GITHUB_HEADERS(self):
|
def GITHUB_HEADERS(self):
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette import status
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
@@ -202,24 +204,35 @@ class PluginManager(metaclass=Singleton):
|
|||||||
# 停止插件
|
# 停止插件
|
||||||
if pid:
|
if pid:
|
||||||
logger.info(f"正在停止插件 {pid}...")
|
logger.info(f"正在停止插件 {pid}...")
|
||||||
|
plugin_obj = self._running_plugins.get(pid)
|
||||||
|
if not plugin_obj:
|
||||||
|
logger.warning(f"插件 {pid} 不存在或未加载")
|
||||||
|
return
|
||||||
|
plugins = {pid: plugin_obj}
|
||||||
else:
|
else:
|
||||||
logger.info("正在停止所有插件...")
|
logger.info("正在停止所有插件...")
|
||||||
for plugin_id, plugin in self._running_plugins.items():
|
plugins = self._running_plugins
|
||||||
if pid and plugin_id != pid:
|
for plugin_id, plugin in plugins.items():
|
||||||
continue
|
|
||||||
eventmanager.disable_event_handler(type(plugin))
|
eventmanager.disable_event_handler(type(plugin))
|
||||||
self.__stop_plugin(plugin)
|
self.__stop_plugin(plugin)
|
||||||
# 清空对像
|
# 清空对像
|
||||||
if pid:
|
if pid:
|
||||||
# 清空指定插件
|
# 清空指定插件
|
||||||
if pid in self._running_plugins:
|
self._running_plugins.pop(pid, None)
|
||||||
self._running_plugins.pop(pid)
|
|
||||||
else:
|
else:
|
||||||
# 清空
|
# 清空
|
||||||
self._plugins = {}
|
self._plugins = {}
|
||||||
self._running_plugins = {}
|
self._running_plugins = {}
|
||||||
logger.info("插件停止完成")
|
logger.info("插件停止完成")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running_plugins(self):
|
||||||
|
"""
|
||||||
|
获取运行态插件列表
|
||||||
|
:return: 运行态插件列表
|
||||||
|
"""
|
||||||
|
return self._running_plugins
|
||||||
|
|
||||||
def reload_monitor(self):
|
def reload_monitor(self):
|
||||||
"""
|
"""
|
||||||
重新加载插件文件修改监测
|
重新加载插件文件修改监测
|
||||||
@@ -407,68 +420,6 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self.plugindata.del_data(pid)
|
self.plugindata.del_data(pid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取插件表单
|
|
||||||
:param pid: 插件ID
|
|
||||||
"""
|
|
||||||
plugin = self._running_plugins.get(pid)
|
|
||||||
if not plugin:
|
|
||||||
return [], {}
|
|
||||||
if hasattr(plugin, "get_form"):
|
|
||||||
return plugin.get_form() or ([], {})
|
|
||||||
return [], {}
|
|
||||||
|
|
||||||
def get_plugin_page(self, pid: str) -> List[dict]:
|
|
||||||
"""
|
|
||||||
获取插件页面
|
|
||||||
:param pid: 插件ID
|
|
||||||
"""
|
|
||||||
plugin = self._running_plugins.get(pid)
|
|
||||||
if not plugin:
|
|
||||||
return []
|
|
||||||
if hasattr(plugin, "get_page"):
|
|
||||||
return plugin.get_page() or []
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_plugin_dashboard(self, pid: str, key: Optional[str] = None, **kwargs) -> Optional[schemas.PluginDashboard]:
|
|
||||||
"""
|
|
||||||
获取插件仪表盘
|
|
||||||
:param pid: 插件ID
|
|
||||||
:param key: 仪表盘key
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __get_params_count(func: Callable):
|
|
||||||
"""
|
|
||||||
获取函数的参数信息
|
|
||||||
"""
|
|
||||||
signature = inspect.signature(func)
|
|
||||||
return len(signature.parameters)
|
|
||||||
|
|
||||||
plugin = self._running_plugins.get(pid)
|
|
||||||
if not plugin:
|
|
||||||
return None
|
|
||||||
if hasattr(plugin, "get_dashboard"):
|
|
||||||
# 检查方法的参数个数
|
|
||||||
params_count = __get_params_count(plugin.get_dashboard)
|
|
||||||
if params_count > 1:
|
|
||||||
dashboard: Tuple = plugin.get_dashboard(key=key, **kwargs)
|
|
||||||
elif params_count > 0:
|
|
||||||
dashboard: Tuple = plugin.get_dashboard(**kwargs)
|
|
||||||
else:
|
|
||||||
dashboard: Tuple = plugin.get_dashboard()
|
|
||||||
if dashboard:
|
|
||||||
cols, attrs, elements = dashboard
|
|
||||||
return schemas.PluginDashboard(
|
|
||||||
id=pid,
|
|
||||||
name=plugin.plugin_name,
|
|
||||||
key=key or "",
|
|
||||||
cols=cols or {},
|
|
||||||
elements=elements,
|
|
||||||
attrs=attrs or {}
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_plugin_state(self, pid: str) -> bool:
|
def get_plugin_state(self, pid: str) -> bool:
|
||||||
"""
|
"""
|
||||||
获取插件状态
|
获取插件状态
|
||||||
@@ -517,16 +468,20 @@ class PluginManager(metaclass=Singleton):
|
|||||||
}]
|
}]
|
||||||
"""
|
"""
|
||||||
ret_apis = []
|
ret_apis = []
|
||||||
for plugin_id, plugin in self._running_plugins.items():
|
if pid:
|
||||||
|
plugins = {pid: self._running_plugins.get(pid)}
|
||||||
|
else:
|
||||||
|
plugins = self._running_plugins
|
||||||
|
for plugin_id, plugin in plugins.items():
|
||||||
if pid and pid != plugin_id:
|
if pid and pid != plugin_id:
|
||||||
continue
|
continue
|
||||||
if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api):
|
if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api):
|
||||||
try:
|
try:
|
||||||
if not plugin.get_state():
|
|
||||||
continue
|
|
||||||
apis = plugin.get_api() or []
|
apis = plugin.get_api() or []
|
||||||
for api in apis:
|
for api in apis:
|
||||||
api["path"] = f"/{plugin_id}{api['path']}"
|
api["path"] = f"/{plugin_id}{api['path']}"
|
||||||
|
if not api.get("auth"):
|
||||||
|
api["auth"] = "apikey"
|
||||||
ret_apis.extend(apis)
|
ret_apis.extend(apis)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取插件 {plugin_id} API出错:{str(e)}")
|
logger.error(f"获取插件 {plugin_id} API出错:{str(e)}")
|
||||||
@@ -558,7 +513,63 @@ class PluginManager(metaclass=Singleton):
|
|||||||
logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}")
|
logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}")
|
||||||
return ret_services
|
return ret_services
|
||||||
|
|
||||||
def get_plugin_dashboard_meta(self):
|
def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取插件模块
|
||||||
|
{
|
||||||
|
plugin_id: {
|
||||||
|
method: function
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
ret_modules = {}
|
||||||
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
|
if pid and pid != plugin_id:
|
||||||
|
continue
|
||||||
|
if hasattr(plugin, "get_module") and ObjectUtils.check_method(plugin.get_module):
|
||||||
|
try:
|
||||||
|
if not plugin.get_state():
|
||||||
|
continue
|
||||||
|
plugin_module = plugin.get_module() or []
|
||||||
|
ret_modules[(plugin_id, plugin.get_name())] = plugin_module
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}")
|
||||||
|
return ret_modules
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
|
||||||
|
"""
|
||||||
|
获取插件的远程入口地址
|
||||||
|
:param plugin_id: 插件 ID
|
||||||
|
:param dist_path: 插件的分发路径
|
||||||
|
:return: 远程入口地址
|
||||||
|
"""
|
||||||
|
if dist_path.startswith("/"):
|
||||||
|
dist_path = dist_path[1:]
|
||||||
|
if dist_path.endswith("/"):
|
||||||
|
dist_path = dist_path[:-1]
|
||||||
|
return f"/plugin/file/{plugin_id.lower()}/{dist_path}/remoteEntry.js"
|
||||||
|
|
||||||
|
def get_plugin_remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取插件联邦组件列表
|
||||||
|
"""
|
||||||
|
remotes = []
|
||||||
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
|
if pid and pid != plugin_id:
|
||||||
|
continue
|
||||||
|
if hasattr(plugin, "get_render_mode"):
|
||||||
|
render_mode, dist_path = plugin.get_render_mode()
|
||||||
|
if render_mode != "vue":
|
||||||
|
continue
|
||||||
|
remotes.append({
|
||||||
|
"id": plugin_id,
|
||||||
|
"url": self.get_plugin_remote_entry(plugin_id, dist_path),
|
||||||
|
"name": plugin.plugin_name,
|
||||||
|
})
|
||||||
|
return remotes
|
||||||
|
|
||||||
|
def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
获取所有插件仪表盘元信息
|
获取所有插件仪表盘元信息
|
||||||
"""
|
"""
|
||||||
@@ -588,6 +599,50 @@ class PluginManager(metaclass=Singleton):
|
|||||||
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
||||||
return dashboard_meta
|
return dashboard_meta
|
||||||
|
|
||||||
|
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
|
||||||
|
"""
|
||||||
|
获取插件仪表盘
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __get_params_count(func: Callable):
|
||||||
|
"""
|
||||||
|
获取函数的参数信息
|
||||||
|
"""
|
||||||
|
signature = inspect.signature(func)
|
||||||
|
return len(signature.parameters)
|
||||||
|
|
||||||
|
# 获取插件实例
|
||||||
|
plugin_instance = self.running_plugins.get(pid)
|
||||||
|
if not plugin_instance:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载")
|
||||||
|
|
||||||
|
# 渲染模式
|
||||||
|
render_mode, _ = plugin_instance.get_render_mode()
|
||||||
|
# 获取插件仪表板
|
||||||
|
try:
|
||||||
|
# 检查方法的参数个数
|
||||||
|
params_count = __get_params_count(plugin_instance.get_dashboard)
|
||||||
|
if params_count > 1:
|
||||||
|
dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent)
|
||||||
|
elif params_count > 0:
|
||||||
|
dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent)
|
||||||
|
else:
|
||||||
|
dashboard: Tuple = plugin_instance.get_dashboard()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||||
|
cols, attrs, elements = dashboard
|
||||||
|
return schemas.PluginDashboard(
|
||||||
|
id=pid,
|
||||||
|
name=plugin_instance.plugin_name,
|
||||||
|
key=key,
|
||||||
|
render_mode=render_mode,
|
||||||
|
cols=cols or {},
|
||||||
|
attrs=attrs or {},
|
||||||
|
elements=elements
|
||||||
|
)
|
||||||
|
|
||||||
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||||
"""
|
"""
|
||||||
获取插件属性
|
获取插件属性
|
||||||
@@ -781,7 +836,8 @@ class PluginManager(metaclass=Singleton):
|
|||||||
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_plugins_from_market(self, market: str, package_version: Optional[str] = None) -> Optional[List[schemas.Plugin]]:
|
def get_plugins_from_market(self, market: str,
|
||||||
|
package_version: Optional[str] = None) -> Optional[List[schemas.Plugin]]:
|
||||||
"""
|
"""
|
||||||
从指定的市场获取插件信息
|
从指定的市场获取插件信息
|
||||||
:param market: 市场的 URL 或标识
|
:param market: 市场的 URL 或标识
|
||||||
@@ -795,7 +851,8 @@ class PluginManager(metaclass=Singleton):
|
|||||||
# 获取在线插件
|
# 获取在线插件
|
||||||
online_plugins = self.pluginhelper.get_plugins(market, package_version)
|
online_plugins = self.pluginhelper.get_plugins(market, package_version)
|
||||||
if online_plugins is None:
|
if online_plugins is None:
|
||||||
logger.warning(f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接")
|
logger.warning(
|
||||||
|
f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接")
|
||||||
return []
|
return []
|
||||||
ret_plugins = []
|
ret_plugins = []
|
||||||
add_time = len(online_plugins)
|
add_time = len(online_plugins)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
from sqlalchemy import Column, Integer, String, Sequence, JSON, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -65,8 +65,11 @@ class DownloadHistory(Base):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
||||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
if tmdbid:
|
||||||
DownloadHistory.doubanid == doubanid).all()
|
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
|
||||||
|
elif doubanid:
|
||||||
|
return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()
|
||||||
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -97,18 +100,18 @@ class DownloadHistory(Base):
|
|||||||
DownloadHistory.type == mtype,
|
DownloadHistory.type == mtype,
|
||||||
DownloadHistory.seasons == season,
|
DownloadHistory.seasons == season,
|
||||||
DownloadHistory.episodes == episode).order_by(
|
DownloadHistory.episodes == episode).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
elif season:
|
elif season:
|
||||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||||
DownloadHistory.type == mtype,
|
DownloadHistory.type == mtype,
|
||||||
DownloadHistory.seasons == season).order_by(
|
DownloadHistory.seasons == season).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
else:
|
else:
|
||||||
# 电视剧所有季集/电影
|
# 电视剧所有季集/电影
|
||||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||||
DownloadHistory.type == mtype).order_by(
|
DownloadHistory.type == mtype).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
# 标题 + 年份
|
# 标题 + 年份
|
||||||
elif title and year:
|
elif title and year:
|
||||||
# 电视剧某季某集
|
# 电视剧某季某集
|
||||||
@@ -117,18 +120,18 @@ class DownloadHistory(Base):
|
|||||||
DownloadHistory.year == year,
|
DownloadHistory.year == year,
|
||||||
DownloadHistory.seasons == season,
|
DownloadHistory.seasons == season,
|
||||||
DownloadHistory.episodes == episode).order_by(
|
DownloadHistory.episodes == episode).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
elif season:
|
elif season:
|
||||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||||
DownloadHistory.year == year,
|
DownloadHistory.year == year,
|
||||||
DownloadHistory.seasons == season).order_by(
|
DownloadHistory.seasons == season).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
else:
|
else:
|
||||||
# 电视剧所有季集/电影
|
# 电视剧所有季集/电影
|
||||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||||
DownloadHistory.year == year).order_by(
|
DownloadHistory.year == year).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|||||||
@@ -1,18 +1,534 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Union
|
from typing import Any, Literal, Optional, List, Dict, Union
|
||||||
from typing import List, Optional, Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from cachetools import TTLCache
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
from app.core.config import global_vars
|
from app.core.config import global_vars
|
||||||
|
from app.core.context import MediaInfo, TorrentInfo
|
||||||
|
from app.core.meta import MetaBase
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.log import logger
|
||||||
|
from app.schemas.message import Notification
|
||||||
|
from app.schemas.tmdb import TmdbEpisode
|
||||||
|
from app.schemas.transfer import TransferInfo
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.singleton import Singleton, SingletonClass
|
from app.utils.singleton import Singleton, SingletonClass
|
||||||
from app.log import logger
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateContextBuilder:
|
||||||
|
"""
|
||||||
|
模板上下文构建器
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._context = {}
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
meta: Optional[MetaBase] = None,
|
||||||
|
mediainfo: Optional[MediaInfo] = None,
|
||||||
|
torrentinfo: Optional[TorrentInfo] = None,
|
||||||
|
transferinfo: Optional[TransferInfo] = None,
|
||||||
|
file_extension: Optional[str] = None,
|
||||||
|
episodes_info: Optional[List[TmdbEpisode]] = None,
|
||||||
|
include_raw_objects: bool = True,
|
||||||
|
**kwargs
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
:param meta: 媒体信息
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
|
:param torrentinfo: 种子信息
|
||||||
|
:param transferinfo: 传输信息
|
||||||
|
:param file_extension: 文件扩展名
|
||||||
|
:param episodes_info: 剧集信息
|
||||||
|
:param include_raw_objects: 是否包含原始对象
|
||||||
|
:return: 渲染上下文字典
|
||||||
|
"""
|
||||||
|
self._context.clear()
|
||||||
|
self._add_episode_details(meta, episodes_info)
|
||||||
|
self._add_media_info(mediainfo)
|
||||||
|
self._add_transfer_info(transferinfo)
|
||||||
|
self._add_torrent_info(torrentinfo)
|
||||||
|
self._add_file_info(file_extension)
|
||||||
|
if kwargs: self._context.update(kwargs)
|
||||||
|
|
||||||
|
if include_raw_objects:
|
||||||
|
self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info)
|
||||||
|
|
||||||
|
# 移除空值
|
||||||
|
return {k: v for k, v in self._context.items() if v is not None}
|
||||||
|
|
||||||
|
def _add_media_info(self, mediainfo: MediaInfo):
|
||||||
|
"""
|
||||||
|
增加媒体信息
|
||||||
|
"""
|
||||||
|
if not mediainfo: return
|
||||||
|
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
|
||||||
|
base_info = {
|
||||||
|
# 标题
|
||||||
|
"title": self.__convert_invalid_characters(mediainfo.title),
|
||||||
|
# 英文标题
|
||||||
|
"en_title": self.__convert_invalid_characters(mediainfo.en_title),
|
||||||
|
# 原语种标题
|
||||||
|
"original_title": self.__convert_invalid_characters(mediainfo.original_title),
|
||||||
|
# 季号
|
||||||
|
"season": self._context.get("season") or mediainfo.season,
|
||||||
|
# Sxx
|
||||||
|
"season_fmt": self._context.get("season_fmt") or season_fmt,
|
||||||
|
# 年份
|
||||||
|
"year": mediainfo.year or self._context.get("year"),
|
||||||
|
# 媒体标题 + 年份
|
||||||
|
"title_year": mediainfo.title_year or self._context.get("title_year"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_meta_season = self._context.get("season")
|
||||||
|
media_info = {
|
||||||
|
# 类型
|
||||||
|
"type": mediainfo.type.value,
|
||||||
|
# 类别
|
||||||
|
"category": mediainfo.category,
|
||||||
|
# 评分
|
||||||
|
"vote_average": mediainfo.vote_average,
|
||||||
|
# 海报
|
||||||
|
"poster": mediainfo.get_poster_image(),
|
||||||
|
# 背景图
|
||||||
|
"backdrop": mediainfo.get_backdrop_image(),
|
||||||
|
# 季年份根据season值获取
|
||||||
|
"season_year": mediainfo.season_years.get(
|
||||||
|
int(_meta_season),
|
||||||
|
None) if (mediainfo.season_years and _meta_season) else None,
|
||||||
|
# 演员
|
||||||
|
"actors": '、 '.join([actor['name'] for actor in mediainfo.actors[:5]]),
|
||||||
|
# 简介
|
||||||
|
"overview": mediainfo.overview,
|
||||||
|
# TMDBID
|
||||||
|
"tmdbid": mediainfo.tmdb_id,
|
||||||
|
# IMDBID
|
||||||
|
"imdbid": mediainfo.imdb_id,
|
||||||
|
# 豆瓣ID
|
||||||
|
"doubanid": mediainfo.douban_id,
|
||||||
|
}
|
||||||
|
self._context.update({**base_info, **media_info})
|
||||||
|
|
||||||
|
def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]):
|
||||||
|
"""
|
||||||
|
添加剧集详细信息
|
||||||
|
"""
|
||||||
|
if not meta:
|
||||||
|
return
|
||||||
|
|
||||||
|
episode_data = {"episode_title": None, "episode_date": None}
|
||||||
|
if meta.begin_episode and episodes:
|
||||||
|
for episode in episodes:
|
||||||
|
if episode.episode_number == meta.begin_episode:
|
||||||
|
episode_data.update({
|
||||||
|
"episode_title": self.__convert_invalid_characters(episode.name),
|
||||||
|
"episode_date": episode.air_date if episode.air_date else None
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
meta_info = {
|
||||||
|
# 原文件名
|
||||||
|
"original_name": meta.title,
|
||||||
|
# 识别名称(优先使用中文)
|
||||||
|
"name": meta.name,
|
||||||
|
# 识别的英文名称(可能为空)
|
||||||
|
"en_name": meta.en_name,
|
||||||
|
# 年份
|
||||||
|
"year": meta.year,
|
||||||
|
# 名字 + 年份
|
||||||
|
"title_year": self._context.get("title_year") or "%s (%s)" % (
|
||||||
|
meta.name, meta.year) if meta.year else meta.name,
|
||||||
|
# 季号
|
||||||
|
"season": meta.season_seq,
|
||||||
|
# Sxx
|
||||||
|
"season_fmt": meta.season,
|
||||||
|
# 集号
|
||||||
|
"episode": meta.episode_seqs,
|
||||||
|
# 季集 SxxExx
|
||||||
|
"season_episode": "%s%s" % (meta.season, meta.episode),
|
||||||
|
# 段/节
|
||||||
|
"part": meta.part,
|
||||||
|
# 自定义占位符
|
||||||
|
"customization": meta.customization,
|
||||||
|
}
|
||||||
|
|
||||||
|
tech_metadata = {
|
||||||
|
# 资源类型
|
||||||
|
"resourceType": meta.resource_type,
|
||||||
|
# 特效
|
||||||
|
"effect": meta.resource_effect,
|
||||||
|
# 版本
|
||||||
|
"edition": meta.edition,
|
||||||
|
# 分辨率
|
||||||
|
"videoFormat": meta.resource_pix,
|
||||||
|
# 质量
|
||||||
|
"resource_term": meta.resource_term,
|
||||||
|
# 制作组/字幕组
|
||||||
|
"releaseGroup": meta.resource_team,
|
||||||
|
# 视频编码
|
||||||
|
"videoCodec": meta.video_encode,
|
||||||
|
# 音频编码
|
||||||
|
"audioCodec": meta.audio_encode,
|
||||||
|
}
|
||||||
|
self._context.update({**meta_info, **tech_metadata, **episode_data})
|
||||||
|
|
||||||
|
def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]):
|
||||||
|
"""
|
||||||
|
添加种子信息
|
||||||
|
"""
|
||||||
|
if not torrentinfo:
|
||||||
|
return
|
||||||
|
if torrentinfo.size:
|
||||||
|
if str(torrentinfo.size).replace(".", "").isdigit():
|
||||||
|
size = StringUtils.str_filesize(torrentinfo.size)
|
||||||
|
else:
|
||||||
|
size = torrentinfo.size
|
||||||
|
else:
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
if torrentinfo.description:
|
||||||
|
html_re = re.compile(r'<[^>]+>', re.S)
|
||||||
|
description = html_re.sub('', torrentinfo.description)
|
||||||
|
torrentinfo.description = re.sub(r'<[^>]+>', '', description)
|
||||||
|
|
||||||
|
torrent_info = {
|
||||||
|
# 种子标题
|
||||||
|
"torrent_title": torrentinfo.title,
|
||||||
|
# 发布时间
|
||||||
|
"pubdate": torrentinfo.pubdate,
|
||||||
|
# 免费剩余时间
|
||||||
|
"freedate": torrentinfo.freedate_diff,
|
||||||
|
# 做种数
|
||||||
|
"seeders": torrentinfo.seeders,
|
||||||
|
# 促销信息
|
||||||
|
"volume_factor": torrentinfo.volume_factor,
|
||||||
|
# Hit&Run
|
||||||
|
"hit_and_run": "是" if torrentinfo.hit_and_run else "否",
|
||||||
|
# 种子标签
|
||||||
|
"labels": ' '.join(torrentinfo.labels),
|
||||||
|
# 描述
|
||||||
|
"description": torrentinfo.description,
|
||||||
|
# 站点名称
|
||||||
|
"site_name": torrentinfo.site_name,
|
||||||
|
# 种子大小
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
self._context.update(torrent_info)
|
||||||
|
|
||||||
|
def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
添加文件转移上下文
|
||||||
|
"""
|
||||||
|
if not transferinfo:
|
||||||
|
return None
|
||||||
|
ctx = {
|
||||||
|
"transfer_type": transferinfo.transfer_type,
|
||||||
|
"file_count": transferinfo.file_count,
|
||||||
|
"total_size": StringUtils.str_filesize(transferinfo.total_size),
|
||||||
|
"err_msg": transferinfo.message,
|
||||||
|
}
|
||||||
|
self._context.update(ctx)
|
||||||
|
|
||||||
|
def _add_file_info(self, file_extension: Optional[str]):
|
||||||
|
"""
|
||||||
|
添加文件信息
|
||||||
|
"""
|
||||||
|
if not file_extension: return
|
||||||
|
file_info = {
|
||||||
|
# 文件后缀
|
||||||
|
"fileExt": file_extension,
|
||||||
|
}
|
||||||
|
self._context.update(file_info)
|
||||||
|
|
||||||
|
def _add_raw_objects(
|
||||||
|
self,
|
||||||
|
meta: Optional[MetaBase],
|
||||||
|
mediainfo: Optional[MediaInfo],
|
||||||
|
torrentinfo: Optional[TorrentInfo],
|
||||||
|
transferinfo: Optional[TransferInfo],
|
||||||
|
episodes_info: Optional[List[TmdbEpisode]],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
添加原始对象引用
|
||||||
|
"""
|
||||||
|
raw_objects = {
|
||||||
|
# 文件元数据
|
||||||
|
"__meta__": meta,
|
||||||
|
# 识别的媒体信息
|
||||||
|
"__mediainfo__": mediainfo,
|
||||||
|
# 种子信息
|
||||||
|
"__torrentinfo__": torrentinfo,
|
||||||
|
# 文件转移信息
|
||||||
|
"__transferinfo__": transferinfo,
|
||||||
|
# 当前季的全部集信息
|
||||||
|
"__episodes_info__": episodes_info,
|
||||||
|
}
|
||||||
|
self._context.update(raw_objects)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __convert_invalid_characters(filename: str):
|
||||||
|
"""
|
||||||
|
将不支持的字符转换为全角字符
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
return filename
|
||||||
|
invalid_characters = r'\/:*?"<>|'
|
||||||
|
# 创建半角到全角字符的转换表
|
||||||
|
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
|
||||||
|
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
|
||||||
|
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
|
||||||
|
# 将不支持的字符替换为对应的全角字符
|
||||||
|
for char in invalid_characters:
|
||||||
|
filename = filename.replace(char, char.translate(translation_table))
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateHelper(metaclass=SingletonClass):
|
||||||
|
"""
|
||||||
|
模板格式渲染帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.builder = TemplateContextBuilder()
|
||||||
|
self.cache = TTLCache(maxsize=100, ttl=600)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_cache_key(cuntent: Union[str, dict]) -> str:
|
||||||
|
"""
|
||||||
|
生成缓存键
|
||||||
|
"""
|
||||||
|
if isinstance(cuntent, dict):
|
||||||
|
base_str = cuntent.get("title", '') + cuntent.get("text", '')
|
||||||
|
return StringUtils.md5_hash(json.dumps(base_str, sort_keys=True, ensure_ascii=False))
|
||||||
|
|
||||||
|
return StringUtils.md5_hash(cuntent)
|
||||||
|
|
||||||
|
def get_cache_context(self, cuntent: Union[str, dict]) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
获取缓存上下文
|
||||||
|
"""
|
||||||
|
cache_key = self._generate_cache_key(cuntent)
|
||||||
|
return self.cache.get(cache_key)
|
||||||
|
|
||||||
|
def set_cache_context(self, cuntent: Union[str, dict], context: dict) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存上下文
|
||||||
|
"""
|
||||||
|
cache_key = self._generate_cache_key(cuntent)
|
||||||
|
self.cache[cache_key] = context
|
||||||
|
|
||||||
|
def render(self,
|
||||||
|
template_content: str,
|
||||||
|
template_type: Literal['string', 'dict', 'literal'] = "literal",
|
||||||
|
**kwargs) -> Optional[Union[str, dict]]:
|
||||||
|
"""
|
||||||
|
根据模板格式渲染内容
|
||||||
|
:param template_content: 模板字符串
|
||||||
|
:param template_type: 模板字符串类型(消息通知`literal`, 路径`string`)
|
||||||
|
:param kwargs: 补传业务对象
|
||||||
|
:raises ValueError: 当模板处理过程中出现错误
|
||||||
|
:return: 渲染后的结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 解析模板字符
|
||||||
|
parsed = self.parse_template_content(template_content, template_type)
|
||||||
|
if not parsed:
|
||||||
|
raise ValueError("模板解析失败")
|
||||||
|
|
||||||
|
context = self.builder.build(**kwargs)
|
||||||
|
if not context:
|
||||||
|
raise ValueError("上下文构建失败")
|
||||||
|
|
||||||
|
rendered = self.render_with_context(parsed, context)
|
||||||
|
if not rendered:
|
||||||
|
raise ValueError("模板渲染失败")
|
||||||
|
|
||||||
|
if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered):
|
||||||
|
# 缓存上下文
|
||||||
|
self.set_cache_context(rendered, context)
|
||||||
|
# 返回渲染结果
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"模板处理失败: {str(e)}")
|
||||||
|
raise ValueError(f"模板处理失败: {str(e)}") from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_with_context(template_content: str, context: dict) -> str:
|
||||||
|
"""
|
||||||
|
使用指定上下文渲染 Jinja2 模板字符串
|
||||||
|
template_content: Jinja2 模板字符串
|
||||||
|
context: 渲染用的上下文数据
|
||||||
|
"""
|
||||||
|
# 渲染模板
|
||||||
|
template = Template(template_content)
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_template_content(template_content: Union[str, dict],
|
||||||
|
template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
解析模板字符
|
||||||
|
:param template_content 模板格式字符
|
||||||
|
:param template_type 模板字符类型
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse_literal(_template_content: str) -> str:
|
||||||
|
"""
|
||||||
|
解析Python字面量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
template_dict = ast.literal_eval(_template_content) if isinstance(_template_content,
|
||||||
|
str) else _template_content
|
||||||
|
if not isinstance(template_dict, dict):
|
||||||
|
raise ValueError("解析结果必须是一个字典")
|
||||||
|
return json.dumps(template_dict, ensure_ascii=False)
|
||||||
|
except (ValueError, SyntaxError) as err:
|
||||||
|
raise ValueError(f"无效的Python字面量格式: {str(err)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if template_type:
|
||||||
|
parse_map = {
|
||||||
|
'string': lambda x: str(x),
|
||||||
|
'dict': lambda x: json.dumps(x, ensure_ascii=False),
|
||||||
|
'literal': parse_literal
|
||||||
|
}
|
||||||
|
return parse_map[template_type](template_content)
|
||||||
|
|
||||||
|
# 自动判断模板类型
|
||||||
|
if isinstance(template_content, dict):
|
||||||
|
return json.dumps(template_content, ensure_ascii=False)
|
||||||
|
elif isinstance(template_content, str):
|
||||||
|
try:
|
||||||
|
json.loads(template_content)
|
||||||
|
return template_content
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
try:
|
||||||
|
return parse_literal(template_content)
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
return template_content
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的模板类型: {type(template_content)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"模板解析失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]:
|
||||||
|
"""
|
||||||
|
处理格式化字符串
|
||||||
|
保留转义字符
|
||||||
|
"""
|
||||||
|
|
||||||
|
def restore_chars(obj: Any) -> Any:
|
||||||
|
"""恢复特殊字符"""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return obj.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\b', '\b').replace(
|
||||||
|
'\\f', '\f')
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {k: restore_chars(v) for k, v in obj.items()}
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [restore_chars(item) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# 定义特殊字符映射
|
||||||
|
|
||||||
|
special_chars = {
|
||||||
|
'\n': '\\n', # 换行符
|
||||||
|
'\r': '\\r', # 回车符
|
||||||
|
'\t': '\\t', # 制表符
|
||||||
|
'\b': '\\b', # 退格符
|
||||||
|
'\f': '\\f', # 换页符
|
||||||
|
}
|
||||||
|
|
||||||
|
# 处理特殊字符
|
||||||
|
processed = rendered
|
||||||
|
for char, escape in special_chars.items():
|
||||||
|
processed = processed.replace(char, escape)
|
||||||
|
|
||||||
|
# 尝试解析为JSON
|
||||||
|
try:
|
||||||
|
rendered_dict = json.loads(processed)
|
||||||
|
return restore_chars(rendered_dict)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
class MessageTemplateHelper:
|
||||||
|
"""
|
||||||
|
消息模板渲染器
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render(message: Notification, *args, **kwargs) -> Optional[Notification]:
|
||||||
|
"""
|
||||||
|
渲染消息模板
|
||||||
|
"""
|
||||||
|
if not MessageTemplateHelper.is_instance_valid(message):
|
||||||
|
if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs):
|
||||||
|
logger.info("将使用模板渲染消息内容")
|
||||||
|
return MessageTemplateHelper._apply_template_data(message, *args, **kwargs)
|
||||||
|
return message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_instance_valid(message: Notification) -> bool:
|
||||||
|
"""
|
||||||
|
检查消息是否有效
|
||||||
|
"""
|
||||||
|
if isinstance(message, Notification):
|
||||||
|
return bool(message.title or message.text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def meets_update_conditions(message: Notification, *args, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否满足消息实例更新条件
|
||||||
|
|
||||||
|
满足条件需同时具备:
|
||||||
|
1. 消息为有效Notification实例
|
||||||
|
2. 消息指定了模板类型(ctype)
|
||||||
|
3. 存在待渲染的模板变量数据
|
||||||
|
"""
|
||||||
|
if isinstance(message, Notification):
|
||||||
|
return True if message.ctype and (args or kwargs) else False
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]:
|
||||||
|
"""
|
||||||
|
更新消息实例
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if template := MessageTemplateHelper._get_template(message):
|
||||||
|
rendered = TemplateHelper().render(template_content=template, *args, **kwargs)
|
||||||
|
for key, value in rendered.items():
|
||||||
|
if hasattr(message, key):
|
||||||
|
setattr(message, key, value)
|
||||||
|
return message
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新Notification时出现错误:{str(e)}")
|
||||||
|
return message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_template(message: Notification) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取消息模板
|
||||||
|
"""
|
||||||
|
template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)
|
||||||
|
return template_dict.get(f"{message.ctype.value}")
|
||||||
|
|
||||||
|
|
||||||
class MessageQueueManager(metaclass=SingletonClass):
|
class MessageQueueManager(metaclass=SingletonClass):
|
||||||
@@ -55,6 +571,7 @@ class MessageQueueManager(metaclass=SingletonClass):
|
|||||||
def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]:
|
def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]:
|
||||||
"""
|
"""
|
||||||
将字符串时间格式转换为分钟数元组
|
将字符串时间格式转换为分钟数元组
|
||||||
|
支持格式为 'HH:MM' 或 'HH:MM:SS' 的时间字符串
|
||||||
"""
|
"""
|
||||||
parsed = []
|
parsed = []
|
||||||
if not periods:
|
if not periods:
|
||||||
@@ -66,9 +583,31 @@ class MessageQueueManager(metaclass=SingletonClass):
|
|||||||
continue
|
continue
|
||||||
if not period.get('start') or not period.get('end'):
|
if not period.get('start') or not period.get('end'):
|
||||||
continue
|
continue
|
||||||
start_h, start_m = map(int, period['start'].split(':'))
|
try:
|
||||||
end_h, end_m = map(int, period['end'].split(':'))
|
# 处理 start 时间
|
||||||
parsed.append((start_h, start_m, end_h, end_m))
|
start_parts = period['start'].split(':')
|
||||||
|
if len(start_parts) == 2:
|
||||||
|
start_h, start_m = map(int, start_parts)
|
||||||
|
elif len(start_parts) >= 3:
|
||||||
|
start_h, start_m = map(int, start_parts[:2]) # 只取前两个部分 (HH:MM)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
# 处理 end 时间
|
||||||
|
end_parts = period['end'].split(':')
|
||||||
|
if len(end_parts) == 2:
|
||||||
|
end_h, end_m = map(int, end_parts)
|
||||||
|
elif len(end_parts) >= 3:
|
||||||
|
end_h, end_m = map(int, end_parts[:2]) # 只取前两个部分 (HH:MM)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed.append((start_h, start_m, end_h, end_m))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"解析时间周期时出现错误:{period}. 错误:{str(e)}. 跳过此周期。")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析时间周期时出现意外错误:{period}. 错误:{str(e)}. 跳过此周期。")
|
||||||
|
continue
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ from typing import List, Any, Callable
|
|||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
FilterFuncType = Callable[[str, Any], bool]
|
FilterFuncType = Callable[[str, Any], bool]
|
||||||
|
|
||||||
|
|
||||||
def _default_filter(name: str, obj: Any) -> bool:
|
def _default_filter(name: str, obj: Any) -> bool:
|
||||||
"""
|
"""
|
||||||
默认过滤器
|
默认过滤器
|
||||||
"""
|
"""
|
||||||
return True
|
return True if name and obj else False
|
||||||
|
|
||||||
|
|
||||||
class ModuleHelper:
|
class ModuleHelper:
|
||||||
"""
|
"""
|
||||||
@@ -76,7 +77,8 @@ class ModuleHelper:
|
|||||||
|
|
||||||
def reload_sub_modules(parent_module, parent_module_name):
|
def reload_sub_modules(parent_module, parent_module_name):
|
||||||
"""重新加载一级子模块"""
|
"""重新加载一级子模块"""
|
||||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name+'.'):
|
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,
|
||||||
|
parent_module_name + '.'):
|
||||||
try:
|
try:
|
||||||
full_sub_module = importlib.import_module(sub_module_name)
|
full_sub_module = importlib.import_module(sub_module_name)
|
||||||
importlib.reload(full_sub_module)
|
importlib.reload(full_sub_module)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import traceback
|
import traceback
|
||||||
@@ -455,15 +456,15 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
:param requirements_file: 依赖的 requirements.txt 文件路径
|
:param requirements_file: 依赖的 requirements.txt 文件路径
|
||||||
:return: (是否成功, 错误信息)
|
:return: (是否成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
|
base_cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||||
strategies = []
|
strategies = []
|
||||||
|
|
||||||
# 添加策略到列表中
|
# 添加策略到列表中
|
||||||
if settings.PIP_PROXY:
|
if settings.PIP_PROXY:
|
||||||
strategies.append(("镜像站", ["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY]))
|
strategies.append(("镜像站", base_cmd + ["-i", settings.PIP_PROXY]))
|
||||||
if settings.PROXY_HOST:
|
if settings.PROXY_HOST:
|
||||||
strategies.append(
|
strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST]))
|
||||||
("代理", ["pip", "install", "-r", str(requirements_file), "--proxy", settings.PROXY_HOST]))
|
strategies.append(("直连", base_cmd))
|
||||||
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file)]))
|
|
||||||
|
|
||||||
# 遍历策略进行安装
|
# 遍历策略进行安装
|
||||||
for strategy_name, pip_command in strategies:
|
for strategy_name, pip_command in strategies:
|
||||||
|
|||||||
@@ -50,3 +50,35 @@ class StorageHelper:
|
|||||||
s.config = conf
|
s.config = conf
|
||||||
break
|
break
|
||||||
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||||
|
|
||||||
|
def add_storage(self, storage: str, name: str, conf: dict):
|
||||||
|
"""
|
||||||
|
添加存储配置
|
||||||
|
"""
|
||||||
|
storagies = self.get_storagies()
|
||||||
|
if not storagies:
|
||||||
|
storagies = [
|
||||||
|
schemas.StorageConf(
|
||||||
|
type=storage,
|
||||||
|
name=name,
|
||||||
|
config=conf
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
storagies.append(schemas.StorageConf(
|
||||||
|
type=storage,
|
||||||
|
name=name,
|
||||||
|
config=conf
|
||||||
|
))
|
||||||
|
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||||
|
|
||||||
|
def reset_storage(self, storage: str):
|
||||||
|
"""
|
||||||
|
重置存储配置
|
||||||
|
"""
|
||||||
|
storagies = self.get_storagies()
|
||||||
|
for s in storagies:
|
||||||
|
if s.type == storage:
|
||||||
|
s.config = {}
|
||||||
|
break
|
||||||
|
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||||
|
|||||||
@@ -39,11 +39,9 @@ class DoubanModule(_ModuleBase):
|
|||||||
测试模块连接性
|
测试模块连接性
|
||||||
"""
|
"""
|
||||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||||
if ret and ret.status_code == 200:
|
if ret is None:
|
||||||
return True, ""
|
return False, "豆瓣网络连接失败"
|
||||||
elif ret:
|
return True, ""
|
||||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
|
||||||
return False, "豆瓣网络连接失败"
|
|
||||||
|
|
||||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Optional, List, Tuple, Union, Dict
|
from typing import Optional, List, Tuple, Union, Dict, Callable
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ from app.core.event import eventmanager
|
|||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
from app.helper.directory import DirectoryHelper
|
from app.helper.directory import DirectoryHelper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper, TemplateHelper
|
||||||
from app.helper.module import ModuleHelper
|
from app.helper.module import ModuleHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
@@ -30,6 +30,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_storage_schemas = []
|
_storage_schemas = []
|
||||||
|
_support_storages = []
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -40,6 +41,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 加载模块
|
# 加载模块
|
||||||
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
|
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
|
||||||
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
|
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
|
||||||
|
# 获取存储类型
|
||||||
|
self._support_storages = [storage.schema.value for storage in self._storage_schemas]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_name() -> str:
|
def get_name() -> str:
|
||||||
@@ -114,6 +117,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
支持的整理方式
|
支持的整理方式
|
||||||
"""
|
"""
|
||||||
|
if storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(storage)
|
storage_oper = self.__get_storage_oper(storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {storage} 的整理方式获取")
|
logger.error(f"不支持 {storage} 的整理方式获取")
|
||||||
@@ -149,6 +154,16 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return
|
return
|
||||||
storage_oper.set_config(conf)
|
storage_oper.set_config(conf)
|
||||||
|
|
||||||
|
def reset_config(self, storage: str) -> None:
|
||||||
|
"""
|
||||||
|
重置存储配置
|
||||||
|
"""
|
||||||
|
storage_oper = self.__get_storage_oper(storage)
|
||||||
|
if not storage_oper:
|
||||||
|
logger.error(f"不支持 {storage} 的重置存储配置")
|
||||||
|
return
|
||||||
|
storage_oper.reset_config()
|
||||||
|
|
||||||
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||||
"""
|
"""
|
||||||
生成二维码
|
生成二维码
|
||||||
@@ -176,6 +191,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
:param recursion: 是否递归,此时只浏览文件
|
:param recursion: 是否递归,此时只浏览文件
|
||||||
:return: 文件项列表
|
:return: 文件项列表
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||||
@@ -206,6 +223,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
查询当前目录下是否存在指定扩展名任意文件
|
查询当前目录下是否存在指定扩展名任意文件
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||||
@@ -239,26 +258,32 @@ class FileManagerModule(_ModuleBase):
|
|||||||
:param name: 目录名
|
:param name: 目录名
|
||||||
:return: 创建的目录
|
:return: 创建的目录
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的目录创建")
|
logger.error(f"不支持 {fileitem.storage} 的目录创建")
|
||||||
return None
|
return None
|
||||||
return storage_oper.create_folder(fileitem, name)
|
return storage_oper.create_folder(fileitem, name)
|
||||||
|
|
||||||
def delete_file(self, fileitem: FileItem) -> bool:
|
def delete_file(self, fileitem: FileItem) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
删除文件或目录
|
删除文件或目录
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的删除处理")
|
logger.error(f"不支持 {fileitem.storage} 的删除处理")
|
||||||
return False
|
return False
|
||||||
return storage_oper.delete(fileitem)
|
return storage_oper.delete(fileitem)
|
||||||
|
|
||||||
def rename_file(self, fileitem: FileItem, name: str) -> bool:
|
def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
重命名文件或目录
|
重命名文件或目录
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的重命名处理")
|
logger.error(f"不支持 {fileitem.storage} 的重命名处理")
|
||||||
@@ -269,6 +294,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
下载文件
|
下载文件
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的下载处理")
|
logger.error(f"不支持 {fileitem.storage} 的下载处理")
|
||||||
@@ -279,6 +306,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的上传处理")
|
logger.error(f"不支持 {fileitem.storage} 的上传处理")
|
||||||
@@ -289,6 +318,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
根据路径获取文件项
|
根据路径获取文件项
|
||||||
"""
|
"""
|
||||||
|
if storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(storage)
|
storage_oper = self.__get_storage_oper(storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {storage} 的文件获取")
|
logger.error(f"不支持 {storage} 的文件获取")
|
||||||
@@ -299,6 +330,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
获取上级目录项
|
获取上级目录项
|
||||||
"""
|
"""
|
||||||
|
if fileitem.storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的文件获取")
|
logger.error(f"不支持 {fileitem.storage} 的文件获取")
|
||||||
@@ -309,6 +342,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
快照存储
|
快照存储
|
||||||
"""
|
"""
|
||||||
|
if storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(storage)
|
storage_oper = self.__get_storage_oper(storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {storage} 的快照处理")
|
logger.error(f"不支持 {storage} 的快照处理")
|
||||||
@@ -319,6 +354,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
存储使用情况
|
存储使用情况
|
||||||
"""
|
"""
|
||||||
|
if storage not in self._support_storages:
|
||||||
|
return None
|
||||||
storage_oper = self.__get_storage_oper(storage)
|
storage_oper = self.__get_storage_oper(storage)
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {storage} 的存储使用情况")
|
logger.error(f"不支持 {storage} 的存储使用情况")
|
||||||
@@ -330,7 +367,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_storage: Optional[str] = None, target_path: Path = None,
|
target_storage: Optional[str] = None, target_path: Path = None,
|
||||||
transfer_type: Optional[str] = None, scrape: Optional[bool] = None,
|
transfer_type: Optional[str] = None, scrape: Optional[bool] = None,
|
||||||
library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,
|
library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,
|
||||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
episodes_info: List[TmdbEpisode] = None,
|
||||||
|
source_oper: Callable = None, target_oper: Callable = None) -> TransferInfo:
|
||||||
"""
|
"""
|
||||||
文件整理
|
文件整理
|
||||||
:param fileitem: 文件信息
|
:param fileitem: 文件信息
|
||||||
@@ -344,6 +382,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
:param library_type_folder: 是否按媒体类型创建目录
|
:param library_type_folder: 是否按媒体类型创建目录
|
||||||
:param library_category_folder: 是否按媒体类别创建目录
|
:param library_category_folder: 是否按媒体类别创建目录
|
||||||
:param episodes_info: 当前季的全部集信息
|
:param episodes_info: 当前季的全部集信息
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:return: {path, target_path, message}
|
:return: {path, target_path, message}
|
||||||
"""
|
"""
|
||||||
# 检查目录路径
|
# 检查目录路径
|
||||||
@@ -362,6 +402,9 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 整理方式
|
# 整理方式
|
||||||
if not transfer_type:
|
if not transfer_type:
|
||||||
transfer_type = target_directory.transfer_type
|
transfer_type = target_directory.transfer_type
|
||||||
|
# 目标存储
|
||||||
|
if not target_storage:
|
||||||
|
target_storage = target_directory.library_storage
|
||||||
# 是否需要重命名
|
# 是否需要重命名
|
||||||
need_rename = target_directory.renaming
|
need_rename = target_directory.renaming
|
||||||
# 是否需要通知
|
# 是否需要通知
|
||||||
@@ -370,9 +413,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
overwrite_mode = target_directory.overwrite_mode
|
overwrite_mode = target_directory.overwrite_mode
|
||||||
# 是否需要刮削
|
# 是否需要刮削
|
||||||
need_scrape = target_directory.scraping if scrape is None else scrape
|
need_scrape = target_directory.scraping if scrape is None else scrape
|
||||||
# 目标存储类型
|
|
||||||
if not target_storage:
|
|
||||||
target_storage = target_directory.library_storage
|
|
||||||
# 拼装媒体库一、二级子目录
|
# 拼装媒体库一、二级子目录
|
||||||
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
|
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
|
||||||
need_type_folder=library_type_folder,
|
need_type_folder=library_type_folder,
|
||||||
@@ -399,6 +439,31 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return TransferInfo(success=False,
|
return TransferInfo(success=False,
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
message=f"{target_directory.name} 未设置整理方式")
|
message=f"{target_directory.name} 未设置整理方式")
|
||||||
|
|
||||||
|
# 源操作对象
|
||||||
|
if not source_oper:
|
||||||
|
source_oper = self.__get_storage_oper(fileitem.storage)
|
||||||
|
if not source_oper:
|
||||||
|
return TransferInfo(success=False,
|
||||||
|
message=f"不支持的存储类型:{fileitem.storage}",
|
||||||
|
fileitem=fileitem,
|
||||||
|
fail_list=[fileitem.path],
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
need_notify=need_notify
|
||||||
|
)
|
||||||
|
# 目的操作对象
|
||||||
|
if not target_oper:
|
||||||
|
if not target_storage:
|
||||||
|
target_storage = fileitem.storage
|
||||||
|
target_oper = self.__get_storage_oper(target_storage)
|
||||||
|
if not target_oper:
|
||||||
|
return TransferInfo(success=False,
|
||||||
|
message=f"不支持的存储类型:{target_storage}",
|
||||||
|
fileitem=fileitem,
|
||||||
|
fail_list=[fileitem.path],
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
need_notify=need_notify)
|
||||||
|
|
||||||
# 整理
|
# 整理
|
||||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||||
return self.transfer_media(fileitem=fileitem,
|
return self.transfer_media(fileitem=fileitem,
|
||||||
@@ -411,7 +476,9 @@ class FileManagerModule(_ModuleBase):
|
|||||||
need_rename=need_rename,
|
need_rename=need_rename,
|
||||||
need_notify=need_notify,
|
need_notify=need_notify,
|
||||||
overwrite_mode=overwrite_mode,
|
overwrite_mode=overwrite_mode,
|
||||||
episodes_info=episodes_info)
|
episodes_info=episodes_info,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper)
|
||||||
|
|
||||||
def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]:
|
def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]:
|
||||||
"""
|
"""
|
||||||
@@ -430,12 +497,17 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __transfer_command(self, fileitem: FileItem, target_storage: str,
|
@staticmethod
|
||||||
target_file: Path, transfer_type: str) -> Tuple[Optional[FileItem], str]:
|
def __transfer_command(fileitem: FileItem, target_storage: str,
|
||||||
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
target_file: Path, transfer_type: str,
|
||||||
|
) -> Tuple[Optional[FileItem], str]:
|
||||||
"""
|
"""
|
||||||
处理单个文件
|
处理单个文件
|
||||||
:param fileitem: 源文件
|
:param fileitem: 源文件
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param target_file: 目标文件路径
|
:param target_file: 目标文件路径
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
"""
|
"""
|
||||||
@@ -459,13 +531,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
and fileitem.storage != "local" and target_storage != "local"):
|
and fileitem.storage != "local" and target_storage != "local"):
|
||||||
return None, f"不支持 {fileitem.storage} 到 {target_storage} 的文件整理"
|
return None, f"不支持 {fileitem.storage} 到 {target_storage} 的文件整理"
|
||||||
|
|
||||||
# 源操作对象
|
|
||||||
source_oper: StorageBase = self.__get_storage_oper(fileitem.storage)
|
|
||||||
# 目的操作对象
|
|
||||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
|
||||||
if not source_oper or not target_oper:
|
|
||||||
return None, f"不支持的存储类型:{fileitem.storage} 或 {target_storage}"
|
|
||||||
|
|
||||||
# 加锁
|
# 加锁
|
||||||
with lock:
|
with lock:
|
||||||
if fileitem.storage == "local" and target_storage == "local":
|
if fileitem.storage == "local" and target_storage == "local":
|
||||||
@@ -568,18 +633,23 @@ class FileManagerModule(_ModuleBase):
|
|||||||
|
|
||||||
return None, "未知错误"
|
return None, "未知错误"
|
||||||
|
|
||||||
def __transfer_other_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
def __transfer_other_files(self, fileitem: FileItem, target_storage: str,
|
||||||
transfer_type: str) -> Tuple[bool, str]:
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
根据文件名整理其他相关文件
|
根据文件名整理其他相关文件
|
||||||
:param fileitem: 源文件
|
:param fileitem: 源文件
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param target_file: 目标路径
|
:param target_file: 目标路径
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
"""
|
"""
|
||||||
# 整理字幕
|
# 整理字幕
|
||||||
state, errmsg = self.__transfer_subtitles(fileitem=fileitem,
|
state, errmsg = self.__transfer_subtitles(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=target_file,
|
target_file=target_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
if not state:
|
if not state:
|
||||||
@@ -587,17 +657,22 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 整理音轨文件
|
# 整理音轨文件
|
||||||
state, errmsg = self.__transfer_audio_track_files(fileitem=fileitem,
|
state, errmsg = self.__transfer_audio_track_files(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=target_file,
|
target_file=target_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
|
|
||||||
return state, errmsg
|
return state, errmsg
|
||||||
|
|
||||||
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str,
|
||||||
transfer_type: str) -> Tuple[bool, str]:
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
根据文件名整理对应字幕文件
|
根据文件名整理对应字幕文件
|
||||||
:param fileitem: 源文件
|
:param fileitem: 源文件
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param target_file: 目标路径
|
:param target_file: 目标路径
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
"""
|
"""
|
||||||
@@ -617,17 +692,12 @@ class FileManagerModule(_ModuleBase):
|
|||||||
|
|
||||||
# 比对文件名并整理字幕
|
# 比对文件名并整理字幕
|
||||||
org_path = Path(fileitem.path)
|
org_path = Path(fileitem.path)
|
||||||
# 列出所有字幕文件
|
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
|
||||||
if not storage_oper:
|
|
||||||
logger.error(f"不支持 {fileitem.storage} 的文件整理")
|
|
||||||
return False, f"不支持的文件存储:{fileitem.storage}"
|
|
||||||
# 查找上级文件项
|
# 查找上级文件项
|
||||||
parent_item: FileItem = storage_oper.get_parent(fileitem)
|
parent_item: FileItem = source_oper.get_parent(fileitem)
|
||||||
if not parent_item:
|
if not parent_item:
|
||||||
return False, f"{org_path} 上级目录获取失败"
|
return False, f"{org_path} 上级目录获取失败"
|
||||||
# 字幕文件列表
|
# 字幕文件列表
|
||||||
file_list: List[FileItem] = storage_oper.list(parent_item) or []
|
file_list: List[FileItem] = source_oper.list(parent_item) or []
|
||||||
file_list = [f for f in file_list if f.type == "file" and f.extension
|
file_list = [f for f in file_list if f.type == "file" and f.extension
|
||||||
and f".{f.extension.lower()}" in settings.RMT_SUBEXT]
|
and f".{f.extension.lower()}" in settings.RMT_SUBEXT]
|
||||||
if len(file_list) == 0:
|
if len(file_list) == 0:
|
||||||
@@ -677,9 +747,9 @@ class FileManagerModule(_ModuleBase):
|
|||||||
}
|
}
|
||||||
new_sub_tag_list = [
|
new_sub_tag_list = [
|
||||||
(".default" + new_file_type if (
|
(".default" + new_file_type if (
|
||||||
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") or
|
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") or
|
||||||
(settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") or
|
(settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") or
|
||||||
(settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
|
(settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
|
||||||
) else new_file_type) if t == 0 else "%s%s(%s)" % (new_file_type,
|
) else new_file_type) if t == 0 else "%s%s(%s)" % (new_file_type,
|
||||||
new_sub_tag_dict.get(
|
new_sub_tag_dict.get(
|
||||||
new_file_type, ""
|
new_file_type, ""
|
||||||
@@ -693,6 +763,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
logger.debug(f"正在处理字幕:{sub_item.name}")
|
logger.debug(f"正在处理字幕:{sub_item.name}")
|
||||||
new_item, errmsg = self.__transfer_command(fileitem=sub_item,
|
new_item, errmsg = self.__transfer_command(fileitem=sub_item,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=new_file,
|
target_file=new_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
if new_item:
|
if new_item:
|
||||||
@@ -705,26 +777,24 @@ class FileManagerModule(_ModuleBase):
|
|||||||
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
|
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str,
|
||||||
transfer_type: str) -> Tuple[bool, str]:
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
根据文件名整理对应音轨文件
|
根据文件名整理对应音轨文件
|
||||||
:param fileitem: 源文件
|
:param fileitem: 源文件
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param target_file: 目标路径
|
:param target_file: 目标路径
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
"""
|
"""
|
||||||
org_path = Path(fileitem.path)
|
org_path = Path(fileitem.path)
|
||||||
# 列出所有音轨文件
|
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
|
||||||
if not storage_oper:
|
|
||||||
logger.error(f"不支持 {fileitem.storage} 的文件整理")
|
|
||||||
return False, f"不支持的文件存储:{fileitem.storage}"
|
|
||||||
# 查找上级文件项
|
# 查找上级文件项
|
||||||
parent_item: FileItem = storage_oper.get_parent(fileitem)
|
parent_item: FileItem = source_oper.get_parent(fileitem)
|
||||||
if not parent_item:
|
if not parent_item:
|
||||||
return False, f"{org_path} 上级目录获取失败"
|
return False, f"{org_path} 上级目录获取失败"
|
||||||
file_list: List[FileItem] = storage_oper.list(parent_item)
|
file_list: List[FileItem] = source_oper.list(parent_item)
|
||||||
# 匹配音轨文件
|
# 匹配音轨文件
|
||||||
pending_file_list: List[FileItem] = [file for file in file_list
|
pending_file_list: List[FileItem] = [file for file in file_list
|
||||||
if Path(file.name).stem == org_path.stem
|
if Path(file.name).stem == org_path.stem
|
||||||
@@ -740,6 +810,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
logger.info(f"正在整理音轨文件:{track_file} 到 {new_track_file}")
|
logger.info(f"正在整理音轨文件:{track_file} 到 {new_track_file}")
|
||||||
new_item, errmsg = self.__transfer_command(fileitem=track_file,
|
new_item, errmsg = self.__transfer_command(fileitem=track_file,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=new_track_file,
|
target_file=new_track_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
if new_item:
|
if new_item:
|
||||||
@@ -750,21 +822,19 @@ class FileManagerModule(_ModuleBase):
|
|||||||
logger.error(f"音轨文件 {org_path.name} 整理失败:{str(error)}")
|
logger.error(f"音轨文件 {org_path.name} 整理失败:{str(error)}")
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo, transfer_type: str,
|
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,
|
||||||
target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
transfer_type: str, target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
|
||||||
"""
|
"""
|
||||||
整理整个文件夹
|
整理整个文件夹
|
||||||
:param fileitem: 源文件
|
:param fileitem: 源文件
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
:param target_path: 目标路径
|
:param target_path: 目标路径
|
||||||
"""
|
"""
|
||||||
# 获取目标目录
|
|
||||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
|
||||||
if not target_oper:
|
|
||||||
return None, f"不支持的文件存储:{target_storage}"
|
|
||||||
|
|
||||||
logger.info(f"正在整理目录:{fileitem.path} 到 {target_path}")
|
logger.info(f"正在整理目录:{fileitem.path} 到 {target_path}")
|
||||||
target_item = target_oper.get_folder(target_path)
|
target_item = target_oper.get_folder(target_path)
|
||||||
if not target_item:
|
if not target_item:
|
||||||
@@ -788,6 +858,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 处理所有文件
|
# 处理所有文件
|
||||||
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
|
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
if state:
|
if state:
|
||||||
@@ -795,29 +867,29 @@ class FileManagerModule(_ModuleBase):
|
|||||||
else:
|
else:
|
||||||
return None, errmsg
|
return None, errmsg
|
||||||
|
|
||||||
def __transfer_dir_files(self, fileitem: FileItem, transfer_type: str,
|
def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,
|
||||||
target_storage: str, target_path: Path) -> Tuple[bool, str]:
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
transfer_type: str, target_path: Path) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
按目录结构整理目录下所有文件
|
按目录结构整理目录下所有文件
|
||||||
:param fileitem: 源文件
|
:param fileitem: 源文件
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param target_path: 目标路径
|
:param target_path: 目标路径
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
"""
|
"""
|
||||||
# 列出所有文件
|
file_list: List[FileItem] = source_oper.list(fileitem)
|
||||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
|
||||||
if not storage_oper:
|
|
||||||
logger.error(f"不支持 {fileitem.storage} 的文件整理")
|
|
||||||
return False, f"不支持的文件存储:{fileitem.storage}"
|
|
||||||
file_list: List[FileItem] = storage_oper.list(fileitem)
|
|
||||||
# 整理文件
|
# 整理文件
|
||||||
for item in file_list:
|
for item in file_list:
|
||||||
if item.type == "dir":
|
if item.type == "dir":
|
||||||
# 递归整理目录
|
# 递归整理目录
|
||||||
new_path = target_path / item.name
|
new_path = target_path / item.name
|
||||||
state, errmsg = self.__transfer_dir_files(fileitem=item,
|
state, errmsg = self.__transfer_dir_files(fileitem=item,
|
||||||
transfer_type=transfer_type,
|
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
|
transfer_type=transfer_type,
|
||||||
target_path=new_path)
|
target_path=new_path)
|
||||||
if not state:
|
if not state:
|
||||||
return False, errmsg
|
return False, errmsg
|
||||||
@@ -826,6 +898,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
new_file = target_path / item.name
|
new_file = target_path / item.name
|
||||||
new_item, errmsg = self.__transfer_command(fileitem=item,
|
new_item, errmsg = self.__transfer_command(fileitem=item,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=new_file,
|
target_file=new_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
if not new_item:
|
if not new_item:
|
||||||
@@ -833,16 +907,22 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 返回成功
|
# 返回成功
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo, target_storage: str, target_file: Path,
|
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo,
|
||||||
|
source_oper: StorageBase, target_oper: StorageBase,
|
||||||
|
target_storage: str, target_file: Path,
|
||||||
transfer_type: str, over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
|
transfer_type: str, over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
|
||||||
"""
|
"""
|
||||||
整理一个文件,同时处理其他相关文件
|
整理一个文件,同时处理其他相关文件
|
||||||
:param fileitem: 原文件
|
:param fileitem: 原文件
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
:param target_file: 新文件
|
:param target_file: 新文件
|
||||||
:param transfer_type: 整理方式
|
:param transfer_type: 整理方式
|
||||||
:param over_flag: 是否覆盖,为True时会先删除再整理
|
:param over_flag: 是否覆盖,为True时会先删除再整理
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
"""
|
"""
|
||||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||||
f"操作类型:{transfer_type}")
|
f"操作类型:{transfer_type}")
|
||||||
@@ -874,12 +954,16 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_file.unlink()
|
target_file.unlink()
|
||||||
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=target_file,
|
target_file=target_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
if new_item:
|
if new_item:
|
||||||
# 处理其他相关文件
|
# 处理其他相关文件
|
||||||
self.__transfer_other_files(fileitem=fileitem,
|
self.__transfer_other_files(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_file=target_file,
|
target_file=target_file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
return new_item, errmsg
|
return new_item, errmsg
|
||||||
@@ -936,11 +1020,13 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_storage: str,
|
target_storage: str,
|
||||||
target_path: Path,
|
target_path: Path,
|
||||||
transfer_type: str,
|
transfer_type: str,
|
||||||
|
source_oper: StorageBase,
|
||||||
|
target_oper: StorageBase,
|
||||||
need_scrape: Optional[bool] = False,
|
need_scrape: Optional[bool] = False,
|
||||||
need_rename: Optional[bool] = True,
|
need_rename: Optional[bool] = True,
|
||||||
need_notify: Optional[bool] = True,
|
need_notify: Optional[bool] = True,
|
||||||
overwrite_mode: Optional[str] = None,
|
overwrite_mode: Optional[str] = None,
|
||||||
episodes_info: List[TmdbEpisode] = None,
|
episodes_info: List[TmdbEpisode] = None
|
||||||
) -> TransferInfo:
|
) -> TransferInfo:
|
||||||
"""
|
"""
|
||||||
识别并整理一个文件或者一个目录下的所有文件
|
识别并整理一个文件或者一个目录下的所有文件
|
||||||
@@ -950,6 +1036,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
:param target_storage: 目标存储
|
:param target_storage: 目标存储
|
||||||
:param target_path: 目标路径
|
:param target_path: 目标路径
|
||||||
:param transfer_type: 文件整理方式
|
:param transfer_type: 文件整理方式
|
||||||
|
:param source_oper: 源存储操作对象
|
||||||
|
:param target_oper: 目标存储操作对象
|
||||||
:param need_scrape: 是否需要刮削
|
:param need_scrape: 是否需要刮削
|
||||||
:param need_rename: 是否需要重命名
|
:param need_rename: 是否需要重命名
|
||||||
:param need_notify: 是否需要通知
|
:param need_notify: 是否需要通知
|
||||||
@@ -977,6 +1065,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 整理目录
|
# 整理目录
|
||||||
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
|
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
target_path=new_path,
|
target_path=new_path,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type)
|
||||||
@@ -1040,8 +1130,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
|
|
||||||
# 判断是否要覆盖
|
# 判断是否要覆盖
|
||||||
overflag = False
|
overflag = False
|
||||||
# 目的操作对象
|
|
||||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
|
||||||
# 计算重命名中的文件夹层级
|
# 计算重命名中的文件夹层级
|
||||||
rename_format_level = len(rename_format.split("/")) - 1
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
folder_path = new_file.parents[rename_format_level - 1]
|
folder_path = new_file.parents[rename_format_level - 1]
|
||||||
@@ -1102,14 +1190,16 @@ class FileManagerModule(_ModuleBase):
|
|||||||
if overwrite_mode == 'latest':
|
if overwrite_mode == 'latest':
|
||||||
# 文件不存在,但仅保留最新版本
|
# 文件不存在,但仅保留最新版本
|
||||||
logger.info(f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
|
logger.info(f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
|
||||||
self.__delete_version_files(target_storage, new_file)
|
self.__delete_version_files(target_oper, new_file)
|
||||||
# 整理文件
|
# 整理文件
|
||||||
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
|
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
target_file=new_file,
|
target_file=new_file,
|
||||||
transfer_type=transfer_type,
|
transfer_type=transfer_type,
|
||||||
over_flag=overflag)
|
over_flag=overflag,
|
||||||
|
source_oper=source_oper,
|
||||||
|
target_oper=target_oper)
|
||||||
if not new_item:
|
if not new_item:
|
||||||
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
|
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
|
||||||
return TransferInfo(success=False,
|
return TransferInfo(success=False,
|
||||||
@@ -1142,97 +1232,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
:param file_ext: 文件扩展名
|
:param file_ext: 文件扩展名
|
||||||
:param episodes_info: 当前季的全部集信息
|
:param episodes_info: 当前季的全部集信息
|
||||||
"""
|
"""
|
||||||
|
return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo,
|
||||||
def __convert_invalid_characters(filename: str):
|
file_extension=file_ext, episodes_info=episodes_info)
|
||||||
if not filename:
|
|
||||||
return filename
|
|
||||||
invalid_characters = r'\/:*?"<>|'
|
|
||||||
# 创建半角到全角字符的转换表
|
|
||||||
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
|
|
||||||
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
|
|
||||||
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
|
|
||||||
# 将不支持的字符替换为对应的全角字符
|
|
||||||
for char in invalid_characters:
|
|
||||||
filename = filename.replace(char, char.translate(translation_table))
|
|
||||||
return filename
|
|
||||||
|
|
||||||
# 获取集标题
|
|
||||||
episode_title = None
|
|
||||||
if meta.begin_episode and episodes_info:
|
|
||||||
for episode in episodes_info:
|
|
||||||
if episode.episode_number == meta.begin_episode:
|
|
||||||
episode_title = episode.name
|
|
||||||
break
|
|
||||||
# 获取集播出日期
|
|
||||||
episode_date = None
|
|
||||||
if meta.begin_episode and episodes_info:
|
|
||||||
for episode in episodes_info:
|
|
||||||
if episode.episode_number == meta.begin_episode:
|
|
||||||
episode_date = episode.air_date
|
|
||||||
break
|
|
||||||
|
|
||||||
return {
|
|
||||||
# 标题
|
|
||||||
"title": __convert_invalid_characters(mediainfo.title),
|
|
||||||
# 英文标题
|
|
||||||
"en_title": __convert_invalid_characters(mediainfo.en_title),
|
|
||||||
# 原语种标题
|
|
||||||
"original_title": __convert_invalid_characters(mediainfo.original_title),
|
|
||||||
# 原文件名
|
|
||||||
"original_name": meta.title,
|
|
||||||
# 识别名称(优先使用中文)
|
|
||||||
"name": meta.name,
|
|
||||||
# 识别的英文名称(可能为空)
|
|
||||||
"en_name": meta.en_name,
|
|
||||||
# 年份
|
|
||||||
"year": mediainfo.year or meta.year,
|
|
||||||
# 季年份根据season值获取
|
|
||||||
"season_year": mediainfo.season_years.get(
|
|
||||||
int(meta.season_seq),
|
|
||||||
None) if (mediainfo.season_years and meta.season_seq) else None,
|
|
||||||
# 资源类型
|
|
||||||
"resourceType": meta.resource_type,
|
|
||||||
# 特效
|
|
||||||
"effect": meta.resource_effect,
|
|
||||||
# 版本
|
|
||||||
"edition": meta.edition,
|
|
||||||
# 分辨率
|
|
||||||
"videoFormat": meta.resource_pix,
|
|
||||||
# 制作组/字幕组
|
|
||||||
"releaseGroup": meta.resource_team,
|
|
||||||
# 视频编码
|
|
||||||
"videoCodec": meta.video_encode,
|
|
||||||
# 音频编码
|
|
||||||
"audioCodec": meta.audio_encode,
|
|
||||||
# TMDBID
|
|
||||||
"tmdbid": mediainfo.tmdb_id,
|
|
||||||
# IMDBID
|
|
||||||
"imdbid": mediainfo.imdb_id,
|
|
||||||
# 豆瓣ID
|
|
||||||
"doubanid": mediainfo.douban_id,
|
|
||||||
# 季号
|
|
||||||
"season": meta.season_seq,
|
|
||||||
# 集号
|
|
||||||
"episode": meta.episode_seqs,
|
|
||||||
# 季集 SxxExx
|
|
||||||
"season_episode": "%s%s" % (meta.season, meta.episode),
|
|
||||||
# 段/节
|
|
||||||
"part": meta.part,
|
|
||||||
# 剧集标题
|
|
||||||
"episode_title": __convert_invalid_characters(episode_title),
|
|
||||||
# 剧集日期根据episodes_info值获取
|
|
||||||
"episode_date": episode_date,
|
|
||||||
# 文件后缀
|
|
||||||
"fileExt": file_ext,
|
|
||||||
# 自定义占位符
|
|
||||||
"customization": meta.customization,
|
|
||||||
# 文件元数据
|
|
||||||
"__meta__": meta,
|
|
||||||
# 识别的媒体信息
|
|
||||||
"__mediainfo__": mediainfo,
|
|
||||||
# 当前季的全部集信息
|
|
||||||
"__episodes_info__": episodes_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
||||||
@@ -1313,7 +1314,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
if media_files:
|
if media_files:
|
||||||
for media_file in media_files:
|
for media_file in media_files:
|
||||||
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
||||||
ret_fileitems.append(media_file)
|
if media_file not in ret_fileitems:
|
||||||
|
ret_fileitems.append(media_file)
|
||||||
return ret_fileitems
|
return ret_fileitems
|
||||||
|
|
||||||
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
|
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
|
||||||
@@ -1351,14 +1353,14 @@ class FileManagerModule(_ModuleBase):
|
|||||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
|
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
|
||||||
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
|
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
|
||||||
|
|
||||||
def __delete_version_files(self, target_storage: str, path: Path) -> bool:
|
@staticmethod
|
||||||
|
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
删除目录下的所有版本文件
|
删除目录下的所有版本文件
|
||||||
:param target_storage: 存储类型
|
:param storage_oper: 存储操作对象
|
||||||
:param path: 目录路径
|
:param path: 目录路径
|
||||||
"""
|
"""
|
||||||
# 存储
|
# 存储
|
||||||
storage_oper = self.__get_storage_oper(target_storage)
|
|
||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
return False
|
return False
|
||||||
# 识别文件中的季集信息
|
# 识别文件中的季集信息
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return transtype in self.transtype
|
return transtype in self.transtype
|
||||||
|
|
||||||
|
def reset_config(self):
|
||||||
|
"""
|
||||||
|
重置置配置
|
||||||
|
"""
|
||||||
|
self.storagehelper.reset_storage(self.schema.value)
|
||||||
|
self.init_storage()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def check(self) -> bool:
|
def check(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -24,6 +25,10 @@ class NoCheckInException(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SessionInvalidException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AliPan(StorageBase, metaclass=Singleton):
|
class AliPan(StorageBase, metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
阿里云盘相关操作
|
阿里云盘相关操作
|
||||||
@@ -177,7 +182,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
确认登录后,获取相关token
|
确认登录后,获取相关token
|
||||||
"""
|
"""
|
||||||
if not self._auth_state:
|
if not self._auth_state:
|
||||||
raise Exception("【阿里云盘】请先生成二维码")
|
raise SessionInvalidException("【阿里云盘】请先生成二维码")
|
||||||
resp = self.session.post(
|
resp = self.session.post(
|
||||||
f"{self.base_url}/oauth/access_token",
|
f"{self.base_url}/oauth/access_token",
|
||||||
json={
|
json={
|
||||||
@@ -188,7 +193,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if resp is None:
|
if resp is None:
|
||||||
raise Exception("【阿里云盘】获取 access_token 失败")
|
raise SessionInvalidException("【阿里云盘】获取 access_token 失败")
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
if result.get("code"):
|
if result.get("code"):
|
||||||
raise Exception(f"【阿里云盘】{result.get('code')} - {result.get('message')}!")
|
raise Exception(f"【阿里云盘】{result.get('code')} - {result.get('message')}!")
|
||||||
@@ -199,7 +204,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
刷新access_token
|
刷新access_token
|
||||||
"""
|
"""
|
||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
raise Exception("【阿里云盘】会话失效,请重新扫码登录!")
|
raise SessionInvalidException("【阿里云盘】会话失效,请重新扫码登录!")
|
||||||
resp = self.session.post(
|
resp = self.session.post(
|
||||||
f"{self.base_url}/oauth/access_token",
|
f"{self.base_url}/oauth/access_token",
|
||||||
json={
|
json={
|
||||||
@@ -335,6 +340,8 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not fileinfo:
|
if not fileinfo:
|
||||||
return schemas.FileItem()
|
return schemas.FileItem()
|
||||||
|
if not parent.endswith("/"):
|
||||||
|
parent += "/"
|
||||||
if fileinfo.get("type") == "folder":
|
if fileinfo.get("type") == "folder":
|
||||||
return schemas.FileItem(
|
return schemas.FileItem(
|
||||||
storage=self.schema.value,
|
storage=self.schema.value,
|
||||||
@@ -437,7 +444,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
"/adrive/v1.0/openFile/create",
|
"/adrive/v1.0/openFile/create",
|
||||||
json={
|
json={
|
||||||
"drive_id": parent_item.drive_id,
|
"drive_id": parent_item.drive_id,
|
||||||
"parent_file_id": parent_item.fileid,
|
"parent_file_id": parent_item.fileid or "root",
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": "folder"
|
"type": "folder"
|
||||||
}
|
}
|
||||||
@@ -628,6 +635,29 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
raise Exception(resp.get("message"))
|
raise Exception(resp.get("message"))
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_progress(desc: str, total: int) -> tqdm:
|
||||||
|
"""
|
||||||
|
创建一个可以输出到日志的进度条
|
||||||
|
"""
|
||||||
|
|
||||||
|
class TqdmToLogger(io.StringIO):
|
||||||
|
def write(s, buf): # noqa
|
||||||
|
buf = buf.strip('\r\n\t ')
|
||||||
|
if buf:
|
||||||
|
logger.info(buf)
|
||||||
|
|
||||||
|
return tqdm(
|
||||||
|
total=total,
|
||||||
|
unit='B',
|
||||||
|
unit_scale=True,
|
||||||
|
desc=desc,
|
||||||
|
file=TqdmToLogger(),
|
||||||
|
mininterval=1.0,
|
||||||
|
maxinterval=5.0,
|
||||||
|
miniters=1
|
||||||
|
)
|
||||||
|
|
||||||
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
||||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
@@ -668,13 +698,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
|
|
||||||
# 4. 初始化进度条
|
# 4. 初始化进度条
|
||||||
logger.info(f"【阿里云盘】开始上传: {local_path} -> {target_path},分片数:{len(part_info_list)}")
|
logger.info(f"【阿里云盘】开始上传: {local_path} -> {target_path},分片数:{len(part_info_list)}")
|
||||||
progress_bar = tqdm(
|
progress_bar = self._log_progress(f"【阿里云盘】{target_name} 上传进度", file_size)
|
||||||
total=file_size,
|
|
||||||
unit='B',
|
|
||||||
unit_scale=True,
|
|
||||||
desc="上传进度",
|
|
||||||
ascii=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 分片上传循环
|
# 5. 分片上传循环
|
||||||
with open(local_path, 'rb') as f:
|
with open(local_path, 'rb') as f:
|
||||||
@@ -828,7 +852,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
if resp.get("code"):
|
if resp.get("code"):
|
||||||
logger.debug(f"【阿里云盘】获取文件信息失败: {resp.get('message')}")
|
logger.debug(f"【阿里云盘】获取文件信息失败: {resp.get('message')}")
|
||||||
return None
|
return None
|
||||||
return self.__get_fileitem(resp, parent=f"{str(path.parent)}/")
|
return self.__get_fileitem(resp, parent=str(path.parent))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}")
|
logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}")
|
||||||
return None
|
return None
|
||||||
@@ -854,7 +878,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
if folder:
|
if folder:
|
||||||
return folder
|
return folder
|
||||||
# 逐级查找和创建目录
|
# 逐级查找和创建目录
|
||||||
fileitem = schemas.FileItem(storage=self.schema.value, path="/")
|
fileitem = schemas.FileItem(storage=self.schema.value, path="/", drive_id=self._default_drive_id)
|
||||||
for part in path.parts[1:]:
|
for part in path.parts[1:]:
|
||||||
dir_file = __find_dir(fileitem, part)
|
dir_file = __find_dir(fileitem, part)
|
||||||
if dir_file:
|
if dir_file:
|
||||||
@@ -957,3 +981,5 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
except NoCheckInException:
|
except NoCheckInException:
|
||||||
return None
|
return None
|
||||||
|
except SessionInvalidException:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import io
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -375,7 +375,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"POST",
|
"POST",
|
||||||
"/open/folder/add",
|
"/open/folder/add",
|
||||||
data={
|
data={
|
||||||
"pid": int(parent_item.fileid),
|
"pid": int(parent_item.fileid or "0"),
|
||||||
"file_name": name
|
"file_name": name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -399,17 +399,37 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
modify_time=int(time.time())
|
modify_time=int(time.time())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_progress(desc: str, total: int) -> tqdm:
|
||||||
|
"""
|
||||||
|
创建一个可以输出到日志的进度条
|
||||||
|
"""
|
||||||
|
|
||||||
|
class TqdmToLogger(io.StringIO):
|
||||||
|
def write(s, buf): # noqa
|
||||||
|
buf = buf.strip('\r\n\t ')
|
||||||
|
if buf:
|
||||||
|
logger.info(buf)
|
||||||
|
|
||||||
|
return tqdm(
|
||||||
|
total=total,
|
||||||
|
unit='B',
|
||||||
|
unit_scale=True,
|
||||||
|
desc=desc,
|
||||||
|
file=TqdmToLogger(),
|
||||||
|
mininterval=1.0,
|
||||||
|
maxinterval=5.0,
|
||||||
|
miniters=1
|
||||||
|
)
|
||||||
|
|
||||||
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
||||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
实现带秒传、断点续传和二次认证的文件上传
|
实现带秒传、断点续传和二次认证的文件上传
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def encode_callback(cb: dict):
|
def encode_callback(cb: str) -> str:
|
||||||
"""
|
return oss2.utils.b64encode_as_string(cb)
|
||||||
回调参数Base64编码函数
|
|
||||||
"""
|
|
||||||
return oss2.utils.b64encode_as_string(json.dumps(cb).strip())
|
|
||||||
|
|
||||||
target_name = new_name or local_path.name
|
target_name = new_name or local_path.name
|
||||||
target_path = Path(target_dir.path) / target_name
|
target_path = Path(target_dir.path) / target_name
|
||||||
@@ -535,12 +555,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
security_token=SecurityToken
|
security_token=SecurityToken
|
||||||
)
|
)
|
||||||
bucket = oss2.Bucket(auth, endpoint, bucket_name) # noqa
|
bucket = oss2.Bucket(auth, endpoint, bucket_name) # noqa
|
||||||
# 处理oss请求回调
|
|
||||||
callback_dict = json.loads(callback.get("callback"))
|
|
||||||
callback_var_dict = json.loads(callback.get("callback_var"))
|
|
||||||
# 补充参数
|
|
||||||
logger.debug(f"【115】上传 Step 6 回调参数:{callback_dict} {callback_var_dict}")
|
|
||||||
# 填写不能包含Bucket名称在内的Object完整路径,例如exampledir/exampleobject.txt。
|
|
||||||
# determine_part_size方法用于确定分片大小,设置分片大小为 100M
|
# determine_part_size方法用于确定分片大小,设置分片大小为 100M
|
||||||
part_size = determine_part_size(file_size, preferred_size=100 * 1024 * 1024)
|
part_size = determine_part_size(file_size, preferred_size=100 * 1024 * 1024)
|
||||||
|
|
||||||
@@ -584,8 +598,8 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
|
|
||||||
# 请求头
|
# 请求头
|
||||||
headers = {
|
headers = {
|
||||||
'X-oss-callback': encode_callback(callback_dict),
|
'X-oss-callback': encode_callback(callback["callback"]),
|
||||||
'x-oss-callback-var': encode_callback(callback_var_dict),
|
'x-oss-callback-var': encode_callback(callback["callback_var"]),
|
||||||
'x-oss-forbid-overwrite': 'false'
|
'x-oss-forbid-overwrite': 'false'
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ class TYemaSiteUserInfo(SiteParserBase):
|
|||||||
self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime"))
|
self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime"))
|
||||||
|
|
||||||
self.upload = user_info.get('uploadSize')
|
self.upload = user_info.get('uploadSize')
|
||||||
self.download = user_info.get('downloadSize')
|
# 使用 promotionDownloadSize 获取真实下载量(考虑促销因素)
|
||||||
|
if "promotionDownloadSize" in user_info:
|
||||||
|
self.download = user_info.get('promotionDownloadSize')
|
||||||
|
else:
|
||||||
|
self.download = user_info.get('downloadSize')
|
||||||
self.ratio = round(self.upload / (self.download or 1), 2)
|
self.ratio = round(self.upload / (self.download or 1), 2)
|
||||||
self.bonus = user_info.get("bonus")
|
self.bonus = user_info.get("bonus")
|
||||||
self.message_unread = 0
|
self.message_unread = 0
|
||||||
|
|||||||
@@ -108,11 +108,17 @@ class MTorrentSpider:
|
|||||||
category = MediaType.MOVIE.value
|
category = MediaType.MOVIE.value
|
||||||
else:
|
else:
|
||||||
category = MediaType.UNKNOWN.value
|
category = MediaType.UNKNOWN.value
|
||||||
labels_value = self._labels.get(result.get('labels') or "0") or ""
|
# 处理馒头新版标签
|
||||||
if labels_value:
|
labels = []
|
||||||
labels = labels_value.split()
|
labels_new = result.get( 'labelsNew' )
|
||||||
|
if labels_new:
|
||||||
|
# 新版标签本身就是list
|
||||||
|
labels = labels_new
|
||||||
else:
|
else:
|
||||||
labels = []
|
# 旧版标签
|
||||||
|
labels_value = self._labels.get(result.get('labels') or "0") or ""
|
||||||
|
if labels_value:
|
||||||
|
labels = labels_value.split()
|
||||||
torrent = {
|
torrent = {
|
||||||
'title': result.get('name'),
|
'title': result.get('name'),
|
||||||
'description': result.get('smallDescr'),
|
'description': result.get('smallDescr'),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
self.cache = TmdbCache()
|
self.cache = TmdbCache()
|
||||||
self.tmdb = TmdbApi()
|
self.tmdb = TmdbApi()
|
||||||
self.category = CategoryHelper()
|
self.category = CategoryHelper()
|
||||||
self.scraper = TmdbScraper(self.tmdb)
|
self.scraper = TmdbScraper()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_name() -> str:
|
def get_name() -> str:
|
||||||
@@ -268,7 +268,7 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
# 当前季第一季时间
|
# 当前季第一季时间
|
||||||
first_date = episodes[0].get("air_date")
|
first_date = episodes[0].get("air_date")
|
||||||
# 判断是不是日期格式
|
# 判断是不是日期格式
|
||||||
if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date):
|
if first_date and re.match(r"^\d{4}-\d{2}-\d{2}$", first_date):
|
||||||
season_years[season] = str(first_date).split("-")[0]
|
season_years[season] = str(first_date).split("-")[0]
|
||||||
if season_years:
|
if season_years:
|
||||||
mediainfo.season_years = season_years
|
mediainfo.season_years = season_years
|
||||||
|
|||||||
@@ -7,15 +7,29 @@ from app.core.context import MediaInfo
|
|||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.dom import DomUtils
|
from app.utils.dom import DomUtils
|
||||||
|
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||||
|
|
||||||
|
|
||||||
class TmdbScraper:
|
class TmdbScraper:
|
||||||
tmdb = None
|
_meta_tmdb = None
|
||||||
_force_nfo = False
|
_img_tmdb = None
|
||||||
_force_img = False
|
|
||||||
|
|
||||||
def __init__(self, tmdb):
|
@property
|
||||||
self.tmdb = tmdb
|
def default_tmdb(self):
|
||||||
|
"""
|
||||||
|
获取元数据TMDB Api
|
||||||
|
"""
|
||||||
|
if not self._meta_tmdb:
|
||||||
|
self._meta_tmdb = TmdbApi(language=settings.TMDB_LOCALE)
|
||||||
|
return self._meta_tmdb
|
||||||
|
|
||||||
|
def original_tmdb(self, mediainfo: Optional[MediaInfo] = None):
|
||||||
|
"""
|
||||||
|
获取图片TMDB Api
|
||||||
|
"""
|
||||||
|
if settings.TMDB_SCRAP_ORIGINAL_IMAGE and mediainfo:
|
||||||
|
return TmdbApi(language=mediainfo.original_language)
|
||||||
|
return self.default_tmdb
|
||||||
|
|
||||||
def get_metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
def get_metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:
|
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:
|
||||||
@@ -33,9 +47,9 @@ class TmdbScraper:
|
|||||||
if season is not None:
|
if season is not None:
|
||||||
# 查询季信息
|
# 查询季信息
|
||||||
if mediainfo.episode_group:
|
if mediainfo.episode_group:
|
||||||
seasoninfo = self.tmdb.get_tv_group_detail(mediainfo.episode_group, season=season)
|
seasoninfo = self.default_tmdb.get_tv_group_detail(mediainfo.episode_group, season=season)
|
||||||
else:
|
else:
|
||||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season=season)
|
seasoninfo = self.default_tmdb.get_tv_season_detail(mediainfo.tmdb_id, season=season)
|
||||||
if episode:
|
if episode:
|
||||||
# 集元数据文件
|
# 集元数据文件
|
||||||
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
|
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
|
||||||
@@ -48,11 +62,12 @@ class TmdbScraper:
|
|||||||
# 电视剧元数据文件
|
# 电视剧元数据文件
|
||||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||||
if doc:
|
if doc:
|
||||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> dict:
|
def get_metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None,
|
||||||
|
episode: Optional[int] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
获取图片名称和url
|
获取图片名称和url
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
@@ -61,13 +76,13 @@ class TmdbScraper:
|
|||||||
"""
|
"""
|
||||||
images = {}
|
images = {}
|
||||||
if season is not None:
|
if season is not None:
|
||||||
# 只需要集的图片
|
# 只需要季集的图片
|
||||||
if episode:
|
if episode:
|
||||||
# 集的图片
|
# 集的图片
|
||||||
if mediainfo.episode_group:
|
if mediainfo.episode_group:
|
||||||
seasoninfo = self.tmdb.get_tv_group_detail(mediainfo.episode_group, season)
|
seasoninfo = self.original_tmdb(mediainfo).get_tv_group_detail(mediainfo.episode_group, season)
|
||||||
else:
|
else:
|
||||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
|
seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||||
if seasoninfo:
|
if seasoninfo:
|
||||||
episodeinfo = self.__get_episode_detail(seasoninfo, episode)
|
episodeinfo = self.__get_episode_detail(seasoninfo, episode)
|
||||||
if episodeinfo and episodeinfo.get("still_path"):
|
if episodeinfo and episodeinfo.get("still_path"):
|
||||||
@@ -77,7 +92,7 @@ class TmdbScraper:
|
|||||||
images[still_name] = still_url
|
images[still_name] = still_url
|
||||||
else:
|
else:
|
||||||
# 季的图片
|
# 季的图片
|
||||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
|
seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||||
if seasoninfo:
|
if seasoninfo:
|
||||||
# TMDB季poster图片
|
# TMDB季poster图片
|
||||||
poster_name, poster_url = self.get_season_poster(seasoninfo, season)
|
poster_name, poster_url = self.get_season_poster(seasoninfo, season)
|
||||||
@@ -85,7 +100,7 @@ class TmdbScraper:
|
|||||||
images[poster_name] = poster_url
|
images[poster_name] = poster_url
|
||||||
return images
|
return images
|
||||||
else:
|
else:
|
||||||
# 主媒体图片
|
# 获取媒体信息中原有图片(TheMovieDb或Fanart)
|
||||||
for attr_name, attr_value in vars(mediainfo).items():
|
for attr_name, attr_value in vars(mediainfo).items():
|
||||||
if attr_value \
|
if attr_value \
|
||||||
and attr_name.endswith("_path") \
|
and attr_name.endswith("_path") \
|
||||||
@@ -94,6 +109,15 @@ class TmdbScraper:
|
|||||||
and attr_value.startswith("http"):
|
and attr_value.startswith("http"):
|
||||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||||
images[image_name] = attr_value
|
images[image_name] = attr_value
|
||||||
|
# 替换原语言Poster
|
||||||
|
if settings.TMDB_SCRAP_ORIGINAL_IMAGE:
|
||||||
|
_mediainfo = self.original_tmdb(mediainfo).get_info(mediainfo.type, mediainfo.tmdb_id)
|
||||||
|
if _mediainfo:
|
||||||
|
for attr_name, attr_value in _mediainfo.items():
|
||||||
|
if attr_name.endswith("_path") and attr_value is not None:
|
||||||
|
image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{attr_value}"
|
||||||
|
image_name = attr_name.replace("_path", "") + Path(image_url).suffix
|
||||||
|
images[image_name] = image_url
|
||||||
return images
|
return images
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -23,31 +23,19 @@ class TmdbApi:
|
|||||||
TMDB识别匹配
|
TMDB识别匹配
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, language: Optional[str] = None):
|
||||||
# TMDB主体
|
# TMDB主体
|
||||||
self.tmdb = TMDb()
|
self.tmdb = TMDb(language=language)
|
||||||
# 域名
|
|
||||||
self.tmdb.domain = settings.TMDB_API_DOMAIN
|
|
||||||
# 开启缓存
|
|
||||||
self.tmdb.cache = True
|
|
||||||
# APIKEY
|
|
||||||
self.tmdb.api_key = settings.TMDB_API_KEY
|
|
||||||
# 语种
|
|
||||||
self.tmdb.language = 'zh'
|
|
||||||
# 代理
|
|
||||||
self.tmdb.proxies = settings.PROXY
|
|
||||||
# 调试模式
|
|
||||||
self.tmdb.debug = False
|
|
||||||
# TMDB查询对象
|
# TMDB查询对象
|
||||||
self.search = Search()
|
self.search = Search(language=language)
|
||||||
self.movie = Movie()
|
self.movie = Movie(language=language)
|
||||||
self.tv = TV()
|
self.tv = TV(language=language)
|
||||||
self.season_obj = Season()
|
self.season_obj = Season(language=language)
|
||||||
self.episode_obj = Episode()
|
self.episode_obj = Episode(language=language)
|
||||||
self.discover = Discover()
|
self.discover = Discover(language=language)
|
||||||
self.trending = Trending()
|
self.trending = Trending(language=language)
|
||||||
self.person = Person()
|
self.person = Person(language=language)
|
||||||
self.collection = Collection()
|
self.collection = Collection(language=language)
|
||||||
|
|
||||||
def search_multiis(self, title: str) -> List[dict]:
|
def search_multiis(self, title: str) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -632,7 +620,8 @@ class TmdbApi:
|
|||||||
# 转换多语种标题
|
# 转换多语种标题
|
||||||
self.__update_tmdbinfo_extra_title(tmdb_info)
|
self.__update_tmdbinfo_extra_title(tmdb_info)
|
||||||
# 转换中文标题
|
# 转换中文标题
|
||||||
self.__update_tmdbinfo_cn_title(tmdb_info)
|
if settings.TMDB_LOCALE == "zh":
|
||||||
|
self.__update_tmdbinfo_cn_title(tmdb_info)
|
||||||
|
|
||||||
return tmdb_info
|
return tmdb_info
|
||||||
|
|
||||||
@@ -647,6 +636,7 @@ class TmdbApi:
|
|||||||
return None
|
return None
|
||||||
# dict[地区:分级]
|
# dict[地区:分级]
|
||||||
ratings = {}
|
ratings = {}
|
||||||
|
results = []
|
||||||
if results := (tmdb_info.get("release_dates") or {}).get("results"):
|
if results := (tmdb_info.get("release_dates") or {}).get("results"):
|
||||||
"""
|
"""
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -17,19 +16,22 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class TMDb(object):
|
class TMDb(object):
|
||||||
TMDB_API_KEY = "TMDB_API_KEY"
|
|
||||||
TMDB_LANGUAGE = "TMDB_LANGUAGE"
|
|
||||||
TMDB_SESSION_ID = "TMDB_SESSION_ID"
|
|
||||||
TMDB_WAIT_ON_RATE_LIMIT = "TMDB_WAIT_ON_RATE_LIMIT"
|
|
||||||
TMDB_DEBUG_ENABLED = "TMDB_DEBUG_ENABLED"
|
|
||||||
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
|
|
||||||
TMDB_PROXIES = "TMDB_PROXIES"
|
|
||||||
TMDB_DOMAIN = "TMDB_DOMAIN"
|
|
||||||
|
|
||||||
_req = None
|
_req = None
|
||||||
_session = None
|
_session = None
|
||||||
|
|
||||||
def __init__(self, obj_cached=True, session=None):
|
def __init__(self, obj_cached=True, session=None, language=None):
|
||||||
|
self._api_key = settings.TMDB_API_KEY
|
||||||
|
self._language = language or settings.TMDB_LOCALE or "en-US"
|
||||||
|
self._session_id = None
|
||||||
|
self._wait_on_rate_limit = True
|
||||||
|
self._debug_enabled = False
|
||||||
|
self._cache_enabled = obj_cached
|
||||||
|
self._proxies = settings.PROXY
|
||||||
|
self._domain = settings.TMDB_API_DOMAIN
|
||||||
|
self._page = None
|
||||||
|
self._total_results = None
|
||||||
|
self._total_pages = None
|
||||||
|
|
||||||
if session is not None:
|
if session is not None:
|
||||||
self._req = RequestUtils(session=session, proxies=self.proxies)
|
self._req = RequestUtils(session=session, proxies=self.proxies)
|
||||||
else:
|
else:
|
||||||
@@ -39,103 +41,88 @@ class TMDb(object):
|
|||||||
self._reset = None
|
self._reset = None
|
||||||
self._timeout = 15
|
self._timeout = 15
|
||||||
self.obj_cached = obj_cached
|
self.obj_cached = obj_cached
|
||||||
if os.environ.get(self.TMDB_LANGUAGE) is None:
|
|
||||||
os.environ[self.TMDB_LANGUAGE] = "en-US"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def page(self):
|
def page(self):
|
||||||
return os.environ["page"]
|
return self._page
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_results(self):
|
def total_results(self):
|
||||||
return os.environ["total_results"]
|
return self._total_results
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_pages(self):
|
def total_pages(self):
|
||||||
return os.environ["total_pages"]
|
return self._total_pages
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_key(self):
|
def api_key(self):
|
||||||
return os.environ.get(self.TMDB_API_KEY)
|
return self._api_key
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain(self):
|
def domain(self):
|
||||||
return os.environ.get(self.TMDB_DOMAIN)
|
return self._domain
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def proxies(self):
|
def proxies(self):
|
||||||
proxy = os.environ.get(self.TMDB_PROXIES)
|
return self._proxies
|
||||||
if proxy is not None:
|
|
||||||
proxy = eval(proxy)
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
@proxies.setter
|
@proxies.setter
|
||||||
def proxies(self, proxies):
|
def proxies(self, proxies):
|
||||||
if proxies is not None:
|
self._proxies = proxies
|
||||||
os.environ[self.TMDB_PROXIES] = str(proxies)
|
|
||||||
|
|
||||||
@api_key.setter
|
@api_key.setter
|
||||||
def api_key(self, api_key):
|
def api_key(self, api_key):
|
||||||
os.environ[self.TMDB_API_KEY] = str(api_key)
|
self._api_key = str(api_key)
|
||||||
|
|
||||||
@domain.setter
|
@domain.setter
|
||||||
def domain(self, domain):
|
def domain(self, domain):
|
||||||
os.environ[self.TMDB_DOMAIN] = str(domain)
|
self._domain = str(domain)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def language(self):
|
def language(self):
|
||||||
return os.environ.get(self.TMDB_LANGUAGE)
|
return self._language
|
||||||
|
|
||||||
@language.setter
|
@language.setter
|
||||||
def language(self, language):
|
def language(self, language):
|
||||||
os.environ[self.TMDB_LANGUAGE] = language
|
self._language = language
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_session(self):
|
def has_session(self):
|
||||||
return True if os.environ.get(self.TMDB_SESSION_ID) else False
|
return True if self._session_id else False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_id(self):
|
def session_id(self):
|
||||||
if not os.environ.get(self.TMDB_SESSION_ID):
|
if not self._session_id:
|
||||||
raise TMDbException("Must Authenticate to create a session run Authentication(username, password)")
|
raise TMDbException("Must Authenticate to create a session run Authentication(username, password)")
|
||||||
return os.environ.get(self.TMDB_SESSION_ID)
|
return self._session_id
|
||||||
|
|
||||||
@session_id.setter
|
@session_id.setter
|
||||||
def session_id(self, session_id):
|
def session_id(self, session_id):
|
||||||
os.environ[self.TMDB_SESSION_ID] = session_id
|
self._session_id = session_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wait_on_rate_limit(self):
|
def wait_on_rate_limit(self):
|
||||||
if os.environ.get(self.TMDB_WAIT_ON_RATE_LIMIT) == "False":
|
return self._wait_on_rate_limit
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
@wait_on_rate_limit.setter
|
@wait_on_rate_limit.setter
|
||||||
def wait_on_rate_limit(self, wait_on_rate_limit):
|
def wait_on_rate_limit(self, wait_on_rate_limit):
|
||||||
os.environ[self.TMDB_WAIT_ON_RATE_LIMIT] = str(wait_on_rate_limit)
|
self._wait_on_rate_limit = bool(wait_on_rate_limit)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def debug(self):
|
def debug(self):
|
||||||
if os.environ.get(self.TMDB_DEBUG_ENABLED) == "True":
|
return self._debug_enabled
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@debug.setter
|
@debug.setter
|
||||||
def debug(self, debug):
|
def debug(self, debug):
|
||||||
os.environ[self.TMDB_DEBUG_ENABLED] = str(debug)
|
self._debug_enabled = bool(debug)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cache(self):
|
def cache(self):
|
||||||
if os.environ.get(self.TMDB_CACHE_ENABLED) == "False":
|
return self._cache_enabled
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
@cache.setter
|
@cache.setter
|
||||||
def cache(self, cache):
|
def cache(self, cache):
|
||||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
self._cache_enabled = bool(cache)
|
||||||
|
|
||||||
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def cached_request(self, method, url, data, json,
|
def cached_request(self, method, url, data, json,
|
||||||
@@ -197,30 +184,30 @@ class TMDb(object):
|
|||||||
else:
|
else:
|
||||||
raise TMDbException("达到请求频率限制,将在 %d 秒后重试..." % sleep_time)
|
raise TMDbException("达到请求频率限制,将在 %d 秒后重试..." % sleep_time)
|
||||||
|
|
||||||
json = req.json()
|
json_data = req.json()
|
||||||
|
|
||||||
if "page" in json:
|
if "page" in json_data:
|
||||||
os.environ["page"] = str(json["page"])
|
self._page = json_data["page"]
|
||||||
|
|
||||||
if "total_results" in json:
|
if "total_results" in json_data:
|
||||||
os.environ["total_results"] = str(json["total_results"])
|
self._total_results = json_data["total_results"]
|
||||||
|
|
||||||
if "total_pages" in json:
|
if "total_pages" in json_data:
|
||||||
os.environ["total_pages"] = str(json["total_pages"])
|
self._total_pages = json_data["total_pages"]
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
logger.info(json)
|
logger.info(json_data)
|
||||||
logger.info(self.cached_request.cache_info())
|
logger.info(self.cached_request.cache_info())
|
||||||
|
|
||||||
if "errors" in json:
|
if "errors" in json_data:
|
||||||
raise TMDbException(json["errors"])
|
raise TMDbException(json_data["errors"])
|
||||||
|
|
||||||
if "success" in json and json["success"] is False:
|
if "success" in json_data and json_data["success"] is False:
|
||||||
raise TMDbException(json["status_message"])
|
raise TMDbException(json_data["status_message"])
|
||||||
|
|
||||||
if key:
|
if key:
|
||||||
return json.get(key)
|
return json_data.get(key)
|
||||||
return json
|
return json_data
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self._session:
|
if self._session:
|
||||||
|
|||||||
5
app/modules/transmission/transmission.py
Normal file → Executable file
5
app/modules/transmission/transmission.py
Normal file → Executable file
@@ -163,8 +163,9 @@ class Transmission:
|
|||||||
if not self.trc:
|
if not self.trc:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
torrent = self.trc.get_torrents(ids=ids, arguments=self._trarg)
|
torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)
|
||||||
if torrent:
|
if len(torrents):
|
||||||
|
torrent = torrents[0]
|
||||||
labels = [str(tag).strip()
|
labels = [str(tag).strip()
|
||||||
for tag in torrent.labels] if hasattr(torrent, "labels") else []
|
for tag in torrent.labels] if hasattr(torrent, "labels") else []
|
||||||
return labels
|
return labels
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class TrimeMedia:
|
|||||||
self._playhost = play_api.host
|
self._playhost = play_api.host
|
||||||
elif play_host:
|
elif play_host:
|
||||||
logger.warning(f"请检查外网播放地址 {play_host}")
|
logger.warning(f"请检查外网播放地址 {play_host}")
|
||||||
|
self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/")
|
||||||
|
|
||||||
self.reconnect()
|
self.reconnect()
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""
|
||||||
|
获取插件名称
|
||||||
|
:return: 插件名称
|
||||||
|
"""
|
||||||
|
return self.plugin_name
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_state(self) -> bool:
|
def get_state(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -76,6 +83,14 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_render_mode() -> Tuple[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
获取插件渲染模式
|
||||||
|
:return: 1、渲染模式,支持:vue/vuetify,默认vuetify;2、vue模式下编译后文件的相对路径,默认为`dist/asserts`,vuetify模式下为None
|
||||||
|
"""
|
||||||
|
return "vuetify", None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_api(self) -> List[Dict[str, Any]]:
|
def get_api(self) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -84,6 +99,7 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"path": "/xx",
|
"path": "/xx",
|
||||||
"endpoint": self.xxx,
|
"endpoint": self.xxx,
|
||||||
"methods": ["GET", "POST"],
|
"methods": ["GET", "POST"],
|
||||||
|
"auth: "apikey", # 鉴权类型:apikey/bear
|
||||||
"summary": "API名称",
|
"summary": "API名称",
|
||||||
"description": "API说明"
|
"description": "API说明"
|
||||||
}]
|
}]
|
||||||
@@ -91,18 +107,19 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
拼装插件配置页面,插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||||
插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
:return: 1、页面配置(vuetify模式)或 None(vue模式);2、默认数据结构
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_page(self) -> List[dict]:
|
def get_page(self) -> Optional[List[dict]]:
|
||||||
"""
|
"""
|
||||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||||
插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||||
|
:return: 页面配置(vuetify模式)或 None(vue模式)
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -119,9 +136,9 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]:
|
||||||
"""
|
"""
|
||||||
获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据)
|
获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置含数据json(vuetify)或 None(vue模式)
|
||||||
1、col配置参考:
|
1、col配置参考:
|
||||||
{
|
{
|
||||||
"cols": 12, "md": 6
|
"cols": 12, "md": 6
|
||||||
@@ -133,7 +150,7 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
|
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
|
||||||
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
|
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
|
||||||
}
|
}
|
||||||
3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
3、vuetify模式页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/;vue模式为None
|
||||||
|
|
||||||
kwargs参数可获取的值:1、user_agent:浏览器UA
|
kwargs参数可获取的值:1、user_agent:浏览器UA
|
||||||
|
|
||||||
@@ -155,6 +172,16 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def get_module(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||||
|
{
|
||||||
|
"id1": self.xxx1,
|
||||||
|
"id2": self.xxx2,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def stop_service(self):
|
def stop_service(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class Scheduler(metaclass=Singleton):
|
|||||||
_jobs = {}
|
_jobs = {}
|
||||||
# 用户认证失败次数
|
# 用户认证失败次数
|
||||||
_auth_count = 0
|
_auth_count = 0
|
||||||
|
# 用户认证失败消息发送
|
||||||
|
_auth_message = False
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.init()
|
self.init()
|
||||||
@@ -586,6 +588,9 @@ class Scheduler(metaclass=Singleton):
|
|||||||
schedulers = []
|
schedulers = []
|
||||||
# 去重
|
# 去重
|
||||||
added = []
|
added = []
|
||||||
|
# 避免_scheduler.shutdown()处于阻塞状态导致的死锁
|
||||||
|
if not self._scheduler or not self._scheduler.running:
|
||||||
|
return []
|
||||||
jobs = self._scheduler.get_jobs()
|
jobs = self._scheduler.get_jobs()
|
||||||
# 按照下次运行时间排序
|
# 按照下次运行时间排序
|
||||||
jobs.sort(key=lambda x: x.next_run_time)
|
jobs.sort(key=lambda x: x.next_run_time)
|
||||||
@@ -658,9 +663,11 @@ class Scheduler(metaclass=Singleton):
|
|||||||
# 最大重试次数
|
# 最大重试次数
|
||||||
__max_try__ = 30
|
__max_try__ = 30
|
||||||
if self._auth_count > __max_try__:
|
if self._auth_count > __max_try__:
|
||||||
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
if not self._auth_message:
|
||||||
message="用户认证失败次数过多,将不再尝试认证!",
|
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
||||||
role="system")
|
message="用户认证失败次数过多,将不再尝试认证!",
|
||||||
|
role="system")
|
||||||
|
self._auth_message = True
|
||||||
return
|
return
|
||||||
logger.info("用户未认证,正在尝试认证...")
|
logger.info("用户未认证,正在尝试认证...")
|
||||||
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
||||||
@@ -675,10 +682,11 @@ class Scheduler(metaclass=Singleton):
|
|||||||
Notification(
|
Notification(
|
||||||
mtype=NotificationType.Manual,
|
mtype=NotificationType.Manual,
|
||||||
title="MoviePilot用户认证成功",
|
title="MoviePilot用户认证成功",
|
||||||
text=f"使用站点:{msg}",
|
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
|
||||||
link=settings.MP_DOMAIN('#/site')
|
link=settings.MP_DOMAIN('#/site')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# 认证通过后重新初始化插件
|
||||||
PluginManager().init_config()
|
PluginManager().init_config()
|
||||||
self.init_plugin_jobs()
|
self.init_plugin_jobs()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List, Set
|
from typing import Optional, Dict, Any, List, Set, Callable
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, root_validator
|
from pydantic import BaseModel, Field, root_validator
|
||||||
|
|
||||||
@@ -307,3 +307,21 @@ class MediaRecognizeConvertEventData(ChainEventData):
|
|||||||
|
|
||||||
# 输出参数
|
# 输出参数
|
||||||
media_dict: dict = Field(default=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)")
|
media_dict: dict = Field(default=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)")
|
||||||
|
|
||||||
|
|
||||||
|
class StorageOperSelectionEventData(ChainEventData):
|
||||||
|
"""
|
||||||
|
StorageOperSelect 事件的数据模型
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
# 输入参数
|
||||||
|
storage (str): 存储类型
|
||||||
|
|
||||||
|
# 输出参数
|
||||||
|
storage_oper (Callable): 存储操作对象
|
||||||
|
"""
|
||||||
|
# 输入参数
|
||||||
|
storage: Optional[str] = Field(default=None, description="存储类型")
|
||||||
|
|
||||||
|
# 输出参数
|
||||||
|
storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional, Union
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Optional, Union
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.schemas.types import NotificationType, MessageChannel
|
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
||||||
|
|
||||||
|
|
||||||
class CommingMessage(BaseModel):
|
class CommingMessage(BaseModel):
|
||||||
@@ -45,6 +45,8 @@ class Notification(BaseModel):
|
|||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
# 消息类型
|
# 消息类型
|
||||||
mtype: Optional[NotificationType] = None
|
mtype: Optional[NotificationType] = None
|
||||||
|
# 内容类型
|
||||||
|
ctype: Optional[ContentType] = None
|
||||||
# 标题
|
# 标题
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
# 文本内容
|
# 文本内容
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ class PluginDashboard(Plugin):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
# 仪表板key
|
# 仪表板key
|
||||||
key: Optional[str] = None
|
key: Optional[str] = None
|
||||||
|
# 演染模式
|
||||||
|
render_mode: Optional[str] = Field(default="vuetify")
|
||||||
# 全局配置
|
# 全局配置
|
||||||
attrs: Optional[dict] = Field(default_factory=dict)
|
attrs: Optional[dict] = Field(default_factory=dict)
|
||||||
# col列数
|
# col列数
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class ChainEventType(Enum):
|
|||||||
RecommendSource = "recommend.source"
|
RecommendSource = "recommend.source"
|
||||||
# 工作流执行
|
# 工作流执行
|
||||||
WorkflowExecution = "workflow.execution"
|
WorkflowExecution = "workflow.execution"
|
||||||
|
# 存储操作选择
|
||||||
|
StorageOperSelection = "storage.operation"
|
||||||
|
|
||||||
|
|
||||||
# 系统配置Key字典
|
# 系统配置Key字典
|
||||||
@@ -149,6 +151,8 @@ class SystemConfigKey(Enum):
|
|||||||
FollowSubscribers = "FollowSubscribers"
|
FollowSubscribers = "FollowSubscribers"
|
||||||
# 通知发送时间
|
# 通知发送时间
|
||||||
NotificationSendTime = "NotificationSendTime"
|
NotificationSendTime = "NotificationSendTime"
|
||||||
|
# 通知消息格式模板
|
||||||
|
NotificationTemplates = "NotificationTemplates"
|
||||||
|
|
||||||
|
|
||||||
# 处理进度Key字典
|
# 处理进度Key字典
|
||||||
@@ -187,6 +191,21 @@ class NotificationType(Enum):
|
|||||||
Other = "其它"
|
Other = "其它"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentType(str, Enum):
|
||||||
|
"""
|
||||||
|
消息内容类型
|
||||||
|
操作状态的通知消息类型标识
|
||||||
|
"""
|
||||||
|
# 订阅添加成功
|
||||||
|
SubscribeAdded = "subscribeAdded"
|
||||||
|
# 订阅完成
|
||||||
|
SubscribeComplete = "subscribeComplete"
|
||||||
|
# 入库成功
|
||||||
|
OrganizeSuccess = "organizeSuccess"
|
||||||
|
# 下载开始(添加下载任务成功)
|
||||||
|
DownloadAdded = "downloadAdded"
|
||||||
|
|
||||||
|
|
||||||
# 消息渠道
|
# 消息渠道
|
||||||
class MessageChannel(Enum):
|
class MessageChannel(Enum):
|
||||||
"""
|
"""
|
||||||
|
|||||||
22
app/startup/command_initializer.py
Normal file
22
app/startup/command_initializer.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from app.command import Command
|
||||||
|
|
||||||
|
|
||||||
|
def init_command():
|
||||||
|
"""
|
||||||
|
初始化命令
|
||||||
|
"""
|
||||||
|
Command()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_command():
|
||||||
|
"""
|
||||||
|
停止命令
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def restart_command():
|
||||||
|
"""
|
||||||
|
重启命令
|
||||||
|
"""
|
||||||
|
Command().init_commands()
|
||||||
@@ -3,10 +3,25 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.startup.workflow_initializer import init_workflow, stop_workflow
|
from app.core.config import global_vars
|
||||||
from app.startup.modules_initializer import shutdown_modules, start_modules
|
from app.startup.command_initializer import init_command, stop_command, restart_command
|
||||||
from app.startup.plugins_initializer import init_plugins_async
|
from app.startup.modules_initializer import init_modules, stop_modules
|
||||||
|
from app.startup.monitor_initializer import stop_monitor, init_monitor
|
||||||
|
from app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins
|
||||||
from app.startup.routers_initializer import init_routers
|
from app.startup.routers_initializer import init_routers
|
||||||
|
from app.startup.scheduler_initializer import stop_scheduler, init_scheduler, init_plugin_scheduler
|
||||||
|
from app.startup.workflow_initializer import init_workflow, stop_workflow
|
||||||
|
|
||||||
|
|
||||||
|
async def init_plugin_system():
|
||||||
|
"""
|
||||||
|
同步插件及重启相关依赖服务
|
||||||
|
"""
|
||||||
|
if await sync_plugins():
|
||||||
|
# 重新注册插件定时服务
|
||||||
|
init_plugin_scheduler()
|
||||||
|
# 重新注册命令
|
||||||
|
restart_command()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -15,29 +30,45 @@ async def lifespan(app: FastAPI):
|
|||||||
定义应用的生命周期事件
|
定义应用的生命周期事件
|
||||||
"""
|
"""
|
||||||
print("Starting up...")
|
print("Starting up...")
|
||||||
# 启动模块
|
# 初始化模块
|
||||||
start_modules(app)
|
init_modules()
|
||||||
# 初始化工作流动作
|
|
||||||
init_workflow(app)
|
|
||||||
# 初始化路由
|
# 初始化路由
|
||||||
init_routers(app)
|
init_routers(app)
|
||||||
# 初始化插件
|
# 初始化插件
|
||||||
plugin_init_task = asyncio.create_task(init_plugins_async())
|
init_plugins()
|
||||||
|
# 初始化定时器
|
||||||
|
init_scheduler()
|
||||||
|
# 初始化监控器
|
||||||
|
init_monitor()
|
||||||
|
# 初始化命令
|
||||||
|
init_command()
|
||||||
|
# 初始化工作流
|
||||||
|
init_workflow()
|
||||||
|
# 插件同步到本地
|
||||||
|
sync_plugins_task = asyncio.create_task(init_plugin_system())
|
||||||
try:
|
try:
|
||||||
# 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环
|
# 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
print("Shutting down...")
|
print("Shutting down...")
|
||||||
|
# 停止信号
|
||||||
|
global_vars.stop_system()
|
||||||
try:
|
try:
|
||||||
# 取消插件初始化
|
sync_plugins_task.cancel()
|
||||||
plugin_init_task.cancel()
|
await sync_plugins_task
|
||||||
await plugin_init_task
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
print("Plugin installation task cancelled.")
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during plugin installation shutdown: {e}")
|
print(str(e))
|
||||||
# 清理模块
|
# 停止工作流
|
||||||
shutdown_modules(app)
|
stop_workflow()
|
||||||
# 关闭工作流
|
# 停止命令
|
||||||
stop_workflow(app)
|
stop_command()
|
||||||
|
# 停止监控器
|
||||||
|
stop_monitor()
|
||||||
|
# 停止定时器
|
||||||
|
stop_scheduler()
|
||||||
|
# 停止插件
|
||||||
|
stop_plugins()
|
||||||
|
# 停止模块
|
||||||
|
stop_modules()
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from app.core.cache import close_cache
|
from app.core.cache import close_cache
|
||||||
from app.core.config import global_vars, settings
|
from app.core.config import settings
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
from app.command import CommandChain
|
||||||
|
|
||||||
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
||||||
try:
|
try:
|
||||||
@@ -18,18 +17,14 @@ except ImportError as e:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
from app.core.plugin import PluginManager
|
|
||||||
from app.helper.thread import ThreadHelper
|
from app.helper.thread import ThreadHelper
|
||||||
from app.helper.display import DisplayHelper
|
from app.helper.display import DisplayHelper
|
||||||
from app.helper.resource import ResourceHelper
|
from app.helper.resource import ResourceHelper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
from app.scheduler import Scheduler
|
|
||||||
from app.monitor import Monitor
|
|
||||||
from app.schemas import Notification, NotificationType
|
from app.schemas import Notification, NotificationType
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.db import close_database
|
from app.db import close_database
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.command import Command, CommandChain
|
|
||||||
|
|
||||||
|
|
||||||
def start_frontend():
|
def start_frontend():
|
||||||
@@ -109,25 +104,16 @@ def check_auth():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def shutdown_modules(_: FastAPI):
|
def stop_modules():
|
||||||
"""
|
"""
|
||||||
服务关闭
|
服务关闭
|
||||||
"""
|
"""
|
||||||
# 停止信号
|
|
||||||
global_vars.stop_system()
|
|
||||||
# 停止模块
|
# 停止模块
|
||||||
ModuleManager().stop()
|
ModuleManager().stop()
|
||||||
# 停止插件
|
|
||||||
PluginManager().stop()
|
|
||||||
PluginManager().stop_monitor()
|
|
||||||
# 停止事件消费
|
# 停止事件消费
|
||||||
EventManager().stop()
|
EventManager().stop()
|
||||||
# 停止虚拟显示
|
# 停止虚拟显示
|
||||||
DisplayHelper().stop()
|
DisplayHelper().stop()
|
||||||
# 停止定时服务
|
|
||||||
Scheduler().stop()
|
|
||||||
# 停止监控
|
|
||||||
Monitor().stop()
|
|
||||||
# 停止线程池
|
# 停止线程池
|
||||||
ThreadHelper().shutdown()
|
ThreadHelper().shutdown()
|
||||||
# 停止缓存连接
|
# 停止缓存连接
|
||||||
@@ -140,7 +126,7 @@ def shutdown_modules(_: FastAPI):
|
|||||||
clear_temp()
|
clear_temp()
|
||||||
|
|
||||||
|
|
||||||
def start_modules(_: FastAPI):
|
def init_modules():
|
||||||
"""
|
"""
|
||||||
启动模块
|
启动模块
|
||||||
"""
|
"""
|
||||||
@@ -156,14 +142,6 @@ def start_modules(_: FastAPI):
|
|||||||
ModuleManager()
|
ModuleManager()
|
||||||
# 启动事件消费
|
# 启动事件消费
|
||||||
EventManager().start()
|
EventManager().start()
|
||||||
# 加载插件
|
|
||||||
PluginManager().start()
|
|
||||||
# 启动监控任务
|
|
||||||
Monitor()
|
|
||||||
# 启动定时服务
|
|
||||||
Scheduler()
|
|
||||||
# 加载命令
|
|
||||||
Command()
|
|
||||||
# 启动前端服务
|
# 启动前端服务
|
||||||
start_frontend()
|
start_frontend()
|
||||||
# 检查认证状态
|
# 检查认证状态
|
||||||
|
|||||||
15
app/startup/monitor_initializer.py
Normal file
15
app/startup/monitor_initializer.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from app.monitor import Monitor
|
||||||
|
|
||||||
|
|
||||||
|
def init_monitor():
|
||||||
|
"""
|
||||||
|
初始化监控器
|
||||||
|
"""
|
||||||
|
Monitor()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_monitor():
|
||||||
|
"""
|
||||||
|
停止监控器
|
||||||
|
"""
|
||||||
|
Monitor().stop()
|
||||||
@@ -1,43 +1,36 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from app.command import Command
|
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.scheduler import Scheduler
|
|
||||||
|
|
||||||
|
|
||||||
async def init_plugins_async():
|
async def sync_plugins() -> bool:
|
||||||
"""
|
"""
|
||||||
初始化安装插件,并动态注册后台任务及API
|
初始化安装插件,并动态注册后台任务及API
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
plugin_manager = PluginManager()
|
plugin_manager = PluginManager()
|
||||||
scheduler = Scheduler()
|
|
||||||
command = Command()
|
|
||||||
|
|
||||||
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
||||||
resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,
|
resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,
|
||||||
"缺失依赖项安装")
|
"缺失依赖项安装")
|
||||||
# 判断是否需要进行插件初始化
|
# 判断是否需要进行插件初始化
|
||||||
if not sync_result and not resolved_dependencies:
|
if not sync_result and not resolved_dependencies:
|
||||||
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装,跳过插件初始化")
|
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# 继续执行后续的插件初始化步骤
|
# 继续执行后续的插件初始化步骤
|
||||||
logger.info("正在初始化所有插件")
|
logger.info("正在重新初始化插件")
|
||||||
# 为避免初始化插件异常,这里所有插件都进行初始化
|
# 重新初始化插件
|
||||||
# 安装完成后重新初始化插件
|
|
||||||
plugin_manager.init_config()
|
plugin_manager.init_config()
|
||||||
# 插件启动后注册后台任务
|
# 重新注册插件API
|
||||||
scheduler.init_plugin_jobs()
|
|
||||||
# 插件启动后注册菜单命令
|
|
||||||
command.init_commands()
|
|
||||||
# 插件启动后注册插件API
|
|
||||||
register_plugin_api()
|
register_plugin_api()
|
||||||
logger.info("所有插件初始化完成")
|
logger.info("所有插件初始化完成")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"插件初始化过程中出现异常: {e}")
|
logger.error(f"插件初始化过程中出现异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def execute_task(loop, task_func, task_name):
|
async def execute_task(loop, task_func, task_name):
|
||||||
@@ -62,3 +55,23 @@ def register_plugin_api():
|
|||||||
"""
|
"""
|
||||||
from app.api.endpoints import plugin
|
from app.api.endpoints import plugin
|
||||||
plugin.register_plugin_api()
|
plugin.register_plugin_api()
|
||||||
|
|
||||||
|
|
||||||
|
def init_plugins():
|
||||||
|
"""
|
||||||
|
初始化插件
|
||||||
|
"""
|
||||||
|
PluginManager().start()
|
||||||
|
register_plugin_api()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_plugins():
|
||||||
|
"""
|
||||||
|
停止插件
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_manager = PluginManager()
|
||||||
|
plugin_manager.stop()
|
||||||
|
plugin_manager.stop_monitor()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停止插件时发生错误:{e}", exc_info=True)
|
||||||
|
|||||||
29
app/startup/scheduler_initializer.py
Normal file
29
app/startup/scheduler_initializer.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from app.scheduler import Scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def init_scheduler():
|
||||||
|
"""
|
||||||
|
初始化定时器
|
||||||
|
"""
|
||||||
|
Scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
"""
|
||||||
|
停止定时器
|
||||||
|
"""
|
||||||
|
Scheduler().stop()
|
||||||
|
|
||||||
|
|
||||||
|
def restart_scheduler():
|
||||||
|
"""
|
||||||
|
重启定时器
|
||||||
|
"""
|
||||||
|
Scheduler().init()
|
||||||
|
|
||||||
|
|
||||||
|
def init_plugin_scheduler():
|
||||||
|
"""
|
||||||
|
初始化插件定时器
|
||||||
|
"""
|
||||||
|
Scheduler().init_plugin_jobs()
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from app.core.workflow import WorkFlowManager
|
from app.core.workflow import WorkFlowManager
|
||||||
|
|
||||||
|
|
||||||
def init_workflow(_: FastAPI):
|
def init_workflow():
|
||||||
"""
|
"""
|
||||||
初始化动作
|
初始化动作
|
||||||
"""
|
"""
|
||||||
WorkFlowManager()
|
WorkFlowManager()
|
||||||
|
|
||||||
|
|
||||||
def stop_workflow(_: FastAPI):
|
def stop_workflow():
|
||||||
"""
|
"""
|
||||||
停止动作
|
停止动作
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -52,21 +52,30 @@ class ObjectUtils:
|
|||||||
# 跳过空行
|
# 跳过空行
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
# 处理多行注释
|
# 处理"""单行注释
|
||||||
|
if (line.startswith(('"""', "'''"))
|
||||||
|
and line.endswith(('"""', "'''"))
|
||||||
|
and len(line) > 3):
|
||||||
|
continue
|
||||||
|
# 处理"""多行注释
|
||||||
if line.startswith(('"""', "'''")):
|
if line.startswith(('"""', "'''")):
|
||||||
in_comment = not in_comment
|
in_comment = not in_comment
|
||||||
continue
|
continue
|
||||||
# 在注释中则跳过
|
# 在注释中则跳过
|
||||||
if in_comment:
|
if in_comment:
|
||||||
continue
|
continue
|
||||||
# 跳过注释、pass语句、装饰器、函数定义行
|
# 跳过#注释、pass语句、装饰器、函数定义行
|
||||||
if line.startswith('#') or line == "pass" or line.startswith('@') or line.startswith('def '):
|
if (line.startswith('#')
|
||||||
|
or line == "pass"
|
||||||
|
or line.startswith('@')
|
||||||
|
or line.startswith('def ')):
|
||||||
continue
|
continue
|
||||||
# 发现有效代码行
|
# 发现有效代码行
|
||||||
return True
|
return True
|
||||||
# 没有有效代码行
|
# 没有有效代码行
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception as err:
|
||||||
|
print(err)
|
||||||
# 源代码分析失败时,进行字节码分析
|
# 源代码分析失败时,进行字节码分析
|
||||||
code_obj = func.__code__
|
code_obj = func.__code__
|
||||||
instructions = list(dis.get_instructions(code_obj))
|
instructions = list(dis.get_instructions(code_obj))
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from app.schemas.types import MediaType
|
|||||||
_special_domains = [
|
_special_domains = [
|
||||||
'u2.dmhy.org',
|
'u2.dmhy.org',
|
||||||
'pt.ecust.pp.ua',
|
'pt.ecust.pp.ua',
|
||||||
'pt.gtkpw.xyz'
|
'pt.gtkpw.xyz',
|
||||||
|
'pt.gtk.pw'
|
||||||
]
|
]
|
||||||
|
|
||||||
# 内置版本号转换字典
|
# 内置版本号转换字典
|
||||||
|
|||||||
37
database/versions/486e56a62dcb_2_1_5.py
Normal file
37
database/versions/486e56a62dcb_2_1_5.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""2.1.5
|
||||||
|
|
||||||
|
Revision ID: 486e56a62dcb
|
||||||
|
Revises: 89d24811e894
|
||||||
|
Create Date: 2025-05-13 19:49:51.271319
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '486e56a62dcb'
|
||||||
|
down_revision = '89d24811e894'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ###
|
||||||
|
_systemconfig = SystemConfigOper()
|
||||||
|
templates = _systemconfig.get(SystemConfigKey.NotificationTemplates)
|
||||||
|
if isinstance(templates, dict):
|
||||||
|
_re = r'(?<={{)(?![^}]*[%|])(\s*)season(\s*)(?=}})|(?<={%)if\s+(?![^%]*[%|])season\s*(?=%)'
|
||||||
|
for k, v in templates.items():
|
||||||
|
# 替换season为season_fmt
|
||||||
|
result = re.sub(_re, r'\1season_fmt\2', v)
|
||||||
|
templates[k] = result
|
||||||
|
# 将更新后的模板存回系统配置
|
||||||
|
_systemconfig.set(SystemConfigKey.NotificationTemplates, templates)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
68
database/versions/89d24811e894_2_1_4.py
Normal file
68
database/versions/89d24811e894_2_1_4.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""2.1.4
|
||||||
|
|
||||||
|
Revision ID: 89d24811e894
|
||||||
|
Revises: 4b544f5d3b07
|
||||||
|
Create Date: 2025-05-03 17:29:07.635618
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '89d24811e894'
|
||||||
|
down_revision = '4b544f5d3b07'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
value = {
|
||||||
|
"organizeSuccess": """
|
||||||
|
{
|
||||||
|
'title': '{{ title_year }}'
|
||||||
|
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
|
||||||
|
'text': '{% if vote_average %}评分:{{ vote_average }},{% endif %}'
|
||||||
|
'类型:{{ type }}'
|
||||||
|
'{% if category %},类别:{{ category }}{% endif %}'
|
||||||
|
'{% if resource_term %},质量:{{ resource_term }}{% endif %},'
|
||||||
|
'共{{ file_count }}个文件,大小:{{ total_size }}'
|
||||||
|
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
|
||||||
|
}""",
|
||||||
|
"downloadAdded": """
|
||||||
|
{
|
||||||
|
'title': '{{ title_year }}'
|
||||||
|
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
|
||||||
|
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
|
||||||
|
'{% if resource_term %}\\n质量:{{ resource_term }}{% endif %}'
|
||||||
|
'{% if size %}\\n大小:{{ size }}{% endif %}'
|
||||||
|
'{% if title %}\\n种子:{{ title }}{% endif %}'
|
||||||
|
'{% if pubdate %}\\n发布时间:{{ pubdate }}{% endif %}'
|
||||||
|
'{% if freedate %}\\n免费时间:{{ freedate }}{% endif %}'
|
||||||
|
'{% if seeders %}\\n做种数:{{ seeders }}{% endif %}'
|
||||||
|
'{% if volume_factor %}\\n促销:{{ volume_factor }}{% endif %}'
|
||||||
|
'{% if hit_and_run %}\\nHit&Run:{{ hit_and_run }}{% endif %}'
|
||||||
|
'{% if labels %}\\n标签:{{ labels }}{% endif %}'
|
||||||
|
'{% if description %}\\n描述:{{ description }}{% endif %}'
|
||||||
|
}""",
|
||||||
|
"subscribeAdded": "{'title': '{{ title_year }}{% if season_fmt %} {{ season_fmt }}{% endif %} 已添加订阅'}",
|
||||||
|
"subscribeComplete": """
|
||||||
|
{
|
||||||
|
'title': '{{ title_year }}'
|
||||||
|
'{% if season_fmt %} {{ season_fmt }}{% endif %} 已完成{{ msgstr }}',
|
||||||
|
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
|
||||||
|
'{% if username %},来自用户:{{ username }}{% endif %}'
|
||||||
|
'{% if actors %}\\n演员:{{ actors }}{% endif %}'
|
||||||
|
'{% if overview %}\\n简介:{{ overview }}{% endif %}'
|
||||||
|
}"""
|
||||||
|
}
|
||||||
|
_systemconfig = SystemConfigOper()
|
||||||
|
if not _systemconfig.get(SystemConfigKey.NotificationTemplates):
|
||||||
|
_systemconfig.set(SystemConfigKey.NotificationTemplates, value)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.4.2'
|
APP_VERSION = 'v2.4.7'
|
||||||
FRONTEND_VERSION = 'v2.4.2'
|
FRONTEND_VERSION = 'v2.4.7'
|
||||||
|
|||||||
Reference in New Issue
Block a user