mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-07 16:53:03 +08:00
Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ab19f3cf | ||
|
|
beebfb2e19 | ||
|
|
cfca90aa7d | ||
|
|
19fe0a32c8 | ||
|
|
76659f8837 | ||
|
|
2254715190 | ||
|
|
ae1a5460d4 | ||
|
|
27d9f910ff | ||
|
|
28db4881d7 | ||
|
|
7c76c3ccd6 | ||
|
|
007bd24374 | ||
|
|
c8dc30287c | ||
|
|
360184bbd1 | ||
|
|
e8ed2454a1 | ||
|
|
923ecf29b8 | ||
|
|
a8f8bf5872 | ||
|
|
bedcd94020 | ||
|
|
959d4da1f8 | ||
|
|
861453c1a8 | ||
|
|
2f4072da0d | ||
|
|
411b5e0ca6 | ||
|
|
3f03963811 | ||
|
|
d43f81e118 | ||
|
|
b97dbd2515 | ||
|
|
c6a20a9ed3 | ||
|
|
27f0f29eef | ||
|
|
223508ae72 | ||
|
|
bce0a4b8cd | ||
|
|
65412a4263 | ||
|
|
0233b78c8e | ||
|
|
b0b25e4cfa | ||
|
|
806288d587 | ||
|
|
97265fc43b | ||
|
|
41ca50d0d4 | ||
|
|
9d02206fd9 | ||
|
|
ba2293eb30 | ||
|
|
8b9e28975d | ||
|
|
22ae8b8f87 | ||
|
|
187e352cbd | ||
|
|
23ef8ad28d | ||
|
|
1dadf56c42 | ||
|
|
52640b80c0 | ||
|
|
fe25f8f48f | ||
|
|
7f59572d8b | ||
|
|
90fc4c6bad | ||
|
|
16b6c0da33 | ||
|
|
488a691f29 | ||
|
|
bcbfe2ccd5 | ||
|
|
bd9a1d7ec7 | ||
|
|
9331ba64d6 | ||
|
|
21e5cb0a03 | ||
|
|
1a8e0c9ecb | ||
|
|
16fc0d31cd | ||
|
|
a622ada58b | ||
|
|
ee9c4948d3 | ||
|
|
cf28e1d963 | ||
|
|
089ec36160 | ||
|
|
04ce774c22 | ||
|
|
99c1422f37 | ||
|
|
b583a60f23 | ||
|
|
7be2910809 | ||
|
|
30de524319 | ||
|
|
c431d5e759 | ||
|
|
184b62b024 | ||
|
|
2751770350 | ||
|
|
75d98aee8e | ||
|
|
48120b9406 | ||
|
|
0e302d7959 | ||
|
|
59cd176f44 | ||
|
|
619f728f09 | ||
|
|
6e8002acc4 | ||
|
|
8a4a6174f7 | ||
|
|
ee6c4823d3 | ||
|
|
14dcb73d06 | ||
|
|
e15107e5ec | ||
|
|
0167a9462e | ||
|
|
7fa1d342ab | ||
|
|
05b9988e1d | ||
|
|
1c09e61219 | ||
|
|
35f0ad7a83 | ||
|
|
7ae1d6763a | ||
|
|
460e859795 | ||
|
|
4b88ec6460 | ||
|
|
27ee13bb7e | ||
|
|
e6cdd337c3 | ||
|
|
7d8dd12131 | ||
|
|
0800e3a136 | ||
|
|
9b0f1a2a04 | ||
|
|
9de3cb0f92 | ||
|
|
c053a8291c | ||
|
|
a0ddfe173b | ||
|
|
17843a7c71 | ||
|
|
324ae5c883 | ||
|
|
ef03989c3f | ||
|
|
63412ddd42 | ||
|
|
30ce32608a | ||
|
|
74799ad096 | ||
|
|
31176f99c8 | ||
|
|
b9439c05ec | ||
|
|
435a04da0c | ||
|
|
0040b266a5 | ||
|
|
645de137f2 | ||
|
|
1883607118 | ||
|
|
4ccae1dac7 | ||
|
|
ff75db310f | ||
|
|
5788520401 | ||
|
|
570dddc120 | ||
|
|
ea31072ae5 | ||
|
|
5eca5a6011 | ||
|
|
67d5357227 | ||
|
|
a0d04ff488 | ||
|
|
f83787508f | ||
|
|
20aba7eb17 | ||
|
|
0cdea3318c | ||
|
|
4dc2c18075 | ||
|
|
74e97abac4 | ||
|
|
b1db95a925 | ||
|
|
9dac9850b6 | ||
|
|
abe091254a | ||
|
|
d2e5367dc6 | ||
|
|
8ccd1f5fe4 | ||
|
|
50bc865dd2 | ||
|
|
74a6ee7066 | ||
|
|
89e76bcb48 | ||
|
|
c55f6baf67 | ||
|
|
ae154489e1 | ||
|
|
fdc79033ce | ||
|
|
9a8aa5e632 | ||
|
|
6b81f3ce5f | ||
|
|
aeaddfe36b | ||
|
|
20c1f30877 | ||
|
|
52ce6ff38e | ||
|
|
c692a3c80e | ||
|
|
491009636a | ||
|
|
ed16ee14ea | ||
|
|
7f2ed09267 | ||
|
|
c0976897ef | ||
|
|
85b55aa924 | ||
|
|
91d0f76783 | ||
|
|
741badf9e6 | ||
|
|
ca1f3ac377 | ||
|
|
e13e1c9ca3 | ||
|
|
06ad042443 | ||
|
|
9d333b855c | ||
|
|
f46e2acd56 | ||
|
|
5ac4d3f4ae | ||
|
|
1614eebc47 | ||
|
|
b50599b71f | ||
|
|
0459025bf8 | ||
|
|
0bd37da8c7 | ||
|
|
da969dde53 | ||
|
|
33fdd6cafa | ||
|
|
2fe68766eb | ||
|
|
205348697c | ||
|
|
9b3533c1da | ||
|
|
c3584e838e | ||
|
|
16d8b3fb58 | ||
|
|
686bbdc16b | ||
|
|
65b17e4f2b | ||
|
|
23c6898789 | ||
|
|
df2a1be2a2 | ||
|
|
2db628a2ba | ||
|
|
b6c40436c9 | ||
|
|
a8a70cac08 | ||
|
|
3eefbf97b1 | ||
|
|
3c423e0838 | ||
|
|
99cde43954 | ||
|
|
fa3a787bf7 | ||
|
|
c776dc8036 | ||
|
|
1ef068351d | ||
|
|
6abe0a1862 | ||
|
|
ff13045f52 | ||
|
|
59c09681cb | ||
|
|
f664cf6fa5 | ||
|
|
01a847a9c2 | ||
|
|
6da655f67f | ||
|
|
21df7dced1 |
17
.github/workflows/build.yml
vendored
17
.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
|
||||
@@ -56,10 +56,22 @@ jobs:
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
|
||||
- name: Get existing release body
|
||||
id: get_release_body
|
||||
continue-on-error: true
|
||||
run: |
|
||||
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
|
||||
jq -r '.body // ""')
|
||||
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$release_body" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tag_name: ${{ env.app_version }}
|
||||
tag_name: v${{ env.app_version }}
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -68,6 +80,7 @@ jobs:
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
name: v${{ env.app_version }}
|
||||
body: ${{ env.RELEASE_BODY }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
|
||||
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" ]
|
||||
@@ -40,54 +40,67 @@ class FetchMediasAction(BaseAction):
|
||||
{
|
||||
"func": RecommendChain().tmdb_trending,
|
||||
"name": '流行趋势',
|
||||
"api_path": "recommend/tmdb_trending"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_showing,
|
||||
"name": '正在热映',
|
||||
"api_path": "recommend/douban_showing"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().bangumi_calendar,
|
||||
"name": 'Bangumi每日放送',
|
||||
"api_path": "recommend/bangumi_calendar"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().tmdb_movies,
|
||||
"name": 'TMDB热门电影',
|
||||
"api_path": "recommend/tmdb_movies"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().tmdb_tvs,
|
||||
"name": 'TMDB热门电视剧',
|
||||
"api_path": "recommend/tmdb_tvs?with_original_language=zh|en|ja|ko"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_hot,
|
||||
"name": '豆瓣热门电影',
|
||||
"api_path": "recommend/douban_movie_hot"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_hot,
|
||||
"name": '豆瓣热门电视剧',
|
||||
"api_path": "recommend/douban_tv_hot"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_animation,
|
||||
"name": '豆瓣热门动漫',
|
||||
"api_path": "recommend/douban_tv_animation"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movies,
|
||||
"name": '豆瓣最新电影',
|
||||
"api_path": "recommend/douban_movies"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tvs,
|
||||
"name": '豆瓣最新电视剧',
|
||||
"api_path": "recommend/douban_tvs"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_top250,
|
||||
"name": '豆瓣电影TOP250',
|
||||
"api_path": "recommend/douban_movie_top250"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_weekly_chinese,
|
||||
"name": '豆瓣国产剧集榜',
|
||||
"api_path": "recommend/douban_tv_weekly_chinese"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_weekly_global,
|
||||
"name": '豆瓣全球剧集榜',
|
||||
"api_path": "recommend/douban_tv_weekly_global"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -124,7 +137,7 @@ class FetchMediasAction(BaseAction):
|
||||
获取数据源
|
||||
"""
|
||||
for s in self.__inner_sources:
|
||||
if s['name'] == source:
|
||||
if s['api_path'] == source:
|
||||
return s
|
||||
return None
|
||||
|
||||
@@ -135,13 +148,14 @@ class FetchMediasAction(BaseAction):
|
||||
params = FetchMediasParams(**params)
|
||||
try:
|
||||
if params.source_type == "ranking":
|
||||
for name in params.sources:
|
||||
for api_path in params.sources:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
source = self.__get_source(name)
|
||||
source = self.__get_source(api_path)
|
||||
if not source:
|
||||
continue
|
||||
logger.info(f"获取媒体数据 {source} ...")
|
||||
name = source.get("name")
|
||||
results = []
|
||||
if source.get("func"):
|
||||
results = source['func']()
|
||||
|
||||
72
app/actions/invoke_plugin.py
Normal file
72
app/actions/invoke_plugin.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class InvokePluginParams(ActionParams):
|
||||
"""
|
||||
调用插件动作参数
|
||||
"""
|
||||
plugin_id: str = Field(default=None, description="插件ID")
|
||||
action_id: str = Field(default=None, description="动作ID")
|
||||
action_params: dict = Field(default={}, description="动作参数")
|
||||
|
||||
|
||||
class InvokePluginAction(BaseAction):
|
||||
"""
|
||||
调用插件
|
||||
"""
|
||||
|
||||
_success = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._success = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "调用插件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "调用插件提供的动作"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return InvokePluginParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self._success
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
执行插件定义的动作
|
||||
"""
|
||||
params = InvokePluginParams(**params)
|
||||
if not params.plugin_id or not params.action_id:
|
||||
return context
|
||||
try:
|
||||
plugin_actions = PluginManager().get_plugin_actions(params.plugin_id)
|
||||
if not plugin_actions:
|
||||
logger.error(f"插件不存在: {params.plugin_id}")
|
||||
return context
|
||||
actions = plugin_actions[0].get("actions", [])
|
||||
action = next((action for action in actions if action.action_id == params.action_id), None)
|
||||
if not action or not action.get("func"):
|
||||
logger.error(f"插件动作不存在: {params.plugin_id} - {params.action_id}")
|
||||
return context
|
||||
# 执行插件动作
|
||||
self._success, context = action["func"](context, **params.action_params)
|
||||
except Exception as e:
|
||||
self._success = False
|
||||
logger.error(f"调用插件动作失败: {e}")
|
||||
return context
|
||||
self.job_done()
|
||||
return context
|
||||
@@ -11,7 +11,7 @@ from app.chain.mediaserver import MediaServerChain
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.utils.web import WebUtils
|
||||
from app.helper.wallpaper import WallpaperHelper
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -55,9 +55,11 @@ def wallpaper() -> Any:
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
url = WebUtils.get_bing_wallpaper()
|
||||
url = WallpaperHelper().get_bing_wallpaper()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
url = MediaServerChain().get_latest_wallpaper()
|
||||
elif settings.WALLPAPER == "customize":
|
||||
url = WallpaperHelper().get_customize_wallpaper()
|
||||
else:
|
||||
url = TmdbChain().get_random_wallpager()
|
||||
if url:
|
||||
@@ -74,8 +76,12 @@ def wallpapers() -> Any:
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
return WebUtils.get_bing_wallpapers()
|
||||
return WallpaperHelper().get_bing_wallpapers()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
return MediaServerChain().get_latest_wallpapers()
|
||||
else:
|
||||
elif settings.WALLPAPER == "tmdb":
|
||||
return TmdbChain().get_trending_wallpapers()
|
||||
elif settings.WALLPAPER == "customize":
|
||||
return WallpaperHelper().get_customize_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}")
|
||||
@@ -116,6 +122,18 @@ def _clean_protected_routes(existing_paths: dict):
|
||||
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
||||
|
||||
|
||||
def register_plugin(plugin_id: str):
|
||||
"""
|
||||
注册一个插件相关的服务
|
||||
"""
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
state: Optional[str] = "all") -> List[schemas.Plugin]:
|
||||
@@ -179,6 +197,18 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return PluginHelper().get_statistic()
|
||||
|
||||
|
||||
@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response)
|
||||
def reload_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重新加载插件
|
||||
"""
|
||||
# 重新加载插件
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
register_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install(plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
@@ -207,36 +237,65 @@ def install(plugin_id: str,
|
||||
install_plugins.append(plugin_id)
|
||||
# 保存设置
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 加载插件到内存
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
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 +306,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)
|
||||
@@ -275,17 +334,46 @@ def reset_plugin(plugin_id: str,
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
# 删除插件所有数据
|
||||
PluginManager().delete_plugin_data(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
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:
|
||||
@@ -306,11 +394,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
# 重新生效插件
|
||||
PluginManager().init_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
register_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -335,7 +419,3 @@ def uninstall_plugin(plugin_id: str,
|
||||
# 移除插件
|
||||
PluginManager().remove_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
# 注册全部插件API
|
||||
register_plugin_api()
|
||||
|
||||
@@ -7,6 +7,7 @@ from starlette.background import BackgroundTasks
|
||||
from app import schemas
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.command import Command
|
||||
from app.core.event import EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
@@ -22,6 +23,7 @@ from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.string import StringUtils
|
||||
from startup.plugins_initializer import register_plugin_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -385,8 +387,11 @@ def auth_site(
|
||||
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
||||
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
||||
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
||||
# 认证成功后,重新初始化插件
|
||||
PluginManager().init_config()
|
||||
Scheduler().init_plugin_jobs()
|
||||
Command().init_commands()
|
||||
register_plugin_api()
|
||||
return schemas.Response(success=status, message=msg)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import global_vars
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.workflow import WorkFlowManager
|
||||
from app.db import get_db
|
||||
from app.db.models.workflow import Workflow
|
||||
@@ -43,6 +44,14 @@ def create_workflow(workflow: schemas.Workflow,
|
||||
return schemas.Response(success=True, message="创建工作流成功")
|
||||
|
||||
|
||||
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
|
||||
def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
return PluginManager().get_plugin_actions(plugin_id)
|
||||
|
||||
|
||||
@router.get("/actions", summary="所有动作", response_model=List[dict])
|
||||
def list_actions(_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
@@ -64,7 +67,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
try:
|
||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
pickle.dump(cache, f) # noqa
|
||||
pickle.dump(cache, f) # noqa
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
finally:
|
||||
@@ -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)
|
||||
@@ -328,7 +374,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("search_torrents", site=site, keywords=keywords,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
|
||||
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点最新一页的种子,多个站点需要多线程处理
|
||||
@@ -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())
|
||||
# 发送消息按设置隔离
|
||||
@@ -579,7 +643,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
self.messageoper.add(**message.dict(), note=note_list)
|
||||
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents)
|
||||
|
||||
def metadata_img(self, mediainfo: MediaInfo,
|
||||
def metadata_img(self, mediainfo: MediaInfo,
|
||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
获取图片名称和url
|
||||
|
||||
@@ -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)
|
||||
# 广播事件
|
||||
|
||||
@@ -449,23 +449,19 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 生成目录内图片文件
|
||||
if init_folder:
|
||||
# 图片
|
||||
for attr_name, attr_value in vars(mediainfo).items():
|
||||
if attr_value \
|
||||
and attr_name.endswith("_path") \
|
||||
and attr_value \
|
||||
and isinstance(attr_value, str) \
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
image_path = filepath / image_name
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
image_path = filepath.with_name(image_name)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
content = __download_image(image_url)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
# 电视剧
|
||||
if fileitem.type == "file":
|
||||
|
||||
@@ -119,7 +119,7 @@ class MessageChain(ChainBase):
|
||||
userid = info.userid
|
||||
# 用户名
|
||||
username = info.username or userid
|
||||
if not userid:
|
||||
if userid is None or userid == '':
|
||||
logger.debug(f'未识别到用户ID:{body}{form}{args}')
|
||||
return
|
||||
# 消息内容
|
||||
@@ -422,13 +422,17 @@ class MessageChain(ChainBase):
|
||||
or text.find("继续") != -1:
|
||||
# 聊天
|
||||
content = text
|
||||
action = "chat"
|
||||
action = "Chat"
|
||||
elif StringUtils.is_link(text):
|
||||
# 链接
|
||||
content = text
|
||||
action = "Link"
|
||||
else:
|
||||
# 搜索
|
||||
content = text
|
||||
action = "Search"
|
||||
|
||||
if action != "chat":
|
||||
if action in ["Search", "ReSearch", "Subscribe", "ReSubscribe"]:
|
||||
# 搜索
|
||||
meta, medias = self.mediachain.search(content)
|
||||
# 识别
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
生成二维码
|
||||
@@ -131,28 +137,43 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
删除媒体文件,以及不含媒体文件的目录
|
||||
"""
|
||||
|
||||
def __is_bluray_dir(_fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
检查是否蓝光目录
|
||||
"""
|
||||
_dir_files = self.list_files(fileitem=_fileitem, recursion=False)
|
||||
if _dir_files:
|
||||
for _f in _dir_files:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
|
||||
if fileitem.path == "/" or len(Path(fileitem.path).parts) <= 2:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
||||
return False
|
||||
if fileitem.type == "dir":
|
||||
# 本身是目录
|
||||
if _blue_dir := self.list_files(fileitem=fileitem, recursion=False):
|
||||
# 删除蓝光目录
|
||||
for _f in _blue_dir:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
logger.warn(f"【{fileitem.storage}】{_f.path} 删除蓝光目录")
|
||||
self.delete_file(_f)
|
||||
if self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(fileitem)
|
||||
return False
|
||||
if __is_bluray_dir(fileitem):
|
||||
logger.warn(f"正在删除蓝光原盘目录:【{fileitem.storage}】{fileitem.path}")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
elif self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,正在删除空目录")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
# 不处理父目录
|
||||
return True
|
||||
elif delete_self:
|
||||
# 本身是文件
|
||||
logger.warn(f"正在删除【{fileitem.storage}】{fileitem.path}")
|
||||
# 本身是文件,需要删除文件
|
||||
logger.warn(f"正在删除文件【{fileitem.storage}】{fileitem.path}")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
|
||||
if mtype:
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
@@ -161,11 +182,14 @@ class StorageChain(ChainBase):
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
return True
|
||||
# 处理上级目录
|
||||
# 处理媒体文件根目录
|
||||
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||
else:
|
||||
# 处理上级目录
|
||||
dir_item = self.get_parent_item(fileitem)
|
||||
|
||||
# 检查和删除上级目录
|
||||
if dir_item and len(Path(dir_item.path).parts) > 2:
|
||||
# 如何目录是所有下载目录、媒体库目录的上级,则不处理
|
||||
for d in self.directoryhelper.get_dirs():
|
||||
@@ -177,7 +201,9 @@ class StorageChain(ChainBase):
|
||||
return True
|
||||
# 不存在其他媒体文件,删除空目录
|
||||
if self.any_files(dir_item, extensions=media_exts) is False:
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(dir_item)
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,正在删除空目录")
|
||||
if not self.delete_file(dir_item):
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 删除失败")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -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,
|
||||
@@ -560,6 +561,26 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
# 所有订阅
|
||||
subscribes = self.subscribeoper.list(self.get_states_for_search('R'))
|
||||
|
||||
# 预识别所有未识别的种子
|
||||
processed_torrents = {}
|
||||
for domain, contexts in torrents.items():
|
||||
processed_torrents[domain] = []
|
||||
for context in contexts:
|
||||
# 复制上下文避免修改原始数据
|
||||
_context = copy.deepcopy(context)
|
||||
torrent_meta = _context.meta_info
|
||||
torrent_mediainfo = _context.media_info
|
||||
|
||||
# 如果种子未识别,尝试识别
|
||||
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||
if torrent_mediainfo:
|
||||
# 更新种子缓存
|
||||
context.media_info = torrent_mediainfo
|
||||
# 添加已预处理
|
||||
processed_torrents[domain].append(_context)
|
||||
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
@@ -603,9 +624,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
custom_words_list = None
|
||||
|
||||
# 遍历缓存种子
|
||||
# 遍历预识别后的种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
for domain, contexts in processed_torrents.items():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if domains and domain not in domains:
|
||||
@@ -637,32 +658,28 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
custom_words=custom_words_list)
|
||||
# 更新元数据缓存
|
||||
context.meta_info = torrent_meta
|
||||
# 媒体信息需要重新识别
|
||||
torrent_mediainfo = None
|
||||
|
||||
# 先判断是否有没识别的种子,否则重新识别
|
||||
if not torrent_mediainfo \
|
||||
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
|
||||
episode_group=subscribe.episode_group)
|
||||
if torrent_mediainfo:
|
||||
# 更新种子缓存
|
||||
context.media_info = torrent_mediainfo
|
||||
else:
|
||||
# 通过标题匹配兜底
|
||||
logger.warn(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
torrent_mediainfo = mediainfo
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
|
||||
episode_group=subscribe.episode_group)
|
||||
if torrent_mediainfo:
|
||||
# 更新种子缓存
|
||||
context.media_info = torrent_mediainfo
|
||||
else:
|
||||
continue
|
||||
|
||||
# 如果仍然没有识别到媒体信息,尝试标题匹配
|
||||
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
logger.warn(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
torrent_mediainfo = mediainfo
|
||||
# 更新种子缓存
|
||||
context.media_info = mediainfo
|
||||
else:
|
||||
continue
|
||||
|
||||
# 直接比对媒体信息
|
||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||
@@ -1013,11 +1030,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,
|
||||
|
||||
69
app/chain/transfer.py
Normal file → Executable file
69
app/chain/transfer.py
Normal file → Executable file
@@ -17,6 +17,7 @@ from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.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, "文件整理模块运行失败"
|
||||
@@ -830,7 +860,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 设置下载任务状态
|
||||
if state:
|
||||
self.transfer_completed(hashs=torrent.hash)
|
||||
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
|
||||
|
||||
# 结束
|
||||
logger.info("所有下载器中下载完成的文件已整理完成")
|
||||
@@ -1344,22 +1374,17 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
发送入库成功的消息
|
||||
"""
|
||||
msg_title = f"{mediainfo.title_year} {meta.season_episode if not season_episode else season_episode} 已入库"
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -89,6 +90,8 @@ class ConfigModel(BaseModel):
|
||||
PROXY_HOST: Optional[str] = None
|
||||
# 登录页面电影海报,tmdb/bing/mediaserver
|
||||
WALLPAPER: str = "tmdb"
|
||||
# 自定义壁纸api地址
|
||||
CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||
# 媒体识别来源 themoviedb/douban
|
||||
@@ -101,6 +104,10 @@ 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原始语种图片
|
||||
TMDB_SCRAP_ORIGINAL_IMAGE: bool = False
|
||||
# TMDB API Key
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
# TVDB API Key
|
||||
@@ -213,7 +220,16 @@ class ConfigModel(BaseModel):
|
||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||
"https://github.com/honue/MoviePilot-Plugins,"
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
||||
"https://github.com/DDS-Derek/MoviePilot-Plugins")
|
||||
"https://github.com/DDS-Derek/MoviePilot-Plugins,"
|
||||
"https://github.com/madrays/MoviePilot-Plugins,"
|
||||
"https://github.com/justzerock/MoviePilot-Plugins,"
|
||||
"https://github.com/KoWming/MoviePilot-Plugins,"
|
||||
"https://github.com/wikrin/MoviePilot-Plugins,"
|
||||
"https://github.com/HankunYu/MoviePilot-Plugins,"
|
||||
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
|
||||
"https://github.com/Aqr-K/MoviePilot-Plugins,"
|
||||
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/gxterry/MoviePilot-Plugins")
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
# 是否开启插件热加载
|
||||
@@ -238,6 +254,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",
|
||||
@@ -353,13 +370,16 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if field_name in fields_not_keep_spaces:
|
||||
value = re.sub(r"\s+", "", value)
|
||||
return value, str(value) != str(original_value)
|
||||
# # 后续考虑支持 list 类型的处理
|
||||
# elif expected_type is list:
|
||||
# if isinstance(value, list):
|
||||
# return value, False
|
||||
# if isinstance(value, str):
|
||||
# items = [item.strip() for item in value.split(",") if item.strip()]
|
||||
# return items, items != original_value.split(",")
|
||||
# 支持 list 类型的处理
|
||||
elif expected_type is list:
|
||||
if isinstance(value, list):
|
||||
return value, str(value) != str(original_value)
|
||||
if isinstance(value, str):
|
||||
items = json.loads(value)
|
||||
if isinstance(original_value, list):
|
||||
return items, items != original_value
|
||||
else:
|
||||
return items, str(items) != str(original_value)
|
||||
# 可根据需要添加更多类型处理
|
||||
else:
|
||||
return value, str(value) != str(original_value)
|
||||
@@ -400,7 +420,13 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
logger.warning(message)
|
||||
return False, message
|
||||
else:
|
||||
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
|
||||
# 如果是列表、字典或集合类型,将其转换为JSON字符串
|
||||
if isinstance(converted_value, (list, dict, set)):
|
||||
value_to_write = json.dumps(converted_value)
|
||||
else:
|
||||
value_to_write = str(converted_value) if converted_value is not None else ""
|
||||
|
||||
set_key(SystemUtils.get_env_path(), field.name, value_to_write)
|
||||
if is_converted:
|
||||
logger.info(f"配置项 '{field.name}' 已自动修正并写入到 'app.env' 文件")
|
||||
return True, message
|
||||
@@ -547,6 +573,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return {
|
||||
"server": self.PROXY_HOST
|
||||
}
|
||||
return None
|
||||
|
||||
@property
|
||||
def GITHUB_HEADERS(self):
|
||||
|
||||
@@ -582,6 +582,12 @@ class MetaBase(object):
|
||||
# Part
|
||||
if not self.part:
|
||||
self.part = meta.part
|
||||
# tmdbid
|
||||
if not self.tmdbid and meta.tmdbid:
|
||||
self.tmdbid = meta.tmdbid
|
||||
# doubanid
|
||||
if not self.doubanid and meta.doubanid:
|
||||
self.doubanid = meta.doubanid
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,7 @@ class MetaVideo(MetaBase):
|
||||
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$"
|
||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||
_name_no_chinese_re = r".*版|.*字幕"
|
||||
@@ -50,8 +50,8 @@ class MetaVideo(MetaBase):
|
||||
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
|
||||
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
||||
_resources_pix_re2 = r"(^[248]+K)"
|
||||
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
||||
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$"
|
||||
|
||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||
"""
|
||||
@@ -592,7 +592,12 @@ class MetaVideo(MetaBase):
|
||||
self._stop_name_flag = True
|
||||
self._last_token_type = "videoencode"
|
||||
if not self.video_encode:
|
||||
self.video_encode = re_res.group(1).upper()
|
||||
if re_res.group(2):
|
||||
self.video_encode = re_res.group(2).upper()
|
||||
elif re_res.group(3):
|
||||
self.video_encode = re_res.group(3).lower()
|
||||
else:
|
||||
self.video_encode = re_res.group(1).upper()
|
||||
self._last_token = self.video_encode
|
||||
elif self.video_encode == "10bit":
|
||||
self.video_encode = f"{re_res.group(1).upper()} 10bit"
|
||||
|
||||
@@ -120,41 +120,69 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
return title, metainfo
|
||||
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
|
||||
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title)
|
||||
if not results:
|
||||
return title, metainfo
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
if mtype[0] == "movies":
|
||||
metainfo['type'] = MediaType.MOVIE
|
||||
elif mtype[0] == "tv":
|
||||
metainfo['type'] = MediaType.TV
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
if results:
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
if mtype[0] == "movies":
|
||||
metainfo['type'] = MediaType.MOVIE
|
||||
elif mtype[0] == "tv":
|
||||
metainfo['type'] = MediaType.TV
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
|
||||
# 支持Emby格式的ID标签
|
||||
# 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式
|
||||
tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
# 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
# 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
# 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
# 计算季集总数
|
||||
if metainfo.get('begin_season') and metainfo.get('end_season'):
|
||||
if metainfo['begin_season'] > metainfo['end_season']:
|
||||
@@ -169,3 +197,67 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
elif metainfo.get('begin_episode') and not metainfo.get('end_episode'):
|
||||
metainfo['total_episode'] = 1
|
||||
return title, metainfo
|
||||
|
||||
|
||||
def test_find_metainfo():
|
||||
"""
|
||||
测试find_metainfo函数的各种ID识别格式
|
||||
"""
|
||||
test_cases = [
|
||||
# 测试 [tmdbid=xxxx] 格式
|
||||
("The Vampire Diaries (2009) [tmdbid=18165]", "18165"),
|
||||
# 测试 [tmdbid-xxxx] 格式
|
||||
("Inception (2010) [tmdbid-27205]", "27205"),
|
||||
# 测试 [tmdb=xxxx] 格式
|
||||
("Breaking Bad (2008) [tmdb=1396]", "1396"),
|
||||
# 测试 [tmdb-xxxx] 格式
|
||||
("Interstellar (2014) [tmdb-157336]", "157336"),
|
||||
# 测试 {tmdbid=xxxx} 格式
|
||||
("Stranger Things (2016) {tmdbid=66732}", "66732"),
|
||||
# 测试 {tmdbid-xxxx} 格式
|
||||
("The Matrix (1999) {tmdbid-603}", "603"),
|
||||
# 测试 {tmdb=xxxx} 格式
|
||||
("Game of Thrones (2011) {tmdb=1399}", "1399"),
|
||||
# 测试 {tmdb-xxxx} 格式
|
||||
("Avatar (2009) {tmdb-19995}", "19995"),
|
||||
]
|
||||
|
||||
for title, expected_tmdbid in test_cases:
|
||||
cleaned_title, metainfo = find_metainfo(title)
|
||||
found_tmdbid = metainfo.get('tmdbid')
|
||||
|
||||
print(f"原标题: {title}")
|
||||
print(f"清理后标题: {cleaned_title}")
|
||||
print(f"期望的tmdbid: {expected_tmdbid}")
|
||||
print(f"识别的tmdbid: {found_tmdbid}")
|
||||
print(f"结果: {'通过' if found_tmdbid == expected_tmdbid else '失败'}")
|
||||
print("-" * 50)
|
||||
|
||||
|
||||
def test_meta_info_path():
|
||||
"""
|
||||
测试MetaInfoPath函数
|
||||
"""
|
||||
# 测试文件路径
|
||||
path_tests = [
|
||||
# 文件名中包含tmdbid
|
||||
Path("/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv"),
|
||||
# 目录名中包含tmdbid
|
||||
Path("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv"),
|
||||
# 父目录名中包含tmdbid
|
||||
Path("/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
# 祖父目录名中包含tmdbid
|
||||
Path("/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv"),
|
||||
]
|
||||
|
||||
for path in path_tests:
|
||||
meta = MetaInfoPath(path)
|
||||
print(f"测试路径: {path}")
|
||||
print(f"识别结果: tmdbid={meta.tmdbid}")
|
||||
print("-" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试函数
|
||||
# test_find_metainfo()
|
||||
test_meta_info_path()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -202,24 +204,35 @@ class PluginManager(metaclass=Singleton):
|
||||
# 停止插件
|
||||
if pid:
|
||||
logger.info(f"正在停止插件 {pid}...")
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
if not plugin_obj:
|
||||
logger.warning(f"插件 {pid} 不存在或未加载")
|
||||
return
|
||||
plugins = {pid: plugin_obj}
|
||||
else:
|
||||
logger.info("正在停止所有插件...")
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
if pid and plugin_id != pid:
|
||||
continue
|
||||
plugins = self._running_plugins
|
||||
for plugin_id, plugin in plugins.items():
|
||||
eventmanager.disable_event_handler(type(plugin))
|
||||
self.__stop_plugin(plugin)
|
||||
# 清空对像
|
||||
if pid:
|
||||
# 清空指定插件
|
||||
if pid in self._running_plugins:
|
||||
self._running_plugins.pop(pid)
|
||||
self._running_plugins.pop(pid, None)
|
||||
else:
|
||||
# 清空
|
||||
self._plugins = {}
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
@property
|
||||
def running_plugins(self):
|
||||
"""
|
||||
获取运行态插件列表
|
||||
:return: 运行态插件列表
|
||||
"""
|
||||
return self._running_plugins
|
||||
|
||||
def reload_monitor(self):
|
||||
"""
|
||||
重新加载插件文件修改监测
|
||||
@@ -407,68 +420,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 +468,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 +513,92 @@ 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
|
||||
|
||||
def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件动作
|
||||
[{
|
||||
"id": "动作ID",
|
||||
"name": "动作名称",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 需要附加传递的参数
|
||||
}]
|
||||
"""
|
||||
ret_actions = []
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_actions") and ObjectUtils.check_method(plugin.get_actions):
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
actions = plugin.get_actions()
|
||||
if actions:
|
||||
ret_actions.append({
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_name": plugin.plugin_name,
|
||||
"actions": actions
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}")
|
||||
return ret_actions
|
||||
|
||||
@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 +628,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 +865,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 +880,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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
@@ -65,8 +65,11 @@ class DownloadHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.doubanid == doubanid).all()
|
||||
if tmdbid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
|
||||
elif doubanid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -81,49 +84,58 @@ class DownloadHistory(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_last_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
def get_last_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
year: Optional[str] = None, season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
"""
|
||||
据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(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 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()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 标题 + 年份
|
||||
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()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
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,534 @@
|
||||
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 {k: v for k, v in self._context.items() if v is not None}
|
||||
|
||||
def _add_media_info(self, mediainfo: MediaInfo):
|
||||
"""
|
||||
增加媒体信息
|
||||
"""
|
||||
if not mediainfo: return
|
||||
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
|
||||
base_info = {
|
||||
# 标题
|
||||
"title": self.__convert_invalid_characters(mediainfo.title),
|
||||
# 英文标题
|
||||
"en_title": self.__convert_invalid_characters(mediainfo.en_title),
|
||||
# 原语种标题
|
||||
"original_title": self.__convert_invalid_characters(mediainfo.original_title),
|
||||
# 季号
|
||||
"season": self._context.get("season") or mediainfo.season,
|
||||
# Sxx
|
||||
"season_fmt": self._context.get("season_fmt") or season_fmt,
|
||||
# 年份
|
||||
"year": mediainfo.year or self._context.get("year"),
|
||||
# 媒体标题 + 年份
|
||||
"title_year": mediainfo.title_year or self._context.get("title_year"),
|
||||
}
|
||||
|
||||
_meta_season = self._context.get("season")
|
||||
media_info = {
|
||||
# 类型
|
||||
"type": mediainfo.type.value,
|
||||
# 类别
|
||||
"category": mediainfo.category,
|
||||
# 评分
|
||||
"vote_average": mediainfo.vote_average,
|
||||
# 海报
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
# 背景图
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
# 季年份根据season值获取
|
||||
"season_year": mediainfo.season_years.get(
|
||||
int(_meta_season),
|
||||
None) if (mediainfo.season_years and _meta_season) else None,
|
||||
# 演员
|
||||
"actors": '、 '.join([actor['name'] for actor in mediainfo.actors[:5]]),
|
||||
# 简介
|
||||
"overview": mediainfo.overview,
|
||||
# TMDBID
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
# IMDBID
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
# 豆瓣ID
|
||||
"doubanid": mediainfo.douban_id,
|
||||
}
|
||||
self._context.update({**base_info, **media_info})
|
||||
|
||||
def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]):
|
||||
"""
|
||||
添加剧集详细信息
|
||||
"""
|
||||
if not meta:
|
||||
return
|
||||
|
||||
episode_data = {"episode_title": None, "episode_date": None}
|
||||
if meta.begin_episode and episodes:
|
||||
for episode in episodes:
|
||||
if episode.episode_number == meta.begin_episode:
|
||||
episode_data.update({
|
||||
"episode_title": self.__convert_invalid_characters(episode.name),
|
||||
"episode_date": episode.air_date if episode.air_date else None
|
||||
})
|
||||
break
|
||||
|
||||
meta_info = {
|
||||
# 原文件名
|
||||
"original_name": meta.title,
|
||||
# 识别名称(优先使用中文)
|
||||
"name": meta.name,
|
||||
# 识别的英文名称(可能为空)
|
||||
"en_name": meta.en_name,
|
||||
# 年份
|
||||
"year": meta.year,
|
||||
# 名字 + 年份
|
||||
"title_year": self._context.get("title_year") or "%s (%s)" % (
|
||||
meta.name, meta.year) if meta.year else meta.name,
|
||||
# 季号
|
||||
"season": meta.season_seq,
|
||||
# Sxx
|
||||
"season_fmt": meta.season,
|
||||
# 集号
|
||||
"episode": meta.episode_seqs,
|
||||
# 季集 SxxExx
|
||||
"season_episode": "%s%s" % (meta.season, meta.episode),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization,
|
||||
}
|
||||
|
||||
tech_metadata = {
|
||||
# 资源类型
|
||||
"resourceType": meta.resource_type,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 版本
|
||||
"edition": meta.edition,
|
||||
# 分辨率
|
||||
"videoFormat": meta.resource_pix,
|
||||
# 质量
|
||||
"resource_term": meta.resource_term,
|
||||
# 制作组/字幕组
|
||||
"releaseGroup": meta.resource_team,
|
||||
# 视频编码
|
||||
"videoCodec": meta.video_encode,
|
||||
# 音频编码
|
||||
"audioCodec": meta.audio_encode,
|
||||
}
|
||||
self._context.update({**meta_info, **tech_metadata, **episode_data})
|
||||
|
||||
def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]):
|
||||
"""
|
||||
添加种子信息
|
||||
"""
|
||||
if not torrentinfo:
|
||||
return
|
||||
if torrentinfo.size:
|
||||
if str(torrentinfo.size).replace(".", "").isdigit():
|
||||
size = StringUtils.str_filesize(torrentinfo.size)
|
||||
else:
|
||||
size = torrentinfo.size
|
||||
else:
|
||||
size = 0
|
||||
|
||||
if torrentinfo.description:
|
||||
html_re = re.compile(r'<[^>]+>', re.S)
|
||||
description = html_re.sub('', torrentinfo.description)
|
||||
torrentinfo.description = re.sub(r'<[^>]+>', '', description)
|
||||
|
||||
torrent_info = {
|
||||
# 种子标题
|
||||
"torrent_title": torrentinfo.title,
|
||||
# 发布时间
|
||||
"pubdate": torrentinfo.pubdate,
|
||||
# 免费剩余时间
|
||||
"freedate": torrentinfo.freedate_diff,
|
||||
# 做种数
|
||||
"seeders": torrentinfo.seeders,
|
||||
# 促销信息
|
||||
"volume_factor": torrentinfo.volume_factor,
|
||||
# Hit&Run
|
||||
"hit_and_run": "是" if torrentinfo.hit_and_run else "否",
|
||||
# 种子标签
|
||||
"labels": ' '.join(torrentinfo.labels),
|
||||
# 描述
|
||||
"description": torrentinfo.description,
|
||||
# 站点名称
|
||||
"site_name": torrentinfo.site_name,
|
||||
# 种子大小
|
||||
"size": size,
|
||||
}
|
||||
self._context.update(torrent_info)
|
||||
|
||||
def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]:
|
||||
"""
|
||||
添加文件转移上下文
|
||||
"""
|
||||
if not transferinfo:
|
||||
return None
|
||||
ctx = {
|
||||
"transfer_type": transferinfo.transfer_type,
|
||||
"file_count": transferinfo.file_count,
|
||||
"total_size": StringUtils.str_filesize(transferinfo.total_size),
|
||||
"err_msg": transferinfo.message,
|
||||
}
|
||||
self._context.update(ctx)
|
||||
|
||||
def _add_file_info(self, file_extension: Optional[str]):
|
||||
"""
|
||||
添加文件信息
|
||||
"""
|
||||
if not file_extension: return
|
||||
file_info = {
|
||||
# 文件后缀
|
||||
"fileExt": file_extension,
|
||||
}
|
||||
self._context.update(file_info)
|
||||
|
||||
def _add_raw_objects(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: Optional[MediaInfo],
|
||||
torrentinfo: Optional[TorrentInfo],
|
||||
transferinfo: Optional[TransferInfo],
|
||||
episodes_info: Optional[List[TmdbEpisode]],
|
||||
):
|
||||
"""
|
||||
添加原始对象引用
|
||||
"""
|
||||
raw_objects = {
|
||||
# 文件元数据
|
||||
"__meta__": meta,
|
||||
# 识别的媒体信息
|
||||
"__mediainfo__": mediainfo,
|
||||
# 种子信息
|
||||
"__torrentinfo__": torrentinfo,
|
||||
# 文件转移信息
|
||||
"__transferinfo__": transferinfo,
|
||||
# 当前季的全部集信息
|
||||
"__episodes_info__": episodes_info,
|
||||
}
|
||||
self._context.update(raw_objects)
|
||||
|
||||
@staticmethod
|
||||
def __convert_invalid_characters(filename: str):
|
||||
"""
|
||||
将不支持的字符转换为全角字符
|
||||
"""
|
||||
if not filename:
|
||||
return filename
|
||||
invalid_characters = r'\/:*?"<>|'
|
||||
# 创建半角到全角字符的转换表
|
||||
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
|
||||
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
|
||||
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
|
||||
# 将不支持的字符替换为对应的全角字符
|
||||
for char in invalid_characters:
|
||||
filename = filename.replace(char, char.translate(translation_table))
|
||||
return filename
|
||||
|
||||
|
||||
class TemplateHelper(metaclass=SingletonClass):
|
||||
"""
|
||||
模板格式渲染帮助类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.builder = TemplateContextBuilder()
|
||||
self.cache = TTLCache(maxsize=100, ttl=600)
|
||||
|
||||
@staticmethod
|
||||
def _generate_cache_key(cuntent: Union[str, dict]) -> str:
|
||||
"""
|
||||
生成缓存键
|
||||
"""
|
||||
if isinstance(cuntent, dict):
|
||||
base_str = cuntent.get("title", '') + cuntent.get("text", '')
|
||||
return StringUtils.md5_hash(json.dumps(base_str, sort_keys=True, ensure_ascii=False))
|
||||
|
||||
return StringUtils.md5_hash(cuntent)
|
||||
|
||||
def get_cache_context(self, cuntent: Union[str, dict]) -> Optional[dict]:
|
||||
"""
|
||||
获取缓存上下文
|
||||
"""
|
||||
cache_key = self._generate_cache_key(cuntent)
|
||||
return self.cache.get(cache_key)
|
||||
|
||||
def set_cache_context(self, cuntent: Union[str, dict], context: dict) -> None:
|
||||
"""
|
||||
设置缓存上下文
|
||||
"""
|
||||
cache_key = self._generate_cache_key(cuntent)
|
||||
self.cache[cache_key] = context
|
||||
|
||||
def render(self,
|
||||
template_content: str,
|
||||
template_type: Literal['string', 'dict', 'literal'] = "literal",
|
||||
**kwargs) -> Optional[Union[str, dict]]:
|
||||
"""
|
||||
根据模板格式渲染内容
|
||||
:param template_content: 模板字符串
|
||||
:param template_type: 模板字符串类型(消息通知`literal`, 路径`string`)
|
||||
:param kwargs: 补传业务对象
|
||||
:raises ValueError: 当模板处理过程中出现错误
|
||||
:return: 渲染后的结果
|
||||
"""
|
||||
try:
|
||||
# 解析模板字符
|
||||
parsed = self.parse_template_content(template_content, template_type)
|
||||
if not parsed:
|
||||
raise ValueError("模板解析失败")
|
||||
|
||||
context = self.builder.build(**kwargs)
|
||||
if not context:
|
||||
raise ValueError("上下文构建失败")
|
||||
|
||||
rendered = self.render_with_context(parsed, context)
|
||||
if not rendered:
|
||||
raise ValueError("模板渲染失败")
|
||||
|
||||
if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered):
|
||||
# 缓存上下文
|
||||
self.set_cache_context(rendered, context)
|
||||
# 返回渲染结果
|
||||
return rendered
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模板处理失败: {str(e)}")
|
||||
raise ValueError(f"模板处理失败: {str(e)}") from e
|
||||
|
||||
@staticmethod
|
||||
def render_with_context(template_content: str, context: dict) -> str:
|
||||
"""
|
||||
使用指定上下文渲染 Jinja2 模板字符串
|
||||
template_content: Jinja2 模板字符串
|
||||
context: 渲染用的上下文数据
|
||||
"""
|
||||
# 渲染模板
|
||||
template = Template(template_content)
|
||||
return template.render(context)
|
||||
|
||||
@staticmethod
|
||||
def parse_template_content(template_content: Union[str, dict],
|
||||
template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]:
|
||||
"""
|
||||
解析模板字符
|
||||
:param template_content 模板格式字符
|
||||
:param template_type 模板字符类型
|
||||
"""
|
||||
|
||||
def parse_literal(_template_content: str) -> str:
|
||||
"""
|
||||
解析Python字面量
|
||||
"""
|
||||
try:
|
||||
template_dict = ast.literal_eval(_template_content) if isinstance(_template_content,
|
||||
str) else _template_content
|
||||
if not isinstance(template_dict, dict):
|
||||
raise ValueError("解析结果必须是一个字典")
|
||||
return json.dumps(template_dict, ensure_ascii=False)
|
||||
except (ValueError, SyntaxError) as err:
|
||||
raise ValueError(f"无效的Python字面量格式: {str(err)}")
|
||||
|
||||
try:
|
||||
if template_type:
|
||||
parse_map = {
|
||||
'string': lambda x: str(x),
|
||||
'dict': lambda x: json.dumps(x, ensure_ascii=False),
|
||||
'literal': parse_literal
|
||||
}
|
||||
return parse_map[template_type](template_content)
|
||||
|
||||
# 自动判断模板类型
|
||||
if isinstance(template_content, dict):
|
||||
return json.dumps(template_content, ensure_ascii=False)
|
||||
elif isinstance(template_content, str):
|
||||
try:
|
||||
json.loads(template_content)
|
||||
return template_content
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
return parse_literal(template_content)
|
||||
except (ValueError, SyntaxError):
|
||||
return template_content
|
||||
else:
|
||||
raise ValueError(f"不支持的模板类型: {type(template_content)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模板解析失败: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]:
|
||||
"""
|
||||
处理格式化字符串
|
||||
保留转义字符
|
||||
"""
|
||||
|
||||
def restore_chars(obj: Any) -> Any:
|
||||
"""恢复特殊字符"""
|
||||
if isinstance(obj, str):
|
||||
return obj.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\b', '\b').replace(
|
||||
'\\f', '\f')
|
||||
elif isinstance(obj, dict):
|
||||
return {k: restore_chars(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [restore_chars(item) for item in obj]
|
||||
return obj
|
||||
|
||||
# 定义特殊字符映射
|
||||
|
||||
special_chars = {
|
||||
'\n': '\\n', # 换行符
|
||||
'\r': '\\r', # 回车符
|
||||
'\t': '\\t', # 制表符
|
||||
'\b': '\\b', # 退格符
|
||||
'\f': '\\f', # 换页符
|
||||
}
|
||||
|
||||
# 处理特殊字符
|
||||
processed = rendered
|
||||
for char, escape in special_chars.items():
|
||||
processed = processed.replace(char, escape)
|
||||
|
||||
# 尝试解析为JSON
|
||||
try:
|
||||
rendered_dict = json.loads(processed)
|
||||
return restore_chars(rendered_dict)
|
||||
except json.JSONDecodeError:
|
||||
return rendered
|
||||
|
||||
|
||||
class MessageTemplateHelper:
|
||||
"""
|
||||
消息模板渲染器
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def render(message: Notification, *args, **kwargs) -> Optional[Notification]:
|
||||
"""
|
||||
渲染消息模板
|
||||
"""
|
||||
if not MessageTemplateHelper.is_instance_valid(message):
|
||||
if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs):
|
||||
logger.info("将使用模板渲染消息内容")
|
||||
return MessageTemplateHelper._apply_template_data(message, *args, **kwargs)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def is_instance_valid(message: Notification) -> bool:
|
||||
"""
|
||||
检查消息是否有效
|
||||
"""
|
||||
if isinstance(message, Notification):
|
||||
return bool(message.title or message.text)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def meets_update_conditions(message: Notification, *args, **kwargs) -> bool:
|
||||
"""
|
||||
判断是否满足消息实例更新条件
|
||||
|
||||
满足条件需同时具备:
|
||||
1. 消息为有效Notification实例
|
||||
2. 消息指定了模板类型(ctype)
|
||||
3. 存在待渲染的模板变量数据
|
||||
"""
|
||||
if isinstance(message, Notification):
|
||||
return True if message.ctype and (args or kwargs) else False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]:
|
||||
"""
|
||||
更新消息实例
|
||||
"""
|
||||
try:
|
||||
if template := MessageTemplateHelper._get_template(message):
|
||||
rendered = TemplateHelper().render(template_content=template, *args, **kwargs)
|
||||
for key, value in rendered.items():
|
||||
if hasattr(message, key):
|
||||
setattr(message, key, value)
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"更新Notification时出现错误:{str(e)}")
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _get_template(message: Notification) -> Optional[str]:
|
||||
"""
|
||||
获取消息模板
|
||||
"""
|
||||
template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)
|
||||
return template_dict.get(f"{message.ctype.value}")
|
||||
|
||||
|
||||
class MessageQueueManager(metaclass=SingletonClass):
|
||||
@@ -55,6 +571,7 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]:
|
||||
"""
|
||||
将字符串时间格式转换为分钟数元组
|
||||
支持格式为 'HH:MM' 或 'HH:MM:SS' 的时间字符串
|
||||
"""
|
||||
parsed = []
|
||||
if not periods:
|
||||
@@ -66,9 +583,31 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
continue
|
||||
if not period.get('start') or not period.get('end'):
|
||||
continue
|
||||
start_h, start_m = map(int, period['start'].split(':'))
|
||||
end_h, end_m = map(int, period['end'].split(':'))
|
||||
parsed.append((start_h, start_m, end_h, end_m))
|
||||
try:
|
||||
# 处理 start 时间
|
||||
start_parts = period['start'].split(':')
|
||||
if len(start_parts) == 2:
|
||||
start_h, start_m = map(int, start_parts)
|
||||
elif len(start_parts) >= 3:
|
||||
start_h, start_m = map(int, start_parts[:2]) # 只取前两个部分 (HH:MM)
|
||||
else:
|
||||
continue
|
||||
# 处理 end 时间
|
||||
end_parts = period['end'].split(':')
|
||||
if len(end_parts) == 2:
|
||||
end_h, end_m = map(int, end_parts)
|
||||
elif len(end_parts) >= 3:
|
||||
end_h, end_m = map(int, end_parts[:2]) # 只取前两个部分 (HH:MM)
|
||||
else:
|
||||
continue
|
||||
|
||||
parsed.append((start_h, start_m, end_h, end_m))
|
||||
except ValueError as e:
|
||||
logger.error(f"解析时间周期时出现错误:{period}. 错误:{str(e)}. 跳过此周期。")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"解析时间周期时出现意外错误:{period}. 错误:{str(e)}. 跳过此周期。")
|
||||
continue
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -7,14 +7,15 @@ from typing import List, Any, Callable
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
FilterFuncType = Callable[[str, Any], bool]
|
||||
|
||||
|
||||
def _default_filter(name: str, obj: Any) -> bool:
|
||||
"""
|
||||
默认过滤器
|
||||
"""
|
||||
return True
|
||||
return True if name and obj else False
|
||||
|
||||
|
||||
class ModuleHelper:
|
||||
"""
|
||||
@@ -76,7 +77,8 @@ class ModuleHelper:
|
||||
|
||||
def reload_sub_modules(parent_module, parent_module_name):
|
||||
"""重新加载一级子模块"""
|
||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name+'.'):
|
||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,
|
||||
parent_module_name + '.'):
|
||||
try:
|
||||
full_sub_module = importlib.import_module(sub_module_name)
|
||||
importlib.reload(full_sub_module)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import traceback
|
||||
@@ -455,15 +456,15 @@ class PluginHelper(metaclass=Singleton):
|
||||
:param requirements_file: 依赖的 requirements.txt 文件路径
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
base_cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||
strategies = []
|
||||
|
||||
# 添加策略到列表中
|
||||
if settings.PIP_PROXY:
|
||||
strategies.append(("镜像站", ["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY]))
|
||||
strategies.append(("镜像站", base_cmd + ["-i", settings.PIP_PROXY]))
|
||||
if settings.PROXY_HOST:
|
||||
strategies.append(
|
||||
("代理", ["pip", "install", "-r", str(requirements_file), "--proxy", settings.PROXY_HOST]))
|
||||
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file)]))
|
||||
strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST]))
|
||||
strategies.append(("直连", base_cmd))
|
||||
|
||||
# 遍历策略进行安装
|
||||
for strategy_name, pip_command in strategies:
|
||||
|
||||
@@ -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])
|
||||
|
||||
105
app/helper/wallpaper.py
Normal file
105
app/helper/wallpaper.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class WallpaperHelper(metaclass=Singleton):
|
||||
|
||||
def __init__(self):
|
||||
self.req = RequestUtils(timeout=5)
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_bing_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取Bing每日壁纸
|
||||
"""
|
||||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||
resp = self.req.get_res(url)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
for image in result.get('images') or []:
|
||||
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return None
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_bing_wallpapers(self, num: int = 7) -> List[str]:
|
||||
"""
|
||||
获取7天的Bing每日壁纸
|
||||
"""
|
||||
url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}"
|
||||
resp = self.req.get_res(url)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []]
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return []
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_customize_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取自定义壁纸api壁纸
|
||||
"""
|
||||
wallpaper_list = self.get_customize_wallpapers()
|
||||
if wallpaper_list:
|
||||
return wallpaper_list[0]
|
||||
return None
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_customize_wallpapers(self) -> List[str]:
|
||||
"""
|
||||
获取自定义壁纸api壁纸
|
||||
"""
|
||||
|
||||
def find_files_with_suffixes(obj, suffixes: List[str]) -> List[str]:
|
||||
"""
|
||||
递归查找对象中所有包含特定后缀的文件,返回匹配的字符串列表
|
||||
支持输入:字典、列表、字符串
|
||||
"""
|
||||
_result = []
|
||||
|
||||
# 处理字符串
|
||||
if isinstance(obj, str):
|
||||
if obj.endswith(tuple(suffixes)):
|
||||
_result.append(obj)
|
||||
|
||||
# 处理字典
|
||||
elif isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
_result.extend(find_files_with_suffixes(value, suffixes))
|
||||
|
||||
# 处理列表
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_result.extend(find_files_with_suffixes(item, suffixes))
|
||||
|
||||
return _result
|
||||
|
||||
# 判断是否存在自定义壁纸api
|
||||
if settings.CUSTOMIZE_WALLPAPER_API_URL:
|
||||
wallpaper_list = []
|
||||
resp = self.req.get_res(settings.CUSTOMIZE_WALLPAPER_API_URL)
|
||||
if resp and resp.status_code == 200:
|
||||
# 如果返回的是图片格式
|
||||
content_type = resp.headers.get('Content-Type')
|
||||
if content_type and content_type.lower() == 'image/jpeg':
|
||||
wallpaper_list.append(settings.CUSTOMIZE_WALLPAPER_API_URL)
|
||||
else:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, list) or isinstance(result, dict) or isinstance(result, str):
|
||||
wallpaper_list = find_files_with_suffixes(result, settings.SECURITY_IMAGE_SUFFIXES)
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return wallpaper_list
|
||||
else:
|
||||
return []
|
||||
@@ -39,11 +39,9 @@ class DoubanModule(_ModuleBase):
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
return False, "豆瓣网络连接失败"
|
||||
if ret is None:
|
||||
return False, "豆瓣网络连接失败"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -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}
|
||||
"""
|
||||
# 检查目录路径
|
||||
@@ -362,6 +402,9 @@ class FileManagerModule(_ModuleBase):
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
transfer_type = target_directory.transfer_type
|
||||
# 目标存储
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
# 是否需要重命名
|
||||
need_rename = target_directory.renaming
|
||||
# 是否需要通知
|
||||
@@ -370,9 +413,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 +439,31 @@ 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:
|
||||
if not target_storage:
|
||||
target_storage = fileitem.storage
|
||||
target_oper = self.__get_storage_oper(target_storage)
|
||||
if not target_oper:
|
||||
return TransferInfo(success=False,
|
||||
message=f"不支持的存储类型:{target_storage}",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
|
||||
# 整理
|
||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||
return self.transfer_media(fileitem=fileitem,
|
||||
@@ -411,7 +476,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 +497,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 +531,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 +633,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 +657,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 +692,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 +747,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 +763,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 +777,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 +810,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 +822,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 +858,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 +867,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 +898,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 +907,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 +954,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 +1020,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 +1036,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 +1065,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 +1130,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 +1190,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 +1232,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:
|
||||
@@ -1313,7 +1314,8 @@ class FileManagerModule(_ModuleBase):
|
||||
if media_files:
|
||||
for media_file in media_files:
|
||||
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
||||
ret_fileitems.append(media_file)
|
||||
if media_file not in ret_fileitems:
|
||||
ret_fileitems.append(media_file)
|
||||
return ret_fileitems
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
|
||||
@@ -1351,14 +1353,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,5 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -24,6 +25,10 @@ class NoCheckInException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SessionInvalidException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AliPan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
阿里云盘相关操作
|
||||
@@ -177,7 +182,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 +193,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 +204,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 +340,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,
|
||||
@@ -428,6 +435,17 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
break
|
||||
return items
|
||||
|
||||
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
自动延迟重试 get_item 模块
|
||||
"""
|
||||
for _ in range(2):
|
||||
time.sleep(2)
|
||||
fileitem = self.get_item(path)
|
||||
if fileitem:
|
||||
return fileitem
|
||||
return None
|
||||
|
||||
def create_folder(self, parent_item: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
@@ -437,7 +455,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"
|
||||
}
|
||||
@@ -450,7 +468,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
# 缓存新目录
|
||||
new_path = Path(parent_item.path) / name
|
||||
self._id_cache[str(new_path)] = (resp.get("drive_id"), resp.get("file_id"))
|
||||
return self.get_item(new_path)
|
||||
return self._delay_get_item(new_path)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_pre_hash(file_path: Path):
|
||||
@@ -628,6 +646,29 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
raise Exception(resp.get("message"))
|
||||
return resp
|
||||
|
||||
@staticmethod
|
||||
def _log_progress(desc: str, total: int) -> tqdm:
|
||||
"""
|
||||
创建一个可以输出到日志的进度条
|
||||
"""
|
||||
|
||||
class TqdmToLogger(io.StringIO):
|
||||
def write(s, buf): # noqa
|
||||
buf = buf.strip('\r\n\t ')
|
||||
if buf:
|
||||
logger.info(buf)
|
||||
|
||||
return tqdm(
|
||||
total=total,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
desc=desc,
|
||||
file=TqdmToLogger(),
|
||||
mininterval=1.0,
|
||||
maxinterval=5.0,
|
||||
miniters=1
|
||||
)
|
||||
|
||||
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
@@ -646,7 +687,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
chunk_size=chunk_size)
|
||||
if create_res.get('rapid_upload', False):
|
||||
logger.info(f"【阿里云盘】{target_name} 秒传完成!")
|
||||
return self.get_item(target_path)
|
||||
return self._delay_get_item(target_path)
|
||||
|
||||
if create_res.get("exist", False):
|
||||
logger.info(f"【阿里云盘】{target_name} 已存在")
|
||||
@@ -668,13 +709,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
|
||||
# 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
|
||||
)
|
||||
progress_bar = self._log_progress(f"【阿里云盘】{target_name} 上传进度", file_size)
|
||||
|
||||
# 5. 分片上传循环
|
||||
with open(local_path, 'rb') as f:
|
||||
@@ -828,7 +863,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 +889,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:
|
||||
@@ -895,7 +930,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
return False
|
||||
# 重命名
|
||||
new_path = Path(path) / fileitem.name
|
||||
new_file = self.get_item(new_path)
|
||||
new_file = self._delay_get_item(new_path)
|
||||
self.rename(new_file, new_name)
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
@@ -957,3 +992,5 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
)
|
||||
except NoCheckInException:
|
||||
return None
|
||||
except SessionInvalidException:
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import io
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -306,6 +306,17 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
sha1.update(chunk)
|
||||
return sha1.hexdigest()
|
||||
|
||||
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
自动延迟重试 get_item 模块
|
||||
"""
|
||||
for _ in range(2):
|
||||
time.sleep(2)
|
||||
fileitem = self.get_item(path)
|
||||
if fileitem:
|
||||
return fileitem
|
||||
return None
|
||||
|
||||
def init_storage(self):
|
||||
pass
|
||||
|
||||
@@ -375,7 +386,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 +410,37 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
modify_time=int(time.time())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _log_progress(desc: str, total: int) -> tqdm:
|
||||
"""
|
||||
创建一个可以输出到日志的进度条
|
||||
"""
|
||||
|
||||
class TqdmToLogger(io.StringIO):
|
||||
def write(s, buf): # noqa
|
||||
buf = buf.strip('\r\n\t ')
|
||||
if buf:
|
||||
logger.info(buf)
|
||||
|
||||
return tqdm(
|
||||
total=total,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
desc=desc,
|
||||
file=TqdmToLogger(),
|
||||
mininterval=1.0,
|
||||
maxinterval=5.0,
|
||||
miniters=1
|
||||
)
|
||||
|
||||
def upload(self, target_dir: schemas.FileItem, local_path: Path,
|
||||
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) -> str:
|
||||
return oss2.utils.b64encode_as_string(cb)
|
||||
|
||||
target_name = new_name or local_path.name
|
||||
target_path = Path(target_dir.path) / target_name
|
||||
@@ -493,7 +524,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
# Step 3: 秒传
|
||||
if init_result.get("status") == 2:
|
||||
logger.info(f"【115】{target_name} 秒传成功")
|
||||
return self.get_item(target_path)
|
||||
return self._delay_get_item(target_path)
|
||||
|
||||
# Step 4: 获取上传凭证
|
||||
token_resp = self._request_api(
|
||||
@@ -535,12 +566,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
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)
|
||||
|
||||
@@ -584,8 +609,8 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
|
||||
# 请求头
|
||||
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:
|
||||
@@ -604,7 +629,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
logger.error(f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}")
|
||||
return None
|
||||
# 返回结果
|
||||
return self.get_item(target_path)
|
||||
return self._delay_get_item(target_path)
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
@@ -769,7 +794,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
return False
|
||||
if resp["state"]:
|
||||
new_path = Path(path) / fileitem.name
|
||||
new_item = self.get_item(new_path)
|
||||
new_item = self._delay_get_item(new_path)
|
||||
self.rename(new_item, new_name)
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
@@ -797,7 +822,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
return False
|
||||
if resp["state"]:
|
||||
new_path = Path(path) / fileitem.name
|
||||
new_file = self.get_item(new_path)
|
||||
new_file = self._delay_get_item(new_path)
|
||||
self.rename(new_file, new_name)
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
|
||||
@@ -21,8 +21,8 @@ class FilterModule(_ModuleBase):
|
||||
rule_set: Dict[str, dict] = {
|
||||
# 蓝光原盘
|
||||
"BLU": {
|
||||
"include": [r'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD'],
|
||||
"exclude": [r'[Hx].?264|[Hx].?265|WEB-?DL|WEB-?RIP|REMUX']
|
||||
"include": [r'(?i)(\bBlu-?Ray\b.*\b(?:VC-?1|AVC|MPEG-?2)\b|\b(?:UHD|4K|2160p)\b(?:.*Blu-?Ray)?.*\b(?:HEVC|H\.?265)\b|\bBlu-?Ray\b.*\b(?:UHD|4K|2160p)\b.*\b(?:HEVC|H\.?265)\b|\b(?:COMPLETE|FULL)\b.*\b(?:(?:UHD|4K|2160p)\b.*)?Blu-?Ray\b|\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\b)'],
|
||||
"exclude": [r'(?i)(\b[XH]\.?264\b|\b[XH]\.?265\b|\bWEB-?DL\b|\bWEB-?RIP\b|\bHDTV(?:RIP)?\b|\bREMUX\b|\bBDRip\b|\bBRRip\b|\bHDRip\b|\bENCODE\b|\b(?<!WEB-|HDTV)RIP\b)']
|
||||
},
|
||||
# 4K
|
||||
"4K": {
|
||||
|
||||
@@ -56,7 +56,11 @@ class TYemaSiteUserInfo(SiteParserBase):
|
||||
self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime"))
|
||||
|
||||
self.upload = user_info.get('uploadSize')
|
||||
self.download = user_info.get('downloadSize')
|
||||
# 使用 promotionDownloadSize 获取真实下载量(考虑促销因素)
|
||||
if "promotionDownloadSize" in user_info:
|
||||
self.download = user_info.get('promotionDownloadSize')
|
||||
else:
|
||||
self.download = user_info.get('downloadSize')
|
||||
self.ratio = round(self.upload / (self.download or 1), 2)
|
||||
self.bonus = user_info.get("bonus")
|
||||
self.message_unread = 0
|
||||
|
||||
@@ -108,11 +108,17 @@ class MTorrentSpider:
|
||||
category = MediaType.MOVIE.value
|
||||
else:
|
||||
category = MediaType.UNKNOWN.value
|
||||
labels_value = self._labels.get(result.get('labels') or "0") or ""
|
||||
if labels_value:
|
||||
labels = labels_value.split()
|
||||
# 处理馒头新版标签
|
||||
labels = []
|
||||
labels_new = result.get( 'labelsNew' )
|
||||
if labels_new:
|
||||
# 新版标签本身就是list
|
||||
labels = labels_new
|
||||
else:
|
||||
labels = []
|
||||
# 旧版标签
|
||||
labels_value = self._labels.get(result.get('labels') or "0") or ""
|
||||
if labels_value:
|
||||
labels = labels_value.split()
|
||||
torrent = {
|
||||
'title': result.get('name'),
|
||||
'description': result.get('smallDescr'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -37,7 +37,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
self.cache = TmdbCache()
|
||||
self.tmdb = TmdbApi()
|
||||
self.category = CategoryHelper()
|
||||
self.scraper = TmdbScraper(self.tmdb)
|
||||
self.scraper = TmdbScraper()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
@@ -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
|
||||
|
||||
@@ -115,11 +115,18 @@ class CategoryHelper(metaclass=Singleton):
|
||||
for attr, value in item.items():
|
||||
if not value:
|
||||
continue
|
||||
info_value = tmdb_info.get(attr)
|
||||
if attr == "release_year":
|
||||
# 发行年份
|
||||
info_value = tmdb_info.get("release_date") or tmdb_info.get("first_air_date")
|
||||
if info_value:
|
||||
info_value = str(info_value)[:4]
|
||||
else:
|
||||
info_value = tmdb_info.get(attr)
|
||||
if not info_value:
|
||||
match_flag = False
|
||||
continue
|
||||
elif attr == "production_countries":
|
||||
# 制片国家
|
||||
info_values = [str(val.get("iso_3166_1")).upper() for val in info_value]
|
||||
else:
|
||||
if isinstance(info_value, list):
|
||||
@@ -128,7 +135,18 @@ class CategoryHelper(metaclass=Singleton):
|
||||
info_values = [str(info_value).upper()]
|
||||
|
||||
if value.find(",") != -1:
|
||||
# , 分隔多个值
|
||||
values = [str(val).upper() for val in value.split(",") if val]
|
||||
elif value.find("-") != -1:
|
||||
# - 表示范围,仅限于数字
|
||||
value_begin = value.split("-")[0]
|
||||
value_end = value.split("-")[1]
|
||||
if value_begin.isdigit() and value_end.isdigit():
|
||||
# 数字范围
|
||||
values = [str(val) for val in range(int(value_begin), int(value_end) + 1)]
|
||||
else:
|
||||
# 字符串范围
|
||||
values = [str(value_begin), str(value_end)]
|
||||
else:
|
||||
values = [str(value).upper()]
|
||||
|
||||
|
||||
@@ -7,15 +7,29 @@ from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.dom import DomUtils
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
|
||||
|
||||
class TmdbScraper:
|
||||
tmdb = None
|
||||
_force_nfo = False
|
||||
_force_img = False
|
||||
_meta_tmdb = None
|
||||
_img_tmdb = None
|
||||
|
||||
def __init__(self, tmdb):
|
||||
self.tmdb = tmdb
|
||||
@property
|
||||
def default_tmdb(self):
|
||||
"""
|
||||
获取元数据TMDB Api
|
||||
"""
|
||||
if not self._meta_tmdb:
|
||||
self._meta_tmdb = TmdbApi(language=settings.TMDB_LOCALE)
|
||||
return self._meta_tmdb
|
||||
|
||||
def original_tmdb(self, mediainfo: Optional[MediaInfo] = None):
|
||||
"""
|
||||
获取图片TMDB Api
|
||||
"""
|
||||
if settings.TMDB_SCRAP_ORIGINAL_IMAGE and mediainfo:
|
||||
return TmdbApi(language=mediainfo.original_language)
|
||||
return self.default_tmdb
|
||||
|
||||
def get_metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:
|
||||
@@ -33,9 +47,9 @@ class TmdbScraper:
|
||||
if season is not None:
|
||||
# 查询季信息
|
||||
if mediainfo.episode_group:
|
||||
seasoninfo = self.tmdb.get_tv_group_detail(mediainfo.episode_group, season=season)
|
||||
seasoninfo = self.default_tmdb.get_tv_group_detail(mediainfo.episode_group, season=season)
|
||||
else:
|
||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season=season)
|
||||
seasoninfo = self.default_tmdb.get_tv_season_detail(mediainfo.tmdb_id, season=season)
|
||||
if episode:
|
||||
# 集元数据文件
|
||||
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
|
||||
@@ -48,11 +62,12 @@ class TmdbScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
def get_metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> dict:
|
||||
def get_metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None,
|
||||
episode: Optional[int] = None) -> dict:
|
||||
"""
|
||||
获取图片名称和url
|
||||
:param mediainfo: 媒体信息
|
||||
@@ -61,13 +76,13 @@ class TmdbScraper:
|
||||
"""
|
||||
images = {}
|
||||
if season is not None:
|
||||
# 只需要集的图片
|
||||
# 只需要季集的图片
|
||||
if episode:
|
||||
# 集的图片
|
||||
if mediainfo.episode_group:
|
||||
seasoninfo = self.tmdb.get_tv_group_detail(mediainfo.episode_group, season)
|
||||
seasoninfo = self.original_tmdb(mediainfo).get_tv_group_detail(mediainfo.episode_group, season)
|
||||
else:
|
||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||
seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||
if seasoninfo:
|
||||
episodeinfo = self.__get_episode_detail(seasoninfo, episode)
|
||||
if episodeinfo and episodeinfo.get("still_path"):
|
||||
@@ -77,7 +92,7 @@ class TmdbScraper:
|
||||
images[still_name] = still_url
|
||||
else:
|
||||
# 季的图片
|
||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||
seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||
if seasoninfo:
|
||||
# TMDB季poster图片
|
||||
poster_name, poster_url = self.get_season_poster(seasoninfo, season)
|
||||
@@ -85,7 +100,7 @@ class TmdbScraper:
|
||||
images[poster_name] = poster_url
|
||||
return images
|
||||
else:
|
||||
# 主媒体图片
|
||||
# 获取媒体信息中原有图片(TheMovieDb或Fanart)
|
||||
for attr_name, attr_value in vars(mediainfo).items():
|
||||
if attr_value \
|
||||
and attr_name.endswith("_path") \
|
||||
@@ -94,6 +109,15 @@ class TmdbScraper:
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
images[image_name] = attr_value
|
||||
# 替换原语言Poster
|
||||
if settings.TMDB_SCRAP_ORIGINAL_IMAGE:
|
||||
_mediainfo = self.original_tmdb(mediainfo).get_info(mediainfo.type, mediainfo.tmdb_id)
|
||||
if _mediainfo:
|
||||
for attr_name, attr_value in _mediainfo.items():
|
||||
if attr_name.endswith("_path") and attr_value is not None:
|
||||
image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{attr_value}"
|
||||
image_name = attr_name.replace("_path", "") + Path(image_url).suffix
|
||||
images[image_name] = image_url
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -23,31 +23,19 @@ class TmdbApi:
|
||||
TMDB识别匹配
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, language: Optional[str] = None):
|
||||
# TMDB主体
|
||||
self.tmdb = TMDb()
|
||||
# 域名
|
||||
self.tmdb.domain = settings.TMDB_API_DOMAIN
|
||||
# 开启缓存
|
||||
self.tmdb.cache = True
|
||||
# APIKEY
|
||||
self.tmdb.api_key = settings.TMDB_API_KEY
|
||||
# 语种
|
||||
self.tmdb.language = 'zh'
|
||||
# 代理
|
||||
self.tmdb.proxies = settings.PROXY
|
||||
# 调试模式
|
||||
self.tmdb.debug = False
|
||||
self.tmdb = TMDb(language=language)
|
||||
# TMDB查询对象
|
||||
self.search = Search()
|
||||
self.movie = Movie()
|
||||
self.tv = TV()
|
||||
self.season_obj = Season()
|
||||
self.episode_obj = Episode()
|
||||
self.discover = Discover()
|
||||
self.trending = Trending()
|
||||
self.person = Person()
|
||||
self.collection = Collection()
|
||||
self.search = Search(language=language)
|
||||
self.movie = Movie(language=language)
|
||||
self.tv = TV(language=language)
|
||||
self.season_obj = Season(language=language)
|
||||
self.episode_obj = Episode(language=language)
|
||||
self.discover = Discover(language=language)
|
||||
self.trending = Trending(language=language)
|
||||
self.person = Person(language=language)
|
||||
self.collection = Collection(language=language)
|
||||
|
||||
def search_multiis(self, title: str) -> List[dict]:
|
||||
"""
|
||||
@@ -632,7 +620,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
|
||||
|
||||
@@ -647,6 +636,7 @@ class TmdbApi:
|
||||
return None
|
||||
# dict[地区:分级]
|
||||
ratings = {}
|
||||
results = []
|
||||
if results := (tmdb_info.get("release_dates") or {}).get("results"):
|
||||
"""
|
||||
[
|
||||
@@ -1344,7 +1334,18 @@ class TmdbApi:
|
||||
return []
|
||||
try:
|
||||
logger.debug(f"正在获取剧集组:{group_id}...")
|
||||
return self.tv.group_episodes(group_id) or []
|
||||
group_seasons = self.tv.group_episodes(group_id) or []
|
||||
return [
|
||||
{
|
||||
**group_season,
|
||||
"episodes": [
|
||||
{**ep, "episode_number": idx}
|
||||
# 剧集组中每个季的episode_number从1开始
|
||||
for idx, ep in enumerate(group_season.get("episodes", []), start=1)
|
||||
]
|
||||
}
|
||||
for group_season in group_seasons
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
return []
|
||||
@@ -1358,9 +1359,6 @@ class TmdbApi:
|
||||
return {}
|
||||
for group_season in group_seasons:
|
||||
if group_season.get('order') == season:
|
||||
# 剧集组中每个季的episode_number从1开始
|
||||
for i, e in enumerate(group_season.get('episodes', []), start=1):
|
||||
e['episode_number'] = i
|
||||
return group_season
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
@@ -17,19 +16,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TMDb(object):
|
||||
TMDB_API_KEY = "TMDB_API_KEY"
|
||||
TMDB_LANGUAGE = "TMDB_LANGUAGE"
|
||||
TMDB_SESSION_ID = "TMDB_SESSION_ID"
|
||||
TMDB_WAIT_ON_RATE_LIMIT = "TMDB_WAIT_ON_RATE_LIMIT"
|
||||
TMDB_DEBUG_ENABLED = "TMDB_DEBUG_ENABLED"
|
||||
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
|
||||
TMDB_PROXIES = "TMDB_PROXIES"
|
||||
TMDB_DOMAIN = "TMDB_DOMAIN"
|
||||
|
||||
_req = None
|
||||
_session = None
|
||||
|
||||
def __init__(self, obj_cached=True, session=None):
|
||||
def __init__(self, obj_cached=True, session=None, language=None):
|
||||
self._api_key = settings.TMDB_API_KEY
|
||||
self._language = language or settings.TMDB_LOCALE or "en-US"
|
||||
self._session_id = None
|
||||
self._wait_on_rate_limit = True
|
||||
self._debug_enabled = False
|
||||
self._cache_enabled = obj_cached
|
||||
self._proxies = settings.PROXY
|
||||
self._domain = settings.TMDB_API_DOMAIN
|
||||
self._page = None
|
||||
self._total_results = None
|
||||
self._total_pages = None
|
||||
|
||||
if session is not None:
|
||||
self._req = RequestUtils(session=session, proxies=self.proxies)
|
||||
else:
|
||||
@@ -39,103 +41,88 @@ class TMDb(object):
|
||||
self._reset = None
|
||||
self._timeout = 15
|
||||
self.obj_cached = obj_cached
|
||||
if os.environ.get(self.TMDB_LANGUAGE) is None:
|
||||
os.environ[self.TMDB_LANGUAGE] = "en-US"
|
||||
|
||||
@property
|
||||
def page(self):
|
||||
return os.environ["page"]
|
||||
return self._page
|
||||
|
||||
@property
|
||||
def total_results(self):
|
||||
return os.environ["total_results"]
|
||||
return self._total_results
|
||||
|
||||
@property
|
||||
def total_pages(self):
|
||||
return os.environ["total_pages"]
|
||||
return self._total_pages
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return os.environ.get(self.TMDB_API_KEY)
|
||||
return self._api_key
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
return os.environ.get(self.TMDB_DOMAIN)
|
||||
return self._domain
|
||||
|
||||
@property
|
||||
def proxies(self):
|
||||
proxy = os.environ.get(self.TMDB_PROXIES)
|
||||
if proxy is not None:
|
||||
proxy = eval(proxy)
|
||||
return proxy
|
||||
return self._proxies
|
||||
|
||||
@proxies.setter
|
||||
def proxies(self, proxies):
|
||||
if proxies is not None:
|
||||
os.environ[self.TMDB_PROXIES] = str(proxies)
|
||||
self._proxies = proxies
|
||||
|
||||
@api_key.setter
|
||||
def api_key(self, api_key):
|
||||
os.environ[self.TMDB_API_KEY] = str(api_key)
|
||||
self._api_key = str(api_key)
|
||||
|
||||
@domain.setter
|
||||
def domain(self, domain):
|
||||
os.environ[self.TMDB_DOMAIN] = str(domain)
|
||||
self._domain = str(domain)
|
||||
|
||||
@property
|
||||
def language(self):
|
||||
return os.environ.get(self.TMDB_LANGUAGE)
|
||||
return self._language
|
||||
|
||||
@language.setter
|
||||
def language(self, language):
|
||||
os.environ[self.TMDB_LANGUAGE] = language
|
||||
self._language = language
|
||||
|
||||
@property
|
||||
def has_session(self):
|
||||
return True if os.environ.get(self.TMDB_SESSION_ID) else False
|
||||
return True if self._session_id else False
|
||||
|
||||
@property
|
||||
def session_id(self):
|
||||
if not os.environ.get(self.TMDB_SESSION_ID):
|
||||
if not self._session_id:
|
||||
raise TMDbException("Must Authenticate to create a session run Authentication(username, password)")
|
||||
return os.environ.get(self.TMDB_SESSION_ID)
|
||||
return self._session_id
|
||||
|
||||
@session_id.setter
|
||||
def session_id(self, session_id):
|
||||
os.environ[self.TMDB_SESSION_ID] = session_id
|
||||
self._session_id = session_id
|
||||
|
||||
@property
|
||||
def wait_on_rate_limit(self):
|
||||
if os.environ.get(self.TMDB_WAIT_ON_RATE_LIMIT) == "False":
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
return self._wait_on_rate_limit
|
||||
|
||||
@wait_on_rate_limit.setter
|
||||
def wait_on_rate_limit(self, wait_on_rate_limit):
|
||||
os.environ[self.TMDB_WAIT_ON_RATE_LIMIT] = str(wait_on_rate_limit)
|
||||
self._wait_on_rate_limit = bool(wait_on_rate_limit)
|
||||
|
||||
@property
|
||||
def debug(self):
|
||||
if os.environ.get(self.TMDB_DEBUG_ENABLED) == "True":
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return self._debug_enabled
|
||||
|
||||
@debug.setter
|
||||
def debug(self, debug):
|
||||
os.environ[self.TMDB_DEBUG_ENABLED] = str(debug)
|
||||
self._debug_enabled = bool(debug)
|
||||
|
||||
@property
|
||||
def cache(self):
|
||||
if os.environ.get(self.TMDB_CACHE_ENABLED) == "False":
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
return self._cache_enabled
|
||||
|
||||
@cache.setter
|
||||
def cache(self, cache):
|
||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||
self._cache_enabled = bool(cache)
|
||||
|
||||
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
||||
def cached_request(self, method, url, data, json,
|
||||
@@ -197,30 +184,30 @@ class TMDb(object):
|
||||
else:
|
||||
raise TMDbException("达到请求频率限制,将在 %d 秒后重试..." % sleep_time)
|
||||
|
||||
json = req.json()
|
||||
json_data = req.json()
|
||||
|
||||
if "page" in json:
|
||||
os.environ["page"] = str(json["page"])
|
||||
if "page" in json_data:
|
||||
self._page = json_data["page"]
|
||||
|
||||
if "total_results" in json:
|
||||
os.environ["total_results"] = str(json["total_results"])
|
||||
if "total_results" in json_data:
|
||||
self._total_results = json_data["total_results"]
|
||||
|
||||
if "total_pages" in json:
|
||||
os.environ["total_pages"] = str(json["total_pages"])
|
||||
if "total_pages" in json_data:
|
||||
self._total_pages = json_data["total_pages"]
|
||||
|
||||
if self.debug:
|
||||
logger.info(json)
|
||||
logger.info(json_data)
|
||||
logger.info(self.cached_request.cache_info())
|
||||
|
||||
if "errors" in json:
|
||||
raise TMDbException(json["errors"])
|
||||
if "errors" in json_data:
|
||||
raise TMDbException(json_data["errors"])
|
||||
|
||||
if "success" in json and json["success"] is False:
|
||||
raise TMDbException(json["status_message"])
|
||||
if "success" in json_data and json_data["success"] is False:
|
||||
raise TMDbException(json_data["status_message"])
|
||||
|
||||
if key:
|
||||
return json.get(key)
|
||||
return json
|
||||
return json_data.get(key)
|
||||
return json_data
|
||||
|
||||
def close(self):
|
||||
if self._session:
|
||||
|
||||
5
app/modules/transmission/transmission.py
Normal file → Executable file
5
app/modules/transmission/transmission.py
Normal file → Executable file
@@ -163,8 +163,9 @@ class Transmission:
|
||||
if not self.trc:
|
||||
return []
|
||||
try:
|
||||
torrent = self.trc.get_torrents(ids=ids, arguments=self._trarg)
|
||||
if torrent:
|
||||
torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)
|
||||
if len(torrents):
|
||||
torrent = torrents[0]
|
||||
labels = [str(tag).strip()
|
||||
for tag in torrent.labels] if hasattr(torrent, "labels") else []
|
||||
return labels
|
||||
|
||||
@@ -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,32 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_module(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||
{
|
||||
"id1": self.xxx1,
|
||||
"id2": self.xxx2,
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_actions(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件工作流动作
|
||||
[{
|
||||
"id": "动作ID",
|
||||
"name": "动作名称",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 需要附加传递的参数
|
||||
}]
|
||||
|
||||
对实现函数的要求:
|
||||
1、函数的第一个参数固定为 ActionContent 实例,如需要传递额外参数,在kwargs中定义
|
||||
2、函数的返回:执行状态 True / False,更新后的 ActionContent 实例
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop_service(self):
|
||||
"""
|
||||
|
||||
@@ -51,6 +51,8 @@ class Scheduler(metaclass=Singleton):
|
||||
_jobs = {}
|
||||
# 用户认证失败次数
|
||||
_auth_count = 0
|
||||
# 用户认证失败消息发送
|
||||
_auth_message = False
|
||||
|
||||
def __init__(self):
|
||||
self.init()
|
||||
@@ -586,6 +588,9 @@ class Scheduler(metaclass=Singleton):
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
# 避免_scheduler.shutdown()处于阻塞状态导致的死锁
|
||||
if not self._scheduler or not self._scheduler.running:
|
||||
return []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
@@ -658,9 +663,11 @@ class Scheduler(metaclass=Singleton):
|
||||
# 最大重试次数
|
||||
__max_try__ = 30
|
||||
if self._auth_count > __max_try__:
|
||||
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
||||
message="用户认证失败次数过多,将不再尝试认证!",
|
||||
role="system")
|
||||
if not self._auth_message:
|
||||
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
||||
message="用户认证失败次数过多,将不再尝试认证!",
|
||||
role="system")
|
||||
self._auth_message = True
|
||||
return
|
||||
logger.info("用户未认证,正在尝试认证...")
|
||||
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
||||
@@ -675,10 +682,11 @@ class Scheduler(metaclass=Singleton):
|
||||
Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title="MoviePilot用户认证成功",
|
||||
text=f"使用站点:{msg}",
|
||||
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
|
||||
link=settings.MP_DOMAIN('#/site')
|
||||
)
|
||||
)
|
||||
# 认证通过后重新初始化插件
|
||||
PluginManager().init_config()
|
||||
self.init_plugin_jobs()
|
||||
|
||||
|
||||
@@ -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="存储操作对象")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional, Union
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -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 = "subscribeAdded"
|
||||
# 订阅完成
|
||||
SubscribeComplete = "subscribeComplete"
|
||||
# 入库成功
|
||||
OrganizeSuccess = "organizeSuccess"
|
||||
# 下载开始(添加下载任务成功)
|
||||
DownloadAdded = "downloadAdded"
|
||||
|
||||
|
||||
# 消息渠道
|
||||
class MessageChannel(Enum):
|
||||
"""
|
||||
|
||||
22
app/startup/command_initializer.py
Normal file
22
app/startup/command_initializer.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.command import Command
|
||||
|
||||
|
||||
def init_command():
|
||||
"""
|
||||
初始化命令
|
||||
"""
|
||||
Command()
|
||||
|
||||
|
||||
def stop_command():
|
||||
"""
|
||||
停止命令
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def restart_command():
|
||||
"""
|
||||
重启命令
|
||||
"""
|
||||
Command().init_commands()
|
||||
@@ -3,10 +3,25 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.startup.workflow_initializer import init_workflow, stop_workflow
|
||||
from app.startup.modules_initializer import shutdown_modules, start_modules
|
||||
from app.startup.plugins_initializer import init_plugins_async
|
||||
from app.core.config import global_vars
|
||||
from app.startup.command_initializer import init_command, stop_command, restart_command
|
||||
from app.startup.modules_initializer import init_modules, stop_modules
|
||||
from app.startup.monitor_initializer import stop_monitor, init_monitor
|
||||
from app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins
|
||||
from app.startup.routers_initializer import init_routers
|
||||
from app.startup.scheduler_initializer import stop_scheduler, init_scheduler, init_plugin_scheduler
|
||||
from app.startup.workflow_initializer import init_workflow, stop_workflow
|
||||
|
||||
|
||||
async def init_plugin_system():
|
||||
"""
|
||||
同步插件及重启相关依赖服务
|
||||
"""
|
||||
if await sync_plugins():
|
||||
# 重新注册插件定时服务
|
||||
init_plugin_scheduler()
|
||||
# 重新注册命令
|
||||
restart_command()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -15,29 +30,45 @@ async def lifespan(app: FastAPI):
|
||||
定义应用的生命周期事件
|
||||
"""
|
||||
print("Starting up...")
|
||||
# 启动模块
|
||||
start_modules(app)
|
||||
# 初始化工作流动作
|
||||
init_workflow(app)
|
||||
# 初始化模块
|
||||
init_modules()
|
||||
# 初始化路由
|
||||
init_routers(app)
|
||||
# 初始化插件
|
||||
plugin_init_task = asyncio.create_task(init_plugins_async())
|
||||
init_plugins()
|
||||
# 初始化定时器
|
||||
init_scheduler()
|
||||
# 初始化监控器
|
||||
init_monitor()
|
||||
# 初始化命令
|
||||
init_command()
|
||||
# 初始化工作流
|
||||
init_workflow()
|
||||
# 插件同步到本地
|
||||
sync_plugins_task = asyncio.create_task(init_plugin_system())
|
||||
try:
|
||||
# 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环
|
||||
yield
|
||||
finally:
|
||||
print("Shutting down...")
|
||||
# 停止信号
|
||||
global_vars.stop_system()
|
||||
try:
|
||||
# 取消插件初始化
|
||||
plugin_init_task.cancel()
|
||||
await plugin_init_task
|
||||
sync_plugins_task.cancel()
|
||||
await sync_plugins_task
|
||||
except asyncio.CancelledError:
|
||||
print("Plugin installation task cancelled.")
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error during plugin installation shutdown: {e}")
|
||||
# 清理模块
|
||||
shutdown_modules(app)
|
||||
# 关闭工作流
|
||||
stop_workflow(app)
|
||||
|
||||
print(str(e))
|
||||
# 停止工作流
|
||||
stop_workflow()
|
||||
# 停止命令
|
||||
stop_command()
|
||||
# 停止监控器
|
||||
stop_monitor()
|
||||
# 停止定时器
|
||||
stop_scheduler()
|
||||
# 停止插件
|
||||
stop_plugins()
|
||||
# 停止模块
|
||||
stop_modules()
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import sys
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.cache import close_cache
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.utils.system import SystemUtils
|
||||
from app.command import CommandChain
|
||||
|
||||
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
||||
try:
|
||||
@@ -18,18 +17,14 @@ except ImportError as e:
|
||||
sys.exit(1)
|
||||
|
||||
from app.core.event import EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.helper.display import DisplayHelper
|
||||
from app.helper.resource import ResourceHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.monitor import Monitor
|
||||
from app.schemas import Notification, NotificationType
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.db import close_database
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.command import Command, CommandChain
|
||||
|
||||
|
||||
def start_frontend():
|
||||
@@ -109,25 +104,16 @@ def check_auth():
|
||||
)
|
||||
|
||||
|
||||
def shutdown_modules(_: FastAPI):
|
||||
def stop_modules():
|
||||
"""
|
||||
服务关闭
|
||||
"""
|
||||
# 停止信号
|
||||
global_vars.stop_system()
|
||||
# 停止模块
|
||||
ModuleManager().stop()
|
||||
# 停止插件
|
||||
PluginManager().stop()
|
||||
PluginManager().stop_monitor()
|
||||
# 停止事件消费
|
||||
EventManager().stop()
|
||||
# 停止虚拟显示
|
||||
DisplayHelper().stop()
|
||||
# 停止定时服务
|
||||
Scheduler().stop()
|
||||
# 停止监控
|
||||
Monitor().stop()
|
||||
# 停止线程池
|
||||
ThreadHelper().shutdown()
|
||||
# 停止缓存连接
|
||||
@@ -140,7 +126,7 @@ def shutdown_modules(_: FastAPI):
|
||||
clear_temp()
|
||||
|
||||
|
||||
def start_modules(_: FastAPI):
|
||||
def init_modules():
|
||||
"""
|
||||
启动模块
|
||||
"""
|
||||
@@ -156,14 +142,6 @@ def start_modules(_: FastAPI):
|
||||
ModuleManager()
|
||||
# 启动事件消费
|
||||
EventManager().start()
|
||||
# 加载插件
|
||||
PluginManager().start()
|
||||
# 启动监控任务
|
||||
Monitor()
|
||||
# 启动定时服务
|
||||
Scheduler()
|
||||
# 加载命令
|
||||
Command()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
# 检查认证状态
|
||||
|
||||
15
app/startup/monitor_initializer.py
Normal file
15
app/startup/monitor_initializer.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.monitor import Monitor
|
||||
|
||||
|
||||
def init_monitor():
|
||||
"""
|
||||
初始化监控器
|
||||
"""
|
||||
Monitor()
|
||||
|
||||
|
||||
def stop_monitor():
|
||||
"""
|
||||
停止监控器
|
||||
"""
|
||||
Monitor().stop()
|
||||
@@ -1,43 +1,36 @@
|
||||
import asyncio
|
||||
|
||||
from app.command import Command
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
|
||||
async def init_plugins_async():
|
||||
async def sync_plugins() -> bool:
|
||||
"""
|
||||
初始化安装插件,并动态注册后台任务及API
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
plugin_manager = PluginManager()
|
||||
scheduler = Scheduler()
|
||||
command = Command()
|
||||
|
||||
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
||||
resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,
|
||||
"缺失依赖项安装")
|
||||
# 判断是否需要进行插件初始化
|
||||
if not sync_result and not resolved_dependencies:
|
||||
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装,跳过插件初始化")
|
||||
return
|
||||
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装")
|
||||
return False
|
||||
|
||||
# 继续执行后续的插件初始化步骤
|
||||
logger.info("正在初始化所有插件")
|
||||
# 为避免初始化插件异常,这里所有插件都进行初始化
|
||||
# 安装完成后重新初始化插件
|
||||
logger.info("正在重新初始化插件")
|
||||
# 重新初始化插件
|
||||
plugin_manager.init_config()
|
||||
# 插件启动后注册后台任务
|
||||
scheduler.init_plugin_jobs()
|
||||
# 插件启动后注册菜单命令
|
||||
command.init_commands()
|
||||
# 插件启动后注册插件API
|
||||
# 重新注册插件API
|
||||
register_plugin_api()
|
||||
logger.info("所有插件初始化完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"插件初始化过程中出现异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def execute_task(loop, task_func, task_name):
|
||||
@@ -62,3 +55,23 @@ def register_plugin_api():
|
||||
"""
|
||||
from app.api.endpoints import plugin
|
||||
plugin.register_plugin_api()
|
||||
|
||||
|
||||
def init_plugins():
|
||||
"""
|
||||
初始化插件
|
||||
"""
|
||||
PluginManager().start()
|
||||
register_plugin_api()
|
||||
|
||||
|
||||
def stop_plugins():
|
||||
"""
|
||||
停止插件
|
||||
"""
|
||||
try:
|
||||
plugin_manager = PluginManager()
|
||||
plugin_manager.stop()
|
||||
plugin_manager.stop_monitor()
|
||||
except Exception as e:
|
||||
logger.error(f"停止插件时发生错误:{e}", exc_info=True)
|
||||
|
||||
29
app/startup/scheduler_initializer.py
Normal file
29
app/startup/scheduler_initializer.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
"""
|
||||
初始化定时器
|
||||
"""
|
||||
Scheduler()
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""
|
||||
停止定时器
|
||||
"""
|
||||
Scheduler().stop()
|
||||
|
||||
|
||||
def restart_scheduler():
|
||||
"""
|
||||
重启定时器
|
||||
"""
|
||||
Scheduler().init()
|
||||
|
||||
|
||||
def init_plugin_scheduler():
|
||||
"""
|
||||
初始化插件定时器
|
||||
"""
|
||||
Scheduler().init_plugin_jobs()
|
||||
@@ -1,16 +1,14 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.workflow import WorkFlowManager
|
||||
|
||||
|
||||
def init_workflow(_: FastAPI):
|
||||
def init_workflow():
|
||||
"""
|
||||
初始化动作
|
||||
"""
|
||||
WorkFlowManager()
|
||||
|
||||
|
||||
def stop_workflow(_: FastAPI):
|
||||
def stop_workflow():
|
||||
"""
|
||||
停止动作
|
||||
"""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -15,7 +15,8 @@ from app.schemas.types import MediaType
|
||||
_special_domains = [
|
||||
'u2.dmhy.org',
|
||||
'pt.ecust.pp.ua',
|
||||
'pt.gtkpw.xyz'
|
||||
'pt.gtkpw.xyz',
|
||||
'pt.gtk.pw'
|
||||
]
|
||||
|
||||
# 内置版本号转换字典
|
||||
@@ -907,3 +908,20 @@ class StringUtils:
|
||||
:return: 如果elem有效(非None且长度大于0),返回True;否则返回False
|
||||
"""
|
||||
return elem is not None and len(elem) > 0
|
||||
|
||||
@staticmethod
|
||||
def is_link(text: str) -> bool:
|
||||
"""
|
||||
检查文件是否为链接地址,支持各类协议
|
||||
:param text: 要检查的文本
|
||||
:return: 如果URL有效,返回True;否则返回False
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
# 检查是否以http、https、ftp等协议开头
|
||||
if re.match(r'^(http|https|ftp|ftps|sftp|ws|wss)://', text):
|
||||
return True
|
||||
# 检查是否为IP地址或域名
|
||||
if re.match(r'^[a-zA-Z0-9.-]+(\.[a-zA-Z]{2,})?$', text):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -12,7 +12,7 @@ class Tokens:
|
||||
self.load_text(text)
|
||||
|
||||
def load_text(self, text):
|
||||
splitted_text = re.split(r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/|~|;|&|\||#|_|「|」|~", text)
|
||||
splitted_text = re.split(r"\.|\s+|\(|\)|\[|]|-|【|】|/|~|;|&|\||#|_|「|」|~", text)
|
||||
for sub_text in splitted_text:
|
||||
if sub_text:
|
||||
self._tokens.append(sub_text)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.cache import cached
|
||||
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
@@ -73,38 +69,3 @@ class WebUtils:
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_bing_wallpaper() -> Optional[str]:
|
||||
"""
|
||||
获取Bing每日壁纸
|
||||
"""
|
||||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
for image in result.get('images') or []:
|
||||
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_bing_wallpapers(num: int = 7) -> List[str]:
|
||||
"""
|
||||
获取7天的Bing每日壁纸
|
||||
"""
|
||||
url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}"
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []]
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return []
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# `original_language` 语种,具体含义参考下方字典
|
||||
# `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典
|
||||
# `genre_ids` 内容类型,具体含义参考下方字典
|
||||
# `release_year` 发行年份,格式:YYYY,电影实际对应`release_date`字段,电视剧实际对应`first_air_date`字段,支持范围设定,如:`YYYY-YYYY`
|
||||
# themoviedb 详情API返回的其它一级字段
|
||||
# 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔
|
||||
|
||||
|
||||
37
database/versions/486e56a62dcb_2_1_5.py
Normal file
37
database/versions/486e56a62dcb_2_1_5.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""2.1.5
|
||||
|
||||
Revision ID: 486e56a62dcb
|
||||
Revises: 89d24811e894
|
||||
Create Date: 2025-05-13 19:49:51.271319
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '486e56a62dcb'
|
||||
down_revision = '89d24811e894'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ###
|
||||
_systemconfig = SystemConfigOper()
|
||||
templates = _systemconfig.get(SystemConfigKey.NotificationTemplates)
|
||||
if isinstance(templates, dict):
|
||||
_re = r'(?<={{)(?![^}]*[%|])(\s*)season(\s*)(?=}})|(?<={%)if\s+(?![^%]*[%|])season\s*(?=%)'
|
||||
for k, v in templates.items():
|
||||
# 替换season为season_fmt
|
||||
result = re.sub(_re, r'\1season_fmt\2', v)
|
||||
templates[k] = result
|
||||
# 将更新后的模板存回系统配置
|
||||
_systemconfig.set(SystemConfigKey.NotificationTemplates, templates)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
68
database/versions/89d24811e894_2_1_4.py
Normal file
68
database/versions/89d24811e894_2_1_4.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""2.1.4
|
||||
|
||||
Revision ID: 89d24811e894
|
||||
Revises: 4b544f5d3b07
|
||||
Create Date: 2025-05-03 17:29:07.635618
|
||||
|
||||
"""
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '89d24811e894'
|
||||
down_revision = '4b544f5d3b07'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
value = {
|
||||
"organizeSuccess": """
|
||||
{
|
||||
'title': '{{ title_year }}'
|
||||
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
|
||||
'text': '{% if vote_average %}评分:{{ vote_average }},{% endif %}'
|
||||
'类型:{{ type }}'
|
||||
'{% if category %},类别:{{ category }}{% endif %}'
|
||||
'{% if resource_term %},质量:{{ resource_term }}{% endif %},'
|
||||
'共{{ file_count }}个文件,大小:{{ total_size }}'
|
||||
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
|
||||
}""",
|
||||
"downloadAdded": """
|
||||
{
|
||||
'title': '{{ title_year }}'
|
||||
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
|
||||
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
|
||||
'{% if resource_term %}\\n质量:{{ resource_term }}{% endif %}'
|
||||
'{% if size %}\\n大小:{{ size }}{% endif %}'
|
||||
'{% if torrent_title %}\\n种子:{{ torrent_title }}{% endif %}'
|
||||
'{% if pubdate %}\\n发布时间:{{ pubdate }}{% endif %}'
|
||||
'{% if freedate %}\\n免费时间:{{ freedate }}{% endif %}'
|
||||
'{% if seeders %}\\n做种数:{{ seeders }}{% endif %}'
|
||||
'{% if volume_factor %}\\n促销:{{ volume_factor }}{% endif %}'
|
||||
'{% if hit_and_run %}\\nHit&Run:{{ hit_and_run }}{% endif %}'
|
||||
'{% if labels %}\\n标签:{{ labels }}{% endif %}'
|
||||
'{% if description %}\\n描述:{{ description }}{% endif %}'
|
||||
}""",
|
||||
"subscribeAdded": "{'title': '{{ title_year }}{% if season_fmt %} {{ season_fmt }}{% endif %} 已添加订阅'}",
|
||||
"subscribeComplete": """
|
||||
{
|
||||
'title': '{{ title_year }}'
|
||||
'{% if season_fmt %} {{ season_fmt }}{% endif %} 已完成{{ msgstr }}',
|
||||
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
|
||||
'{% if username %},来自用户:{{ username }}{% endif %}'
|
||||
'{% if actors %}\\n演员:{{ actors }}{% endif %}'
|
||||
'{% if overview %}\\n简介:{{ overview }}{% endif %}'
|
||||
}"""
|
||||
}
|
||||
_systemconfig = SystemConfigOper()
|
||||
if not _systemconfig.get(SystemConfigKey.NotificationTemplates):
|
||||
_systemconfig.set(SystemConfigKey.NotificationTemplates, value)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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};
|
||||
# 可以添加更多后端服务器作为负载均衡
|
||||
}
|
||||
|
||||
}
|
||||
@@ -668,7 +668,7 @@ meta_cases = [{
|
||||
"restype": "UHD BluRay DoVi",
|
||||
"pix": "1080p",
|
||||
"video_codec": "X265",
|
||||
"audio_codec": "DD 7.1"
|
||||
"audio_codec": "DD+ 7.1"
|
||||
}
|
||||
}, {
|
||||
"title": "Childhood.In.A.Capsule.S01E16.2022.1080p.KKTV.WEB-DL.X264.AAC-ADWeb.mkv",
|
||||
@@ -968,7 +968,7 @@ meta_cases = [{
|
||||
"year": "2023",
|
||||
"part": "",
|
||||
"season": "S02",
|
||||
"episode": "E01-E08",
|
||||
"episode": "",
|
||||
"restype": "WEB-DL",
|
||||
"pix": "2160p",
|
||||
"video_codec": "H265",
|
||||
@@ -1016,7 +1016,7 @@ meta_cases = [{
|
||||
"year": "2019",
|
||||
"part": "",
|
||||
"season": "S01",
|
||||
"episode": "E01-E36",
|
||||
"episode": "",
|
||||
"restype": "WEB-DL",
|
||||
"pix": "2160p",
|
||||
"video_codec": "H265",
|
||||
@@ -1037,4 +1037,84 @@ meta_cases = [{
|
||||
"video_codec": "",
|
||||
"audio_codec": ""
|
||||
}
|
||||
}, {
|
||||
"path": "/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv",
|
||||
"target": {
|
||||
"type": "电视剧",
|
||||
"cn_name": "",
|
||||
"en_name": "The Vampire Diaries",
|
||||
"year": "2009",
|
||||
"part": "",
|
||||
"season": "S01",
|
||||
"episode": "E01",
|
||||
"restype": "",
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 18165
|
||||
}
|
||||
}, {
|
||||
"path": "/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv",
|
||||
"target": {
|
||||
"type": "未知",
|
||||
"cn_name": "",
|
||||
"en_name": "Inception",
|
||||
"year": "2010",
|
||||
"part": "",
|
||||
"season": "",
|
||||
"episode": "",
|
||||
"restype": "",
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 27205
|
||||
}
|
||||
}, {
|
||||
"path": "/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv",
|
||||
"target": {
|
||||
"type": "电视剧",
|
||||
"cn_name": "",
|
||||
"en_name": "Breaking Bad",
|
||||
"year": "2008",
|
||||
"part": "",
|
||||
"season": "S01",
|
||||
"episode": "E01",
|
||||
"restype": "",
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 1396
|
||||
}
|
||||
}, {
|
||||
"path": "/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv",
|
||||
"target": {
|
||||
"type": "电视剧",
|
||||
"cn_name": "",
|
||||
"en_name": "Game Of Thrones",
|
||||
"year": "2011",
|
||||
"part": "",
|
||||
"season": "S01",
|
||||
"episode": "E01",
|
||||
"restype": "",
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 1399
|
||||
}
|
||||
}, {
|
||||
"path": "/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv",
|
||||
"target": {
|
||||
"type": "未知",
|
||||
"cn_name": "",
|
||||
"en_name": "Avatar",
|
||||
"year": "2009",
|
||||
"part": "",
|
||||
"season": "",
|
||||
"episode": "",
|
||||
"restype": "",
|
||||
"pix": "1080p",
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"tmdbid": 19995
|
||||
}
|
||||
}]
|
||||
|
||||
@@ -7,6 +7,7 @@ if __name__ == '__main__':
|
||||
|
||||
# 测试名称识别
|
||||
suite.addTest(MetaInfoTest('test_metainfo'))
|
||||
suite.addTest(MetaInfoTest('test_emby_format_ids'))
|
||||
|
||||
# 运行测试
|
||||
runner = unittest.TextTestRunner()
|
||||
|
||||
@@ -32,4 +32,32 @@ class MetaInfoTest(TestCase):
|
||||
"video_codec": meta_info.video_encode or "",
|
||||
"audio_codec": meta_info.audio_encode or ""
|
||||
}
|
||||
|
||||
# 检查tmdbid
|
||||
if info.get("target").get("tmdbid"):
|
||||
target["tmdbid"] = meta_info.tmdbid
|
||||
|
||||
self.assertEqual(target, info.get("target"))
|
||||
|
||||
def test_emby_format_ids(self):
|
||||
"""
|
||||
测试Emby格式ID识别
|
||||
"""
|
||||
# 测试文件路径
|
||||
test_paths = [
|
||||
# 文件名中包含tmdbid
|
||||
("/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", 18165),
|
||||
# 目录名中包含tmdbid
|
||||
("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", 27205),
|
||||
# 父目录名中包含tmdbid
|
||||
("/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", 1396),
|
||||
# 祖父目录名中包含tmdbid
|
||||
("/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", 1399),
|
||||
# 测试{tmdb-xxx}格式
|
||||
("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995),
|
||||
]
|
||||
|
||||
for path_str, expected_tmdbid in test_paths:
|
||||
meta = MetaInfoPath(Path(path_str))
|
||||
self.assertEqual(meta.tmdbid, expected_tmdbid,
|
||||
f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}")
|
||||
|
||||
@@ -2,6 +2,7 @@ from unittest import TestCase
|
||||
from tests.cases.groups import release_group_cases
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
|
||||
|
||||
class MetaInfoTest(TestCase):
|
||||
def test_release_group(self):
|
||||
for info in release_group_cases:
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.4.0'
|
||||
FRONTEND_VERSION = 'v2.4.0'
|
||||
APP_VERSION = 'v2.4.9'
|
||||
FRONTEND_VERSION = 'v2.4.9'
|
||||
|
||||
Reference in New Issue
Block a user