mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 09:13:15 +08:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
65b17e4f2b | ||
|
|
23c6898789 | ||
|
|
df2a1be2a2 | ||
|
|
2db628a2ba | ||
|
|
b6c40436c9 | ||
|
|
a8a70cac08 | ||
|
|
3eefbf97b1 | ||
|
|
3c423e0838 | ||
|
|
99cde43954 | ||
|
|
fa3a787bf7 | ||
|
|
c776dc8036 | ||
|
|
1ef068351d | ||
|
|
6abe0a1862 | ||
|
|
ff13045f52 | ||
|
|
59c09681cb | ||
|
|
f664cf6fa5 | ||
|
|
01a847a9c2 | ||
|
|
6da655f67f | ||
|
|
21df7dced1 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
file: docker/Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
|
||||
55
.github/workflows/bulit-lite.yml
vendored
55
.github/workflows/bulit-lite.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: MoviePilot Builder v2 Lite
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
paths:
|
||||
- 'version.py'
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
tags: |
|
||||
type=raw,value=lite-latest
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.lite
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
@@ -1,92 +0,0 @@
|
||||
FROM python:3.12.8-slim-bookworm
|
||||
ENV LANG="C.UTF-8" \
|
||||
TZ="Asia/Shanghai" \
|
||||
HOME="/moviepilot" \
|
||||
CONFIG_DIR="/config" \
|
||||
TERM="xterm" \
|
||||
DISPLAY=:987 \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
WORKDIR "/app"
|
||||
RUN apt-get update -y \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
gettext-base \
|
||||
locales \
|
||||
procps \
|
||||
gosu \
|
||||
bash \
|
||||
wget \
|
||||
curl \
|
||||
busybox \
|
||||
dumb-init \
|
||||
jq \
|
||||
fuse3 \
|
||||
rsync \
|
||||
ffmpeg \
|
||||
nano \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
elif [ "$(uname -m)" = "aarch64" ]; \
|
||||
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
||||
fi \
|
||||
&& curl https://rclone.org/install.sh | bash \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY requirements.in requirements.in
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install Cython pip-tools \
|
||||
&& pip-compile requirements.in \
|
||||
&& pip install -r requirements.txt \
|
||||
&& playwright install-deps chromium \
|
||||
&& apt-get remove -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY . .
|
||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& cp -f /app/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 918 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& python3 /app/setup.py \
|
||||
&& find /app/app -type f -name "*.py" ! -path "/app/app/main.py" -exec rm -f {} \; \
|
||||
&& FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/version.py) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \
|
||||
&& cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \
|
||||
while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
||||
&& rm -rf /tmp/* /app/build
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
@@ -77,5 +77,7 @@ def wallpapers() -> Any:
|
||||
return WebUtils.get_bing_wallpapers()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
return MediaServerChain().get_latest_wallpapers()
|
||||
else:
|
||||
elif settings.WALLPAPER == "tmdb":
|
||||
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)
|
||||
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:
|
||||
"""
|
||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import mimetypes
|
||||
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.command import Command
|
||||
@@ -16,7 +19,6 @@ from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||||
|
||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,9 +68,13 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||
try:
|
||||
api["path"] = api_path
|
||||
allow_anonymous = api.pop("allow_anonymous", False)
|
||||
auth_mode = api.pop("auth", "apikey")
|
||||
dependencies = api.setdefault("dependencies", [])
|
||||
if not allow_anonymous and Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
if not allow_anonymous:
|
||||
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"])
|
||||
is_modified = True
|
||||
logger.debug(f"Added plugin route: {api_path}")
|
||||
@@ -218,25 +224,60 @@ def install(plugin_id: str,
|
||||
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="获取插件表单页面")
|
||||
def plugin_form(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件配置表单
|
||||
根据插件ID获取插件配置表单或Vue组件URL
|
||||
"""
|
||||
conf, model = PluginManager().get_plugin_form(plugin_id)
|
||||
return {
|
||||
"conf": conf,
|
||||
"model": model
|
||||
}
|
||||
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:
|
||||
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="获取插件数据页面")
|
||||
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获取插件数据页面
|
||||
"""
|
||||
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="获取所有插件仪表板元信息")
|
||||
@@ -247,22 +288,22 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li
|
||||
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="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=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)
|
||||
return plugin_dashboard_by_key(plugin_id, "", user_agent)
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||
@@ -286,6 +327,41 @@ def reset_plugin(plugin_id: str,
|
||||
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="获取插件配置")
|
||||
def plugin_config(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
|
||||
@@ -31,7 +31,7 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
|
||||
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
||||
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
二维码登录确认
|
||||
@@ -56,6 +56,16 @@ def save(name: str,
|
||||
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])
|
||||
def list_files(fileitem: schemas.FileItem,
|
||||
sort: Optional[str] = 'updated_at',
|
||||
|
||||
@@ -171,10 +171,13 @@ def cache_img(
|
||||
|
||||
|
||||
@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: 新增敏感配置项时要在此处添加排除项
|
||||
info = settings.dict(
|
||||
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
|
||||
"""
|
||||
# 季信息
|
||||
seas: List[int] = []
|
||||
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return [SonarrSeries()]
|
||||
tvdbid = mediainfo.tvdb_id
|
||||
if not tvdbid:
|
||||
return [SonarrSeries()]
|
||||
|
||||
# 季信息
|
||||
if mediainfo.seasons:
|
||||
seas = list(mediainfo.seasons)
|
||||
else:
|
||||
mediainfo = None
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
|
||||
# 季信息
|
||||
seas: List[int] = []
|
||||
sea_num = tvdbinfo.get('season')
|
||||
if sea_num:
|
||||
seas = list(range(1, int(sea_num) + 1))
|
||||
# 季信息
|
||||
sea_num = tvdbinfo.get('season')
|
||||
if sea_num:
|
||||
seas = list(range(1, int(sea_num) + 1))
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
if not mediainfo:
|
||||
# 根据TVDB查询媒体信息
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import gc
|
||||
import pickle
|
||||
import traceback
|
||||
from abc import ABCMeta
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
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.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.message_oper import MessageOper
|
||||
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.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
@@ -42,6 +44,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
send_callback=self.run_module
|
||||
)
|
||||
self.useroper = UserOper()
|
||||
self.pluginmanager = PluginManager()
|
||||
|
||||
@staticmethod
|
||||
def load_cache(filename: str) -> Any:
|
||||
@@ -97,7 +100,50 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return ret is 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 = sorted(modules, key=lambda x: x.get_priority())
|
||||
@@ -114,10 +160,10 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
result = func(*args, **kwargs)
|
||||
elif ObjectUtils.check_signature(func, result):
|
||||
# 返回结果与方法签名一致,将结果传入(不能多个模块同时运行的需要通过开关控制)
|
||||
# 返回结果与方法签名一致,将结果传入
|
||||
result = func(result)
|
||||
elif isinstance(result, list):
|
||||
# 返回为列表,有多个模块运行结果时进行合并(不能多个模块同时运行的需要通过开关控制)
|
||||
# 返回为列表,有多个模块运行结果时进行合并
|
||||
temp = func(*args, **kwargs)
|
||||
if isinstance(temp, list):
|
||||
result.extend(temp)
|
||||
@@ -401,7 +447,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
target_storage: Optional[str] = None, target_path: Path = None,
|
||||
transfer_type: Optional[str] = None, scrape: 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: 文件信息
|
||||
@@ -415,6 +462,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param library_type_folder: 是否按类型创建目录
|
||||
:param library_category_folder: 是否按类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param source_oper: 源存储操作类
|
||||
:param target_oper: 目标存储操作类
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer",
|
||||
@@ -424,7 +473,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
transfer_type=transfer_type, scrape=scrape,
|
||||
library_type_folder=library_type_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:
|
||||
"""
|
||||
@@ -492,13 +542,27 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
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: 成功或失败
|
||||
"""
|
||||
# 保存原消息
|
||||
# 渲染消息
|
||||
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
|
||||
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
|
||||
# 保存消息
|
||||
self.messagehelper.put(message, role="user", title=message.title)
|
||||
self.messageoper.add(**message.dict())
|
||||
# 发送消息按设置隔离
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
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.string import StringUtils
|
||||
|
||||
@@ -38,63 +38,6 @@ class DownloadChain(ChainBase):
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
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,
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
@@ -384,8 +327,21 @@ class DownloadChain(ChainBase):
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
|
||||
# 下载成功发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
||||
username=username, download_episodes=download_episodes)
|
||||
self.post_message(
|
||||
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)
|
||||
# 广播事件
|
||||
|
||||
@@ -297,7 +297,7 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
if StringUtils.get_url_domain(inx.get("domain")) == sub_domain:
|
||||
return inx.get("domain")
|
||||
for ext_d in inx.get("ext_domains"):
|
||||
for ext_d in inx.get("ext_domains", []):
|
||||
if StringUtils.get_url_domain(ext_d) == sub_domain:
|
||||
return ext_d
|
||||
return sub_domain
|
||||
|
||||
@@ -24,6 +24,12 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
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]]:
|
||||
"""
|
||||
生成二维码
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.helper.subscribe import SubscribeHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
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
|
||||
|
||||
|
||||
@@ -228,22 +228,23 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
userid=userid))
|
||||
return None, err_msg
|
||||
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:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 订阅成功按规则发送消息
|
||||
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||
text=text,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=username))
|
||||
self.post_message(
|
||||
schemas.Notification(
|
||||
mtype=NotificationType.Subscribe,
|
||||
ctype=ContentType.SubscribeAdded,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=username
|
||||
),
|
||||
meta=metainfo,
|
||||
mediainfo=mediainfo,
|
||||
username=username
|
||||
)
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeAdded, {
|
||||
"subscribe_id": sid,
|
||||
@@ -1013,11 +1014,19 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 完成订阅按规则发送消息
|
||||
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=subscribe.username))
|
||||
self.post_message(
|
||||
schemas.Notification(
|
||||
mtype=NotificationType.Subscribe,
|
||||
ctype=ContentType.SubscribeComplete,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=subscribe.username
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
msgstr=msgstr,
|
||||
username=subscribe.username
|
||||
)
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
@@ -29,7 +30,8 @@ from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
||||
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
||||
SystemConfigKey
|
||||
SystemConfigKey, ChainEventType, ContentType
|
||||
from app.schemas import StorageOperSelectionEventData
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -699,10 +701,36 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
storage=task.fileitem.storage,
|
||||
src_path=Path(task.fileitem.path),
|
||||
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)
|
||||
|
||||
# 广播事件,请示额外的源存储支持
|
||||
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,
|
||||
meta=task.meta,
|
||||
@@ -714,7 +742,9 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
episodes_info=task.episodes_info,
|
||||
scrape=task.scrape,
|
||||
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:
|
||||
logger.error("文件整理模块运行失败")
|
||||
return False, "文件整理模块运行失败"
|
||||
@@ -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} 已入库"
|
||||
if mediainfo.vote_average:
|
||||
msg_str = f"评分:{mediainfo.vote_average},类型:{mediainfo.type.value}"
|
||||
else:
|
||||
msg_str = f"类型:{mediainfo.type.value}"
|
||||
if mediainfo.category:
|
||||
msg_str = f"{msg_str},类别:{mediainfo.category}"
|
||||
if meta.resource_term:
|
||||
msg_str = f"{msg_str},质量:{meta.resource_term}"
|
||||
msg_str = f"{msg_str},共{transferinfo.file_count}个文件," \
|
||||
f"大小:{StringUtils.str_filesize(transferinfo.total_size)}"
|
||||
if transferinfo.message:
|
||||
msg_str = f"{msg_str},以下文件处理失败:\n{transferinfo.message}"
|
||||
# 发送
|
||||
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')))
|
||||
self.post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Organize,
|
||||
ctype=ContentType.OrganizeSuccess,
|
||||
image=mediainfo.get_message_image(),
|
||||
username=username,
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
season_episode=season_episode,
|
||||
username=username
|
||||
)
|
||||
|
||||
@@ -101,6 +101,8 @@ class ConfigModel(BaseModel):
|
||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||
# TMDB API地址
|
||||
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
||||
# TMDB元数据语言
|
||||
TMDB_LOCALE: str = "zh"
|
||||
# TMDB API Key
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
# TVDB API Key
|
||||
@@ -238,6 +240,7 @@ class ConfigModel(BaseModel):
|
||||
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
||||
default_factory=lambda: ["image.tmdb.org",
|
||||
"static-mdb.v.geilijiasu.com",
|
||||
"bing.com",
|
||||
"doubanio.com",
|
||||
"lain.bgm.tv",
|
||||
"raw.githubusercontent.com",
|
||||
|
||||
@@ -7,8 +7,10 @@ import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
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.observers import Observer
|
||||
|
||||
@@ -220,6 +222,14 @@ class PluginManager(metaclass=Singleton):
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
@property
|
||||
def running_plugins(self):
|
||||
"""
|
||||
获取运行态插件列表
|
||||
:return: 运行态插件列表
|
||||
"""
|
||||
return self._running_plugins
|
||||
|
||||
def reload_monitor(self):
|
||||
"""
|
||||
重新加载插件文件修改监测
|
||||
@@ -407,68 +417,6 @@ class PluginManager(metaclass=Singleton):
|
||||
self.plugindata.del_data(pid)
|
||||
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:
|
||||
"""
|
||||
获取插件状态
|
||||
@@ -517,16 +465,20 @@ class PluginManager(metaclass=Singleton):
|
||||
}]
|
||||
"""
|
||||
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:
|
||||
continue
|
||||
if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api):
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
apis = plugin.get_api() or []
|
||||
for api in apis:
|
||||
api["path"] = f"/{plugin_id}{api['path']}"
|
||||
if not api.get("auth"):
|
||||
api["auth"] = "apikey"
|
||||
ret_apis.extend(apis)
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {plugin_id} API出错:{str(e)}")
|
||||
@@ -558,7 +510,63 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}")
|
||||
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 +596,50 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
||||
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:
|
||||
"""
|
||||
获取插件属性
|
||||
@@ -781,7 +833,8 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
||||
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 或标识
|
||||
@@ -795,7 +848,8 @@ class PluginManager(metaclass=Singleton):
|
||||
# 获取在线插件
|
||||
online_plugins = self.pluginhelper.get_plugins(market, package_version)
|
||||
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 []
|
||||
ret_plugins = []
|
||||
add_time = len(online_plugins)
|
||||
|
||||
@@ -113,6 +113,7 @@ class DownloadHistoryOper(DbOper):
|
||||
season: Optional[str] = None, episode: Optional[str] = None, tmdbid=None) -> List[DownloadHistory]:
|
||||
"""
|
||||
按类型、标题、年份、季集查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
return DownloadHistory.get_last_by(db=self._db,
|
||||
mtype=mtype,
|
||||
|
||||
@@ -85,45 +85,54 @@ class DownloadHistory(Base):
|
||||
year: Optional[str] = None, season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
据tmdbid、season、season_episode查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
result = None
|
||||
if tmdbid and not season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧所有季集|电影
|
||||
if not season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
if season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -1,18 +1,528 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Union
|
||||
from typing import List, Optional, Callable
|
||||
from typing import Any, Literal, Optional, List, Dict, Union
|
||||
from typing import Callable
|
||||
|
||||
from cachetools import TTLCache
|
||||
from jinja2 import Template
|
||||
|
||||
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.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.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 self._context
|
||||
|
||||
def _add_media_info(self, mediainfo: MediaInfo):
|
||||
"""
|
||||
增加媒体信息
|
||||
"""
|
||||
if not mediainfo: return
|
||||
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,
|
||||
# 年份
|
||||
"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,
|
||||
# 集号
|
||||
"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({k: v for k, v in raw_objects.items() if v is not None})
|
||||
|
||||
@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):
|
||||
|
||||
@@ -50,3 +50,35 @@ class StorageHelper:
|
||||
s.config = conf
|
||||
break
|
||||
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])
|
||||
|
||||
@@ -1031,6 +1031,8 @@ class Emby:
|
||||
eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id,
|
||||
image_type="Backdrop")
|
||||
|
||||
eventItem.json_object = message
|
||||
|
||||
return eventItem
|
||||
|
||||
def get_data(self, url: str) -> Optional[Response]:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.core.event import eventmanager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
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.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
@@ -30,6 +30,7 @@ class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
|
||||
_storage_schemas = []
|
||||
_support_storages = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -40,6 +41,8 @@ class FileManagerModule(_ModuleBase):
|
||||
# 加载模块
|
||||
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
|
||||
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
|
||||
# 获取存储类型
|
||||
self._support_storages = [storage.schema.value for storage in self._storage_schemas]
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的整理方式获取")
|
||||
@@ -149,6 +154,16 @@ class FileManagerModule(_ModuleBase):
|
||||
return
|
||||
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]]:
|
||||
"""
|
||||
生成二维码
|
||||
@@ -176,6 +191,8 @@ class FileManagerModule(_ModuleBase):
|
||||
:param recursion: 是否递归,此时只浏览文件
|
||||
:return: 文件项列表
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||
@@ -239,26 +258,32 @@ class FileManagerModule(_ModuleBase):
|
||||
:param name: 目录名
|
||||
:return: 创建的目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的目录创建")
|
||||
return None
|
||||
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)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的删除处理")
|
||||
return False
|
||||
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)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
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)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的存储使用情况")
|
||||
@@ -330,7 +367,8 @@ class FileManagerModule(_ModuleBase):
|
||||
target_storage: Optional[str] = None, target_path: Path = None,
|
||||
transfer_type: Optional[str] = None, scrape: 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: 文件信息
|
||||
@@ -344,6 +382,8 @@ class FileManagerModule(_ModuleBase):
|
||||
:param library_type_folder: 是否按媒体类型创建目录
|
||||
:param library_category_folder: 是否按媒体类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
# 检查目录路径
|
||||
@@ -370,9 +410,6 @@ class FileManagerModule(_ModuleBase):
|
||||
overwrite_mode = target_directory.overwrite_mode
|
||||
# 是否需要刮削
|
||||
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,
|
||||
need_type_folder=library_type_folder,
|
||||
@@ -399,6 +436,29 @@ class FileManagerModule(_ModuleBase):
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
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:
|
||||
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}")
|
||||
return self.transfer_media(fileitem=fileitem,
|
||||
@@ -411,7 +471,9 @@ class FileManagerModule(_ModuleBase):
|
||||
need_rename=need_rename,
|
||||
need_notify=need_notify,
|
||||
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]:
|
||||
"""
|
||||
@@ -430,12 +492,17 @@ class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
pass
|
||||
|
||||
def __transfer_command(self, fileitem: FileItem, target_storage: str,
|
||||
target_file: Path, transfer_type: str) -> Tuple[Optional[FileItem], str]:
|
||||
@staticmethod
|
||||
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 target_storage: 目标存储
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param target_file: 目标文件路径
|
||||
:param transfer_type: 整理方式
|
||||
"""
|
||||
@@ -459,13 +526,6 @@ class FileManagerModule(_ModuleBase):
|
||||
and fileitem.storage != "local" and target_storage != "local"):
|
||||
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:
|
||||
if fileitem.storage == "local" and target_storage == "local":
|
||||
@@ -568,18 +628,23 @@ class FileManagerModule(_ModuleBase):
|
||||
|
||||
return None, "未知错误"
|
||||
|
||||
def __transfer_other_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
||||
transfer_type: str) -> Tuple[bool, str]:
|
||||
def __transfer_other_files(self, fileitem: FileItem, target_storage: str,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据文件名整理其他相关文件
|
||||
:param fileitem: 源文件
|
||||
:param target_storage: 目标存储
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param target_file: 目标路径
|
||||
:param transfer_type: 整理方式
|
||||
"""
|
||||
# 整理字幕
|
||||
state, errmsg = self.__transfer_subtitles(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=target_file,
|
||||
transfer_type=transfer_type)
|
||||
if not state:
|
||||
@@ -587,17 +652,22 @@ class FileManagerModule(_ModuleBase):
|
||||
# 整理音轨文件
|
||||
state, errmsg = self.__transfer_audio_track_files(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=target_file,
|
||||
transfer_type=transfer_type)
|
||||
|
||||
return state, errmsg
|
||||
|
||||
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
||||
transfer_type: str) -> Tuple[bool, str]:
|
||||
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据文件名整理对应字幕文件
|
||||
:param fileitem: 源文件
|
||||
:param target_storage: 目标存储
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param target_file: 目标路径
|
||||
:param transfer_type: 整理方式
|
||||
"""
|
||||
@@ -617,17 +687,12 @@ class FileManagerModule(_ModuleBase):
|
||||
|
||||
# 比对文件名并整理字幕
|
||||
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:
|
||||
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
|
||||
and f".{f.extension.lower()}" in settings.RMT_SUBEXT]
|
||||
if len(file_list) == 0:
|
||||
@@ -677,9 +742,9 @@ class FileManagerModule(_ModuleBase):
|
||||
}
|
||||
new_sub_tag_list = [
|
||||
(".default" + new_file_type if (
|
||||
(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 == "eng" and new_file_type == ".eng")
|
||||
(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 == "eng" and new_file_type == ".eng")
|
||||
) else new_file_type) if t == 0 else "%s%s(%s)" % (new_file_type,
|
||||
new_sub_tag_dict.get(
|
||||
new_file_type, ""
|
||||
@@ -693,6 +758,8 @@ class FileManagerModule(_ModuleBase):
|
||||
logger.debug(f"正在处理字幕:{sub_item.name}")
|
||||
new_item, errmsg = self.__transfer_command(fileitem=sub_item,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type)
|
||||
if new_item:
|
||||
@@ -705,26 +772,24 @@ class FileManagerModule(_ModuleBase):
|
||||
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
|
||||
return True, ""
|
||||
|
||||
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
||||
transfer_type: str) -> Tuple[bool, str]:
|
||||
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据文件名整理对应音轨文件
|
||||
:param fileitem: 源文件
|
||||
:param target_storage: 目标存储
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param target_file: 目标路径
|
||||
:param transfer_type: 整理方式
|
||||
"""
|
||||
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:
|
||||
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
|
||||
if Path(file.name).stem == org_path.stem
|
||||
@@ -740,6 +805,8 @@ class FileManagerModule(_ModuleBase):
|
||||
logger.info(f"正在整理音轨文件:{track_file} 到 {new_track_file}")
|
||||
new_item, errmsg = self.__transfer_command(fileitem=track_file,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=new_track_file,
|
||||
transfer_type=transfer_type)
|
||||
if new_item:
|
||||
@@ -750,21 +817,19 @@ class FileManagerModule(_ModuleBase):
|
||||
logger.error(f"音轨文件 {org_path.name} 整理失败:{str(error)}")
|
||||
return True, ""
|
||||
|
||||
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo, transfer_type: str,
|
||||
target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
|
||||
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
transfer_type: str, target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
|
||||
"""
|
||||
整理整个文件夹
|
||||
:param fileitem: 源文件
|
||||
:param mediainfo: 媒体信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param transfer_type: 整理方式
|
||||
:param target_storage: 目标存储
|
||||
: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}")
|
||||
target_item = target_oper.get_folder(target_path)
|
||||
if not target_item:
|
||||
@@ -788,6 +853,8 @@ class FileManagerModule(_ModuleBase):
|
||||
# 处理所有文件
|
||||
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type)
|
||||
if state:
|
||||
@@ -795,29 +862,29 @@ class FileManagerModule(_ModuleBase):
|
||||
else:
|
||||
return None, errmsg
|
||||
|
||||
def __transfer_dir_files(self, fileitem: FileItem, transfer_type: str,
|
||||
target_storage: str, target_path: Path) -> Tuple[bool, str]:
|
||||
def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
transfer_type: str, target_path: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
按目录结构整理目录下所有文件
|
||||
:param fileitem: 源文件
|
||||
:param target_storage: 目标存储
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param target_path: 目标路径
|
||||
:param transfer_type: 整理方式
|
||||
"""
|
||||
# 列出所有文件
|
||||
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)
|
||||
file_list: List[FileItem] = source_oper.list(fileitem)
|
||||
# 整理文件
|
||||
for item in file_list:
|
||||
if item.type == "dir":
|
||||
# 递归整理目录
|
||||
new_path = target_path / item.name
|
||||
state, errmsg = self.__transfer_dir_files(fileitem=item,
|
||||
transfer_type=transfer_type,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
transfer_type=transfer_type,
|
||||
target_path=new_path)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -826,6 +893,8 @@ class FileManagerModule(_ModuleBase):
|
||||
new_file = target_path / item.name
|
||||
new_item, errmsg = self.__transfer_command(fileitem=item,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type)
|
||||
if not new_item:
|
||||
@@ -833,16 +902,22 @@ class FileManagerModule(_ModuleBase):
|
||||
# 返回成功
|
||||
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]:
|
||||
"""
|
||||
整理一个文件,同时处理其他相关文件
|
||||
:param fileitem: 原文件
|
||||
:param mediainfo: 媒体信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param target_storage: 目标存储
|
||||
:param target_file: 新文件
|
||||
:param transfer_type: 整理方式
|
||||
:param over_flag: 是否覆盖,为True时会先删除再整理
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
"""
|
||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||
f"操作类型:{transfer_type}")
|
||||
@@ -874,12 +949,16 @@ class FileManagerModule(_ModuleBase):
|
||||
target_file.unlink()
|
||||
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=target_file,
|
||||
transfer_type=transfer_type)
|
||||
if new_item:
|
||||
# 处理其他相关文件
|
||||
self.__transfer_other_files(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=target_file,
|
||||
transfer_type=transfer_type)
|
||||
return new_item, errmsg
|
||||
@@ -936,11 +1015,13 @@ class FileManagerModule(_ModuleBase):
|
||||
target_storage: str,
|
||||
target_path: Path,
|
||||
transfer_type: str,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
need_scrape: Optional[bool] = False,
|
||||
need_rename: Optional[bool] = True,
|
||||
need_notify: Optional[bool] = True,
|
||||
overwrite_mode: Optional[str] = None,
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
episodes_info: List[TmdbEpisode] = None
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并整理一个文件或者一个目录下的所有文件
|
||||
@@ -950,6 +1031,8 @@ class FileManagerModule(_ModuleBase):
|
||||
:param target_storage: 目标存储
|
||||
:param target_path: 目标路径
|
||||
:param transfer_type: 文件整理方式
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:param need_scrape: 是否需要刮削
|
||||
:param need_rename: 是否需要重命名
|
||||
:param need_notify: 是否需要通知
|
||||
@@ -977,6 +1060,8 @@ class FileManagerModule(_ModuleBase):
|
||||
# 整理目录
|
||||
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_storage=target_storage,
|
||||
target_path=new_path,
|
||||
transfer_type=transfer_type)
|
||||
@@ -1040,8 +1125,6 @@ class FileManagerModule(_ModuleBase):
|
||||
|
||||
# 判断是否要覆盖
|
||||
overflag = False
|
||||
# 目的操作对象
|
||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
||||
# 计算重命名中的文件夹层级
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
folder_path = new_file.parents[rename_format_level - 1]
|
||||
@@ -1102,14 +1185,16 @@ class FileManagerModule(_ModuleBase):
|
||||
if overwrite_mode == 'latest':
|
||||
# 文件不存在,但仅保留最新版本
|
||||
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,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_file=new_file,
|
||||
transfer_type=transfer_type,
|
||||
over_flag=overflag)
|
||||
over_flag=overflag,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper)
|
||||
if not new_item:
|
||||
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
|
||||
return TransferInfo(success=False,
|
||||
@@ -1142,97 +1227,8 @@ class FileManagerModule(_ModuleBase):
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# 获取集标题
|
||||
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,
|
||||
}
|
||||
return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo,
|
||||
file_extension=file_ext, episodes_info=episodes_info)
|
||||
|
||||
@staticmethod
|
||||
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
||||
@@ -1351,14 +1347,14 @@ class FileManagerModule(_ModuleBase):
|
||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{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: 目录路径
|
||||
"""
|
||||
# 存储
|
||||
storage_oper = self.__get_storage_oper(target_storage)
|
||||
if not storage_oper:
|
||||
return False
|
||||
# 识别文件中的季集信息
|
||||
|
||||
@@ -61,6 +61,13 @@ class StorageBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return transtype in self.transtype
|
||||
|
||||
def reset_config(self):
|
||||
"""
|
||||
重置置配置
|
||||
"""
|
||||
self.storagehelper.reset_storage(self.schema.value)
|
||||
self.init_storage()
|
||||
|
||||
@abstractmethod
|
||||
def check(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple, Union
|
||||
|
||||
@@ -24,6 +26,10 @@ class NoCheckInException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SessionInvalidException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AliPan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
阿里云盘相关操作
|
||||
@@ -50,6 +56,13 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
# CID和路径缓存
|
||||
_id_cache: Dict[str, Tuple[str, str]] = {}
|
||||
|
||||
# 最大线程数
|
||||
MAX_WORKERS = 10
|
||||
# 最大分片大小(1GB)
|
||||
MAX_PART_SIZE = 1024 * 1024 * 1024
|
||||
# 最小分片大小(100MB)
|
||||
MIN_PART_SIZE = 100 * 1024 * 1024
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.session = requests.Session()
|
||||
@@ -177,7 +190,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
确认登录后,获取相关token
|
||||
"""
|
||||
if not self._auth_state:
|
||||
raise Exception("【阿里云盘】请先生成二维码")
|
||||
raise SessionInvalidException("【阿里云盘】请先生成二维码")
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}/oauth/access_token",
|
||||
json={
|
||||
@@ -188,7 +201,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
if resp is None:
|
||||
raise Exception("【阿里云盘】获取 access_token 失败")
|
||||
raise SessionInvalidException("【阿里云盘】获取 access_token 失败")
|
||||
result = resp.json()
|
||||
if result.get("code"):
|
||||
raise Exception(f"【阿里云盘】{result.get('code')} - {result.get('message')}!")
|
||||
@@ -199,7 +212,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
刷新access_token
|
||||
"""
|
||||
if not refresh_token:
|
||||
raise Exception("【阿里云盘】会话失效,请重新扫码登录!")
|
||||
raise SessionInvalidException("【阿里云盘】会话失效,请重新扫码登录!")
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}/oauth/access_token",
|
||||
json={
|
||||
@@ -335,6 +348,8 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
if not fileinfo:
|
||||
return schemas.FileItem()
|
||||
if not parent.endswith("/"):
|
||||
parent += "/"
|
||||
if fileinfo.get("type") == "folder":
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
@@ -437,7 +452,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
"/adrive/v1.0/openFile/create",
|
||||
json={
|
||||
"drive_id": parent_item.drive_id,
|
||||
"parent_file_id": parent_item.fileid,
|
||||
"parent_file_id": parent_item.fileid or "root",
|
||||
"name": name,
|
||||
"type": "folder"
|
||||
}
|
||||
@@ -583,13 +598,6 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
raise Exception(resp.get("message"))
|
||||
return resp.get('part_info_list', [])
|
||||
|
||||
@staticmethod
|
||||
def _upload_part(upload_url: str, data: bytes):
|
||||
"""
|
||||
上传单个分片
|
||||
"""
|
||||
return requests.put(upload_url, data=data)
|
||||
|
||||
def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict:
|
||||
"""
|
||||
获取已上传分片列表
|
||||
@@ -628,22 +636,77 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
raise Exception(resp.get("message"))
|
||||
return resp
|
||||
|
||||
def _calc_parts(self, file_size: int) -> Tuple[int, int]:
|
||||
"""
|
||||
计算最优分片大小和线程数,在最大分片大小和最小分片大小之间取最优值
|
||||
:param file_size: 文件大小
|
||||
:return: 分片大小,线程数
|
||||
"""
|
||||
if file_size <= self.MIN_PART_SIZE:
|
||||
return file_size, 1
|
||||
if file_size >= self.MAX_PART_SIZE:
|
||||
part_size = self.MAX_PART_SIZE
|
||||
else:
|
||||
part_size = max(self.MIN_PART_SIZE, file_size // self.MAX_WORKERS)
|
||||
return part_size, (file_size + part_size - 1) // part_size
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _upload_part(upload_url: str, data: bytes, part_num: int) -> Tuple[int, str, int]:
|
||||
"""
|
||||
上传单个分片
|
||||
"""
|
||||
try:
|
||||
response = requests.put(upload_url, data=data)
|
||||
if response and response.status_code == 200:
|
||||
logger.info(f"【阿里云盘】分片 {part_num} 上传完成")
|
||||
return part_num, response.headers.get('ETag', ''), len(data)
|
||||
else:
|
||||
raise Exception(f"上传失败: {response.status_code if response else 'No Response'}")
|
||||
except Exception as e:
|
||||
logger.error(f"【阿里云盘】分片 {part_num} 上传失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
文件上传:分片、支持秒传
|
||||
文件上传:多线程分片、支持秒传
|
||||
"""
|
||||
target_name = new_name or local_path.name
|
||||
target_path = Path(target_dir.path) / target_name
|
||||
file_size = local_path.stat().st_size
|
||||
|
||||
# 1. 创建文件并检查秒传
|
||||
chunk_size = 100 * 1024 * 1024 # 分片大小 100M
|
||||
# 1. 计算分片大小和线程数
|
||||
part_size, workers = self._calc_parts(file_size)
|
||||
|
||||
# 2. 创建文件并检查秒传
|
||||
create_res = self._create_file(drive_id=target_dir.drive_id,
|
||||
parent_file_id=target_dir.fileid,
|
||||
file_name=target_name,
|
||||
file_path=local_path,
|
||||
chunk_size=chunk_size)
|
||||
chunk_size=part_size)
|
||||
if create_res.get('rapid_upload', False):
|
||||
logger.info(f"【阿里云盘】{target_name} 秒传完成!")
|
||||
return self.get_item(target_path)
|
||||
@@ -652,93 +715,78 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
logger.info(f"【阿里云盘】{target_name} 已存在")
|
||||
return self.get_item(target_path)
|
||||
|
||||
# 2. 准备分片上传参数
|
||||
# 3. 准备分片上传参数
|
||||
file_id = create_res.get('file_id')
|
||||
if not file_id:
|
||||
logger.warn(f"【阿里云盘】创建 {target_name} 文件失败!")
|
||||
return None
|
||||
upload_id = create_res.get('upload_id')
|
||||
part_info_list = create_res.get('part_info_list')
|
||||
uploaded_parts = set()
|
||||
uploaded_parts = {}
|
||||
|
||||
# 3. 获取已上传分片
|
||||
# 4. 获取已上传分片
|
||||
uploaded_info = self._list_uploaded_parts(drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id)
|
||||
for part in uploaded_info.get('uploaded_parts', []):
|
||||
uploaded_parts.add(part['part_number'])
|
||||
uploaded_parts[part['part_number']] = part.get('etag', '')
|
||||
|
||||
# 4. 初始化进度条
|
||||
logger.info(f"【阿里云盘】开始上传: {local_path} -> {target_path},分片数:{len(part_info_list)}")
|
||||
progress_bar = tqdm(
|
||||
total=file_size,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
desc="上传进度",
|
||||
ascii=True
|
||||
)
|
||||
# 5. 初始化进度条
|
||||
logger.info(f"【阿里云盘】开始上传: {local_path} -> {target_path},"
|
||||
f"分片大小:{StringUtils.str_filesize(part_size)},线程数:{workers}")
|
||||
progress_bar = self._log_progress(f"【阿里云盘】{target_name} 上传进度", file_size)
|
||||
|
||||
# 5. 分片上传循环
|
||||
with open(local_path, 'rb') as f:
|
||||
for part_info in part_info_list:
|
||||
part_num = part_info['part_number']
|
||||
# 7. 创建线程池
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = []
|
||||
|
||||
# 计算分片参数
|
||||
start = (part_num - 1) * chunk_size
|
||||
end = min(start + chunk_size, file_size)
|
||||
current_chunk_size = end - start
|
||||
# 提交上传任务
|
||||
with open(local_path, 'rb') as f:
|
||||
for part_info in part_info_list:
|
||||
part_num = part_info['part_number']
|
||||
|
||||
# 更新进度条(已存在的分片)
|
||||
if part_num in uploaded_parts:
|
||||
progress_bar.update(current_chunk_size)
|
||||
continue
|
||||
# 跳过已上传的分片
|
||||
if part_num in uploaded_parts:
|
||||
start = (part_num - 1) * part_size
|
||||
end = min(start + part_size, file_size)
|
||||
progress_bar.update(end - start)
|
||||
continue
|
||||
|
||||
# 准备分片数据
|
||||
f.seek(start)
|
||||
data = f.read(current_chunk_size)
|
||||
# 准备分片数据
|
||||
start = (part_num - 1) * part_size
|
||||
end = min(start + part_size, file_size)
|
||||
f.seek(start)
|
||||
data = f.read(end - start)
|
||||
|
||||
# 上传分片(带重试逻辑)
|
||||
success = False
|
||||
for attempt in range(3): # 最大重试次数
|
||||
try:
|
||||
# 获取当前上传地址(可能刷新)
|
||||
if attempt > 0:
|
||||
new_urls = self._refresh_upload_urls(drive_id=target_dir.drive_id, file_id=file_id,
|
||||
upload_id=upload_id, part_numbers=[part_num])
|
||||
upload_url = new_urls[0]['upload_url']
|
||||
else:
|
||||
upload_url = part_info['upload_url']
|
||||
# 提交上传任务
|
||||
future = pool.submit(
|
||||
self._upload_part,
|
||||
part_info['upload_url'],
|
||||
data,
|
||||
part_num
|
||||
)
|
||||
futures.append((part_num, future))
|
||||
|
||||
# 执行上传
|
||||
logger.info(
|
||||
f"【阿里云盘】开始 第{attempt + 1}次 上传 {target_name} 分片 {part_num} ...")
|
||||
response = self._upload_part(upload_url=upload_url, data=data)
|
||||
if response is None:
|
||||
continue
|
||||
if response.status_code == 200:
|
||||
success = True
|
||||
break
|
||||
else:
|
||||
logger.warn(
|
||||
f"【阿里云盘】{target_name} 分片 {part_num} 第 {attempt + 1} 次上传失败:{response.text}!")
|
||||
except Exception as e:
|
||||
logger.warn(f"【阿里云盘】{target_name} 分片 {part_num} 上传异常: {str(e)}!")
|
||||
# 等待所有任务完成
|
||||
for part_num, future in futures:
|
||||
try:
|
||||
num, etag, uploaded = future.result()
|
||||
uploaded_parts[num] = etag
|
||||
progress_bar.update(uploaded)
|
||||
except Exception as e:
|
||||
logger.error(f"【阿里云盘】分片上传失败: {str(e)}")
|
||||
progress_bar.close()
|
||||
return None
|
||||
|
||||
# 处理上传结果
|
||||
if success:
|
||||
uploaded_parts.add(part_num)
|
||||
progress_bar.update(current_chunk_size)
|
||||
else:
|
||||
raise Exception(f"【阿里云盘】{target_name} 分片 {part_num} 上传失败!")
|
||||
# 8. 关闭进度条
|
||||
progress_bar.close()
|
||||
|
||||
# 6. 关闭进度条
|
||||
if progress_bar:
|
||||
progress_bar.close()
|
||||
|
||||
# 7. 完成上传
|
||||
# 9. 完成上传
|
||||
result = self._complete_upload(drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id)
|
||||
if not result:
|
||||
raise Exception("【阿里云盘】完成上传失败!")
|
||||
if result.get("code"):
|
||||
logger.warn(f"【阿里云盘】{target_name} 上传失败:{result.get('message')}!")
|
||||
return None
|
||||
|
||||
return self.__get_fileitem(result, parent=target_dir.path)
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
@@ -828,7 +876,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
if resp.get("code"):
|
||||
logger.debug(f"【阿里云盘】获取文件信息失败: {resp.get('message')}")
|
||||
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:
|
||||
logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}")
|
||||
return None
|
||||
@@ -854,7 +902,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
if 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:]:
|
||||
dir_file = __find_dir(fileitem, part)
|
||||
if dir_file:
|
||||
@@ -957,3 +1005,5 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
)
|
||||
except NoCheckInException:
|
||||
return None
|
||||
except SessionInvalidException:
|
||||
return None
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import io
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple, Union
|
||||
|
||||
import oss2
|
||||
import requests
|
||||
from oss2 import SizedFileAdapter, determine_part_size
|
||||
from oss2.models import PartInfo
|
||||
from tqdm import tqdm
|
||||
|
||||
@@ -54,6 +54,13 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
# CID和路径缓存
|
||||
_id_cache: Dict[str, str] = {}
|
||||
|
||||
# 最大线程数
|
||||
MAX_WORKERS = 10
|
||||
# 最大分片大小(1GB)
|
||||
MAX_PART_SIZE = 1024 * 1024 * 1024
|
||||
# 最小分片大小(100MB)
|
||||
MIN_PART_SIZE = 100 * 1024 * 1024
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.session = requests.Session()
|
||||
@@ -375,7 +382,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"POST",
|
||||
"/open/folder/add",
|
||||
data={
|
||||
"pid": int(parent_item.fileid),
|
||||
"pid": int(parent_item.fileid or "0"),
|
||||
"file_name": name
|
||||
}
|
||||
)
|
||||
@@ -399,17 +406,66 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
modify_time=int(time.time())
|
||||
)
|
||||
|
||||
def _calc_parts(self, file_size: int) -> Tuple[int, int]:
|
||||
"""
|
||||
计算最优分片大小和线程数,在最大分片大小和最小分片大小之间取最优值
|
||||
:param file_size: 文件大小
|
||||
:return: 分片大小,线程数
|
||||
"""
|
||||
if file_size <= self.MIN_PART_SIZE:
|
||||
return file_size, 1
|
||||
if file_size >= self.MAX_PART_SIZE:
|
||||
part_size = self.MAX_PART_SIZE
|
||||
else:
|
||||
part_size = max(self.MIN_PART_SIZE, file_size // self.MAX_WORKERS)
|
||||
return part_size, (file_size + part_size - 1) // part_size
|
||||
|
||||
@staticmethod
|
||||
def _upload_part(bucket: oss2.Bucket, object_name: str, upload_id: str,
|
||||
part_number: int, part_data: bytes) -> Tuple[PartInfo, int]:
|
||||
"""
|
||||
上传单个分片
|
||||
"""
|
||||
try:
|
||||
result = bucket.upload_part(object_name, upload_id, part_number, part_data)
|
||||
part_info = PartInfo(part_number, result.etag)
|
||||
logger.info(f"【115】分片 {part_number} 上传完成")
|
||||
return part_info, len(part_data)
|
||||
except Exception as e:
|
||||
logger.error(f"【115】分片 {part_number} 上传失败: {str(e)}")
|
||||
raise
|
||||
|
||||
@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,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
实现带秒传、断点续传和二次认证的文件上传
|
||||
实现带秒传、断点续传和多线程并发上传
|
||||
"""
|
||||
|
||||
def encode_callback(cb: dict):
|
||||
"""
|
||||
回调参数Base64编码函数
|
||||
"""
|
||||
return oss2.utils.b64encode_as_string(json.dumps(cb).strip())
|
||||
def encode_callback(cb: str):
|
||||
return oss2.utils.b64encode_as_string(cb)
|
||||
|
||||
target_name = new_name or local_path.name
|
||||
target_path = Path(target_dir.path) / target_name
|
||||
@@ -440,6 +496,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
if not init_resp.get("state"):
|
||||
logger.warn(f"【115】初始化上传失败: {init_resp.get('error')}")
|
||||
return None
|
||||
|
||||
# 结果
|
||||
init_result = init_resp.get("data")
|
||||
logger.debug(f"【115】上传 Step 1 初始化结果: {init_result}")
|
||||
@@ -457,15 +514,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
sign_checks = sign_check.split("-")
|
||||
start = int(sign_checks[0])
|
||||
end = int(sign_checks[1])
|
||||
# 计算指定区间的SHA1
|
||||
# sign_check (用下划线隔开,截取上传文内容的sha1)(单位是byte): "2392148-2392298"
|
||||
with open(local_path, "rb") as f:
|
||||
# 取2392148-2392298之间的内容(包含2392148、2392298)的sha1
|
||||
f.seek(start)
|
||||
chunk = f.read(end - start + 1)
|
||||
sign_val = hashlib.sha1(chunk).hexdigest().upper()
|
||||
# 重新初始化请求
|
||||
# sign_key,sign_val(根据sign_check计算的值大写的sha1值)
|
||||
init_data.update({
|
||||
"pick_code": pick_code,
|
||||
"sign_key": sign_key,
|
||||
@@ -478,7 +530,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
)
|
||||
if not init_resp:
|
||||
return None
|
||||
# 二次认证结果
|
||||
init_result = init_resp.get("data")
|
||||
logger.debug(f"【115】上传 Step 2 二次认证结果: {init_result}")
|
||||
if not pick_code:
|
||||
@@ -505,7 +556,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
logger.warn("【115】获取上传凭证失败")
|
||||
return None
|
||||
logger.debug(f"【115】上传 Step 4 获取上传凭证结果: {token_resp}")
|
||||
# 上传凭证
|
||||
|
||||
endpoint = token_resp.get("endpoint")
|
||||
AccessKeyId = token_resp.get("AccessKeyId")
|
||||
AccessKeySecret = token_resp.get("AccessKeySecret")
|
||||
@@ -528,66 +579,75 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
if resume_resp.get("callback"):
|
||||
callback = resume_resp["callback"]
|
||||
|
||||
# Step 6: 对象存储上传
|
||||
# Step 6: 多线程分片上传
|
||||
auth = oss2.StsAuth(
|
||||
access_key_id=AccessKeyId,
|
||||
access_key_secret=AccessKeySecret,
|
||||
security_token=SecurityToken
|
||||
)
|
||||
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
|
||||
part_size = determine_part_size(file_size, preferred_size=100 * 1024 * 1024)
|
||||
|
||||
# 计算分片大小和线程数
|
||||
part_size, workers = self._calc_parts(file_size)
|
||||
logger.info(f"【115】开始上传: {local_path} -> {target_path},"
|
||||
f"分片大小:{StringUtils.str_filesize(part_size)},线程数:{workers}")
|
||||
|
||||
# 初始化进度条
|
||||
logger.info(f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}")
|
||||
progress_bar = tqdm(
|
||||
total=file_size,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
desc="上传进度",
|
||||
ascii=True
|
||||
)
|
||||
progress_bar = self._log_progress(f"【115】{target_name} 上传进度", file_size)
|
||||
|
||||
# 初始化分片
|
||||
# 初始化分片上传
|
||||
upload_id = bucket.init_multipart_upload(object_name,
|
||||
params={
|
||||
"encoding-type": "url",
|
||||
"sequential": ""
|
||||
}).upload_id
|
||||
parts = []
|
||||
# 逐个上传分片
|
||||
with open(local_path, 'rb') as fileobj:
|
||||
part_number = 1
|
||||
offset = 0
|
||||
while offset < file_size:
|
||||
num_to_upload = min(part_size, file_size - offset)
|
||||
# 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。
|
||||
logger.info(f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}")
|
||||
result = bucket.upload_part(object_name, upload_id, part_number,
|
||||
data=SizedFileAdapter(fileobj, num_to_upload))
|
||||
parts.append(PartInfo(part_number, result.etag))
|
||||
logger.info(f"【115】{target_name} 分片 {part_number} 上传完成")
|
||||
offset += num_to_upload
|
||||
part_number += 1
|
||||
# 更新进度
|
||||
progress_bar.update(num_to_upload)
|
||||
|
||||
# 关闭进度条
|
||||
if progress_bar:
|
||||
progress_bar.close()
|
||||
# 创建线程池
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = []
|
||||
parts = []
|
||||
|
||||
# 请求头
|
||||
# 提交上传任务
|
||||
with open(local_path, 'rb') as fileobj:
|
||||
part_number = 1
|
||||
offset = 0
|
||||
while offset < file_size:
|
||||
size = min(part_size, file_size - offset)
|
||||
fileobj.seek(offset)
|
||||
part_data = fileobj.read(size)
|
||||
future = pool.submit(
|
||||
self._upload_part,
|
||||
bucket,
|
||||
object_name,
|
||||
upload_id,
|
||||
part_number,
|
||||
part_data
|
||||
)
|
||||
futures.append(future)
|
||||
offset += size
|
||||
part_number += 1
|
||||
|
||||
# 等待所有任务完成
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
part_info, uploaded = future.result()
|
||||
parts.append(part_info)
|
||||
progress_bar.update(uploaded)
|
||||
except Exception as e:
|
||||
logger.error(f"【115】分片上传失败: {str(e)}")
|
||||
progress_bar.close()
|
||||
return None
|
||||
|
||||
# 按分片号排序
|
||||
parts.sort(key=lambda x: x.part_number)
|
||||
|
||||
# 完成上传
|
||||
headers = {
|
||||
'X-oss-callback': encode_callback(callback_dict),
|
||||
'x-oss-callback-var': encode_callback(callback_var_dict),
|
||||
'X-oss-callback': encode_callback(callback["callback"]),
|
||||
'x-oss-callback-var': encode_callback(callback["callback_var"]),
|
||||
'x-oss-forbid-overwrite': 'false'
|
||||
}
|
||||
|
||||
try:
|
||||
result = bucket.complete_multipart_upload(object_name, upload_id, parts,
|
||||
headers=headers)
|
||||
@@ -603,6 +663,9 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
else:
|
||||
logger.error(f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}")
|
||||
return None
|
||||
finally:
|
||||
progress_bar.close()
|
||||
|
||||
# 返回结果
|
||||
return self.get_item(target_path)
|
||||
|
||||
|
||||
@@ -696,6 +696,8 @@ class Jellyfin:
|
||||
# jellyfin 的 webhook 不含 item_path,需要单独获取
|
||||
eventItem.item_path = self.get_item_path_by_id(eventItem.item_id)
|
||||
|
||||
eventItem.json_object = message
|
||||
|
||||
return eventItem
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -703,6 +703,8 @@ class Plex:
|
||||
eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id,
|
||||
image_type="Backdrop")
|
||||
|
||||
eventItem.json_object = message
|
||||
|
||||
return eventItem
|
||||
|
||||
def get_plex(self):
|
||||
|
||||
@@ -268,7 +268,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
# 当前季第一季时间
|
||||
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]
|
||||
if season_years:
|
||||
mediainfo.season_years = season_years
|
||||
|
||||
@@ -33,7 +33,7 @@ class TmdbApi:
|
||||
# APIKEY
|
||||
self.tmdb.api_key = settings.TMDB_API_KEY
|
||||
# 语种
|
||||
self.tmdb.language = 'zh'
|
||||
self.tmdb.language = settings.TMDB_LOCALE
|
||||
# 代理
|
||||
self.tmdb.proxies = settings.PROXY
|
||||
# 调试模式
|
||||
@@ -632,7 +632,8 @@ class TmdbApi:
|
||||
# 转换多语种标题
|
||||
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
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class TrimeMedia:
|
||||
self._playhost = play_api.host
|
||||
elif play_host:
|
||||
logger.warning(f"请检查外网播放地址 {play_host}")
|
||||
self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/")
|
||||
|
||||
self.reconnect()
|
||||
|
||||
|
||||
@@ -55,6 +55,13 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""
|
||||
获取插件名称
|
||||
:return: 插件名称
|
||||
"""
|
||||
return self.plugin_name
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
@@ -76,6 +83,14 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
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
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -84,6 +99,7 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"path": "/xx",
|
||||
"endpoint": self.xxx,
|
||||
"methods": ["GET", "POST"],
|
||||
"auth: "apikey", # 鉴权类型:apikey/bear
|
||||
"summary": "API名称",
|
||||
"description": "API说明"
|
||||
}]
|
||||
@@ -91,18 +107,19 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@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
|
||||
|
||||
@abstractmethod
|
||||
def get_page(self) -> List[dict]:
|
||||
def get_page(self) -> Optional[List[dict]]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||
:return: 页面配置(vuetify模式)或 None(vue模式)
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -119,9 +136,9 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
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配置参考:
|
||||
{
|
||||
"cols": 12, "md": 6
|
||||
@@ -133,7 +150,7 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
|
||||
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
|
||||
}
|
||||
3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||
3、vuetify模式页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/;vue模式为None
|
||||
|
||||
kwargs参数可获取的值:1、user_agent:浏览器UA
|
||||
|
||||
@@ -155,6 +172,16 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_module(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||
{
|
||||
"id1": self.xxx1,
|
||||
"id2": self.xxx2,
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop_service(self):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -307,3 +307,21 @@ class MediaRecognizeConvertEventData(ChainEventData):
|
||||
|
||||
# 输出参数
|
||||
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="存储操作对象")
|
||||
|
||||
@@ -160,6 +160,7 @@ class WebhookEventInfo(BaseModel):
|
||||
save_reason: Optional[str] = None
|
||||
item_isvirtual: Optional[bool] = None
|
||||
media_type: Optional[str] = None
|
||||
json_object: Optional[dict] = {}
|
||||
|
||||
|
||||
class MediaServerPlayItem(BaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.types import NotificationType, MessageChannel
|
||||
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
||||
|
||||
|
||||
class CommingMessage(BaseModel):
|
||||
@@ -45,6 +45,8 @@ class Notification(BaseModel):
|
||||
source: Optional[str] = None
|
||||
# 消息类型
|
||||
mtype: Optional[NotificationType] = None
|
||||
# 内容类型
|
||||
ctype: Optional[ContentType] = None
|
||||
# 标题
|
||||
title: Optional[str] = None
|
||||
# 文本内容
|
||||
|
||||
@@ -59,6 +59,8 @@ class PluginDashboard(Plugin):
|
||||
name: Optional[str] = None
|
||||
# 仪表板key
|
||||
key: Optional[str] = None
|
||||
# 演染模式
|
||||
render_mode: Optional[str] = Field(default="vuetify")
|
||||
# 全局配置
|
||||
attrs: Optional[dict] = Field(default_factory=dict)
|
||||
# col列数
|
||||
|
||||
@@ -89,6 +89,8 @@ class ChainEventType(Enum):
|
||||
RecommendSource = "recommend.source"
|
||||
# 工作流执行
|
||||
WorkflowExecution = "workflow.execution"
|
||||
# 存储操作选择
|
||||
StorageOperSelection = "storage.operation"
|
||||
|
||||
|
||||
# 系统配置Key字典
|
||||
@@ -149,6 +151,8 @@ class SystemConfigKey(Enum):
|
||||
FollowSubscribers = "FollowSubscribers"
|
||||
# 通知发送时间
|
||||
NotificationSendTime = "NotificationSendTime"
|
||||
# 通知消息格式模板
|
||||
NotificationTemplates = "NotificationTemplates"
|
||||
|
||||
|
||||
# 处理进度Key字典
|
||||
@@ -187,6 +191,21 @@ class NotificationType(Enum):
|
||||
Other = "其它"
|
||||
|
||||
|
||||
class ContentType(str, Enum):
|
||||
"""
|
||||
消息内容类型
|
||||
操作状态的通知消息类型标识
|
||||
"""
|
||||
# 订阅添加成功
|
||||
SubscribeAdded: str = "subscribeAdded"
|
||||
# 订阅完成
|
||||
SubscribeComplete: str = "subscribeComplete"
|
||||
# 入库成功
|
||||
OrganizeSuccess: str = "organizeSuccess"
|
||||
# 下载开始(添加下载任务成功)
|
||||
DownloadAdded: str = "downloadAdded"
|
||||
|
||||
|
||||
# 消息渠道
|
||||
class MessageChannel(Enum):
|
||||
"""
|
||||
|
||||
@@ -52,21 +52,30 @@ class ObjectUtils:
|
||||
# 跳过空行
|
||||
if not line:
|
||||
continue
|
||||
# 处理多行注释
|
||||
# 处理"""单行注释
|
||||
if (line.startswith(('"""', "'''"))
|
||||
and line.endswith(('"""', "'''"))
|
||||
and len(line) > 3):
|
||||
continue
|
||||
# 处理"""多行注释
|
||||
if line.startswith(('"""', "'''")):
|
||||
in_comment = not in_comment
|
||||
continue
|
||||
# 在注释中则跳过
|
||||
if in_comment:
|
||||
continue
|
||||
# 跳过注释、pass语句、装饰器、函数定义行
|
||||
if line.startswith('#') or line == "pass" or line.startswith('@') or line.startswith('def '):
|
||||
# 跳过#注释、pass语句、装饰器、函数定义行
|
||||
if (line.startswith('#')
|
||||
or line == "pass"
|
||||
or line.startswith('@')
|
||||
or line.startswith('def ')):
|
||||
continue
|
||||
# 发现有效代码行
|
||||
return True
|
||||
# 没有有效代码行
|
||||
return False
|
||||
except Exception:
|
||||
except Exception as err:
|
||||
print(err)
|
||||
# 源代码分析失败时,进行字节码分析
|
||||
code_obj = func.__code__
|
||||
instructions = list(dis.get_instructions(code_obj))
|
||||
|
||||
67
database/versions/89d24811e894_2_1_4.py
Normal file
67
database/versions/89d24811e894_2_1_4.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""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 }} {{ 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 }} {{season}} 已添加订阅'}",
|
||||
"subscribeComplete": """
|
||||
{
|
||||
'title': '{{ title_year }} {{season}} 已完成{{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
|
||||
@@ -45,7 +45,7 @@ RUN apt-get update -y \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY requirements.in requirements.in
|
||||
COPY ../requirements.in requirements.in
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
@@ -61,12 +61,13 @@ RUN apt-get update -y \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY . .
|
||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& cp -f /app/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
COPY .. .
|
||||
RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
|
||||
&& cp -f /app/docker/nginx.template.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh \
|
||||
&& cp -f /app/docker/entrypoint.sh /entrypoint.sh \
|
||||
&& cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
|
||||
&& chmod +x /entrypoint.sh /usr/local/bin/mp_update.sh \
|
||||
&& mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 918 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||
@@ -87,4 +88,4 @@ RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& rm -rf /tmp/*
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
101
docker/cert.sh
Normal file
101
docker/cert.sh
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
Green="\033[32m"
|
||||
Red="\033[31m"
|
||||
Yellow='\033[33m'
|
||||
Font="\033[0m"
|
||||
INFO="[${Green}INFO${Font}]"
|
||||
ERROR="[${Red}ERROR${Font}]"
|
||||
WARN="[${Yellow}WARN${Font}]"
|
||||
function INFO() {
|
||||
echo -e "${INFO} ${1}"
|
||||
}
|
||||
function ERROR() {
|
||||
echo -e "${ERROR} ${1}"
|
||||
}
|
||||
function WARN() {
|
||||
echo -e "${WARN} ${1}"
|
||||
}
|
||||
|
||||
# 核心条件验证
|
||||
if [ "${ENABLE_SSL}" = "true" ] && \
|
||||
[ "${AUTO_ISSUE_CERT}" = "true" ] && \
|
||||
[ -n "${SSL_DOMAIN}" ]; then
|
||||
|
||||
# 创建证书目录
|
||||
mkdir -p /config/certs/"${SSL_DOMAIN}"
|
||||
chown moviepilot:moviepilot /config/certs -R
|
||||
|
||||
# 安装acme.sh(使用官方安装脚本)
|
||||
if [ ! -d "/config/acme.sh" ]; then
|
||||
INFO "→ 安装acme.sh..."
|
||||
|
||||
# 生成安装参数
|
||||
INSTALL_ARGS=(
|
||||
"--install-online"
|
||||
"--home" "/config/acme.sh"
|
||||
"--config-home" "/config/acme.sh/data"
|
||||
"--cert-home" "/config/certs"
|
||||
)
|
||||
|
||||
# 添加邮箱参数(如果设置)
|
||||
if [ -n "${SSL_EMAIL}" ]; then
|
||||
INSTALL_ARGS+=("--accountemail" "${SSL_EMAIL}")
|
||||
else
|
||||
WARN "未设置SSL_EMAIL,建议配置邮箱用于证书过期提醒"
|
||||
fi
|
||||
|
||||
# 执行官方安装命令
|
||||
curl -sSL https://get.acme.sh | sh -s -- "${INSTALL_ARGS[@]}"
|
||||
fi
|
||||
|
||||
# 签发证书(仅当证书不存在时)
|
||||
if [ ! -f "/config/certs/${SSL_DOMAIN}/fullchain.pem" ]; then
|
||||
# 必要参数检查
|
||||
REQUIRED_VARS=("DNS_PROVIDER")
|
||||
for var in "${REQUIRED_VARS[@]}"; do
|
||||
eval "value=\${${var}}"
|
||||
[ -z "$value" ] && { ERROR "必须设置环境变量: ${var}"; exit 1; }
|
||||
done
|
||||
|
||||
INFO "→ 签发证书: ${SSL_DOMAIN} (DNS验证方式: ${DNS_PROVIDER})"
|
||||
|
||||
# 加载ACME环境变量(带安全过滤)
|
||||
INFO "正在加载ACME环境变量..."
|
||||
env | grep '^ACME_ENV_' | while read -r line; do
|
||||
key="${line#ACME_ENV_}"
|
||||
key="${key%%=*}"
|
||||
value="${line#ACME_ENV_${key}=}"
|
||||
|
||||
# 过滤非法变量名
|
||||
if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
|
||||
export "$key"="$value"
|
||||
INFO "已加载环境变量: ${key}=******"
|
||||
else
|
||||
WARN "跳过无效变量名: ${key}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 签发证书
|
||||
/config/acme.sh/acme.sh --issue \
|
||||
--dns "${DNS_PROVIDER}" \
|
||||
--domain "${SSL_DOMAIN}" \
|
||||
--key-file /config/certs/"${SSL_DOMAIN}"/privkey.pem \
|
||||
--fullchain-file /config/certs/"${SSL_DOMAIN}"/fullchain.pem \
|
||||
--reloadcmd "nginx -s reload" \
|
||||
--force
|
||||
|
||||
# 创建稳定符号链接
|
||||
ln -sf /config/certs/"${SSL_DOMAIN}" /config/certs/latest
|
||||
fi
|
||||
|
||||
# 配置自动更新任务
|
||||
INFO "→ 配置cron自动更新..."
|
||||
echo "0 3 * * * /config/acme.sh/acme.sh --cron --home /config/acme.sh && nginx -s reload" > /etc/cron.d/acme
|
||||
chmod 644 /etc/cron.d/acme
|
||||
service cron start
|
||||
|
||||
elif [ "${ENABLE_SSL}" = "true" ] && [ "${AUTO_ISSUE_CERT}" = "true" ] && [ -z "${SSL_DOMAIN}" ]; then
|
||||
WARN "已启用自动签发证书但未设置SSL_DOMAIN,跳过证书管理"
|
||||
fi
|
||||
97
docker/entrypoint.sh
Normal file
97
docker/entrypoint.sh
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2016
|
||||
# shellcheck disable=SC2155
|
||||
|
||||
Green="\033[32m"
|
||||
Red="\033[31m"
|
||||
Yellow='\033[33m'
|
||||
Font="\033[0m"
|
||||
INFO="[${Green}INFO${Font}]"
|
||||
ERROR="[${Red}ERROR${Font}]"
|
||||
WARN="[${Yellow}WARN${Font}]"
|
||||
function INFO() {
|
||||
echo -e "${INFO} ${1}"
|
||||
}
|
||||
function ERROR() {
|
||||
echo -e "${ERROR} ${1}"
|
||||
}
|
||||
function WARN() {
|
||||
echo -e "${WARN} ${1}"
|
||||
}
|
||||
|
||||
# 生成HTTPS配置块
|
||||
if [ "${ENABLE_SSL}" = "true" ]; then
|
||||
export HTTPS_SERVER_CONF=$(cat <<EOF
|
||||
server {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name ${SSL_DOMAIN:-moviepilot};
|
||||
|
||||
# SSL证书路径
|
||||
ssl_certificate /config/certs/latest/fullchain.pem;
|
||||
ssl_certificate_key /config/certs/latest/privkey.pem;
|
||||
|
||||
# SSL安全配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# 公共配置
|
||||
include common.conf;
|
||||
}
|
||||
EOF
|
||||
)
|
||||
else
|
||||
export HTTPS_SERVER_CONF="# HTTPS未启用"
|
||||
fi
|
||||
|
||||
# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值
|
||||
export NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-10m}
|
||||
envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}${ENABLE_SSL}${HTTPS_SERVER_CONF}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
|
||||
# 自动更新
|
||||
cd /
|
||||
source /usr/local/bin/mp_update.sh
|
||||
cd /app || exit
|
||||
# 更改 moviepilot userid 和 groupid
|
||||
groupmod -o -g "${PGID}" moviepilot
|
||||
usermod -o -u "${PUID}" moviepilot
|
||||
# 更改文件权限
|
||||
chown -R moviepilot:moviepilot \
|
||||
"${HOME}" \
|
||||
/app \
|
||||
/public \
|
||||
/config \
|
||||
/var/lib/nginx \
|
||||
/var/log/nginx
|
||||
chown moviepilot:moviepilot /etc/hosts /tmp
|
||||
# 下载浏览器内核
|
||||
if [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$PROXY_HOST" =~ ^https?:// ]]; then
|
||||
HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}" gosu moviepilot:moviepilot playwright install chromium
|
||||
else
|
||||
gosu moviepilot:moviepilot playwright install chromium
|
||||
fi
|
||||
# 证书管理
|
||||
source /app/docker/cert.sh
|
||||
# 启动前端nginx服务
|
||||
INFO "→ 启动前端nginx服务..."
|
||||
nginx
|
||||
# 启动docker http proxy nginx
|
||||
if [ -S "/var/run/docker.sock" ]; then
|
||||
INFO "→ 启动 Docker Proxy..."
|
||||
nginx -c /etc/nginx/docker_http_proxy.conf
|
||||
# 上面nginx是通过root启动的,会将目录权限改成root,所以需要重新再设置一遍权限
|
||||
chown -R moviepilot:moviepilot \
|
||||
/var/lib/nginx \
|
||||
/var/log/nginx
|
||||
fi
|
||||
# 设置后端服务权限掩码
|
||||
umask "${UMASK}"
|
||||
# 启动后端服务
|
||||
INFO "→ 启动后端服务..."
|
||||
exec dumb-init gosu moviepilot:moviepilot python3 app/main.py
|
||||
100
docker/nginx.common.conf
Normal file
100
docker/nginx.common.conf
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
# 公共根目录
|
||||
root /public;
|
||||
|
||||
# 主应用路由
|
||||
location / {
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 图片类静态资源
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# assets目录
|
||||
location /assets {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 站点图标
|
||||
location /api/v1/site/icon/ {
|
||||
# 站点图标缓存
|
||||
proxy_cache my_cache;
|
||||
# 缓存响应码为200和302的请求1小时
|
||||
proxy_cache_valid 200 302 1h;
|
||||
# 缓存其他响应码的请求5分钟
|
||||
proxy_cache_valid any 5m;
|
||||
# 缓存键的生成规则
|
||||
proxy_cache_key "$scheme$request_method$host$request_uri";
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
|
||||
# 向后端API转发请求
|
||||
proxy_pass http://backend_api;
|
||||
}
|
||||
|
||||
|
||||
# 本地CookieCloud
|
||||
location /cookiecloud {
|
||||
proxy_pass http://backend_api;
|
||||
rewrite ^.+mock-server/?(.*)$ /$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Nginx-Proxy true;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# SSE特殊配置
|
||||
location ~ ^/api/v1/system/(message|progress/) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
# 禁用缓存
|
||||
add_header Cache-Control no-cache;
|
||||
add_header X-Accel-Buffering no;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# 代理设置
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# API代理配置
|
||||
location /api {
|
||||
proxy_pass http://backend_api;
|
||||
rewrite ^.+mock-server/?(.*)$ /$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Nginx-Proxy true;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
50
docker/nginx.template.conf
Normal file
50
docker/nginx.template.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
user moviepilot;
|
||||
worker_processes auto;
|
||||
worker_cpu_affinity auto;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
# 设置缓存路径和缓存区大小
|
||||
proxy_cache_path /tmp levels=1:2 keys_zone=my_cache:10m max_size=100m inactive=60m use_temp_path=off;
|
||||
|
||||
sendfile on;
|
||||
|
||||
keepalive_timeout 3600;
|
||||
|
||||
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_proxied any;
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
|
||||
# HTTP
|
||||
server {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
listen ${NGINX_PORT};
|
||||
listen [::]:${NGINX_PORT};
|
||||
server_name moviepilot;
|
||||
|
||||
# 公共配置
|
||||
include common.conf;
|
||||
}
|
||||
|
||||
# HTTPS
|
||||
${HTTPS_SERVER_CONF}
|
||||
|
||||
upstream backend_api {
|
||||
# 后端API的地址和端口
|
||||
server 127.0.0.1:${PORT};
|
||||
# 可以添加更多后端服务器作为负载均衡
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function download_and_unzip() {
|
||||
local max_retries=3
|
||||
local url="$1"
|
||||
local target_dir="$2"
|
||||
INFO "正在下载 ${url}..."
|
||||
INFO "→ 正在下载 ${url}..."
|
||||
while [ $retries -lt $max_retries ]; do
|
||||
if curl ${CURL_OPTIONS} "${url}" ${CURL_HEADERS} | busybox unzip -d ${TMP_PATH} - > /dev/null; then
|
||||
if [ -e ${TMP_PATH}/MoviePilot-* ]; then
|
||||
@@ -54,19 +54,19 @@ function install_backend_and_download_resources() {
|
||||
return 1
|
||||
fi
|
||||
INFO "后端程序下载成功"
|
||||
INFO "依赖安装中..."
|
||||
INFO "→ 正在安装依赖..."
|
||||
if ! pip install ${PIP_OPTIONS} --upgrade --root-user-action=ignore pip > /dev/null; then
|
||||
ERROR "pip 更新失败,请重新拉取镜像"
|
||||
return 1
|
||||
fi
|
||||
if ! pip install ${PIP_OPTIONS} --root-user-action=ignore -r ${TMP_PATH}/App/requirements.txt > /dev/null; then
|
||||
ERROR "安装依赖失败,请重新拉取镜像"
|
||||
ERROR "依赖安装失败,请重新拉取镜像"
|
||||
return 1
|
||||
fi
|
||||
INFO "安装依赖成功"
|
||||
INFO "依赖安装成功"
|
||||
# 如果是"heads/v2.zip",则查找v2开头的最新版本号
|
||||
if [[ "${1}" == "heads/v2.zip" ]]; then
|
||||
INFO "正在获取前端最新版本号..."
|
||||
INFO "→ 正在获取前端最新版本号..."
|
||||
# 获取所有发布的版本列表,并筛选出以v2开头的版本号
|
||||
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v2\.")
|
||||
if [ -z "$releases" ]; then
|
||||
@@ -78,7 +78,7 @@ function install_backend_and_download_resources() {
|
||||
fi
|
||||
INFO "前端最新版本号:${frontend_version}"
|
||||
else
|
||||
INFO "正在获取前端版本号..."
|
||||
INFO "→ 正在获取前端版本号..."
|
||||
# 从后端文件中读取前端版本号
|
||||
frontend_version=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" ${TMP_PATH}/App/version.py)
|
||||
if [[ "${frontend_version}" != *v* ]]; then
|
||||
@@ -94,13 +94,13 @@ function install_backend_and_download_resources() {
|
||||
fi
|
||||
INFO "前端程序下载成功"
|
||||
# 备份插件目录
|
||||
INFO "备份插件目录中..."
|
||||
INFO "→ 正在备份插件目录..."
|
||||
rm -rf /plugins
|
||||
mkdir -p /plugins
|
||||
cp -a /app/app/plugins/* /plugins/
|
||||
rm -f /plugins/__init__.py
|
||||
# 备份站点资源
|
||||
INFO "备份站点资源目录中..."
|
||||
INFO "→ 正在备份站点资源目录..."
|
||||
rm -rf /resources_bakcup
|
||||
mkdir /resources_bakcup
|
||||
cp -a /app/app/helper/user.sites.bin /resources_bakcup
|
||||
@@ -118,14 +118,13 @@ function install_backend_and_download_resources() {
|
||||
# 恢复插件目录
|
||||
cp -a /plugins/* /app/app/plugins/
|
||||
# 更新站点资源
|
||||
INFO "开始更新站点资源..."
|
||||
INFO "→ 开始更新站点资源..."
|
||||
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"; then
|
||||
cp -a /resources_bakcup/* /app/app/helper/
|
||||
rm -rf /resources_bakcup
|
||||
WARN "站点资源下载失败,继续使用旧的资源来启动..."
|
||||
return 1
|
||||
fi
|
||||
INFO "站点资源下载成功"
|
||||
# 复制新站点资源
|
||||
cp -a ${TMP_PATH}/Resources/resources/* /app/app/helper/
|
||||
INFO "站点资源更新成功"
|
||||
43
entrypoint
43
entrypoint
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2016
|
||||
|
||||
# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值
|
||||
export NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-10m}
|
||||
envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
|
||||
# 自动更新
|
||||
cd /
|
||||
/usr/local/bin/mp_update
|
||||
cd /app || exit
|
||||
# 更改 moviepilot userid 和 groupid
|
||||
groupmod -o -g "${PGID}" moviepilot
|
||||
usermod -o -u "${PUID}" moviepilot
|
||||
# 更改文件权限
|
||||
chown -R moviepilot:moviepilot \
|
||||
"${HOME}" \
|
||||
/app \
|
||||
/public \
|
||||
/config \
|
||||
/var/lib/nginx \
|
||||
/var/log/nginx
|
||||
chown moviepilot:moviepilot /etc/hosts /tmp
|
||||
# 下载浏览器内核
|
||||
if [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$https_proxy" =~ ^https?:// ]] || [[ "$PROXY_HOST" =~ ^https?:// ]]; then
|
||||
HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}" gosu moviepilot:moviepilot playwright install chromium
|
||||
else
|
||||
gosu moviepilot:moviepilot playwright install chromium
|
||||
fi
|
||||
# 启动前端nginx服务
|
||||
nginx
|
||||
# 启动docker http proxy nginx
|
||||
if [ -S "/var/run/docker.sock" ]; then
|
||||
nginx -c /etc/nginx/docker_http_proxy.conf
|
||||
# 上面nginx是通过root启动的,会将目录权限改成root,所以需要重新再设置一遍权限
|
||||
chown -R moviepilot:moviepilot \
|
||||
/var/lib/nginx \
|
||||
/var/log/nginx
|
||||
fi
|
||||
# 设置后端服务权限掩码
|
||||
umask "${UMASK}"
|
||||
# 启动后端服务
|
||||
exec dumb-init gosu moviepilot:moviepilot python3 app/main.py
|
||||
142
nginx.conf
142
nginx.conf
@@ -1,142 +0,0 @@
|
||||
user moviepilot;
|
||||
worker_processes auto;
|
||||
worker_cpu_affinity auto;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
|
||||
# 设置缓存路径和缓存区大小
|
||||
proxy_cache_path /tmp levels=1:2 keys_zone=my_cache:10m max_size=100m inactive=60m use_temp_path=off;
|
||||
|
||||
sendfile on;
|
||||
|
||||
keepalive_timeout 3600;
|
||||
|
||||
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_proxied any;
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
|
||||
server {
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
listen ${NGINX_PORT};
|
||||
listen [::]:${NGINX_PORT};
|
||||
server_name moviepilot;
|
||||
|
||||
location / {
|
||||
# 主目录
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
root /public;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
root /public;
|
||||
}
|
||||
|
||||
location /assets {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
root /public;
|
||||
}
|
||||
|
||||
location /api/v1/site/icon/ {
|
||||
# 站点图标缓存
|
||||
proxy_cache my_cache;
|
||||
# 缓存响应码为200和302的请求1小时
|
||||
proxy_cache_valid 200 302 1h;
|
||||
# 缓存其他响应码的请求5分钟
|
||||
proxy_cache_valid any 5m;
|
||||
# 缓存键的生成规则
|
||||
proxy_cache_key "$scheme$request_method$host$request_uri";
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
|
||||
# 向后端API转发请求
|
||||
proxy_pass http://backend_api;
|
||||
}
|
||||
|
||||
location /cookiecloud {
|
||||
# 后端cookiecloud地址
|
||||
proxy_pass http://backend_api;
|
||||
rewrite ^.+mock-server/?(.*)$ /$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Nginx-Proxy true;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/system/(message|progress/) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
# 禁用缓存
|
||||
add_header Cache-Control no-cache;
|
||||
add_header X-Accel-Buffering no;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# 代理设置
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location /api {
|
||||
# 后端API
|
||||
proxy_pass http://backend_api;
|
||||
rewrite ^.+mock-server/?(.*)$ /$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Nginx-Proxy true;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
|
||||
upstream backend_api {
|
||||
# 后端API的地址和端口
|
||||
server 127.0.0.1:${PORT};
|
||||
# 可以添加更多后端服务器作为负载均衡
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.4.0'
|
||||
FRONTEND_VERSION = 'v2.4.0'
|
||||
APP_VERSION = 'v2.4.5'
|
||||
FRONTEND_VERSION = 'v2.4.5'
|
||||
|
||||
Reference in New Issue
Block a user