mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-10 14:52:40 +08:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfe113f6c3 | ||
|
|
83500128c9 | ||
|
|
2bff3a80da | ||
|
|
3dd7b33f3e | ||
|
|
8de487b0bf | ||
|
|
ce88a6818f | ||
|
|
6172832f41 | ||
|
|
a0ed228f4b | ||
|
|
01fd56a019 | ||
|
|
087fcd340a | ||
|
|
b3b09f3c03 | ||
|
|
11d17bf21a | ||
|
|
b1ee80edee | ||
|
|
107d496adb | ||
|
|
9f1112b58d | ||
|
|
989d6e3fe7 | ||
|
|
3999c64853 | ||
|
|
760e3d6de0 | ||
|
|
02111a3b9f | ||
|
|
e6af2c0f34 | ||
|
|
bd4c639761 | ||
|
|
d39b7ec021 | ||
|
|
63ca5f5017 | ||
|
|
2202cf457b | ||
|
|
5d04b7abd6 | ||
|
|
0588d5d5f3 | ||
|
|
5a59e443d7 | ||
|
|
470f4df979 | ||
|
|
84bda71330 | ||
|
|
ea883255cb | ||
|
|
e9abb69fb5 | ||
|
|
ff63390794 | ||
|
|
78b3135276 | ||
|
|
15bd2c09ed | ||
|
|
34d44857e4 | ||
|
|
dccded2d3e | ||
|
|
295cafc060 | ||
|
|
c792e97f67 | ||
|
|
d30a02987d | ||
|
|
84d4c9cf73 | ||
|
|
21ecd1f708 | ||
|
|
248b9a8e8c | ||
|
|
3c7abfada6 | ||
|
|
f363656e0a | ||
|
|
e9ee9dbce1 | ||
|
|
ab0b8653ab | ||
|
|
20711e17fb | ||
|
|
a89bd8b816 | ||
|
|
3692cfea64 | ||
|
|
81d9d39029 | ||
|
|
f5a61ceff1 | ||
|
|
404a7b8337 | ||
|
|
71ce3a2920 | ||
|
|
3a27656769 | ||
|
|
27b1e0ffd5 | ||
|
|
1401ea74dd | ||
|
|
cb93a63970 | ||
|
|
da4ff99570 | ||
|
|
b3c0dc813b | ||
|
|
a7b51d9fcc | ||
|
|
76f1de42a8 | ||
|
|
bad016b2b4 | ||
|
|
5cd48d5447 | ||
|
|
41ff5363ea | ||
|
|
85014f4acb | ||
|
|
d9a68daddd | ||
|
|
141e78f274 | ||
|
|
de98ccd33c | ||
|
|
d490dadfdd | ||
|
|
f46bbf73ba | ||
|
|
17eba86f7a | ||
|
|
fdf25b8c66 | ||
|
|
516cb443b9 | ||
|
|
7c4c3b3f9a | ||
|
|
e298a1a8a0 | ||
|
|
fd9eef2089 | ||
|
|
78dab04c96 | ||
|
|
c34475653f | ||
|
|
eb6a6eee0a | ||
|
|
48f6a45194 | ||
|
|
c8ae6bcc78 | ||
|
|
7f6beb2a78 | ||
|
|
ea160afd90 | ||
|
|
29df0813fd | ||
|
|
b014c4a4e5 | ||
|
|
f173c21695 | ||
|
|
dc41f4946a | ||
|
|
fed754f03a | ||
|
|
382d9ed525 | ||
|
|
e3707f39bb | ||
|
|
9df8d3d360 | ||
|
|
5b3c310cda | ||
|
|
79d692771e | ||
|
|
f74ffed3ae | ||
|
|
0325d7f4f1 | ||
|
|
3926298907 | ||
|
|
d98376b490 | ||
|
|
219690afc0 | ||
|
|
bcb1fc1600 | ||
|
|
923be7e1e9 | ||
|
|
951353ee0b | ||
|
|
52bdfa7f9a | ||
|
|
4af29aa76d | ||
|
|
8efa6a742b | ||
|
|
ada5e1cca5 | ||
|
|
859191203f | ||
|
|
cab4055315 | ||
|
|
cacee7abfe | ||
|
|
61694f4c2b | ||
|
|
9c328e3d1c | ||
|
|
b2fe86c744 | ||
|
|
600e32d3e4 | ||
|
|
3ad733bab4 | ||
|
|
1799b63abb | ||
|
|
d71dc13e32 | ||
|
|
f4633788e9 | ||
|
|
2250e7db39 | ||
|
|
b1bb0ced7a | ||
|
|
28aecd79c6 | ||
|
|
d097ef45eb | ||
|
|
dac718edc8 | ||
|
|
598ab23a2c | ||
|
|
8be6e28933 | ||
|
|
bd6805be58 | ||
|
|
c147d36cb2 | ||
|
|
7a5d210167 | ||
|
|
ef335f2b8e | ||
|
|
19eca11d17 | ||
|
|
ab99bd356a | ||
|
|
70f2d72532 | ||
|
|
0ca995da0f | ||
|
|
2a67abe62d | ||
|
|
03a07ac7bf | ||
|
|
f104c903ec | ||
|
|
6b74a8e266 | ||
|
|
cadd885dbf | ||
|
|
7e0cad8491 | ||
|
|
4c05e9fb2b | ||
|
|
42311f0118 | ||
|
|
951be74a21 |
21
README.md
21
README.md
@@ -51,6 +51,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**
|
||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`
|
||||
- **NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||
- **PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||
- **SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
@@ -84,7 +85,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
||||
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
||||
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
||||
- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
|
||||
- `wechat`设置项:
|
||||
|
||||
@@ -108,6 +109,11 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
||||
|
||||
- `synologychat`设置项:
|
||||
|
||||
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
||||
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
||||
|
||||
|
||||
- **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
@@ -127,7 +133,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
|
||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
|
||||
- **MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
- **MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
|
||||
- `emby`设置项:
|
||||
|
||||
@@ -145,6 +151,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **PLEX_TOKEN:** Plex网页Url中的`X-Plex-Token`,通过浏览器F12->网络从请求URL中获取
|
||||
|
||||
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
|
||||
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割
|
||||
|
||||
|
||||
### 2. **用户认证**
|
||||
@@ -182,10 +189,11 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `original_title`: 原语种标题
|
||||
> `name`: 识别名称
|
||||
> `year`: 年份
|
||||
> `edition`: 版本
|
||||
> `resourceType`:资源类型
|
||||
> `effect`:特效
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `effect`: 特效
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
@@ -206,6 +214,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
> `episode_title`: 集标题
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
@@ -228,9 +237,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
- 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用,无法同步的站点可手动新增。
|
||||
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`,后台API端口:`3001`。
|
||||
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
|
||||
- 通过微信/Telegram/Slack远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示),微信需要在官方页面设置回调地址,地址相对路径为:`/api/v1/message/`。
|
||||
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址,地址相对路径为:`/api/v1/message/`。
|
||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`(`3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`3001`端口),可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`API服务端口`),可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
||||
|
||||
**注意**
|
||||
|
||||
@@ -27,4 +27,4 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
pass
|
||||
|
||||
40
alembic/versions/232dfa044617_1_0_6.py
Normal file
40
alembic/versions/232dfa044617_1_0_6.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""1.0.6
|
||||
|
||||
Revision ID: 232dfa044617
|
||||
Revises: e734c7fe6056
|
||||
Create Date: 2023-09-19 21:34:41.994617
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '232dfa044617'
|
||||
down_revision = 'e734c7fe6056'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# 搜索优先级
|
||||
op.execute("delete from systemconfig where key = 'SearchFilterRules';")
|
||||
op.execute(
|
||||
"insert into systemconfig(key, value) VALUES('SearchFilterRules', (select value from systemconfig where key= 'FilterRules'));")
|
||||
# 订阅优先级
|
||||
op.execute("delete from systemconfig where key = 'SubscribeFilterRules';")
|
||||
op.execute(
|
||||
"insert into systemconfig(key, value) VALUES('SubscribeFilterRules', (select value from systemconfig where key= 'FilterRules'));")
|
||||
# 洗版优先级
|
||||
op.execute("delete from systemconfig where key = 'BestVersionFilterRules';")
|
||||
op.execute(
|
||||
"insert into systemconfig(key, value) VALUES('BestVersionFilterRules', (select value from systemconfig where key= 'FilterRules2'));")
|
||||
# 删除旧的优先级规则
|
||||
op.execute("delete from systemconfig where key = 'FilterRules';")
|
||||
op.execute("delete from systemconfig where key = 'FilterRules2';")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
29
alembic/versions/30329639c12b_1_0_7.py
Normal file
29
alembic/versions/30329639c12b_1_0_7.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""1.0.7
|
||||
|
||||
Revision ID: 30329639c12b
|
||||
Revises: 232dfa044617
|
||||
Create Date: 2023-09-23 08:25:59.776488
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '30329639c12b'
|
||||
down_revision = '232dfa044617'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute("delete from systemconfig where key = 'DefaultFilterRules';")
|
||||
op.execute(
|
||||
"insert into systemconfig(key, value) VALUES('DefaultFilterRules', (select value from systemconfig where key= 'DefaultIncludeExcludeFilter'));")
|
||||
op.execute("delete from systemconfig where key = 'DefaultIncludeExcludeFilter';")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
30
alembic/versions/a521fbc28b18_1_0_9.py
Normal file
30
alembic/versions/a521fbc28b18_1_0_9.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""1_0_9
|
||||
|
||||
Revision ID: a521fbc28b18
|
||||
Revises: b2f011d3a8b7
|
||||
Create Date: 2023-09-28 13:37:16.479360
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a521fbc28b18'
|
||||
down_revision = 'b2f011d3a8b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
try:
|
||||
with op.batch_alter_table("downloadhistory") as batch_op:
|
||||
batch_op.add_column(sa.Column('date', sa.String, nullable=True))
|
||||
batch_op.add_column(sa.Column('channel', sa.String, nullable=True))
|
||||
except Exception as e:
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
29
alembic/versions/b2f011d3a8b7_1_0_8.py
Normal file
29
alembic/versions/b2f011d3a8b7_1_0_8.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""1_0_8
|
||||
|
||||
Revision ID: b2f011d3a8b7
|
||||
Revises: 30329639c12b
|
||||
Create Date: 2023-09-28 10:15:58.410003
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b2f011d3a8b7'
|
||||
down_revision = '30329639c12b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
try:
|
||||
with op.batch_alter_table("downloadhistory") as batch_op:
|
||||
batch_op.add_column(sa.Column('userid', sa.String, nullable=True))
|
||||
except Exception as e:
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from requests import Session
|
||||
@@ -11,9 +11,7 @@ from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -24,14 +22,16 @@ def statistic(db: Session = Depends(get_db),
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
"""
|
||||
media_statistic = DashboardChain(db).media_statistic()
|
||||
if media_statistic:
|
||||
return schemas.Statistic(
|
||||
movie_count=media_statistic.movie_count,
|
||||
tv_count=media_statistic.tv_count,
|
||||
episode_count=media_statistic.episode_count,
|
||||
user_count=media_statistic.user_count
|
||||
)
|
||||
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain(db).media_statistic()
|
||||
if media_statistics:
|
||||
# 汇总各媒体库统计信息
|
||||
ret_statistic = schemas.Statistic()
|
||||
for media_statistic in media_statistics:
|
||||
ret_statistic.movie_count += media_statistic.movie_count
|
||||
ret_statistic.tv_count += media_statistic.tv_count
|
||||
ret_statistic.episode_count += media_statistic.episode_count
|
||||
ret_statistic.user_count += media_statistic.user_count
|
||||
return ret_statistic
|
||||
else:
|
||||
return schemas.Statistic()
|
||||
|
||||
@@ -64,13 +64,16 @@ def downloader(db: Session = Depends(get_db),
|
||||
"""
|
||||
transfer_info = DashboardChain(db).downloader_info()
|
||||
free_space = SystemUtils.free_space(Path(settings.DOWNLOAD_PATH))
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=transfer_info.download_speed,
|
||||
upload_speed=transfer_info.upload_speed,
|
||||
download_size=transfer_info.download_size,
|
||||
upload_size=transfer_info.upload_size,
|
||||
free_space=free_space
|
||||
)
|
||||
if transfer_info:
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=transfer_info.download_speed,
|
||||
upload_speed=transfer_info.upload_speed,
|
||||
download_size=transfer_info.download_size,
|
||||
upload_size=transfer_info.upload_size,
|
||||
free_space=free_space
|
||||
)
|
||||
else:
|
||||
return schemas.DownloaderInfo()
|
||||
|
||||
|
||||
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||
@@ -78,37 +81,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询后台服务信息
|
||||
"""
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = Scheduler().list()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
if not StringUtils.is_chinese(job.name):
|
||||
continue
|
||||
if not job.next_run_time:
|
||||
status = "已停止"
|
||||
next_run = ""
|
||||
else:
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
if not next_run:
|
||||
status = "正在运行"
|
||||
else:
|
||||
status = "阻塞" if job.pending else "等待"
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job.id,
|
||||
name=job.name,
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
|
||||
return schedulers
|
||||
return Scheduler().list()
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
|
||||
@@ -6,11 +6,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import EventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -62,20 +64,29 @@ def transfer_history(title: str = None,
|
||||
|
||||
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
delete_file: bool = False,
|
||||
deletesrc: bool = False,
|
||||
deletedest: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除转移历史记录
|
||||
"""
|
||||
# 触发删除事件
|
||||
if delete_file:
|
||||
history = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
return schemas.Response(success=False, msg="记录不存在")
|
||||
# 册除文件
|
||||
if history.dest:
|
||||
TransferChain(db).delete_files(Path(history.dest))
|
||||
history = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
return schemas.Response(success=False, msg="记录不存在")
|
||||
# 册除媒体库文件
|
||||
if deletedest and history.dest:
|
||||
TransferChain(db).delete_files(Path(history.dest))
|
||||
# 删除源文件
|
||||
if deletesrc and history.src:
|
||||
TransferChain(db).delete_files(Path(history.src))
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src
|
||||
}
|
||||
)
|
||||
# 删除记录
|
||||
TransferHistory.delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
@@ -83,15 +94,18 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
|
||||
@router.post("/transfer", summary="历史记录重新转移", response_model=schemas.Response)
|
||||
def redo_transfer_history(history_in: schemas.TransferHistory,
|
||||
mtype: str,
|
||||
new_tmdbid: int,
|
||||
mtype: str = None,
|
||||
new_tmdbid: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
历史记录重新转移
|
||||
历史记录重新转移,不输入 mtype 和 new_tmdbid 时,自动使用文件名重新识别
|
||||
"""
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
|
||||
mtype=MediaType(mtype), tmdbid=new_tmdbid)
|
||||
if mtype and new_tmdbid:
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
|
||||
mtype=MediaType(mtype), tmdbid=new_tmdbid)
|
||||
else:
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id)
|
||||
if state:
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,7 @@ from app.core.security import get_password_hash
|
||||
from app.db import get_db
|
||||
from app.db.models.user import User
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.web import WebUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -67,21 +66,10 @@ def bing_wallpaper() -> Any:
|
||||
"""
|
||||
获取Bing每日壁纸
|
||||
"""
|
||||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||
try:
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return schemas.Response(success=False)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
for image in result.get('images') or []:
|
||||
return schemas.Response(success=False,
|
||||
message=f"https://cn.bing.com{image.get('url')}" if 'url' in image else '')
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
url = WebUtils.get_bing_wallpaper()
|
||||
if url:
|
||||
return schemas.Response(success=False,
|
||||
message=url)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@@ -90,14 +78,10 @@ def tmdb_wallpaper(db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取TMDB电影海报
|
||||
"""
|
||||
infos = TmdbChain(db).tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
|
||||
)
|
||||
wallpager = TmdbChain(db).get_random_wallpager()
|
||||
if wallpager:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=wallpager
|
||||
)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
@@ -73,7 +73,9 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
||||
if not switchs:
|
||||
for noti in NotificationType:
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, telegram=True, slack=True))
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||
telegram=True, slack=True,
|
||||
synologychat=True))
|
||||
else:
|
||||
for switch in switchs:
|
||||
return_list.append(NotificationSwitch(**switch))
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.event import EventManager
|
||||
@@ -15,19 +14,13 @@ from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_cookiecloud_sync(db: Session):
|
||||
"""
|
||||
后台启动CookieCloud站点同步
|
||||
"""
|
||||
CookieCloudChain(db).process(manual=True)
|
||||
|
||||
|
||||
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
||||
def read_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
@@ -38,7 +31,7 @@ def read_sites(db: Session = Depends(get_db),
|
||||
|
||||
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response)
|
||||
def update_site(
|
||||
def add_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
site_in: schemas.Site,
|
||||
@@ -101,12 +94,11 @@ def delete_site(
|
||||
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
运行CookieCloud同步站点信息
|
||||
"""
|
||||
background_tasks.add_task(start_cookiecloud_sync, db)
|
||||
background_tasks.add_task(Scheduler().start, job_id="cookiecloud")
|
||||
return schemas.Response(success=True, message="CookieCloud同步任务已启动!")
|
||||
|
||||
|
||||
@@ -119,7 +111,8 @@ def cookie_cloud_sync(db: Session = Depends(get_db),
|
||||
Site.reset(db)
|
||||
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
|
||||
SystemConfigOper().set(SystemConfigKey.RssSites, [])
|
||||
CookieCloudChain().process(manual=True)
|
||||
# 启动定时服务
|
||||
Scheduler().start("cookiecloud", manual=True)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
@@ -234,14 +227,14 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
||||
获取站点列表
|
||||
"""
|
||||
# 选中的rss站点
|
||||
rss_sites = SystemConfigOper().get(SystemConfigKey.RssSites)
|
||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
# 所有站点
|
||||
all_site = Site.list_order_by_pri(db)
|
||||
if not rss_sites or not all_site:
|
||||
if not selected_sites or not all_site:
|
||||
return []
|
||||
|
||||
# 选中的rss站点
|
||||
rss_sites = [site for site in all_site if site and site.id in rss_sites]
|
||||
rss_sites = [site for site in all_site if site and site.id in selected_sites]
|
||||
return rss_sites
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from typing import List, Any, Optional
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -12,6 +12,7 @@ from app.db import get_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_user
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
@@ -26,13 +27,6 @@ def start_subscribe_add(db: Session, title: str, year: str,
|
||||
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||
|
||||
|
||||
def start_subscribe_search(db: Session, sid: Optional[int], state: Optional[str]):
|
||||
"""
|
||||
启动订阅搜索任务
|
||||
"""
|
||||
SubscribeChain(db).search(sid=sid, state=state, manual=True)
|
||||
|
||||
|
||||
@router.get("/", summary="所有订阅", response_model=List[schemas.Subscribe])
|
||||
def read_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -94,7 +88,7 @@ def update_subscribe(
|
||||
subscribe = Subscribe.get(db, subscribe_in.id)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
if subscribe_in.sites:
|
||||
if subscribe_in.sites is not None:
|
||||
subscribe_in.sites = json.dumps(subscribe_in.sites)
|
||||
# 避免更新缺失集数
|
||||
subscribe_dict = subscribe_in.dict()
|
||||
@@ -140,35 +134,38 @@ def subscribe_mediaid(
|
||||
|
||||
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
|
||||
def refresh_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新所有订阅
|
||||
"""
|
||||
SubscribeChain(db).refresh()
|
||||
Scheduler().start("subscribe_refresh")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
|
||||
def check_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新所有订阅
|
||||
刷新订阅 TMDB 信息
|
||||
"""
|
||||
SubscribeChain(db).check()
|
||||
Scheduler().start("subscribe_tmdb")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
搜索所有订阅
|
||||
"""
|
||||
background_tasks.add_task(start_subscribe_search, db=db, sid=None, state='R')
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=None,
|
||||
state='R',
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -176,12 +173,17 @@ def search_subscribes(
|
||||
def search_subscribe(
|
||||
subscribe_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据订阅编号搜索订阅
|
||||
"""
|
||||
background_tasks.add_task(start_subscribe_search, db=db, sid=subscribe_id, state=None)
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=subscribe_id,
|
||||
state=None,
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import time
|
||||
import tailer
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
import tailer
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -16,6 +16,7 @@ from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
@@ -25,7 +26,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
||||
def get_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号
|
||||
"""
|
||||
@@ -83,7 +84,7 @@ def set_setting(key: str, value: Union[list, dict, str, int] = None,
|
||||
|
||||
|
||||
@router.get("/message", summary="实时消息")
|
||||
def get_progress(token: str):
|
||||
def get_message(token: str):
|
||||
"""
|
||||
实时获取系统消息,返回格式为SSE
|
||||
"""
|
||||
@@ -169,31 +170,33 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
|
||||
@router.get("/ruletest", summary="优先级规则测试", response_model=schemas.Response)
|
||||
def ruletest(title: str,
|
||||
subtitle: str = None,
|
||||
ruletype: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
||||
"""
|
||||
torrent = schemas.TorrentInfo(
|
||||
title=title,
|
||||
description=subtitle,
|
||||
)
|
||||
if ruletype == "2":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.FilterRules2)
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.BestVersionFilterRules)
|
||||
elif ruletype == "3":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.SearchFilterRules)
|
||||
else:
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.FilterRules)
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.SubscribeFilterRules)
|
||||
if not rule_string:
|
||||
return schemas.Response(success=False, message="过滤规则未设置!")
|
||||
return schemas.Response(success=False, message="优先级规则未设置!")
|
||||
|
||||
# 过滤
|
||||
result = SearchChain(db).filter_torrents(rule_string=rule_string,
|
||||
torrent_list=[torrent])
|
||||
if not result:
|
||||
return schemas.Response(success=False, message="不符合过滤规则!")
|
||||
return schemas.Response(success=False, message="不符合优先级规则!")
|
||||
return schemas.Response(success=True, data={
|
||||
"priority": 100 - result[0].pri_order + 1
|
||||
})
|
||||
@@ -209,3 +212,15 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
# 执行重启
|
||||
ret, msg = SystemUtils.restart()
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def execute_command(jobid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
执行命令
|
||||
"""
|
||||
if not jobid:
|
||||
return schemas.Response(success=False, message="命令不能为空!")
|
||||
Scheduler().start(jobid)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -684,7 +684,7 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
||||
"monitored": True,
|
||||
}],
|
||||
year=subscribe.year,
|
||||
remotePoster=subscribe.image,
|
||||
remotePoster=subscribe.poster,
|
||||
tmdbId=subscribe.tmdbid,
|
||||
tvdbId=subscribe.tvdbid,
|
||||
imdbId=subscribe.imdbid,
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo
|
||||
WebhookEventInfo, TmdbEpisode
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@@ -223,16 +223,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
def filter_torrents(self, rule_string: str,
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list] = None) -> List[TorrentInfo]:
|
||||
season_episodes: Dict[int, list] = None,
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子资源
|
||||
:param rule_string: 过滤规则
|
||||
:param torrent_list: 资源列表
|
||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 过滤后的资源列表,添加资源优先级
|
||||
"""
|
||||
return self.run_module("filter_torrents", rule_string=rule_string,
|
||||
torrent_list=torrent_list, season_episodes=season_episodes)
|
||||
torrent_list=torrent_list, season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None
|
||||
@@ -271,7 +274,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs)
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None) -> Optional[TransferInfo]:
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -279,10 +283,12 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:param target: 转移目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target)
|
||||
transfer_type=transfer_type, target=target,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None:
|
||||
"""
|
||||
@@ -333,14 +339,14 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_path: 文件路径
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
|
||||
self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
|
||||
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
@@ -388,22 +394,22 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
注册菜单命令
|
||||
"""
|
||||
return self.run_module("register_commands", commands=commands)
|
||||
self.run_module("register_commands", commands=commands)
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
定时任务,每10分钟调用一次,模块实现该接口以实现定时服务
|
||||
"""
|
||||
return self.run_module("scheduler_job")
|
||||
self.run_module("scheduler_job")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
清理缓存,模块实现该接口响应清理缓存事件
|
||||
"""
|
||||
return self.run_module("clear_cache")
|
||||
self.run_module("clear_cache")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import base64
|
||||
from typing import Tuple, Optional, Union
|
||||
from typing import Tuple, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from lxml import etree
|
||||
@@ -16,7 +16,6 @@ from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
|
||||
@@ -40,21 +39,6 @@ class CookieCloudChain(ChainBase):
|
||||
password=settings.COOKIECLOUD_PASSWORD
|
||||
)
|
||||
|
||||
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
远程触发同步站点,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title="开始同步CookieCloud站点 ...", userid=userid))
|
||||
# 开始同步
|
||||
success, msg = self.process()
|
||||
if success:
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title=f"同步站点成功,{msg}", userid=userid))
|
||||
else:
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title=f"同步站点失败:{msg}", userid=userid))
|
||||
|
||||
def process(self, manual=False) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过CookieCloud同步站点Cookie
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
|
||||
@@ -6,7 +8,7 @@ class DashboardChain(ChainBase):
|
||||
"""
|
||||
各类仪表板统计处理链
|
||||
"""
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> Optional[List[schemas.Statistic]]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Set, Dict, Union
|
||||
|
||||
@@ -15,6 +18,7 @@ from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -36,8 +40,10 @@ class DownloadChain(ChainBase):
|
||||
发送添加下载的消息
|
||||
"""
|
||||
msg_text = ""
|
||||
if userid:
|
||||
msg_text = f"用户:{userid}"
|
||||
if torrent.site_name:
|
||||
msg_text = f"站点:{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:
|
||||
@@ -79,8 +85,68 @@ class DownloadChain(ChainBase):
|
||||
下载种子文件,如果是磁力链,会返回磁力链接本身
|
||||
:return: 种子路径,种子目录名,种子文件清单
|
||||
"""
|
||||
|
||||
def __get_redict_url(url: str, ua: str = None, cookie: str = None) -> Optional[str]:
|
||||
"""
|
||||
获取下载链接, url格式:[base64]url
|
||||
"""
|
||||
# 获取[]中的内容
|
||||
m = re.search(r"\[(.*)](.*)", url)
|
||||
if m:
|
||||
# 参数
|
||||
base64_str = m.group(1)
|
||||
# URL
|
||||
url = m.group(2)
|
||||
if not base64_str:
|
||||
return url
|
||||
# 解码参数
|
||||
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
|
||||
req_params: Dict[str, dict] = json.loads(req_str)
|
||||
if req_params.get('method') == 'get':
|
||||
# GET请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie
|
||||
).get_res(url, params=req_params.get('params'))
|
||||
else:
|
||||
# POST请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie
|
||||
).post_res(url, params=req_params.get('params'))
|
||||
if not res:
|
||||
return None
|
||||
if not req_params.get('result'):
|
||||
return res.text
|
||||
else:
|
||||
data = res.json()
|
||||
for key in str(req_params.get('result')).split("."):
|
||||
data = data.get(key)
|
||||
if not data:
|
||||
return None
|
||||
logger.info(f"获取到下载地址:{data}")
|
||||
return data
|
||||
return None
|
||||
|
||||
# 获取下载链接
|
||||
if not torrent.enclosure:
|
||||
return None, "", []
|
||||
if torrent.enclosure.startswith("magnet:"):
|
||||
return torrent.enclosure, "", []
|
||||
|
||||
if torrent.enclosure.startswith("["):
|
||||
# 需要解码获取下载地址
|
||||
torrent_url = __get_redict_url(url=torrent.enclosure,
|
||||
ua=torrent.site_ua,
|
||||
cookie=torrent.site_cookie)
|
||||
else:
|
||||
torrent_url = torrent.enclosure
|
||||
if not torrent_url:
|
||||
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}!")
|
||||
return None, "", []
|
||||
# 下载种子文件
|
||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||
url=torrent.enclosure,
|
||||
url=torrent_url,
|
||||
cookie=torrent.site_cookie,
|
||||
ua=torrent.site_ua,
|
||||
proxy=torrent.site_proxy)
|
||||
@@ -90,7 +156,7 @@ class DownloadChain(ChainBase):
|
||||
return content, "", []
|
||||
|
||||
if not torrent_file:
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent.enclosure}")
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Manual,
|
||||
@@ -122,7 +188,9 @@ class DownloadChain(ChainBase):
|
||||
_folder_name = ""
|
||||
if not torrent_file:
|
||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||
content, _folder_name, _file_list = self.download_torrent(_torrent, userid=userid)
|
||||
content, _folder_name, _file_list = self.download_torrent(_torrent,
|
||||
channel=channel,
|
||||
userid=userid)
|
||||
if not content:
|
||||
return
|
||||
else:
|
||||
@@ -201,7 +269,10 @@ class DownloadChain(ChainBase):
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
torrent_description=_torrent.description,
|
||||
torrent_site=_torrent.site_name
|
||||
torrent_site=_torrent.site_name,
|
||||
userid=userid,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
@@ -225,7 +296,7 @@ class DownloadChain(ChainBase):
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
|
||||
# 发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel)
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel, userid=userid)
|
||||
# 下载成功后处理
|
||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||
# 广播事件
|
||||
@@ -253,12 +324,14 @@ class DownloadChain(ChainBase):
|
||||
contexts: List[Context],
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
save_path: str = None,
|
||||
channel: MessageChannel = None,
|
||||
userid: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
:param contexts: 资源上下文列表
|
||||
:param no_exists: 缺失的剧集信息
|
||||
:param save_path: 保存路径
|
||||
:param channel: 通知渠道
|
||||
:param userid: 用户ID
|
||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id] = {season: NotExistMediaInfo}
|
||||
"""
|
||||
@@ -323,7 +396,8 @@ class DownloadChain(ChainBase):
|
||||
# 如果是电影,直接下载
|
||||
for context in contexts:
|
||||
if context.media_info.type == MediaType.MOVIE:
|
||||
if self.download_single(context, save_path=save_path, userid=userid):
|
||||
if self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid):
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
|
||||
@@ -390,11 +464,13 @@ class DownloadChain(ChainBase):
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
save_path=save_path,
|
||||
channel=channel,
|
||||
userid=userid
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path, userid=userid)
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid)
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
@@ -452,7 +528,8 @@ class DownloadChain(ChainBase):
|
||||
# 为需要集的子集则下载
|
||||
if torrent_episodes.issubset(set(need_episodes)):
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path, userid=userid)
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
@@ -508,7 +585,7 @@ class DownloadChain(ChainBase):
|
||||
and len(meta.season_list) == 1 \
|
||||
and meta.season_list[0] == need_season:
|
||||
# 检查种子看是否有需要的集
|
||||
content, _, torrent_files = self.download_torrent(torrent, userid=userid)
|
||||
content, _, torrent_files = self.download_torrent(torrent)
|
||||
if not content:
|
||||
continue
|
||||
if isinstance(content, str):
|
||||
@@ -529,6 +606,7 @@ class DownloadChain(ChainBase):
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
episodes=selected_episodes,
|
||||
save_path=save_path,
|
||||
channel=channel,
|
||||
userid=userid
|
||||
)
|
||||
if not download_id:
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -38,12 +38,8 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
logger.info(f'开始识别媒体信息,文件:{path} ...')
|
||||
file_path = Path(path)
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=file_path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=file_path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
# 识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=file_meta)
|
||||
if not mediainfo:
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.core.config import settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.log import logger
|
||||
from app.schemas import MessageChannel, Notification
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
@@ -23,33 +22,23 @@ class MediaServerChain(ChainBase):
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
|
||||
def librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库
|
||||
"""
|
||||
return self.run_module("mediaserver_librarys")
|
||||
return self.run_module("mediaserver_librarys", server=server)
|
||||
|
||||
def items(self, library_id: Union[str, int]) -> Generator:
|
||||
def items(self, server: str, library_id: Union[str, int]) -> Generator:
|
||||
"""
|
||||
获取媒体服务器所有项目
|
||||
"""
|
||||
return self.run_module("mediaserver_items", library_id=library_id)
|
||||
return self.run_module("mediaserver_items", server=server, library_id=library_id)
|
||||
|
||||
def episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
"""
|
||||
获取媒体服务器剧集信息
|
||||
"""
|
||||
return self.run_module("mediaserver_tv_episodes", item_id=item_id)
|
||||
|
||||
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
同步豆瓣想看数据,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="开始媒体服务器 ...", userid=userid))
|
||||
self.sync()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="同步媒体服务器完成!", userid=userid))
|
||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
@@ -59,37 +48,49 @@ class MediaServerChain(ChainBase):
|
||||
# 媒体服务器同步使用独立的会话
|
||||
_db = SessionFactory()
|
||||
_dbOper = MediaServerOper(_db)
|
||||
logger.info("开始同步媒体库数据 ...")
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
# 清空登记薄
|
||||
_dbOper.empty(server=settings.MEDIASERVER)
|
||||
for library in self.librarys():
|
||||
logger.info(f"正在同步媒体库 {library.name} ...")
|
||||
library_count = 0
|
||||
for item in self.items(library.id):
|
||||
if not item:
|
||||
# 同步黑名单
|
||||
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
|
||||
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
|
||||
# 设置的媒体服务器
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
mediaservers = settings.MEDIASERVER.split(",")
|
||||
# 遍历媒体服务器
|
||||
for mediaserver in mediaservers:
|
||||
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
||||
for library in self.librarys(mediaserver):
|
||||
# 同步黑名单 跳过
|
||||
if library.name in sync_blacklist:
|
||||
continue
|
||||
if not item.item_id:
|
||||
continue
|
||||
# 计数
|
||||
library_count += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
||||
if item_type == "电视剧":
|
||||
# 查询剧集信息
|
||||
espisodes_info = self.episodes(item.item_id) or []
|
||||
for episode in espisodes_info:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
# 插入数据
|
||||
item_dict = item.dict()
|
||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
||||
item_dict['item_type'] = item_type
|
||||
_dbOper.add(**item_dict)
|
||||
logger.info(f"媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
logger.info(f"正在同步 {mediaserver} 媒体库 {library.name} ...")
|
||||
library_count = 0
|
||||
for item in self.items(mediaserver, library.id):
|
||||
if not item:
|
||||
continue
|
||||
if not item.item_id:
|
||||
continue
|
||||
# 计数
|
||||
library_count += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
||||
if item_type == "电视剧":
|
||||
# 查询剧集信息
|
||||
espisodes_info = self.episodes(mediaserver, item.item_id) or []
|
||||
for episode in espisodes_info:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
# 插入数据
|
||||
item_dict = item.dict()
|
||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
||||
item_dict['item_type'] = item_type
|
||||
_dbOper.add(**item_dict)
|
||||
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
# 关闭数据库连接
|
||||
if _db:
|
||||
_db.close()
|
||||
|
||||
@@ -187,7 +187,7 @@ class MessageChain(ChainBase):
|
||||
# 下载种子
|
||||
context: Context = cache_list[int(text) - 1]
|
||||
# 下载
|
||||
self.downloadchain.download_single(context, userid=userid)
|
||||
self.downloadchain.download_single(context, userid=userid, channel=channel)
|
||||
|
||||
elif text.lower() == "p":
|
||||
# 上一页
|
||||
@@ -348,6 +348,7 @@ class MessageChain(ChainBase):
|
||||
# 批量下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||
no_exists=no_exists,
|
||||
channel=channel,
|
||||
userid=userid)
|
||||
if downloads and not lefts:
|
||||
# 全部下载完成
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pickle
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
@@ -80,7 +81,8 @@ class SearchChain(ChainBase):
|
||||
keyword: str = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
filter_rule: str = None,
|
||||
priority_rule: str = None,
|
||||
filter_rule: Dict[str, str] = None,
|
||||
area: str = "title") -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
@@ -88,6 +90,7 @@ class SearchChain(ChainBase):
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param priority_rule: 优先级规则,为空时使用搜索优先级规则
|
||||
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
||||
:param area: 搜索范围,title or imdbid
|
||||
"""
|
||||
@@ -128,19 +131,26 @@ class SearchChain(ChainBase):
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
# 过滤种子
|
||||
if filter_rule is None:
|
||||
# 取默认过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
if filter_rule:
|
||||
logger.info(f'开始过滤资源,当前规则:{filter_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=filter_rule,
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始过滤资源,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=torrents,
|
||||
season_episodes=season_episodes)
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
torrents = result
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤条件的资源')
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用默认过滤规则再次过滤
|
||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
||||
filter_rule=filter_rule)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 匹配的资源
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
@@ -247,14 +257,14 @@ class SearchChain(ChainBase):
|
||||
"""
|
||||
# 未开启的站点不搜索
|
||||
indexer_sites = []
|
||||
|
||||
# 配置的索引站点
|
||||
if sites:
|
||||
config_indexers = [str(sid) for sid in sites]
|
||||
else:
|
||||
config_indexers = [str(sid) for sid in self.systemconfig.get(SystemConfigKey.IndexerSites) or []]
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
for indexer in self.siteshelper.get_indexers():
|
||||
# 检查站点索引开关
|
||||
if not config_indexers or str(indexer.get("id")) in config_indexers:
|
||||
if not sites or indexer.get("id") in sites:
|
||||
# 站点流控
|
||||
state, msg = self.siteshelper.check(indexer.get("domain"))
|
||||
if state:
|
||||
@@ -264,6 +274,7 @@ class SearchChain(ChainBase):
|
||||
if not indexer_sites:
|
||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
||||
return []
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
# 开始计时
|
||||
@@ -305,3 +316,44 @@ class SearchChain(ChainBase):
|
||||
self.progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return results
|
||||
|
||||
def filter_torrents_by_rule(self,
|
||||
torrents: List[TorrentInfo],
|
||||
filter_rule: Dict[str, str] = None
|
||||
) -> List[TorrentInfo]:
|
||||
"""
|
||||
使用过滤规则过滤种子
|
||||
:param torrents: 种子列表
|
||||
:param filter_rule: 过滤规则
|
||||
"""
|
||||
|
||||
# 取默认过滤规则
|
||||
if not filter_rule:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
if not filter_rule:
|
||||
return torrents
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
过滤种子
|
||||
"""
|
||||
# 包含
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
return list(filter(lambda t: __filter_torrent(t), torrents))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from typing import Union, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -28,6 +29,66 @@ class SiteChain(ChainBase):
|
||||
self.cookiehelper = CookieHelper()
|
||||
self.message = MessageHelper()
|
||||
|
||||
# 特殊站点登录验证
|
||||
self.special_site_test = {
|
||||
"zhuque.in": self.__zhuque_test,
|
||||
# "m-team.io": self.__mteam_test,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def __zhuque_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:zhuique
|
||||
"""
|
||||
# 获取token
|
||||
token = None
|
||||
res = RequestUtils(
|
||||
ua=site.ua,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).get_res(url=site.url)
|
||||
if res and res.status_code == 200:
|
||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||
if csrf_token:
|
||||
token = csrf_token.group(1)
|
||||
if not token:
|
||||
return False, "无法获取Token"
|
||||
# 调用查询用户信息接口
|
||||
user_res = RequestUtils(
|
||||
headers={
|
||||
'X-CSRF-TOKEN': token,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": f"{site.ua}"
|
||||
},
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).get_res(url=f"{site.url}api/user/getInfo")
|
||||
if user_res and user_res.status_code == 200:
|
||||
user_info = user_res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
|
||||
@staticmethod
|
||||
def __mteam_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:m-team
|
||||
"""
|
||||
url = f"{site.url}api/member/profile"
|
||||
res = RequestUtils(
|
||||
ua=site.ua,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).post_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
|
||||
def test(self, url: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试站点是否可用
|
||||
@@ -39,6 +100,12 @@ class SiteChain(ChainBase):
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if not site_info:
|
||||
return False, f"站点【{url}】不存在"
|
||||
|
||||
# 特殊站点测试
|
||||
if self.special_site_test.get(domain):
|
||||
return self.special_site_test[domain](site_info)
|
||||
|
||||
# 通用站点测试
|
||||
site_url = site_info.url
|
||||
site_cookie = site_info.cookie
|
||||
ua = site_info.ua
|
||||
|
||||
@@ -132,45 +132,6 @@ class SubscribeChain(ChainBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
远程刷新订阅,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始刷新订阅 ...", userid=userid))
|
||||
self.refresh()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅刷新完成!", userid=userid))
|
||||
|
||||
def remote_search(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
远程搜索订阅,发送消息
|
||||
"""
|
||||
if arg_str and not str(arg_str).isdigit():
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="请输入正确的命令格式:/subscribe_search [id],"
|
||||
"[id]为订阅编号,不输入订阅编号时搜索所有订阅", userid=userid))
|
||||
return
|
||||
if arg_str:
|
||||
sid = int(arg_str)
|
||||
subscribe = self.subscribeoper.get(sid)
|
||||
if not subscribe:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅编号 {sid} 不存在!", userid=userid))
|
||||
return
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始搜索 {subscribe.name} ...", userid=userid))
|
||||
# 搜索订阅
|
||||
self.search(sid=int(arg_str))
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"{subscribe.name} 搜索完成!", userid=userid))
|
||||
else:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始搜索所有订阅 ...", userid=userid))
|
||||
self.search(state='R')
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅搜索完成!", userid=userid))
|
||||
|
||||
def search(self, sid: int = None, state: str = 'N', manual: bool = False):
|
||||
"""
|
||||
订阅搜索
|
||||
@@ -262,16 +223,25 @@ class SubscribeChain(ChainBase):
|
||||
sites = json.loads(subscribe.sites)
|
||||
else:
|
||||
sites = None
|
||||
# 过滤规则
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
# 默认过滤规则
|
||||
if subscribe.include or subscribe.exclude:
|
||||
filter_rule = {
|
||||
"include": subscribe.include,
|
||||
"exclude": subscribe.exclude
|
||||
}
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
priority_rule=priority_rule,
|
||||
filter_rule=filter_rule)
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
@@ -285,16 +255,6 @@ class SubscribeChain(ChainBase):
|
||||
torrent_meta = context.meta_info
|
||||
torrent_info = context.torrent_info
|
||||
torrent_mediainfo = context.media_info
|
||||
# 包含
|
||||
if subscribe.include:
|
||||
if not re.search(r"%s" % subscribe.include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
continue
|
||||
# 排除
|
||||
if subscribe.exclude:
|
||||
if re.search(r"%s" % subscribe.exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 如果是电视剧过滤掉已经下载的集数
|
||||
@@ -340,7 +300,7 @@ class SubscribeChain(ChainBase):
|
||||
# 更新订阅剩余集数和时间
|
||||
update_date = True if downloads else False
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if sid:
|
||||
@@ -487,6 +447,10 @@ class SubscribeChain(ChainBase):
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
# 默认过滤规则
|
||||
default_filter = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
include = subscribe.include or default_filter.get("include")
|
||||
exclude = subscribe.exclude or default_filter.get("exclude")
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
@@ -499,14 +463,15 @@ class SubscribeChain(ChainBase):
|
||||
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
|
||||
or torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
# 过滤规则
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
result: List[TorrentInfo] = self.filter_torrents(
|
||||
rule_string=filter_rule,
|
||||
torrent_list=[torrent_info])
|
||||
torrent_list=[torrent_info],
|
||||
mediainfo=torrent_mediainfo)
|
||||
if result is not None and not result:
|
||||
# 不符合过滤规则
|
||||
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||
@@ -558,14 +523,16 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 包含
|
||||
if subscribe.include:
|
||||
if not re.search(r"%s" % subscribe.include,
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
continue
|
||||
# 排除
|
||||
if subscribe.exclude:
|
||||
if re.search(r"%s" % subscribe.exclude,
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
continue
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
@@ -588,7 +555,7 @@ class SubscribeChain(ChainBase):
|
||||
update_date = True if downloads else False
|
||||
# 未完成下载,计算剩余集数
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
else:
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
@@ -621,7 +588,8 @@ class SubscribeChain(ChainBase):
|
||||
if len(episodes) > (subscribe.total_episode or 0):
|
||||
total_episode = len(episodes)
|
||||
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
||||
logger.info(f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
|
||||
logger.info(
|
||||
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
|
||||
else:
|
||||
total_episode = subscribe.total_episode
|
||||
lack_episode = subscribe.lack_episode
|
||||
@@ -682,9 +650,9 @@ class SubscribeChain(ChainBase):
|
||||
return False
|
||||
|
||||
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
"""
|
||||
更新订阅剩余集数
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import random
|
||||
from typing import Optional, List
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TmdbChain(ChainBase):
|
||||
class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TheMovieDB处理链
|
||||
"""
|
||||
@@ -106,3 +110,17 @@ class TmdbChain(ChainBase):
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("person_credits", person_id=person_id, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_random_wallpager(self):
|
||||
"""
|
||||
获取随机壁纸,缓存1个小时
|
||||
"""
|
||||
infos = self.tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
|
||||
return None
|
||||
|
||||
@@ -60,7 +60,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
return self.load_cache(self._rss_file) or {}
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=600))
|
||||
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=600))
|
||||
def browse(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||
@@ -73,7 +73,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
return []
|
||||
return self.refresh_torrents(site=site)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=300))
|
||||
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=300))
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||
@@ -129,7 +129,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 刷新站点
|
||||
if not sites:
|
||||
sites = [str(sid) for sid in (self.systemconfig.get(SystemConfigKey.RssSites) or [])]
|
||||
sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 读取缓存
|
||||
torrents_cache = self.get_torrents()
|
||||
@@ -139,7 +139,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
# 未开启的站点不刷新
|
||||
if sites and str(indexer.get("id")) not in sites:
|
||||
if sites and indexer.get("id") not in sites:
|
||||
continue
|
||||
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||
if stype == "spider":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import glob
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
@@ -8,10 +9,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
@@ -40,6 +42,7 @@ class TransferChain(ChainBase):
|
||||
self.transferhis = TransferHistoryOper(self._db)
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain(self._db)
|
||||
self.tmdbchain = TmdbChain(self._db)
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def process(self) -> bool:
|
||||
@@ -109,17 +112,6 @@ class TransferChain(ChainBase):
|
||||
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
|
||||
return False, f"{path.name} 没有找到可转移的媒体文件"
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 有集自定义格式
|
||||
formaterHandler = FormatParser(eformat=epformat.format,
|
||||
details=epformat.detail,
|
||||
@@ -128,17 +120,24 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
# 总数
|
||||
# 目录所有文件清单
|
||||
transfer_files = SystemUtils.list_files(directory=path,
|
||||
extensions=settings.RMT_MEDIAEXT,
|
||||
min_filesize=min_filesize)
|
||||
if formaterHandler:
|
||||
# 有集自定义格式,过滤文件
|
||||
transfer_files = [f for f in transfer_files if formaterHandler.match(f.name)]
|
||||
# 总数
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 总文件数
|
||||
total_num = len(transfer_files)
|
||||
# 已处理数量
|
||||
processed_num = 0
|
||||
# 失败数量
|
||||
fail_num = 0
|
||||
# 跳过数量
|
||||
skip_num = 0
|
||||
self.progress.update(value=0,
|
||||
text=f"开始转移 {path},共 {total_num} 个文件 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
@@ -148,6 +147,15 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 处理所有待转移目录或文件,默认一个转移路径或文件只有一个媒体信息
|
||||
for trans_path in trans_paths:
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 如果是目录且不是⼀蓝光原盘,获取所有文件并转移
|
||||
if (not trans_path.is_file()
|
||||
and not SystemUtils.is_bluray_dir(trans_path)):
|
||||
@@ -164,7 +172,6 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 转移所有文件
|
||||
for file_path in file_paths:
|
||||
|
||||
# 回收站及隐藏的文件不处理
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str.find('/@Recycle/') != -1 \
|
||||
@@ -172,6 +179,9 @@ class TransferChain(ChainBase):
|
||||
or file_path_str.find('/.') != -1 \
|
||||
or file_path_str.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_path_str} 是回收站或隐藏的文件")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
@@ -186,6 +196,9 @@ class TransferChain(ChainBase):
|
||||
break
|
||||
if is_blocked:
|
||||
err_msgs.append(f"{file_path.name} 命中整理屏蔽词")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 转移成功的不再处理
|
||||
@@ -193,6 +206,9 @@ class TransferChain(ChainBase):
|
||||
transferd = self.transferhis.get_by_src(file_path_str)
|
||||
if transferd and transferd.status:
|
||||
logger.info(f"{file_path} 已成功转移过,如需重新处理,请删除历史记录。")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 更新进度
|
||||
@@ -201,12 +217,8 @@ class TransferChain(ChainBase):
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
if not meta:
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=file_path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=file_path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
# 文件元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
else:
|
||||
file_meta = meta
|
||||
|
||||
@@ -217,6 +229,9 @@ class TransferChain(ChainBase):
|
||||
if not file_meta:
|
||||
logger.error(f"{file_path} 无法识别有效信息")
|
||||
err_msgs.append(f"{file_path} 无法识别有效信息")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 自定义识别
|
||||
@@ -240,7 +255,7 @@ class TransferChain(ChainBase):
|
||||
# 新增转移失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
meta=file_meta,
|
||||
download_hash=download_hash
|
||||
)
|
||||
@@ -249,6 +264,9 @@ class TransferChain(ChainBase):
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
@@ -260,31 +278,17 @@ class TransferChain(ChainBase):
|
||||
|
||||
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
|
||||
|
||||
# 电视剧没有集无法转移
|
||||
if file_mediainfo.type == MediaType.TV and not file_meta.episode:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:未识别到集数")
|
||||
err_msgs.append(f"{file_path.name} 未识别到集数")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo
|
||||
)
|
||||
# 发送消息
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 入库失败!",
|
||||
text=f"原因:未识别到集数",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
continue
|
||||
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=file_mediainfo)
|
||||
|
||||
# 获取集数据
|
||||
if file_mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 获取下载hash
|
||||
if not download_hash:
|
||||
download_file = self.downloadhis.get_file_by_fullpath(file_path_str)
|
||||
if download_file:
|
||||
@@ -295,18 +299,19 @@ class TransferChain(ChainBase):
|
||||
mediainfo=file_mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=transfer_type,
|
||||
target=target)
|
||||
target=target,
|
||||
episodes_info=episodes_info)
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return False, "文件转移模块运行失败"
|
||||
if not transferinfo.target_path:
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
|
||||
err_msgs.append(f"{file_path.name} {transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
@@ -319,6 +324,9 @@ class TransferChain(ChainBase):
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 汇总信息
|
||||
@@ -342,7 +350,7 @@ class TransferChain(ChainBase):
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
@@ -358,8 +366,7 @@ class TransferChain(ChainBase):
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 目录或文件转移完成
|
||||
self.progress.update(value=100,
|
||||
text=f"所有文件转移完成,正在执行后续处理 ...",
|
||||
self.progress.update(text=f"{trans_path} 转移完成,正在执行后续处理 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 执行后续处理
|
||||
@@ -386,10 +393,16 @@ class TransferChain(ChainBase):
|
||||
'mediainfo': media,
|
||||
'transferinfo': transfer_info
|
||||
})
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"成功 {total_num - len(err_msgs)} 个,失败 {len(err_msgs)} 个")
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个")
|
||||
|
||||
self.progress.update(value=100,
|
||||
text=f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个",
|
||||
key=ProgressKey.FileTransfer)
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
return True, "\n".join(err_msgs)
|
||||
|
||||
@@ -473,7 +486,8 @@ class TransferChain(ChainBase):
|
||||
text=errmsg, userid=userid))
|
||||
return
|
||||
|
||||
def re_transfer(self, logid: int, mtype: MediaType, tmdbid: int) -> Tuple[bool, str]:
|
||||
def re_transfer(self, logid: int,
|
||||
mtype: MediaType = None, tmdbid: int = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据历史记录,重新识别转移,只处理对应的src目录
|
||||
:param logid: 历史记录ID
|
||||
@@ -489,12 +503,17 @@ class TransferChain(ChainBase):
|
||||
src_path = Path(history.src)
|
||||
if not src_path.exists():
|
||||
return False, f"源目录不存在:{src_path}"
|
||||
dest_path = Path(history.dest) if history.dest else None
|
||||
# 查询媒体信息
|
||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
|
||||
if mtype and tmdbid:
|
||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
|
||||
else:
|
||||
meta = MetaInfoPath(src_path)
|
||||
mediainfo = self.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
return False, f"未识别到媒体信息,类型:{mtype.value},tmdbid:{tmdbid}"
|
||||
# 重新执行转移
|
||||
logger.info(f"{mtype.value} {tmdbid} 识别为:{mediainfo.title_year}")
|
||||
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
@@ -506,6 +525,7 @@ class TransferChain(ChainBase):
|
||||
state, errmsg = self.do_transfer(path=src_path,
|
||||
mediainfo=mediainfo,
|
||||
download_hash=history.download_hash,
|
||||
target=dest_path,
|
||||
force=True)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -596,13 +616,16 @@ class TransferChain(ChainBase):
|
||||
def delete_files(path: Path):
|
||||
"""
|
||||
删除转移后的文件以及空目录
|
||||
:param path: 文件路径
|
||||
"""
|
||||
logger.info(f"开始删除文件以及空目录:{path} ...")
|
||||
if not path.exists():
|
||||
return
|
||||
if path.is_file():
|
||||
# 删除文件
|
||||
path.unlink()
|
||||
# 删除文件、nfo、jpg
|
||||
files = glob.glob(f"{Path(path.parent).joinpath(path.stem)}*")
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.warn(f"文件 {path} 已删除")
|
||||
# 需要删除父目录
|
||||
elif str(path.parent) == str(path.root):
|
||||
@@ -615,11 +638,24 @@ class TransferChain(ChainBase):
|
||||
# 删除目录
|
||||
logger.warn(f"目录 {path} 已删除")
|
||||
# 需要删除父目录
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
files = SystemUtils.list_files(parent_path, settings.RMT_MEDIAEXT)
|
||||
if not files:
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
|
||||
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
|
||||
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
|
||||
# 媒体库二级分类根路径
|
||||
library_root_names = [
|
||||
settings.LIBRARY_MOVIE_NAME or '电影',
|
||||
settings.LIBRARY_TV_NAME or '电视剧',
|
||||
settings.LIBRARY_ANIME_NAME or '动漫',
|
||||
]
|
||||
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
# 遍历父目录到媒体库二级分类根路径
|
||||
if str(parent_path.name) in library_root_names:
|
||||
break
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
|
||||
# 当前路径下没有媒体文件则删除
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
from app.chain import ChainBase
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MediaImageType, MediaType, NotificationType
|
||||
from app.utils.http import WebUtils
|
||||
from app.utils.web import WebUtils
|
||||
|
||||
|
||||
class WebhookChain(ChainBase):
|
||||
|
||||
125
app/command.py
125
app/command.py
@@ -1,11 +1,9 @@
|
||||
import traceback
|
||||
from threading import Thread, Event
|
||||
from typing import Any, Union
|
||||
from typing import Any, Union, Dict
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
@@ -15,6 +13,8 @@ from app.core.event import eventmanager, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db import SessionFactory
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
@@ -49,13 +49,15 @@ class Command(metaclass=Singleton):
|
||||
self.pluginmanager = PluginManager()
|
||||
# 处理链
|
||||
self.chain = CommandChian(self._db)
|
||||
# 定时服务管理
|
||||
self.scheduler = Scheduler()
|
||||
# 内置命令
|
||||
self._commands = {
|
||||
"/cookiecloud": {
|
||||
"func": CookieCloudChain(self._db).remote_sync,
|
||||
"id": "cookiecloud",
|
||||
"type": "scheduler",
|
||||
"description": "同步站点",
|
||||
"category": "站点",
|
||||
"data": {}
|
||||
"category": "站点"
|
||||
},
|
||||
"/sites": {
|
||||
"func": SiteChain(self._db).remote_list,
|
||||
@@ -79,10 +81,10 @@ class Command(metaclass=Singleton):
|
||||
"data": {}
|
||||
},
|
||||
"/mediaserver_sync": {
|
||||
"func": MediaServerChain(self._db).remote_sync,
|
||||
"id": "mediaserver_sync",
|
||||
"type": "scheduler",
|
||||
"description": "同步媒体服务器",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"category": "管理"
|
||||
},
|
||||
"/subscribes": {
|
||||
"func": SubscribeChain(self._db).remote_list,
|
||||
@@ -91,22 +93,27 @@ class Command(metaclass=Singleton):
|
||||
"data": {}
|
||||
},
|
||||
"/subscribe_refresh": {
|
||||
"func": SubscribeChain(self._db).remote_refresh,
|
||||
"id": "subscribe_refresh",
|
||||
"type": "scheduler",
|
||||
"description": "刷新订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
"category": "订阅"
|
||||
},
|
||||
"/subscribe_search": {
|
||||
"func": SubscribeChain(self._db).remote_search,
|
||||
"id": "subscribe_search",
|
||||
"type": "scheduler",
|
||||
"description": "搜索订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
"category": "订阅"
|
||||
},
|
||||
"/subscribe_delete": {
|
||||
"func": SubscribeChain(self._db).remote_delete,
|
||||
"description": "删除订阅",
|
||||
"data": {}
|
||||
},
|
||||
"/subscribe_tmdb": {
|
||||
"id": "subscribe_tmdb",
|
||||
"type": "scheduler",
|
||||
"description": "订阅元数据更新"
|
||||
},
|
||||
"/downloading": {
|
||||
"func": DownloadChain(self._db).remote_downloading,
|
||||
"description": "正在下载",
|
||||
@@ -114,10 +121,10 @@ class Command(metaclass=Singleton):
|
||||
"data": {}
|
||||
},
|
||||
"/transfer": {
|
||||
"func": TransferChain(self._db).process,
|
||||
"id": "transfer",
|
||||
"type": "scheduler",
|
||||
"description": "下载文件整理",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"category": "管理"
|
||||
},
|
||||
"/redo": {
|
||||
"func": TransferChain(self._db).remote_transfer,
|
||||
@@ -175,6 +182,56 @@ class Command(metaclass=Singleton):
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
def __run_command(self, command: Dict[str, any],
|
||||
data_str: str = "",
|
||||
channel: MessageChannel = None, userid: Union[str, int] = None):
|
||||
"""
|
||||
运行定时服务
|
||||
"""
|
||||
if command.get("type") == "scheduler":
|
||||
# 定时服务
|
||||
if userid:
|
||||
self.chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
title=f"开始执行 {command.get('description')} ...",
|
||||
userid=userid
|
||||
)
|
||||
)
|
||||
|
||||
# 执行定时任务
|
||||
self.scheduler.start(job_id=command.get("id"))
|
||||
|
||||
if userid:
|
||||
self.chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
title=f"{command.get('description')} 执行完成",
|
||||
userid=userid
|
||||
)
|
||||
)
|
||||
else:
|
||||
# 命令
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
data = cmd_data.get("data") or {}
|
||||
data['channel'] = channel
|
||||
data['user'] = userid
|
||||
cmd_data['data'] = data
|
||||
command['func'](**cmd_data)
|
||||
elif args_num == 2:
|
||||
# 没有输入参数,只输入渠道和用户ID
|
||||
command['func'](channel, userid)
|
||||
elif args_num > 2:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid)
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止事件处理线程
|
||||
@@ -216,27 +273,19 @@ class Command(metaclass=Singleton):
|
||||
command = self.get(cmd)
|
||||
if command:
|
||||
try:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
data = cmd_data.get("data") or {}
|
||||
data['channel'] = channel
|
||||
data['user'] = userid
|
||||
cmd_data['data'] = data
|
||||
command['func'](**cmd_data)
|
||||
elif args_num == 2:
|
||||
# 没有输入参数,只输入渠道和用户ID
|
||||
command['func'](channel, userid)
|
||||
elif args_num > 2:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid)
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
logger.info(f"开始执行:{command.get('description')} ...")
|
||||
|
||||
# 执行命令
|
||||
self.__run_command(command, data_str=data_str,
|
||||
channel=channel, userid=userid)
|
||||
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
else:
|
||||
logger.info(f"{command.get('description')} 执行完成")
|
||||
except Exception as err:
|
||||
logger.error(f"执行命令 {cmd} 出错:{str(err)}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -76,7 +76,7 @@ class Settings(BaseSettings):
|
||||
AUTH_SITE: str = ""
|
||||
# 交互搜索自动下载用户ID,使用,分割
|
||||
AUTO_DOWNLOAD_USER: str = None
|
||||
# 消息通知渠道 telegram/wechat/slack
|
||||
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
MESSAGER: str = "telegram"
|
||||
# WeChat企业ID
|
||||
WECHAT_CORPID: str = None
|
||||
@@ -106,6 +106,10 @@ class Settings(BaseSettings):
|
||||
SLACK_APP_TOKEN: str = ""
|
||||
# Slack 频道名称
|
||||
SLACK_CHANNEL: str = ""
|
||||
# SynologyChat Webhook
|
||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||
# SynologyChat Token
|
||||
SYNOLOGYCHAT_TOKEN: str = ""
|
||||
# 下载器 qbittorrent/transmission
|
||||
DOWNLOADER: str = "qbittorrent"
|
||||
# 下载器监控开关
|
||||
@@ -138,12 +142,14 @@ class Settings(BaseSettings):
|
||||
DOWNLOAD_CATEGORY: bool = False
|
||||
# 下载站点字幕
|
||||
DOWNLOAD_SUBTITLE: bool = True
|
||||
# 媒体服务器 emby/jellyfin/plex
|
||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
MEDIASERVER: str = "emby"
|
||||
# 入库刷新媒体库
|
||||
REFRESH_MEDIASERVER: bool = True
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST: str = None
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST: str = None
|
||||
# EMBY Api Key
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
@@ -272,7 +272,7 @@ class MediaInfo:
|
||||
初始化媒信息
|
||||
"""
|
||||
|
||||
def __directors_actors(tmdbinfo: dict):
|
||||
def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]:
|
||||
"""
|
||||
查询导演和演员
|
||||
:param tmdbinfo: TMDB元数据
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.core.meta.words import WordsMatcher
|
||||
|
||||
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
"""
|
||||
媒体整理入口,根据名称和副标题,判断是哪种类型的识别,返回对应对象
|
||||
根据标题和副标题识别元数据
|
||||
:param title: 标题、种子名、文件名
|
||||
:param subtitle: 副标题、描述
|
||||
:return: MetaAnime、MetaVideo
|
||||
@@ -33,6 +33,20 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
return meta
|
||||
|
||||
|
||||
def MetaInfoPath(path: Path) -> MetaBase:
|
||||
"""
|
||||
根据路径识别元数据
|
||||
:param path: 路径
|
||||
"""
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
return file_meta
|
||||
|
||||
|
||||
def is_anime(name: str) -> bool:
|
||||
"""
|
||||
判断是否为动漫
|
||||
|
||||
@@ -74,6 +74,16 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadFiles.delete_by_fullpath(self._db, fullpath)
|
||||
|
||||
def get_hash_by_fullpath(self, fullpath: str) -> str:
|
||||
"""
|
||||
按fullpath查询下载文件记录hash
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
if fileinfo:
|
||||
return fileinfo.download_hash
|
||||
return ""
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
|
||||
"""
|
||||
分页查询下载历史
|
||||
@@ -98,3 +108,11 @@ class DownloadHistoryOper(DbOper):
|
||||
season=season,
|
||||
episode=episode,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def list_by_user_date(self, date: str, userid: str = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_user_date(db=self._db,
|
||||
date=date,
|
||||
userid=userid)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Self, List
|
||||
|
||||
from sqlalchemy.orm import as_declarative, declared_attr, Session
|
||||
|
||||
@@ -16,13 +16,13 @@ class Base:
|
||||
db.rollback()
|
||||
raise err
|
||||
|
||||
def create(self, db: Session):
|
||||
def create(self, db: Session) -> Self:
|
||||
db.add(self)
|
||||
self.commit(db)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def get(cls, db: Session, rid: int):
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
|
||||
def update(self, db: Session, payload: dict):
|
||||
@@ -42,7 +42,7 @@ class Base:
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def list(cls, db: Session):
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
return db.query(cls).all()
|
||||
|
||||
def to_dict(self):
|
||||
|
||||
@@ -35,6 +35,12 @@ class DownloadHistory(Base):
|
||||
torrent_description = Column(String)
|
||||
# 种子站点
|
||||
torrent_site = Column(String)
|
||||
# 下载用户
|
||||
userid = Column(String)
|
||||
# 下载渠道
|
||||
channel = Column(String)
|
||||
# 创建时间
|
||||
date = Column(String)
|
||||
# 附加信息
|
||||
note = Column(String)
|
||||
|
||||
@@ -90,6 +96,19 @@ class DownloadHistory(Base):
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
def list_by_user_date(db: Session, date: str, userid: str = None):
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
if userid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.userid == userid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
|
||||
class DownloadFiles(Base):
|
||||
"""
|
||||
|
||||
@@ -65,6 +65,10 @@ class TransferHistory(Base):
|
||||
def get_by_src(db: Session, src: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
||||
|
||||
@staticmethod
|
||||
def list_by_hash(db: Session, download_hash: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
def statistic(db: Session, days: int = 7):
|
||||
"""
|
||||
|
||||
@@ -36,6 +36,13 @@ class TransferHistoryOper(DbOper):
|
||||
"""
|
||||
return TransferHistory.get_by_src(self._db, src)
|
||||
|
||||
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
|
||||
"""
|
||||
按种子hash查询转移记录
|
||||
:param download_hash: 种子hash
|
||||
"""
|
||||
return TransferHistory.list_by_hash(self._db, download_hash)
|
||||
|
||||
def add(self, **kwargs) -> TransferHistory:
|
||||
"""
|
||||
新增转移历史
|
||||
|
||||
@@ -23,14 +23,17 @@ class CookieHelper:
|
||||
"password": [
|
||||
'//input[@name="password"]',
|
||||
'//input[@id="form_item_password"]',
|
||||
'//input[@id="password"]'
|
||||
'//input[@id="password"]',
|
||||
'//input[@type="password"]'
|
||||
],
|
||||
"captcha": [
|
||||
'//input[@name="imagestring"]',
|
||||
'//input[@name="captcha"]',
|
||||
'//input[@id="form_item_captcha"]'
|
||||
'//input[@id="form_item_captcha"]',
|
||||
'//input[@placeholder="驗證碼"]'
|
||||
],
|
||||
"captcha_img": [
|
||||
'//img[@alt="captcha"]/@src',
|
||||
'//img[@alt="CAPTCHA"]/@src',
|
||||
'//img[@alt="SECURITY CODE"]/@src',
|
||||
'//img[@id="LAY-user-get-vercode"]/@src',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class NfoReader:
|
||||
@@ -8,6 +9,9 @@ class NfoReader:
|
||||
self.tree = ET.parse(xml_file_path)
|
||||
self.root = self.tree.getroot()
|
||||
|
||||
def get_element_value(self, element_path):
|
||||
def get_element_value(self, element_path) -> Optional[str]:
|
||||
element = self.root.find(element_path)
|
||||
return element.text if element is not None else None
|
||||
|
||||
def get_elements(self, element_path) -> List[ET.Element]:
|
||||
return self.root.findall(element_path)
|
||||
|
||||
@@ -58,6 +58,8 @@ def checkMessage(channel_type: MessageChannel):
|
||||
return None
|
||||
if channel_type == MessageChannel.Slack and not switch.get("slack"):
|
||||
return None
|
||||
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||
return None
|
||||
return func(self, message, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union, Any, List, Generator
|
||||
|
||||
@@ -29,7 +28,7 @@ class EmbyModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.emby.is_inactive():
|
||||
self.emby = Emby()
|
||||
self.emby.reconnect()
|
||||
|
||||
def user_authenticate(self, name: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -41,7 +40,7 @@ class EmbyModule(_ModuleBase):
|
||||
# Emby认证
|
||||
return self.emby.authenticate(name, password)
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -49,11 +48,7 @@ class EmbyModule(_ModuleBase):
|
||||
:param args: 请求参数
|
||||
:return: 字典,解析为消息时需要包含:title、text、image
|
||||
"""
|
||||
if form and form.get("data"):
|
||||
result = form.get("data")
|
||||
else:
|
||||
result = json.dumps(dict(args))
|
||||
return self.emby.get_webhook_message(result)
|
||||
return self.emby.get_webhook_message(form, args)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
@@ -87,7 +82,7 @@ class EmbyModule(_ModuleBase):
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -103,25 +98,27 @@ class EmbyModule(_ModuleBase):
|
||||
target_path=file_path
|
||||
)
|
||||
]
|
||||
return self.emby.refresh_library_by_items(items)
|
||||
self.emby.refresh_library_by_items(items)
|
||||
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> List[schemas.Statistic]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.emby.get_medias_count()
|
||||
user_count = self.emby.get_user_count()
|
||||
return schemas.Statistic(
|
||||
return [schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=user_count or 0
|
||||
)
|
||||
)]
|
||||
|
||||
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
librarys = self.emby.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
@@ -133,10 +130,12 @@ class EmbyModule(_ModuleBase):
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
|
||||
def mediaserver_items(self, library_id: str) -> Generator:
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
媒体库项目列表
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
items = self.emby.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
@@ -153,10 +152,13 @@ class EmbyModule(_ModuleBase):
|
||||
path=item.get("path"),
|
||||
)
|
||||
|
||||
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
|
||||
@@ -35,6 +35,13 @@ class Emby(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.user else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.user = self.get_user()
|
||||
self.folders = self.get_emby_folders()
|
||||
|
||||
def get_emby_folders(self) -> List[dict]:
|
||||
"""
|
||||
获取Emby媒体库路径列表
|
||||
@@ -272,8 +279,8 @@ class Emby(metaclass=Singleton):
|
||||
return None
|
||||
return ""
|
||||
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: str = None,
|
||||
tmdb_id: int = None) -> Optional[List[dict]]:
|
||||
"""
|
||||
@@ -338,10 +345,12 @@ class Emby(metaclass=Singleton):
|
||||
if not item_id:
|
||||
return {}
|
||||
# 验证tmdbid是否相同
|
||||
item_tmdbid = (self.get_iteminfo(item_id).get("ProviderIds") or {}).get("Tmdb")
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(tmdb_id) != str(item_tmdbid):
|
||||
return {}
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
item_tmdbid = (item_info.get("ProviderIds") or {}).get("Tmdb")
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(tmdb_id) != str(item_tmdbid):
|
||||
return {}
|
||||
# /Shows/Id/Episodes 查集的信息
|
||||
if not season:
|
||||
season = ""
|
||||
@@ -479,14 +488,14 @@ class Emby(metaclass=Singleton):
|
||||
# 匹配子目录
|
||||
subfolder_path = Path(subfolder.get("Path"))
|
||||
if item_path.is_relative_to(subfolder_path):
|
||||
return subfolder.get("Id")
|
||||
return folder.get("Id")
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
|
||||
subfolder.get("Path")):
|
||||
return folder.get("Id")
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
|
||||
subfolder.get("Path")):
|
||||
return folder.get("Id")
|
||||
# 刷新根目录
|
||||
return "/"
|
||||
|
||||
@@ -543,7 +552,7 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
yield {}
|
||||
|
||||
def get_webhook_message(self, message_str: str) -> WebhookEventInfo:
|
||||
def get_webhook_message(self, form: any, args: dict) -> Optional[WebhookEventInfo]:
|
||||
"""
|
||||
解析Emby Webhook报文
|
||||
电影:
|
||||
@@ -781,9 +790,22 @@ class Emby(metaclass=Singleton):
|
||||
}
|
||||
}
|
||||
"""
|
||||
message = json.loads(message_str)
|
||||
if not form and not args:
|
||||
return None
|
||||
try:
|
||||
if form and form.get("data"):
|
||||
result = form.get("data")
|
||||
else:
|
||||
result = json.dumps(dict(args))
|
||||
message = json.loads(result)
|
||||
except Exception as e:
|
||||
logger.debug(f"解析emby webhook报文出错:" + str(e))
|
||||
return None
|
||||
eventType = message.get('Event')
|
||||
if not eventType:
|
||||
return None
|
||||
logger.info(f"接收到emby webhook:{message}")
|
||||
eventItem = WebhookEventInfo(event=message.get('Event', ''), channel="emby")
|
||||
eventItem = WebhookEventInfo(event=eventType, channel="emby")
|
||||
if message.get('Item'):
|
||||
if message.get('Item', {}).get('Type') == 'Episode':
|
||||
eventItem.item_type = "TV"
|
||||
|
||||
@@ -329,7 +329,11 @@ class FanartModule(_ModuleBase):
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
|
||||
else:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||
if mediainfo.tvdb_id:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取Fanart图片")
|
||||
return
|
||||
if not result or result.get('status') == 'error':
|
||||
logger.warn(f"没有获取到 {mediainfo.title_year} 的Fanart图片数据")
|
||||
return
|
||||
@@ -351,6 +355,7 @@ class FanartModule(_ModuleBase):
|
||||
# 季图片格式 seasonxx-poster
|
||||
image_name = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
|
||||
if not mediainfo.get_image(image_name):
|
||||
# 没有图片才设置
|
||||
mediainfo.set_image(image_name, image_obj.get('url'))
|
||||
|
||||
return mediainfo
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas import TransferInfo, ExistMediaInfo
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
@@ -30,7 +30,8 @@ class FileTransferModule(_ModuleBase):
|
||||
pass
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None) -> TransferInfo:
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -38,20 +39,26 @@ class FileTransferModule(_ModuleBase):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移方式
|
||||
:param target: 目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
# 获取目标路径
|
||||
if not target:
|
||||
target = self.get_target_path(in_path=path)
|
||||
else:
|
||||
target = self.get_library_path(target)
|
||||
if not target:
|
||||
logger.error("未找到媒体库目录,无法转移文件")
|
||||
return TransferInfo(message="未找到媒体库目录,无法转移文件")
|
||||
return TransferInfo(success=False,
|
||||
path=path,
|
||||
message="未找到媒体库目录")
|
||||
# 转移
|
||||
return self.transfer_media(in_path=path,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target_dir=target)
|
||||
target_dir=target,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
@staticmethod
|
||||
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
|
||||
@@ -316,9 +323,11 @@ class FileTransferModule(_ModuleBase):
|
||||
over_flag=over_flag)
|
||||
|
||||
@staticmethod
|
||||
def __get_library_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
|
||||
"""
|
||||
根据设置并装媒体库目录
|
||||
:param mediainfo: 媒体信息
|
||||
:target_dir: 媒体库根目录
|
||||
"""
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
@@ -349,25 +358,31 @@ class FileTransferModule(_ModuleBase):
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target_dir: Path,
|
||||
episodes_info: List[TmdbEpisode] = None
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并转移一个文件或者一个目录下的所有文件
|
||||
:param in_path: 转移的路径,可能是一个文件也可以是一个目录
|
||||
:param in_meta:预识别元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param target_dir: 目的文件夹,非空的转移到该文件夹,为空时则按类型转移到配置文件中的媒体库文件夹
|
||||
:param target_dir: 媒体库根目录
|
||||
:param transfer_type: 文件转移方式
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: TransferInfo、错误信息
|
||||
"""
|
||||
# 检查目录路径
|
||||
if not in_path.exists():
|
||||
return TransferInfo(message=f"{in_path} 路径不存在")
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{in_path} 路径不存在")
|
||||
|
||||
if not target_dir.exists():
|
||||
return TransferInfo(message=f"{target_dir} 目标路径不存在")
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{target_dir} 目标路径不存在")
|
||||
|
||||
# 媒体库目录
|
||||
target_dir = self.__get_library_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
# 媒体库目的目录
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
@@ -393,27 +408,39 @@ class FileTransferModule(_ModuleBase):
|
||||
transfer_type=transfer_type)
|
||||
if retcode != 0:
|
||||
logger.error(f"文件夹 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(message=f"文件夹 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(success=False,
|
||||
message=f"错误码:{retcode}",
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
is_bluray=bluray_flag)
|
||||
|
||||
logger.info(f"文件夹 {in_path} 转移成功")
|
||||
# 返回转移后的路径
|
||||
return TransferInfo(path=in_path,
|
||||
return TransferInfo(success=True,
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
total_size=new_path.stat().st_size,
|
||||
is_bluray=bluray_flag)
|
||||
else:
|
||||
# 转移单个文件
|
||||
# 文件结束季为空
|
||||
in_meta.end_season = None
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
if in_meta.begin_episode is None:
|
||||
logger.warn(f"文件 {in_path} 转移失败:未识别到文件集数")
|
||||
return TransferInfo(success=False,
|
||||
message=f"未识别到文件集数",
|
||||
path=in_path,
|
||||
fail_list=[str(in_path)])
|
||||
|
||||
# 文件总季数为1
|
||||
if in_meta.total_season:
|
||||
in_meta.total_season = 1
|
||||
|
||||
# 文件不可能有多集
|
||||
if in_meta.total_episode > 2:
|
||||
in_meta.total_episode = 1
|
||||
in_meta.end_episode = None
|
||||
# 文件结束季为空
|
||||
in_meta.end_season = None
|
||||
# 文件总季数为1
|
||||
if in_meta.total_season:
|
||||
in_meta.total_season = 1
|
||||
# 文件不可能超过2集
|
||||
if in_meta.total_episode > 2:
|
||||
in_meta.total_episode = 1
|
||||
in_meta.end_episode = None
|
||||
|
||||
# 目的文件名
|
||||
new_file = self.get_rename_path(
|
||||
@@ -422,6 +449,7 @@ class FileTransferModule(_ModuleBase):
|
||||
rename_dict=self.__get_naming_dict(
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=in_path.suffix
|
||||
)
|
||||
)
|
||||
@@ -440,11 +468,15 @@ class FileTransferModule(_ModuleBase):
|
||||
over_flag=overflag)
|
||||
if retcode != 0:
|
||||
logger.error(f"文件 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(message=f"文件 {in_path.name} 转移失败,错误码:{retcode}",
|
||||
return TransferInfo(success=False,
|
||||
message=f"错误码:{retcode}",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
|
||||
logger.info(f"文件 {in_path} 转移成功")
|
||||
return TransferInfo(path=in_path,
|
||||
return TransferInfo(success=True,
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
file_count=1,
|
||||
total_size=new_file.stat().st_size,
|
||||
@@ -453,13 +485,23 @@ class FileTransferModule(_ModuleBase):
|
||||
file_list_new=[str(new_file)])
|
||||
|
||||
@staticmethod
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None) -> dict:
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> dict:
|
||||
"""
|
||||
根据媒体信息,返回Format字典
|
||||
:param meta: 文件元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
# 获取集标题
|
||||
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
|
||||
|
||||
return {
|
||||
# 标题
|
||||
"title": mediainfo.title,
|
||||
@@ -471,14 +513,16 @@ class FileTransferModule(_ModuleBase):
|
||||
"name": meta.name,
|
||||
# 年份
|
||||
"year": mediainfo.year or meta.year,
|
||||
# 资源类型
|
||||
"resourceType": meta.resource_type,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 版本
|
||||
"edition": meta.edition,
|
||||
# 分辨率
|
||||
"videoFormat": meta.resource_pix,
|
||||
# 制作组/字幕组
|
||||
"releaseGroup": meta.resource_team,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 视频编码
|
||||
"videoCodec": meta.video_encode,
|
||||
# 音频编码
|
||||
@@ -495,6 +539,8 @@ class FileTransferModule(_ModuleBase):
|
||||
"season_episode": "%s%s" % (meta.season, meta.episodes),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 剧集标题
|
||||
"episode_title": episode_title,
|
||||
# 文件后缀
|
||||
"fileExt": file_ext
|
||||
}
|
||||
@@ -514,6 +560,26 @@ class FileTransferModule(_ModuleBase):
|
||||
else:
|
||||
return Path(render_str)
|
||||
|
||||
@staticmethod
|
||||
def get_library_path(path: Path):
|
||||
"""
|
||||
根据目录查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return path
|
||||
# 目的路径,多路径以,分隔
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
for libpath in dest_paths:
|
||||
try:
|
||||
if path.is_relative_to(libpath):
|
||||
return libpath
|
||||
except Exception as e:
|
||||
logger.debug(f"计算媒体库路径时出错:{e}")
|
||||
continue
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def get_target_path(in_path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
@@ -533,7 +599,7 @@ class FileTransferModule(_ModuleBase):
|
||||
if in_path:
|
||||
for path in dest_paths:
|
||||
try:
|
||||
relative = Path(in_path).relative_to(path).as_posix()
|
||||
relative = in_path.relative_to(path).as_posix()
|
||||
if len(relative) > max_length:
|
||||
max_length = len(relative)
|
||||
target_path = path
|
||||
@@ -569,14 +635,15 @@ class FileTransferModule(_ModuleBase):
|
||||
if not target_dir:
|
||||
continue
|
||||
# 媒体分类路径
|
||||
target_dir = self.__get_library_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 相对路径
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
rel_path = self.get_rename_path(
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||
rename_dict=self.__get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 取相对路径的第1层目录
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
from typing import List, Tuple, Union, Dict, Optional
|
||||
|
||||
from app.core.context import TorrentInfo
|
||||
from app.core.context import TorrentInfo, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
@@ -9,9 +9,10 @@ from app.modules.filter.RuleParser import RuleParser
|
||||
|
||||
|
||||
class FilterModule(_ModuleBase):
|
||||
|
||||
# 规则解析器
|
||||
parser: RuleParser = None
|
||||
# 媒体信息
|
||||
media: MediaInfo = None
|
||||
|
||||
# 内置规则集
|
||||
rule_set: Dict[str, dict] = {
|
||||
@@ -37,8 +38,12 @@ class FilterModule(_ModuleBase):
|
||||
},
|
||||
# 中字
|
||||
"CNSUB": {
|
||||
"include": [r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
|
||||
"exclude": []
|
||||
"include": [
|
||||
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
|
||||
"exclude": [],
|
||||
"tmdb": {
|
||||
"original_language": "zh,cn"
|
||||
}
|
||||
},
|
||||
# 特效字幕
|
||||
"SPECSUB": {
|
||||
@@ -91,7 +96,7 @@ class FilterModule(_ModuleBase):
|
||||
},
|
||||
# 国语配音
|
||||
"CNVOI": {
|
||||
"include": [r'[国國][语語]配音|[国國]配'],
|
||||
"include": [r'[国國][语語]配音|[国國]配|[国國][语語]'],
|
||||
"exclude": []
|
||||
}
|
||||
}
|
||||
@@ -107,16 +112,19 @@ class FilterModule(_ModuleBase):
|
||||
|
||||
def filter_torrents(self, rule_string: str,
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list] = None) -> List[TorrentInfo]:
|
||||
season_episodes: Dict[int, list] = None,
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子资源
|
||||
:param rule_string: 过滤规则
|
||||
:param torrent_list: 资源列表
|
||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||
:param mediainfo: 媒体信息
|
||||
:return: 过滤后的资源列表,添加资源优先级
|
||||
"""
|
||||
if not rule_string:
|
||||
return torrent_list
|
||||
self.media = mediainfo
|
||||
# 返回种子列表
|
||||
ret_torrents = []
|
||||
for torrent in torrent_list:
|
||||
@@ -215,6 +223,11 @@ class FilterModule(_ModuleBase):
|
||||
if not self.rule_set.get(rule_name):
|
||||
# 规则不存在
|
||||
return False
|
||||
# TMDB规则
|
||||
tmdb = self.rule_set[rule_name].get("tmdb")
|
||||
# 符合TMDB规则的直接返回True,即不过滤
|
||||
if tmdb and self.__match_tmdb(tmdb):
|
||||
return True
|
||||
# 包含规则项
|
||||
includes = self.rule_set[rule_name].get("include") or []
|
||||
# 排除规则项
|
||||
@@ -236,3 +249,44 @@ class FilterModule(_ModuleBase):
|
||||
# FREE规则不匹配
|
||||
return False
|
||||
return True
|
||||
|
||||
def __match_tmdb(self, tmdb: dict) -> bool:
|
||||
"""
|
||||
判断种子是否匹配TMDB规则
|
||||
"""
|
||||
def __get_media_value(key: str):
|
||||
try:
|
||||
return getattr(self.media, key)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
if not self.media:
|
||||
return False
|
||||
|
||||
for attr, value in tmdb.items():
|
||||
if not value:
|
||||
continue
|
||||
# 获取media信息的值
|
||||
info_value = __get_media_value(attr)
|
||||
if not info_value:
|
||||
# 没有该值,不匹配
|
||||
return False
|
||||
elif attr == "production_countries":
|
||||
# 国家信息
|
||||
info_values = [str(val.get("iso_3166_1")).upper() for val in info_value]
|
||||
else:
|
||||
# media信息转化为数组
|
||||
if isinstance(info_value, list):
|
||||
info_values = [str(val).upper() for val in info_value]
|
||||
else:
|
||||
info_values = [str(info_value).upper()]
|
||||
# 过滤值转化为数组
|
||||
if value.find(",") != -1:
|
||||
values = [str(val).upper() for val in value.split(",")]
|
||||
else:
|
||||
values = [str(value).upper()]
|
||||
# 没有交集为不匹配
|
||||
if not set(values).intersection(set(info_values)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -6,6 +6,7 @@ from ruamel.yaml import CommentedMap
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.indexer.mtorrent import MTorrentSpider
|
||||
from app.modules.indexer.spider import TorrentSpider
|
||||
from app.modules.indexer.tnode import TNodeSpider
|
||||
from app.modules.indexer.torrentleech import TorrentLeech
|
||||
@@ -74,6 +75,12 @@ class IndexerModule(_ModuleBase):
|
||||
keyword=search_word,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "mTorrent":
|
||||
error_flag, result_array = MTorrentSpider(site).search(
|
||||
keyword=search_word,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page
|
||||
)
|
||||
else:
|
||||
error_flag, result_array = self.__spider_search(
|
||||
keyword=search_word,
|
||||
|
||||
144
app/modules/indexer/mtorrent.py
Normal file
144
app/modules/indexer/mtorrent.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Tuple, List
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class MTorrentSpider:
|
||||
_indexerid = None
|
||||
_domain = None
|
||||
_name = ""
|
||||
_proxy = None
|
||||
_cookie = None
|
||||
_ua = None
|
||||
_size = 100
|
||||
_searchurl = "%sapi/torrent/search"
|
||||
_downloadurl = "%sapi/torrent/genDlToken"
|
||||
_pageurl = "%sdetail/%s"
|
||||
|
||||
# 电影分类
|
||||
_movie_category = ['401', '419', '420', '421', '439', '405', '404']
|
||||
_tv_category = ['403', '402', '435', '438', '404', '405']
|
||||
|
||||
# 标签
|
||||
_labels = {
|
||||
0: "",
|
||||
4: "中字",
|
||||
6: "国配",
|
||||
}
|
||||
|
||||
def __init__(self, indexer: CommentedMap):
|
||||
if indexer:
|
||||
self._indexerid = indexer.get('id')
|
||||
self._domain = indexer.get('domain')
|
||||
self._searchurl = self._searchurl % self._domain
|
||||
self._name = indexer.get('name')
|
||||
if indexer.get('proxy'):
|
||||
self._proxy = settings.PROXY
|
||||
self._cookie = indexer.get('cookie')
|
||||
self._ua = indexer.get('ua')
|
||||
|
||||
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
|
||||
if not mtype:
|
||||
categories = []
|
||||
elif mtype == MediaType.TV:
|
||||
categories = self._tv_category
|
||||
else:
|
||||
categories = self._movie_category
|
||||
params = {
|
||||
"keyword": keyword,
|
||||
"categories": categories,
|
||||
"pageNumber": int(page) + 1,
|
||||
"pageSize": self._size,
|
||||
"visible": 1
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"{self._ua}"
|
||||
},
|
||||
cookies=self._cookie,
|
||||
proxies=self._proxy,
|
||||
referer=f"{self._domain}browse",
|
||||
timeout=30
|
||||
).post_res(url=self._searchurl, json=params)
|
||||
torrents = []
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get('data', {}).get("data") or []
|
||||
for result in results:
|
||||
torrent = {
|
||||
'title': result.get('name'),
|
||||
'description': result.get('smallDescr'),
|
||||
'enclosure': self.__get_download_url(result.get('id')),
|
||||
'pubdate': StringUtils.format_timestamp(result.get('createdDate')),
|
||||
'size': result.get('size'),
|
||||
'seeders': result.get('status', {}).get("seeders"),
|
||||
'peers': result.get('status', {}).get("leechers"),
|
||||
'grabs': result.get('status', {}).get("timesCompleted"),
|
||||
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('status', {}).get("discount")),
|
||||
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('status', {}).get("discount")),
|
||||
'page_url': self._pageurl % (self._domain, result.get('id')),
|
||||
'imdbid': self.__find_imdbid(result.get('imdb')),
|
||||
'labels': [self._labels.get(result.get('labels') or 0)] if result.get('labels') else []
|
||||
}
|
||||
torrents.append(torrent)
|
||||
elif res is not None:
|
||||
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
|
||||
return True, []
|
||||
else:
|
||||
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
|
||||
return True, []
|
||||
return False, torrents
|
||||
|
||||
@staticmethod
|
||||
def __find_imdbid(imdb: str) -> str:
|
||||
if imdb:
|
||||
m = re.search(r"tt\d+", imdb)
|
||||
if m:
|
||||
return m.group(0)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_downloadvolumefactor(discount: str) -> float:
|
||||
discount_dict = {
|
||||
"FREE": 0,
|
||||
"PERCENT_50": 0.5,
|
||||
"PERCENT_70": 0.3,
|
||||
"_2X_FREE": 0,
|
||||
"_2X_PERCENT_50": 0.5
|
||||
}
|
||||
if discount:
|
||||
return discount_dict.get(discount, 1)
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def __get_uploadvolumefactor(discount: str) -> float:
|
||||
uploadvolumefactor_dict = {
|
||||
"_2X": 2.0,
|
||||
"_2X_FREE": 2.0,
|
||||
"_2X_PERCENT_50": 2.0
|
||||
}
|
||||
if discount:
|
||||
return uploadvolumefactor_dict.get(discount, 1)
|
||||
return 1
|
||||
|
||||
def __get_download_url(self, torrent_id: str) -> str:
|
||||
url = self._downloadurl % self._domain
|
||||
params = {
|
||||
'method': 'post',
|
||||
'params': {
|
||||
'id': torrent_id
|
||||
},
|
||||
'result': 'data'
|
||||
}
|
||||
# base64编码
|
||||
base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8')
|
||||
return f"[{base64_str}]{url}"
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union, Any, List, Generator
|
||||
|
||||
@@ -26,7 +25,7 @@ class JellyfinModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.jellyfin.is_inactive():
|
||||
self.jellyfin = Jellyfin()
|
||||
self.jellyfin.reconnect()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
@@ -41,7 +40,7 @@ class JellyfinModule(_ModuleBase):
|
||||
# Jellyfin认证
|
||||
return self.jellyfin.authenticate(name, password)
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -49,7 +48,7 @@ class JellyfinModule(_ModuleBase):
|
||||
:param args: 请求参数
|
||||
:return: 字典,解析为消息时需要包含:title、text、image
|
||||
"""
|
||||
return self.jellyfin.get_webhook_message(json.loads(body))
|
||||
return self.jellyfin.get_webhook_message(body)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
@@ -83,32 +82,34 @@ class JellyfinModule(_ModuleBase):
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_path: 文件路径
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.jellyfin.refresh_root_library()
|
||||
self.jellyfin.refresh_root_library()
|
||||
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> List[schemas.Statistic]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.jellyfin.get_medias_count()
|
||||
user_count = self.jellyfin.get_user_count()
|
||||
return schemas.Statistic(
|
||||
return [schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=user_count or 0
|
||||
)
|
||||
)]
|
||||
|
||||
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
librarys = self.jellyfin.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
@@ -120,10 +121,12 @@ class JellyfinModule(_ModuleBase):
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
|
||||
def mediaserver_items(self, library_id: str) -> Generator:
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
媒体库项目列表
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
items = self.jellyfin.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
@@ -140,10 +143,13 @@ class JellyfinModule(_ModuleBase):
|
||||
path=item.get("path"),
|
||||
)
|
||||
|
||||
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
seasoninfo = self.jellyfin.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
|
||||
@@ -33,6 +33,13 @@ class Jellyfin(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.user else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.user = self.get_user()
|
||||
self.serverid = self.get_server_id()
|
||||
|
||||
def __get_jellyfin_librarys(self) -> List[dict]:
|
||||
"""
|
||||
获取Jellyfin媒体库的信息
|
||||
@@ -387,7 +394,7 @@ class Jellyfin(metaclass=Singleton):
|
||||
logger.error(f"连接Library/Refresh出错:" + str(e))
|
||||
return False
|
||||
|
||||
def get_webhook_message(self, message: dict) -> WebhookEventInfo:
|
||||
def get_webhook_message(self, body: any) -> Optional[WebhookEventInfo]:
|
||||
"""
|
||||
解析Jellyfin报文
|
||||
{
|
||||
@@ -450,9 +457,21 @@ class Jellyfin(metaclass=Singleton):
|
||||
"UserId": "9783d2432b0d40a8a716b6aa46xxxxx"
|
||||
}
|
||||
"""
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
message = json.loads(body)
|
||||
except Exception as e:
|
||||
logger.debug(f"解析Jellyfin Webhook报文出错:" + str(e))
|
||||
return None
|
||||
if not message:
|
||||
return None
|
||||
logger.info(f"接收到jellyfin webhook:{message}")
|
||||
eventType = message.get('NotificationType')
|
||||
if not eventType:
|
||||
return None
|
||||
eventItem = WebhookEventInfo(
|
||||
event=message.get('NotificationType', ''),
|
||||
event=eventType,
|
||||
channel="jellyfin"
|
||||
)
|
||||
eventItem.item_id = message.get('ItemId')
|
||||
|
||||
@@ -29,9 +29,9 @@ class PlexModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.plex.is_inactive():
|
||||
self.plex = Plex()
|
||||
self.plex.reconnect()
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -39,7 +39,7 @@ class PlexModule(_ModuleBase):
|
||||
:param args: 请求参数
|
||||
:return: 字典,解析为消息时需要包含:title、text、image
|
||||
"""
|
||||
return self.plex.get_webhook_message(form.get("payload"))
|
||||
return self.plex.get_webhook_message(form)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
@@ -77,7 +77,7 @@ class PlexModule(_ModuleBase):
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -93,24 +93,26 @@ class PlexModule(_ModuleBase):
|
||||
target_path=file_path
|
||||
)
|
||||
]
|
||||
return self.plex.refresh_library_by_items(items)
|
||||
self.plex.refresh_library_by_items(items)
|
||||
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> List[schemas.Statistic]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.plex.get_medias_count()
|
||||
return schemas.Statistic(
|
||||
return [schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=1
|
||||
)
|
||||
)]
|
||||
|
||||
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
librarys = self.plex.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
@@ -122,10 +124,12 @@ class PlexModule(_ModuleBase):
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
|
||||
def mediaserver_items(self, library_id: str) -> Generator:
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
媒体库项目列表
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
items = self.plex.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
@@ -142,10 +146,13 @@ class PlexModule(_ModuleBase):
|
||||
path=item.get("path"),
|
||||
)
|
||||
|
||||
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
seasoninfo = self.plex.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
|
||||
@@ -38,6 +38,17 @@ class Plex(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self._plex else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
try:
|
||||
self._plex = PlexServer(self._host, self._token)
|
||||
self._libraries = self._plex.library.sections()
|
||||
except Exception as e:
|
||||
self._plex = None
|
||||
logger.error(f"Plex服务器连接失败:{str(e)}")
|
||||
|
||||
def get_librarys(self):
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
@@ -278,7 +289,7 @@ class Plex(metaclass=Singleton):
|
||||
if hasattr(lib, "locations") and lib.locations:
|
||||
for location in lib.locations:
|
||||
if is_subpath(path, Path(location)):
|
||||
return lib.key, location
|
||||
return lib.key, str(path)
|
||||
except Exception as err:
|
||||
logger.error(f"查找媒体库出错:{err}")
|
||||
return "", ""
|
||||
@@ -353,7 +364,7 @@ class Plex(metaclass=Singleton):
|
||||
logger.error(f"获取媒体库列表出错:{err}")
|
||||
yield {}
|
||||
|
||||
def get_webhook_message(self, message_str: str) -> WebhookEventInfo:
|
||||
def get_webhook_message(self, form: any) -> Optional[WebhookEventInfo]:
|
||||
"""
|
||||
解析Plex报文
|
||||
eventItem 字段的含义
|
||||
@@ -457,9 +468,21 @@ class Plex(metaclass=Singleton):
|
||||
}
|
||||
}
|
||||
"""
|
||||
message = json.loads(message_str)
|
||||
if not form:
|
||||
return None
|
||||
payload = form.get("payload")
|
||||
if not payload:
|
||||
return None
|
||||
try:
|
||||
message = json.loads(payload)
|
||||
except Exception as e:
|
||||
logger.debug(f"解析plex webhook出错:{str(e)}")
|
||||
return None
|
||||
eventType = message.get('event')
|
||||
if not eventType:
|
||||
return None
|
||||
logger.info(f"接收到plex webhook:{message}")
|
||||
eventItem = WebhookEventInfo(event=message.get('event', ''), channel="plex")
|
||||
eventItem = WebhookEventInfo(event=eventType, channel="plex")
|
||||
if message.get('Metadata'):
|
||||
if message.get('Metadata', {}).get('type') == 'episode':
|
||||
eventItem.item_type = "TV"
|
||||
|
||||
@@ -34,7 +34,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if self.qbittorrent.is_inactive():
|
||||
self.qbittorrent = Qbittorrent()
|
||||
self.qbittorrent.reconnect()
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
|
||||
|
||||
@@ -35,6 +35,12 @@ class Qbittorrent(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.qbc else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.qbc = self.__login_qbittorrent()
|
||||
|
||||
def __login_qbittorrent(self) -> Optional[Client]:
|
||||
"""
|
||||
连接qbittorrent
|
||||
|
||||
85
app/modules/synologychat/__init__.py
Normal file
85
app/modules/synologychat/__init__.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from typing import Optional, Union, List, Tuple, Any
|
||||
|
||||
from app.core.context import MediaInfo, Context
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, checkMessage
|
||||
from app.modules.synologychat.synologychat import SynologyChat
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||
|
||||
|
||||
class SynologyChatModule(_ModuleBase):
|
||||
synologychat: SynologyChat = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.synologychat = SynologyChat()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "synologychat"
|
||||
|
||||
def message_parser(self, body: Any, form: Any,
|
||||
args: Any) -> Optional[CommingMessage]:
|
||||
"""
|
||||
解析消息内容,返回字典,注意以下约定值:
|
||||
userid: 用户ID
|
||||
username: 用户名
|
||||
text: 内容
|
||||
:param body: 请求体
|
||||
:param form: 表单
|
||||
:param args: 参数
|
||||
:return: 渠道、消息体
|
||||
"""
|
||||
try:
|
||||
message: dict = form
|
||||
if not message:
|
||||
return None
|
||||
# 校验token
|
||||
token = message.get("token")
|
||||
if not token or not self.synologychat.check_token(token):
|
||||
return None
|
||||
# 文本
|
||||
text = message.get("text")
|
||||
# 用户ID
|
||||
user_id = int(message.get("user_id"))
|
||||
# 获取用户名
|
||||
user_name = message.get("username")
|
||||
if text and user_id:
|
||||
logger.info(f"收到SynologyChat消息:userid={user_id}, username={user_name}, text={text}")
|
||||
return CommingMessage(channel=MessageChannel.SynologyChat,
|
||||
userid=user_id, username=user_name, text=text)
|
||||
except Exception as err:
|
||||
logger.debug(f"解析SynologyChat消息失败:{err}")
|
||||
return None
|
||||
|
||||
@checkMessage(MessageChannel.SynologyChat)
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
发送消息
|
||||
:param message: 消息体
|
||||
:return: 成功或失败
|
||||
"""
|
||||
self.synologychat.send_msg(title=message.title, text=message.text,
|
||||
image=message.image, userid=message.userid)
|
||||
|
||||
@checkMessage(MessageChannel.SynologyChat)
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
||||
"""
|
||||
发送媒体信息选择列表
|
||||
:param message: 消息体
|
||||
:param medias: 媒体列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.synologychat.send_meidas_msg(title=message.title, medias=medias,
|
||||
userid=message.userid)
|
||||
|
||||
@checkMessage(MessageChannel.SynologyChat)
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
||||
"""
|
||||
发送种子信息选择列表
|
||||
:param message: 消息体
|
||||
:param torrents: 种子列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
|
||||
203
app/modules/synologychat/synologychat.py
Normal file
203
app/modules/synologychat/synologychat.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, List
|
||||
from urllib.parse import quote
|
||||
from threading import Lock
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
lock = Lock()
|
||||
|
||||
|
||||
class SynologyChat(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
self._req = RequestUtils(content_type="application/x-www-form-urlencoded")
|
||||
self._webhook_url = settings.SYNOLOGYCHAT_WEBHOOK
|
||||
self._token = settings.SYNOLOGYCHAT_TOKEN
|
||||
if self._webhook_url:
|
||||
self._domain = StringUtils.get_base_url(self._webhook_url)
|
||||
|
||||
def check_token(self, token: str) -> bool:
|
||||
return True if token == self._token else False
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param image: 消息图片地址
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:user_id: 发送消息的目标用户ID,为空则发给管理员
|
||||
"""
|
||||
if not title and not text:
|
||||
logger.error("标题和内容不能同时为空")
|
||||
return False
|
||||
if not self._webhook_url or not self._token:
|
||||
return False
|
||||
try:
|
||||
# 拼装消息内容
|
||||
titles = str(title).split('\n')
|
||||
if len(titles) > 1:
|
||||
title = titles[0]
|
||||
if not text:
|
||||
text = "\n".join(titles[1:])
|
||||
else:
|
||||
text = f"%s\n%s" % ("\n".join(titles[1:]), text)
|
||||
|
||||
if text:
|
||||
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
|
||||
else:
|
||||
caption = title
|
||||
payload_data = {'text': quote(caption)}
|
||||
if image:
|
||||
payload_data['file_url'] = quote(image)
|
||||
if userid:
|
||||
payload_data['user_ids'] = [int(userid)]
|
||||
else:
|
||||
userids = self.__get_bot_users()
|
||||
if not userids:
|
||||
logger.error("SynologyChat机器人没有对任何用户可见")
|
||||
return False
|
||||
payload_data['user_ids'] = userids
|
||||
|
||||
return self.__send_request(payload_data)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
|
||||
return False
|
||||
|
||||
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送列表类消息
|
||||
"""
|
||||
if not medias:
|
||||
return False
|
||||
if not self._webhook_url or not self._token:
|
||||
return False
|
||||
try:
|
||||
if not title or not isinstance(medias, list):
|
||||
return False
|
||||
index, image, caption = 1, "", "*%s*" % title
|
||||
for media in medias:
|
||||
if not image:
|
||||
image = media.get_message_image()
|
||||
if media.vote_average:
|
||||
caption = "%s\n%s. <%s|%s>\n_%s,%s_" % (caption,
|
||||
index,
|
||||
media.detail_link,
|
||||
media.title_year,
|
||||
f"类型:{media.type.value}",
|
||||
f"评分:{media.vote_average}")
|
||||
else:
|
||||
caption = "%s\n%s. <%s|%s>\n_%s_" % (caption,
|
||||
index,
|
||||
media.detail_link,
|
||||
media.title_year,
|
||||
f"类型:{media.type.value}")
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
userids = [int(userid)]
|
||||
else:
|
||||
userids = self.__get_bot_users()
|
||||
payload_data = {
|
||||
"text": quote(caption),
|
||||
"user_ids": userids
|
||||
}
|
||||
return self.__send_request(payload_data)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
|
||||
return False
|
||||
|
||||
def send_torrents_msg(self, torrents: List[Context],
|
||||
userid: str = "", title: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送列表消息
|
||||
"""
|
||||
if not self._webhook_url or not self._token:
|
||||
return None
|
||||
|
||||
if not torrents:
|
||||
return False
|
||||
|
||||
try:
|
||||
index, caption = 1, "*%s*" % title
|
||||
for context in torrents:
|
||||
torrent = context.torrent_info
|
||||
site_name = torrent.site_name
|
||||
meta = MetaInfo(torrent.title, torrent.description)
|
||||
link = torrent.page_url
|
||||
title = f"{meta.season_episode} " \
|
||||
f"{meta.resource_term} " \
|
||||
f"{meta.video_term} " \
|
||||
f"{meta.release_group}"
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
free = torrent.volume_factor
|
||||
seeder = f"{torrent.seeders}↑"
|
||||
description = torrent.description
|
||||
caption = f"{caption}\n{index}.【{site_name}】<{link}|{title}> " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
|
||||
f"_{description}_"
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
userids = [int(userid)]
|
||||
else:
|
||||
userids = self.__get_bot_users()
|
||||
|
||||
payload_data = {
|
||||
"text": quote(caption),
|
||||
"user_ids": userids
|
||||
}
|
||||
return self.__send_request(payload_data)
|
||||
except Exception as msg_e:
|
||||
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
|
||||
return False
|
||||
|
||||
def __get_bot_users(self):
|
||||
"""
|
||||
查询机器人可见的用户列表
|
||||
"""
|
||||
if not self._domain or not self._token:
|
||||
return []
|
||||
req_url = f"{self._domain}" \
|
||||
f"/webapi/entry.cgi?api=SYNO.Chat.External&method=user_list&version=2&token=" \
|
||||
f"{self._token}"
|
||||
ret = self._req.get_res(url=req_url)
|
||||
if ret and ret.status_code == 200:
|
||||
users = ret.json().get("data", {}).get("users", []) or []
|
||||
return [user.get("user_id") for user in users]
|
||||
else:
|
||||
return []
|
||||
|
||||
def __send_request(self, payload_data):
|
||||
"""
|
||||
发送消息请求
|
||||
"""
|
||||
payload = f"payload={json.dumps(payload_data)}"
|
||||
ret = self._req.post_res(url=self._webhook_url, data=payload)
|
||||
if ret and ret.status_code == 200:
|
||||
result = ret.json()
|
||||
if result:
|
||||
errno = result.get('error', {}).get('code')
|
||||
errmsg = result.get('error', {}).get('errors')
|
||||
if not errno:
|
||||
return True
|
||||
logger.error(f"SynologyChat返回错误:{errno}-{errmsg}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"SynologyChat返回:{ret.text}")
|
||||
return False
|
||||
elif ret is not None:
|
||||
logger.error(f"SynologyChat请求失败,错误码:{ret.status_code},错误原因:{ret.reason}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"SynologyChat请求失败,未获取到返回信息")
|
||||
return False
|
||||
@@ -52,7 +52,7 @@ class Telegram(metaclass=Singleton):
|
||||
定义线程函数来运行 infinity_polling
|
||||
"""
|
||||
try:
|
||||
_bot.infinity_polling(long_polling_timeout=10)
|
||||
_bot.infinity_polling(long_polling_timeout=30, logger_level=None)
|
||||
except Exception as err:
|
||||
logger.error(f"Telegram消息接收服务异常:{err}")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TmdbScraper:
|
||||
@@ -121,8 +122,25 @@ class TmdbScraper:
|
||||
except Exception as e:
|
||||
logger.error(f"{file_path} 刮削失败:{e}")
|
||||
|
||||
@staticmethod
|
||||
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
||||
def __get_chinese_name(self, person: dict):
|
||||
"""
|
||||
获取TMDB别名中的中文名
|
||||
"""
|
||||
if not person.get("id"):
|
||||
return ""
|
||||
try:
|
||||
personinfo = self.tmdb.get_person_detail(person.get("id"))
|
||||
if personinfo:
|
||||
also_known_as = personinfo.get("also_known_as") or []
|
||||
if also_known_as:
|
||||
for name in also_known_as:
|
||||
if name and StringUtils.is_chinese(name):
|
||||
return name
|
||||
except Exception as err:
|
||||
logger.error(f"获取人物中文名失败:{err}")
|
||||
return person.get("name") or ""
|
||||
|
||||
def __gen_common_nfo(self, mediainfo: MediaInfo, doc, root):
|
||||
"""
|
||||
生成公共NFO
|
||||
"""
|
||||
@@ -155,18 +173,19 @@ class TmdbScraper:
|
||||
xoutline.appendChild(doc.createCDATASection(mediainfo.overview or ""))
|
||||
# 导演
|
||||
for director in mediainfo.directors:
|
||||
xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "")
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(director)
|
||||
xdirector = DomUtils.add_node(doc, root, "director", cn_name)
|
||||
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
|
||||
# 演员
|
||||
for actor in mediainfo.actors:
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(actor)
|
||||
xactor = DomUtils.add_node(doc, root, "actor")
|
||||
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
|
||||
DomUtils.add_node(doc, xactor, "name", cn_name)
|
||||
DomUtils.add_node(doc, xactor, "type", "Actor")
|
||||
DomUtils.add_node(doc, xactor, "role", actor.get("character") or actor.get("role") or "")
|
||||
DomUtils.add_node(doc, xactor, "order", actor.get("order") if actor.get("order") is not None else "")
|
||||
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
|
||||
DomUtils.add_node(doc, xactor, "thumb", actor.get('image'))
|
||||
DomUtils.add_node(doc, xactor, "profile", actor.get('profile'))
|
||||
# 风格
|
||||
genres = mediainfo.genres or []
|
||||
for genre in genres:
|
||||
@@ -307,14 +326,18 @@ class TmdbScraper:
|
||||
directors = episodeinfo.get("crew") or []
|
||||
for director in directors:
|
||||
if director.get("known_for_department") == "Directing":
|
||||
xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "")
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(director)
|
||||
xdirector = DomUtils.add_node(doc, root, "director", cn_name)
|
||||
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
|
||||
# 演员
|
||||
actors = episodeinfo.get("guest_stars") or []
|
||||
for actor in actors:
|
||||
if actor.get("known_for_department") == "Acting":
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(actor)
|
||||
xactor = DomUtils.add_node(doc, root, "actor")
|
||||
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
|
||||
DomUtils.add_node(doc, xactor, "name", cn_name)
|
||||
DomUtils.add_node(doc, xactor, "type", "Actor")
|
||||
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
|
||||
# 保存文件
|
||||
@@ -336,6 +359,8 @@ class TmdbScraper:
|
||||
logger.info(f"图片已保存:{file_path}")
|
||||
else:
|
||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||
except RequestException as err:
|
||||
raise err
|
||||
except Exception as err:
|
||||
logger.error(f"{file_path.stem}图片下载失败:{err}")
|
||||
|
||||
|
||||
@@ -1136,6 +1136,26 @@ class TmdbHelper:
|
||||
def get_person_detail(self, person_id: int) -> dict:
|
||||
"""
|
||||
获取人物详情
|
||||
{
|
||||
"adult": false,
|
||||
"also_known_as": [
|
||||
"Michael Chen",
|
||||
"Chen He",
|
||||
"陈赫"
|
||||
],
|
||||
"biography": "陈赫,xxx",
|
||||
"birthday": "1985-11-09",
|
||||
"deathday": null,
|
||||
"gender": 2,
|
||||
"homepage": "https://movie.douban.com/celebrity/1313841/",
|
||||
"id": 1397016,
|
||||
"imdb_id": "nm4369305",
|
||||
"known_for_department": "Acting",
|
||||
"name": "Chen He",
|
||||
"place_of_birth": "Fuzhou,Fujian Province,China",
|
||||
"popularity": 9.228,
|
||||
"profile_path": "/2Bk39zVuoHUNHtpZ7LVg7OgkDd4.jpg"
|
||||
}
|
||||
"""
|
||||
if not self.person:
|
||||
return {}
|
||||
|
||||
@@ -30,6 +30,7 @@ class TMDb(object):
|
||||
self.__class__._session = requests.Session() if session is None else session
|
||||
self._remaining = 40
|
||||
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"
|
||||
@@ -131,12 +132,14 @@ class TMDb(object):
|
||||
|
||||
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
|
||||
def cached_request(self, method, url, data, json):
|
||||
return requests.request(method, url, data=data, json=json, proxies=self.proxies)
|
||||
return requests.request(method, url, data=data, json=json,
|
||||
timeout=self._timeout, proxies=self.proxies)
|
||||
|
||||
def cache_clear(self):
|
||||
return self.cached_request.cache_clear()
|
||||
|
||||
def _request_obj(self, action, params="", call_cached=True, method="GET", data=None, json=None, key=None):
|
||||
def _request_obj(self, action, params="", call_cached=True,
|
||||
method="GET", data=None, json=None, key=None):
|
||||
if self.api_key is None or self.api_key == "":
|
||||
raise TMDbException("No API key found.")
|
||||
|
||||
@@ -151,7 +154,8 @@ class TMDb(object):
|
||||
if self.cache and self.obj_cached and call_cached and method != "POST":
|
||||
req = self.cached_request(method, url, data, json)
|
||||
else:
|
||||
req = self.__class__._session.request(method, url, data=data, json=json, proxies=self.proxies)
|
||||
req = self.__class__._session.request(method, url, data=data, json=json,
|
||||
timeout=self._timeout, proxies=self.proxies)
|
||||
|
||||
headers = req.headers
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class TransmissionModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.transmission.is_inactive():
|
||||
self.transmission = Transmission()
|
||||
self.transmission.reconnect()
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
|
||||
@@ -131,7 +131,7 @@ class TransmissionModule(_ModuleBase):
|
||||
title=torrent.name,
|
||||
path=Path(torrent.download_dir) / torrent.name,
|
||||
hash=torrent.hashString,
|
||||
tags=torrent.labels
|
||||
tags=",".join(torrent.labels or [])
|
||||
))
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
|
||||
@@ -56,6 +56,12 @@ class Transmission(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.trc else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.trc = self.__login_transmission()
|
||||
|
||||
def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Tuple[List[Torrent], bool]:
|
||||
"""
|
||||
|
||||
551
app/plugins/autoclean/__init__.py
Normal file
551
app/plugins/autoclean/__init__.py
Normal file
@@ -0,0 +1,551 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType, DownloadHistory
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class AutoClean(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "定时清理媒体库"
|
||||
# 插件描述
|
||||
plugin_desc = "定时清理用户下载的种子、源文件、媒体库文件。"
|
||||
# 插件图标
|
||||
plugin_icon = "clean.png"
|
||||
# 主题色
|
||||
plugin_color = "#3377ed"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "autoclean_"
|
||||
# 加载顺序
|
||||
plugin_order = 23
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_cron = None
|
||||
_type = None
|
||||
_onlyonce = False
|
||||
_notify = False
|
||||
_cleantype = None
|
||||
_cleanuser = None
|
||||
_cleandate = None
|
||||
_downloadhis = None
|
||||
_transferhis = None
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._notify = config.get("notify")
|
||||
self._cleantype = config.get("cleantype")
|
||||
self._cleanuser = config.get("cleanuser")
|
||||
self._cleandate = config.get("cleandate")
|
||||
|
||||
# 加载模块
|
||||
if self._enabled:
|
||||
self._downloadhis = DownloadHistoryOper(self.db)
|
||||
self._transferhis = TransferHistoryOper(self.db)
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self._cron:
|
||||
try:
|
||||
self._scheduler.add_job(func=self.__clean,
|
||||
trigger=CronTrigger.from_crontab(self._cron),
|
||||
name="定时清理媒体库")
|
||||
except Exception as err:
|
||||
logger.error(f"定时任务配置错误:{err}")
|
||||
|
||||
if self._onlyonce:
|
||||
logger.info(f"定时清理媒体库服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__clean, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="定时清理媒体库")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"onlyonce": False,
|
||||
"cron": self._cron,
|
||||
"cleantype": self._cleantype,
|
||||
"enabled": self._enabled,
|
||||
"cleanuser": self._cleanuser,
|
||||
"cleandate": self._cleandate,
|
||||
"notify": self._notify,
|
||||
})
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __clean(self):
|
||||
"""
|
||||
定时清理媒体库
|
||||
"""
|
||||
if not self._cleandate:
|
||||
logger.error("未配置清理媒体库时间,停止运行")
|
||||
return
|
||||
|
||||
# 清理日期
|
||||
current_time = datetime.now()
|
||||
days_ago = current_time - timedelta(days=int(self._cleandate))
|
||||
clean_date = days_ago.strftime("%Y-%m-%d")
|
||||
|
||||
# 查询用户清理日期之后的下载历史
|
||||
if not self._cleanuser:
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date)
|
||||
logger.info(f'获取到日期 {clean_date} 之后的下载历史 {len(downloadhis_list)} 条')
|
||||
|
||||
self.__clean_history(date=clean_date, downloadhis_list=downloadhis_list)
|
||||
else:
|
||||
for userid in str(self._cleanuser).split(","):
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
|
||||
userid=userid)
|
||||
logger.info(
|
||||
f'获取到用户 {userid} 日期 {clean_date} 之后的下载历史 {len(downloadhis_list)} 条')
|
||||
self.__clean_history(date=clean_date, downloadhis_list=downloadhis_list, userid=userid)
|
||||
|
||||
def __clean_history(self, date: str, downloadhis_list: List[DownloadHistory], userid: str = None):
|
||||
"""
|
||||
清理下载历史、转移记录
|
||||
"""
|
||||
if not downloadhis_list:
|
||||
logger.warn(f"未获取到日期 {date} 之后的下载记录,停止运行")
|
||||
return
|
||||
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 创建一个字典来保存分组结果
|
||||
downloadhis_grouped_dict: Dict[tuple, List[DownloadHistory]] = defaultdict(list)
|
||||
# 遍历DownloadHistory对象列表
|
||||
for downloadhis in downloadhis_list:
|
||||
# 获取type和tmdbid的值
|
||||
dtype = downloadhis.type
|
||||
tmdbid = downloadhis.tmdbid
|
||||
|
||||
# 将DownloadHistory对象添加到对应分组的列表中
|
||||
downloadhis_grouped_dict[(dtype, tmdbid)].append(downloadhis)
|
||||
|
||||
# 输出分组结果
|
||||
for key, downloadhis_list in downloadhis_grouped_dict.items():
|
||||
logger.info(f"开始清理 {key}")
|
||||
|
||||
del_transferhis_cnt = 0
|
||||
del_media_name = downloadhis_list[0].title
|
||||
del_media_user = downloadhis_list[0].userid
|
||||
del_media_type = downloadhis_list[0].type
|
||||
del_media_year = downloadhis_list[0].year
|
||||
del_media_season = downloadhis_list[0].seasons
|
||||
del_media_episode = downloadhis_list[0].episodes
|
||||
del_image = downloadhis_list[0].image
|
||||
for downloadhis in downloadhis_list:
|
||||
if not downloadhis.download_hash:
|
||||
logger.debug(f'下载历史 {downloadhis.id} {downloadhis.title} 未获取到download_hash,跳过处理')
|
||||
continue
|
||||
# 根据hash获取转移记录
|
||||
transferhis_list = self._transferhis.list_by_hash(download_hash=downloadhis.download_hash)
|
||||
if not transferhis_list:
|
||||
logger.warn(f"下载历史 {downloadhis.download_hash} 未查询到转移记录,跳过处理")
|
||||
continue
|
||||
|
||||
for history in transferhis_list:
|
||||
# 册除媒体库文件
|
||||
if str(self._cleantype == "dest") or str(self._cleantype == "all"):
|
||||
TransferChain(self.db).delete_files(Path(history.dest))
|
||||
# 删除记录
|
||||
self._transferhis.delete(history.id)
|
||||
# 删除源文件
|
||||
if str(self._cleantype == "src") or str(self._cleantype == "all"):
|
||||
TransferChain(self.db).delete_files(Path(history.src))
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src
|
||||
}
|
||||
)
|
||||
|
||||
# 累加删除数量
|
||||
del_transferhis_cnt += len(transferhis_list)
|
||||
|
||||
# 发送消息
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title="【定时清理媒体库任务完成】",
|
||||
text=f"清理媒体名称 {del_media_name}\n"
|
||||
f"下载媒体用户 {del_media_user}\n"
|
||||
f"删除历史记录 {del_transferhis_cnt}",
|
||||
userid=userid)
|
||||
|
||||
history.append({
|
||||
"type": del_media_type,
|
||||
"title": del_media_name,
|
||||
"year": del_media_year,
|
||||
"season": del_media_season,
|
||||
"episode": del_media_episode,
|
||||
"image": del_image,
|
||||
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
|
||||
})
|
||||
|
||||
# 保存历史
|
||||
self.save_data("history", history)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '开启通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '0 0 ? ? ?'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'cleantype',
|
||||
'label': '清理方式',
|
||||
'items': [
|
||||
{'title': '媒体库文件', 'value': 'dest'},
|
||||
{'title': '源文件', 'value': 'src'},
|
||||
{'title': '所有文件', 'value': 'all'},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cleandate',
|
||||
'label': '清理媒体日期',
|
||||
'placeholder': '清理多少天之前的下载记录(天)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cleanuser',
|
||||
'label': '清理下载用户',
|
||||
'placeholder': '多个用户,分割'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"notify": False,
|
||||
"cleantype": "dest",
|
||||
"cron": "",
|
||||
"cleanuser": "",
|
||||
"cleandate": 30
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
htype = history.get("type")
|
||||
title = history.get("title")
|
||||
year = history.get("year")
|
||||
season = history.get("season")
|
||||
episode = history.get("episode")
|
||||
image = history.get("image")
|
||||
del_time = history.get("del_time")
|
||||
|
||||
if season:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'年份:{year}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'季:{season}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'集:{episode}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
else:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'年份:{year}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': image,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': sub_contents
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
@@ -14,6 +14,7 @@ from ruamel.yaml import CommentedMap
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.event import EventManager, eventmanager, Event
|
||||
from app.db.models.site import Site
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.module import ModuleHelper
|
||||
@@ -85,11 +86,18 @@ class AutoSignIn(_PluginBase):
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._notify = config.get("notify")
|
||||
self._queue_cnt = config.get("queue_cnt") or 5
|
||||
self._sign_sites = config.get("sign_sites")
|
||||
self._login_sites = config.get("login_sites")
|
||||
self._sign_sites = config.get("sign_sites") or []
|
||||
self._login_sites = config.get("login_sites") or []
|
||||
self._retry_keyword = config.get("retry_keyword")
|
||||
self._clean = config.get("clean")
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")]
|
||||
self._sign_sites = [site.get("id") for site in all_sites if site.get("id") in self._sign_sites]
|
||||
self._login_sites = [site.get("id") for site in all_sites if site.get("id") in self._login_sites]
|
||||
# 保存配置
|
||||
self.__update_config()
|
||||
|
||||
# 加载模块
|
||||
if self._enabled or self._onlyonce:
|
||||
|
||||
@@ -237,8 +245,8 @@ class AutoSignIn(_PluginBase):
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
# 站点的可选项
|
||||
site_options = [{"title": site.get("name"), "value": site.get("id")}
|
||||
for site in self.sites.get_indexers()]
|
||||
site_options = [{"title": site.name, "value": site.id}
|
||||
for site in Site.list_order_by_pri(self.db)]
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
@@ -592,11 +600,6 @@ class AutoSignIn(_PluginBase):
|
||||
# 今日没数据
|
||||
if not today_history or self._clean:
|
||||
logger.info(f"今日 {today} 未{type},开始{type}已选站点")
|
||||
# 过滤删除的站点
|
||||
if type == "签到":
|
||||
self._sign_sites = [site.get("id") for site in do_sites if site]
|
||||
if type == "登录":
|
||||
self._login_sites = [site.get("id") for site in do_sites if site]
|
||||
if self._clean:
|
||||
# 关闭开关
|
||||
self._clean = False
|
||||
@@ -946,30 +949,25 @@ class AutoSignIn(_PluginBase):
|
||||
site_id = event.event_data.get("site_id")
|
||||
config = self.get_config()
|
||||
if config:
|
||||
sign_sites = config.get("sign_sites")
|
||||
if sign_sites:
|
||||
if isinstance(sign_sites, str):
|
||||
sign_sites = [sign_sites]
|
||||
self._sign_sites = self.__remove_site_id(config.get("sign_sites") or [], site_id)
|
||||
self._login_sites = self.__remove_site_id(config.get("login_sites") or [], site_id)
|
||||
# 保存配置
|
||||
self.__update_config()
|
||||
|
||||
# 删除对应站点
|
||||
if site_id:
|
||||
sign_sites = [site for site in sign_sites if int(site) != int(site_id)]
|
||||
else:
|
||||
# 清空
|
||||
sign_sites = []
|
||||
def __remove_site_id(self, do_sites, site_id):
|
||||
if do_sites:
|
||||
if isinstance(do_sites, str):
|
||||
do_sites = [do_sites]
|
||||
|
||||
# 若无站点,则停止
|
||||
if len(sign_sites) == 0:
|
||||
self._enabled = False
|
||||
# 删除对应站点
|
||||
if site_id:
|
||||
do_sites = [site for site in do_sites if int(site) != int(site_id)]
|
||||
else:
|
||||
# 清空
|
||||
do_sites = []
|
||||
|
||||
# 保存配置
|
||||
self.update_config(
|
||||
{
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"cron": self._cron,
|
||||
"onlyonce": self._onlyonce,
|
||||
"queue_cnt": self._queue_cnt,
|
||||
"sign_sites": sign_sites
|
||||
}
|
||||
)
|
||||
# 若无站点,则停止
|
||||
if len(do_sites) == 0:
|
||||
self._enabled = False
|
||||
|
||||
return do_sites
|
||||
|
||||
@@ -131,130 +131,130 @@ class BestFilmVersion(_PluginBase):
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'only_once',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'webhook_enabled',
|
||||
'label': 'Webhook',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,'
|
||||
'Webhook需要在媒体服务器设置发送Webhook报文。'
|
||||
'Plex使用主动获取时,建议执行周期设置大于1小时,'
|
||||
'收藏Api调用Plex官网接口,有频率限制。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"cron": "*/30 * * * *",
|
||||
"webhook_enabled": False,
|
||||
"only_once": False
|
||||
}
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'only_once',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'webhook_enabled',
|
||||
'label': 'Webhook',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,'
|
||||
'Webhook需要在媒体服务器设置发送Webhook报文。'
|
||||
'Plex使用主动获取时,建议执行周期设置大于1小时,'
|
||||
'收藏Api调用Plex官网接口,有频率限制。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"cron": "*/30 * * * *",
|
||||
"webhook_enabled": False,
|
||||
"only_once": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
@@ -386,81 +386,85 @@ class BestFilmVersion(_PluginBase):
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
all_item = []
|
||||
# 媒体服务器类型,多个以,分隔
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
media_servers = settings.MEDIASERVER.split(',')
|
||||
|
||||
# 读取收藏
|
||||
if settings.MEDIASERVER == 'jellyfin':
|
||||
self.jellyfin_get_items(all_item)
|
||||
elif settings.MEDIASERVER == 'emby':
|
||||
self.emby_get_items(all_item)
|
||||
else:
|
||||
resp = self.plex_get_watchlist()
|
||||
if not resp:
|
||||
return
|
||||
all_item.extend(resp)
|
||||
all_items = {}
|
||||
for media_server in media_servers:
|
||||
if media_server == 'jellyfin':
|
||||
all_items['jellyfin'] = self.jellyfin_get_items()
|
||||
elif media_server == 'emby':
|
||||
all_items['emby'] = self.emby_get_items()
|
||||
else:
|
||||
all_items['plex'] = self.plex_get_watchlist()
|
||||
|
||||
def function(y, x):
|
||||
return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1]
|
||||
|
||||
# all_item 根据电影名去重
|
||||
result = reduce(function, all_item, [])
|
||||
|
||||
for data in result:
|
||||
# 检查缓存
|
||||
if data.get('Name') in caches:
|
||||
continue
|
||||
|
||||
# 获取详情
|
||||
if settings.MEDIASERVER == 'jellyfin':
|
||||
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
|
||||
elif settings.MEDIASERVER == 'emby':
|
||||
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
|
||||
else:
|
||||
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
|
||||
|
||||
logger.info(f'BestFilmVersion插件 item打印 {item_info_resp}')
|
||||
if not item_info_resp:
|
||||
continue
|
||||
|
||||
# 只接受Movie类型
|
||||
if data.get('Type') != 'Movie':
|
||||
continue
|
||||
|
||||
# 获取tmdb_id
|
||||
media_info_ids = item_info_resp.get('ExternalUrls')
|
||||
if not media_info_ids:
|
||||
continue
|
||||
for media_info_id in media_info_ids:
|
||||
if 'TheMovieDb' != media_info_id.get('Name'):
|
||||
# 处理所有结果
|
||||
for server, all_item in all_items.items():
|
||||
# all_item 根据电影名去重
|
||||
result = reduce(function, all_item, [])
|
||||
for data in result:
|
||||
# 检查缓存
|
||||
if data.get('Name') in caches:
|
||||
continue
|
||||
tmdb_find_id = str(media_info_id.get('Url')).split('/')
|
||||
tmdb_find_id.reverse()
|
||||
tmdb_id = tmdb_find_id[0]
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbID:{tmdb_id}')
|
||||
|
||||
# 获取详情
|
||||
if server == 'jellyfin':
|
||||
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
|
||||
elif server == 'emby':
|
||||
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
|
||||
else:
|
||||
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
|
||||
|
||||
logger.info(f'BestFilmVersion插件 item打印 {item_info_resp}')
|
||||
if not item_info_resp:
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.get('Name'))
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
|
||||
# 只接受Movie类型
|
||||
if data.get('Type') != 'Movie':
|
||||
continue
|
||||
|
||||
# 获取tmdb_id
|
||||
media_info_ids = item_info_resp.get('ExternalUrls')
|
||||
if not media_info_ids:
|
||||
continue
|
||||
for media_info_id in media_info_ids:
|
||||
if 'TheMovieDb' != media_info_id.get('Name'):
|
||||
continue
|
||||
tmdb_find_id = str(media_info_id.get('Url')).split('/')
|
||||
tmdb_find_id.reverse()
|
||||
tmdb_id = tmdb_find_id[0]
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbID:{tmdb_id}')
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.get('Name'))
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 保存缓存
|
||||
@@ -468,13 +472,14 @@ class BestFilmVersion(_PluginBase):
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def jellyfin_get_items(self, all_item):
|
||||
def jellyfin_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
users_url = "{HOST}Users?&apikey={APIKEY}"
|
||||
users = self.get_users(Jellyfin().get_data(users_url))
|
||||
if not users:
|
||||
logger.info(f"bestfilmversion/users_url: {users_url}")
|
||||
return
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "{HOST}Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
@@ -490,14 +495,16 @@ class BestFilmVersion(_PluginBase):
|
||||
resp = self.get_items(Jellyfin().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
all_item.extend(resp)
|
||||
all_items.extend(resp)
|
||||
return all_items
|
||||
|
||||
def emby_get_items(self, all_item):
|
||||
def emby_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
get_users_url = "{HOST}Users?&api_key={APIKEY}"
|
||||
users = self.get_users(Emby().get_data(get_users_url))
|
||||
if not users:
|
||||
return
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "{HOST}emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
@@ -512,7 +519,8 @@ class BestFilmVersion(_PluginBase):
|
||||
resp = self.get_items(Emby().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
all_item.extend(resp)
|
||||
all_items.extend(resp)
|
||||
return all_items
|
||||
|
||||
@staticmethod
|
||||
def get_items(resp: Response):
|
||||
@@ -538,7 +546,7 @@ class BestFilmVersion(_PluginBase):
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def plex_get_watchlist():
|
||||
def plex_get_watchlist() -> List[dict]:
|
||||
# 根据加入日期 降序排序
|
||||
url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \
|
||||
f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \
|
||||
|
||||
@@ -43,7 +43,7 @@ class BrushFlow(_PluginBase):
|
||||
# 加载顺序
|
||||
plugin_order = 21
|
||||
# 可使用的用户级别
|
||||
auth_level = 3
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
siteshelper = None
|
||||
@@ -120,6 +120,9 @@ class BrushFlow(_PluginBase):
|
||||
self.save_data("statistic", {})
|
||||
# 清除种子记录
|
||||
self.save_data("torrents", {})
|
||||
# 关闭一次性开关
|
||||
self._clear_task = False
|
||||
self.__update_config()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
@@ -729,6 +732,7 @@ class BrushFlow(_PluginBase):
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"onlyonce": False,
|
||||
"clear_task": False,
|
||||
"freeleech": "free"
|
||||
}
|
||||
|
||||
@@ -1218,7 +1222,8 @@ class BrushFlow(_PluginBase):
|
||||
"seed_inactivetime": self._seed_inactivetime,
|
||||
"up_speed": self._up_speed,
|
||||
"dl_speed": self._dl_speed,
|
||||
"save_path": self._save_path
|
||||
"save_path": self._save_path,
|
||||
"clear_task": self._clear_task
|
||||
})
|
||||
|
||||
def brush(self):
|
||||
@@ -1265,12 +1270,6 @@ class BrushFlow(_PluginBase):
|
||||
f"{task.get('site_name')}{task.get('title')}" for task in task_info.values()
|
||||
]:
|
||||
continue
|
||||
# 保种体积(GB) 促销
|
||||
if self._disksize \
|
||||
and (torrents_size + torrent.size) > float(self._disksize) * 1024**3:
|
||||
logger.warn(f"当前做种体积 {StringUtils.str_filesize(torrents_size)} "
|
||||
f"已超过保种体积 {self._disksize},停止新增任务")
|
||||
break
|
||||
# 促销
|
||||
if self._freeleech and torrent.downloadvolumefactor != 0:
|
||||
continue
|
||||
@@ -1349,6 +1348,12 @@ class BrushFlow(_PluginBase):
|
||||
logger.warn(f"当前总下载带宽 {StringUtils.str_filesize(current_download_speed)} "
|
||||
f"已达到最大值 {self._maxdlspeed} KB/s,暂时停止新增任务")
|
||||
break
|
||||
# 保种体积(GB)
|
||||
if self._disksize \
|
||||
and (torrents_size + torrent.size) > float(self._disksize) * 1024**3:
|
||||
logger.warn(f"当前做种体积 {StringUtils.str_filesize(torrents_size)} "
|
||||
f"已超过保种体积 {self._disksize},停止新增任务")
|
||||
break
|
||||
# 添加下载任务
|
||||
hash_string = self.__download(torrent=torrent)
|
||||
if not hash_string:
|
||||
@@ -1767,19 +1772,22 @@ class BrushFlow(_PluginBase):
|
||||
"""
|
||||
发送删除种子的消息
|
||||
"""
|
||||
if self._notify:
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【刷流任务删种】",
|
||||
text=f"站点:{site_name}\n"
|
||||
f"标题:{torrent_title}\n"
|
||||
f"原因:{reason}"
|
||||
))
|
||||
if not self._notify:
|
||||
return
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【刷流任务删种】",
|
||||
text=f"站点:{site_name}\n"
|
||||
f"标题:{torrent_title}\n"
|
||||
f"原因:{reason}"
|
||||
))
|
||||
|
||||
def __send_add_message(self, torrent: TorrentInfo):
|
||||
"""
|
||||
发送添加下载的消息
|
||||
"""
|
||||
if not self._notify:
|
||||
return
|
||||
msg_text = ""
|
||||
if torrent.site_name:
|
||||
msg_text = f"站点:{torrent.site_name}"
|
||||
@@ -1819,25 +1827,29 @@ class BrushFlow(_PluginBase):
|
||||
|
||||
def __get_downloader_info(self) -> schemas.DownloaderInfo:
|
||||
"""
|
||||
获取下载器实时信息
|
||||
获取下载器实时信息(所有下载器)
|
||||
"""
|
||||
if self._downloader == "qbittorrent":
|
||||
# 调用Qbittorrent API查询实时信息
|
||||
ret_info = schemas.DownloaderInfo()
|
||||
|
||||
# Qbittorrent
|
||||
if self.qb:
|
||||
info = self.qb.transfer_info()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.get("dl_info_speed"),
|
||||
upload_speed=info.get("up_info_speed"),
|
||||
download_size=info.get("dl_info_data"),
|
||||
upload_size=info.get("up_info_data")
|
||||
)
|
||||
else:
|
||||
if info:
|
||||
ret_info.download_speed += info.get("dl_info_speed")
|
||||
ret_info.upload_speed += info.get("up_info_speed")
|
||||
ret_info.download_size += info.get("dl_info_data")
|
||||
ret_info.upload_size += info.get("up_info_data")
|
||||
|
||||
# Transmission
|
||||
if self.tr:
|
||||
info = self.tr.transfer_info()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.download_speed,
|
||||
upload_speed=info.upload_speed,
|
||||
download_size=info.current_stats.downloaded_bytes,
|
||||
upload_size=info.current_stats.uploaded_bytes
|
||||
)
|
||||
if info:
|
||||
ret_info.download_speed += info.download_speed
|
||||
ret_info.upload_speed += info.upload_speed
|
||||
ret_info.download_size += info.current_stats.downloaded_bytes
|
||||
ret_info.upload_size += info.current_stats.uploaded_bytes
|
||||
|
||||
return ret_info
|
||||
|
||||
def __get_downloading_count(self) -> int:
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import List, Tuple, Dict, Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import TransferInfo
|
||||
@@ -183,7 +183,7 @@ class ChineseSubFinder(_PluginBase):
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def download(self, event):
|
||||
def download(self, event: Event):
|
||||
"""
|
||||
调用ChineseSubFinder下载字幕
|
||||
"""
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from python_hosts import Hosts, HostsEntry
|
||||
from requests import Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
@@ -79,20 +84,25 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
if self.get_state() or self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self.get_state() and self._cron:
|
||||
logger.info(f"Cloudflare CDN优选服务启动,周期:{self._cron}")
|
||||
self._scheduler.add_job(func=self.__cloudflareSpeedTest,
|
||||
trigger=CronTrigger.from_crontab(self._cron),
|
||||
name="Cloudflare优选")
|
||||
try:
|
||||
if self.get_state() and self._cron:
|
||||
logger.info(f"Cloudflare CDN优选服务启动,周期:{self._cron}")
|
||||
self._scheduler.add_job(func=self.__cloudflareSpeedTest,
|
||||
trigger=CronTrigger.from_crontab(self._cron),
|
||||
name="Cloudflare优选")
|
||||
|
||||
if self._onlyonce:
|
||||
logger.info(f"Cloudflare CDN优选服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__cloudflareSpeedTest, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="Cloudflare优选")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.__update_config()
|
||||
if self._onlyonce:
|
||||
logger.info(f"Cloudflare CDN优选服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__cloudflareSpeedTest, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="Cloudflare优选")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.__update_config()
|
||||
except Exception as err:
|
||||
logger.error(f"Cloudflare CDN优选服务出错:{str(err)}")
|
||||
self.systemmessage.put(f"Cloudflare CDN优选服务出错:{str(err)}")
|
||||
return
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
@@ -142,13 +152,35 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
if err_flag:
|
||||
logger.info("正在进行CLoudflare CDN优选,请耐心等待")
|
||||
# 执行优选命令,-dd不测速
|
||||
cf_command = f'cd {self._cf_path} && chmod a+x {self._binary_name} && ./{self._binary_name} {self._additional_args} -o {self._result_file}' + (
|
||||
f' -f {self._cf_ipv4}' if self._ipv4 else '') + (f' -f {self._cf_ipv6}' if self._ipv6 else '')
|
||||
if SystemUtils.is_windows():
|
||||
cf_command = f'cd \"{self._cf_path}\" && CloudflareST {self._additional_args} -o \"{self._result_file}\"' + (
|
||||
f' -f \"{self._cf_ipv4}\"' if self._ipv4 else '') + (f' -f \"{self._cf_ipv6}\"' if self._ipv6 else '')
|
||||
else:
|
||||
cf_command = f'cd {self._cf_path} && chmod a+x {self._binary_name} && ./{self._binary_name} {self._additional_args} -o {self._result_file}' + (
|
||||
f' -f {self._cf_ipv4}' if self._ipv4 else '') + (f' -f {self._cf_ipv6}' if self._ipv6 else '')
|
||||
logger.info(f'正在执行优选命令 {cf_command}')
|
||||
os.system(cf_command)
|
||||
if SystemUtils.is_windows():
|
||||
process = subprocess.Popen(cf_command, shell=True)
|
||||
# 执行命令后无法退出 采用异步和设置超时方案
|
||||
# 设置超时时间为120秒
|
||||
if cf_command.__contains__("-dd"):
|
||||
time.sleep(120)
|
||||
else:
|
||||
time.sleep(600)
|
||||
# 如果没有在120秒内完成任务,那么杀死该进程
|
||||
if process.poll() is None:
|
||||
os.system('taskkill /F /IM CloudflareST.exe')
|
||||
else:
|
||||
os.system(cf_command)
|
||||
|
||||
# 获取优选后最优ip
|
||||
best_ip = SystemUtils.execute("sed -n '2,1p' " + self._result_file + " | awk -F, '{print $1}'")
|
||||
if SystemUtils.is_windows():
|
||||
powershell_command = f"powershell.exe -Command \"Get-Content \'{self._result_file}\' | Select-Object -Skip 1 -First 1 | Write-Output\""
|
||||
logger.info(f'正在执行powershell命令 {powershell_command}')
|
||||
best_ip = SystemUtils.execute(powershell_command)
|
||||
best_ip = best_ip.split(',')[0]
|
||||
else:
|
||||
best_ip = SystemUtils.execute("sed -n '2,1p' " + self._result_file + " | awk -F, '{print $1}'")
|
||||
logger.info(f"\n获取到最优ip==>[{best_ip}]")
|
||||
|
||||
# 替换自定义Hosts插件数据库hosts
|
||||
@@ -246,7 +278,10 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
# 是否重新安装
|
||||
if self._re_install:
|
||||
install_flag = True
|
||||
os.system(f'rm -rf {self._cf_path}')
|
||||
if SystemUtils.is_windows():
|
||||
os.system(f'rd /s /q \"{self._cf_path}\"')
|
||||
else:
|
||||
os.system(f'rm -rf {self._cf_path}')
|
||||
logger.info(f'删除CloudflareSpeedTest目录 {self._cf_path},开始重新安装')
|
||||
|
||||
# 判断目录是否存在
|
||||
@@ -277,7 +312,8 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
|
||||
# 重装后数据库有版本数据,但是本地没有则重装
|
||||
if not install_flag and release_version == self._version and not Path(
|
||||
f'{self._cf_path}/{self._binary_name}').exists():
|
||||
f'{self._cf_path}/{self._binary_name}').exists() and not Path(
|
||||
f'{self._cf_path}/CloudflareST.exe').exists():
|
||||
logger.warn(f"未检测到CloudflareSpeedTest本地版本,重新安装")
|
||||
install_flag = True
|
||||
|
||||
@@ -287,9 +323,11 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
|
||||
# 检查环境、安装
|
||||
if SystemUtils.is_windows():
|
||||
# todo
|
||||
logger.error(f"CloudflareSpeedTest暂不支持windows平台")
|
||||
return False, None
|
||||
# windows
|
||||
cf_file_name = 'CloudflareST_windows_amd64.zip'
|
||||
download_url = f'{self._release_prefix}/{release_version}/{cf_file_name}'
|
||||
return self.__os_install(download_url, cf_file_name, release_version,
|
||||
f"ditto -V -x -k --sequesterRsrc {self._cf_path}/{cf_file_name} {self._cf_path}")
|
||||
elif SystemUtils.is_macos():
|
||||
# mac
|
||||
uname = SystemUtils.execute('uname -m')
|
||||
@@ -317,14 +355,31 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
proxies = settings.PROXY
|
||||
https_proxy = proxies.get("https") if proxies and proxies.get("https") else None
|
||||
if https_proxy:
|
||||
os.system(
|
||||
f'wget -P {self._cf_path} --no-check-certificate -e use_proxy=yes -e https_proxy={https_proxy} {download_url}')
|
||||
if SystemUtils.is_windows():
|
||||
self.__get_windows_cloudflarest(download_url, proxies)
|
||||
else:
|
||||
os.system(
|
||||
f'wget -P {self._cf_path} --no-check-certificate -e use_proxy=yes -e https_proxy={https_proxy} {download_url}')
|
||||
else:
|
||||
os.system(f'wget -P {self._cf_path} https://ghproxy.com/{download_url}')
|
||||
if SystemUtils.is_windows():
|
||||
self.__get_windows_cloudflarest(download_url, proxies)
|
||||
else:
|
||||
os.system(f'wget -P {self._cf_path} https://ghproxy.com/{download_url}')
|
||||
|
||||
# 判断是否下载好安装包
|
||||
if Path(f'{self._cf_path}/{cf_file_name}').exists():
|
||||
try:
|
||||
if SystemUtils.is_windows():
|
||||
with zipfile.ZipFile(f'{self._cf_path}/{cf_file_name}', 'r') as zip_ref:
|
||||
# 解压ZIP文件中的所有文件到指定目录
|
||||
zip_ref.extractall(self._cf_path)
|
||||
if Path(f'{self._cf_path}\\CloudflareST.exe').exists():
|
||||
logger.info(f"CloudflareSpeedTest安装成功,当前版本:{release_version}")
|
||||
return True, release_version
|
||||
else:
|
||||
logger.error(f"CloudflareSpeedTest安装失败,请检查")
|
||||
os.system(f'rd /s /q \"{self._cf_path}\"')
|
||||
return False, None
|
||||
# 解压
|
||||
os.system(f'{unzip_command}')
|
||||
# 删除压缩包
|
||||
@@ -338,23 +393,42 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
return False, None
|
||||
except Exception as err:
|
||||
# 如果升级失败但是有可执行文件CloudflareST,则可继续运行,反之停止
|
||||
if Path(f'{self._cf_path}/{self._binary_name}').exists():
|
||||
if Path(f'{self._cf_path}/{self._binary_name}').exists() or \
|
||||
Path(f'{self._cf_path}\\CloudflareST.exe').exists():
|
||||
logger.error(f"CloudflareSpeedTest安装失败:{str(err)},继续使用现版本运行")
|
||||
return True, None
|
||||
else:
|
||||
logger.error(f"CloudflareSpeedTest安装失败:{str(err)},无可用版本,停止运行")
|
||||
os.removedirs(self._cf_path)
|
||||
if SystemUtils.is_windows():
|
||||
os.system(f'rd /s /q \"{self._cf_path}\"')
|
||||
else:
|
||||
os.removedirs(self._cf_path)
|
||||
return False, None
|
||||
else:
|
||||
# 如果升级失败但是有可执行文件CloudflareST,则可继续运行,反之停止
|
||||
if Path(f'{self._cf_path}/{self._binary_name}').exists():
|
||||
if Path(f'{self._cf_path}/{self._binary_name}').exists() or \
|
||||
Path(f'{self._cf_path}\\CloudflareST.exe').exists():
|
||||
logger.warn(f"CloudflareSpeedTest安装失败,存在可执行版本,继续运行")
|
||||
return True, None
|
||||
else:
|
||||
logger.error(f"CloudflareSpeedTest安装失败,无可用版本,停止运行")
|
||||
os.removedirs(self._cf_path)
|
||||
if SystemUtils.is_windows():
|
||||
os.system(f'rd /s /q \"{self._cf_path}\"')
|
||||
else:
|
||||
os.removedirs(self._cf_path)
|
||||
return False, None
|
||||
|
||||
def __get_windows_cloudflarest(self, download_url, proxies):
|
||||
response = Response()
|
||||
try:
|
||||
response = requests.get(download_url, stream=True, proxies=proxies if proxies else None)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"CloudflareSpeedTest下载失败:{str(e)}")
|
||||
if response.status_code == 200:
|
||||
with open(f'{self._cf_path}\\CloudflareST_windows_amd64.zip', 'wb') as file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
file.write(chunk)
|
||||
|
||||
@staticmethod
|
||||
def __get_release_version():
|
||||
"""
|
||||
|
||||
@@ -12,10 +12,11 @@ from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
@@ -74,6 +75,7 @@ class DirMonitor(_PluginBase):
|
||||
transferhis = None
|
||||
downloadhis = None
|
||||
transferchian = None
|
||||
tmdbchain = None
|
||||
_observer = []
|
||||
_enabled = False
|
||||
_notify = False
|
||||
@@ -93,7 +95,7 @@ class DirMonitor(_PluginBase):
|
||||
self.transferhis = TransferHistoryOper(self.db)
|
||||
self.downloadhis = DownloadHistoryOper(self.db)
|
||||
self.transferchian = TransferChain(self.db)
|
||||
|
||||
self.tmdbchain = TmdbChain(self.db)
|
||||
# 清空配置
|
||||
self._dirconf = {}
|
||||
|
||||
@@ -237,13 +239,8 @@ class DirMonitor(_PluginBase):
|
||||
logger.info(f"{event_path} 已整理过")
|
||||
return
|
||||
|
||||
# 上级目录元数据
|
||||
meta = MetaInfo(title=file_path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=file_path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(meta)
|
||||
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
if not file_meta.name:
|
||||
logger.error(f"{file_path.name} 无法识别有效信息")
|
||||
return
|
||||
@@ -279,6 +276,13 @@ class DirMonitor(_PluginBase):
|
||||
# 更新媒体图片
|
||||
self.chain.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 获取集数据
|
||||
if mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 获取downloadhash
|
||||
download_hash = self.get_download_hash(src=str(file_path))
|
||||
|
||||
@@ -287,12 +291,13 @@ class DirMonitor(_PluginBase):
|
||||
path=file_path,
|
||||
transfer_type=self._transfer_type,
|
||||
target=target,
|
||||
meta=file_meta)
|
||||
meta=file_meta,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return
|
||||
if not transferinfo.target_path:
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
@@ -343,7 +348,7 @@ class DirMonitor(_PluginBase):
|
||||
}
|
||||
"""
|
||||
# 发送消息汇总
|
||||
media_list = self._medias.get(mediainfo.title_year + " " + meta.season) or {}
|
||||
media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {}
|
||||
if media_list:
|
||||
media_files = media_list.get("files") or []
|
||||
if media_files:
|
||||
@@ -384,7 +389,7 @@ class DirMonitor(_PluginBase):
|
||||
],
|
||||
"time": datetime.now()
|
||||
}
|
||||
self._medias[mediainfo.title_year + " " + meta.season] = media_list
|
||||
self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
|
||||
|
||||
# 汇总刷新媒体库
|
||||
if settings.REFRESH_MEDIASERVER:
|
||||
@@ -598,7 +603,7 @@ class DirMonitor(_PluginBase):
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录,支持两种配置方式:\n'
|
||||
'监控目录\n'
|
||||
'监控目录:转移目的目录'
|
||||
'监控目录:转移目的目录(需同时在媒体库目录中配置该目的目录)'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
320
app/plugins/downloadingmsg/__init__.py
Normal file
320
app/plugins/downloadingmsg/__init__.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import settings
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional, Union
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType, TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, MessageChannel
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class DownloadingMsg(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "下载进度推送"
|
||||
# 插件描述
|
||||
plugin_desc = "定时推送正在下载进度。"
|
||||
# 插件图标
|
||||
plugin_icon = "downloadmsg.png"
|
||||
# 主题色
|
||||
plugin_color = "#3DE75D"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "downloading_"
|
||||
# 加载顺序
|
||||
plugin_order = 22
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_seconds = None
|
||||
_type = None
|
||||
_adminuser = None
|
||||
_downloadhis = None
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._seconds = config.get("seconds") or 300
|
||||
self._type = config.get("type") or 'admin'
|
||||
self._adminuser = config.get("adminuser")
|
||||
|
||||
# 加载模块
|
||||
if self._enabled:
|
||||
self._downloadhis = DownloadHistoryOper(self.db)
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self._seconds:
|
||||
try:
|
||||
self._scheduler.add_job(func=self.__downloading,
|
||||
trigger='interval',
|
||||
seconds=int(self._seconds),
|
||||
name="下载进度推送")
|
||||
except Exception as err:
|
||||
logger.error(f"定时任务配置错误:{err}")
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __downloading(self):
|
||||
"""
|
||||
定时推送正在下载进度
|
||||
"""
|
||||
# 正在下载种子
|
||||
torrents = DownloadChain(self.db).list_torrents(status=TorrentStatus.DOWNLOADING)
|
||||
if not torrents:
|
||||
logger.info("当前没有正在下载的任务!")
|
||||
return
|
||||
# 推送用户
|
||||
if self._type == "admin" or self._type == "both":
|
||||
if not self._adminuser:
|
||||
logger.error("未配置管理员用户")
|
||||
return
|
||||
|
||||
for userid in str(self._adminuser).split(","):
|
||||
self.__send_msg(torrents=torrents, userid=userid)
|
||||
|
||||
if self._type == "user" or self._type == "both":
|
||||
user_torrents = {}
|
||||
# 根据正在下载种子hash获取下载历史
|
||||
for torrent in torrents:
|
||||
downloadhis = self._downloadhis.get_by_hash(download_hash=torrent.hash)
|
||||
if not downloadhis:
|
||||
logger.warn(f"种子 {torrent.hash} 未获取到MoviePilot下载历史,无法推送下载进度")
|
||||
continue
|
||||
if not downloadhis.userid:
|
||||
logger.debug(f"种子 {torrent.hash} 未获取到下载用户记录,无法推送下载进度")
|
||||
continue
|
||||
user_torrent = user_torrents.get(downloadhis.userid) or []
|
||||
user_torrent.append(torrent)
|
||||
user_torrents[downloadhis.userid] = user_torrent
|
||||
|
||||
if not user_torrents or not user_torrents.keys():
|
||||
logger.warn("未获取到用户下载记录,无法推送下载进度")
|
||||
return
|
||||
|
||||
# 推送用户下载任务进度
|
||||
for userid in list(user_torrents.keys()):
|
||||
if not userid:
|
||||
continue
|
||||
# 如果用户是管理员,无需重复推送
|
||||
if self._type == "admin" or self._type == "both" and self._adminuser and userid in str(
|
||||
self._adminuser).split(","):
|
||||
logger.debug("管理员已推送")
|
||||
continue
|
||||
|
||||
user_torrent = user_torrents.get(userid)
|
||||
if not user_torrent:
|
||||
logger.warn(f"未获取到用户 {userid} 下载任务")
|
||||
continue
|
||||
self.__send_msg(torrents=user_torrent,
|
||||
userid=userid)
|
||||
|
||||
if self._type == "all":
|
||||
self.__send_msg(torrents=torrents)
|
||||
|
||||
def __send_msg(self, torrents: Optional[List[Union[TransferTorrent, DownloadingTorrent]]], userid: str = None):
|
||||
"""
|
||||
发送消息
|
||||
"""
|
||||
title = f"共 {len(torrents)} 个任务正在下载:"
|
||||
messages = []
|
||||
index = 1
|
||||
channel_value = None
|
||||
for torrent in torrents:
|
||||
year = None
|
||||
name = None
|
||||
se = None
|
||||
ep = None
|
||||
# 先查询下载记录,没有再识别
|
||||
downloadhis = self._downloadhis.get_by_hash(download_hash=torrent.hash)
|
||||
if downloadhis:
|
||||
name = downloadhis.title
|
||||
year = downloadhis.year
|
||||
se = downloadhis.seasons
|
||||
ep = downloadhis.episodes
|
||||
if not channel_value:
|
||||
channel_value = downloadhis.channel
|
||||
else:
|
||||
try:
|
||||
context = MediaChain(self.db).recognize_by_title(title=torrent.title)
|
||||
if not context or not context.media_info:
|
||||
continue
|
||||
media_info = context.media_info
|
||||
year = media_info.year
|
||||
name = media_info.title
|
||||
if media_info.number_of_seasons:
|
||||
se = f"S{str(media_info.number_of_seasons).rjust(2, '0')}"
|
||||
if media_info.number_of_episodes:
|
||||
ep = f"E{str(media_info.number_of_episodes).rjust(2, '0')}"
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
# 拼装标题
|
||||
if year:
|
||||
media_name = "%s (%s) %s%s" % (name, year, se, ep)
|
||||
elif name:
|
||||
media_name = "%s %s%s" % (name, se, ep)
|
||||
else:
|
||||
media_name = torrent.title
|
||||
|
||||
messages.append(f"{index}. {media_name}\n"
|
||||
f"{torrent.title} "
|
||||
f"{StringUtils.str_filesize(torrent.size)} "
|
||||
f"{round(torrent.progress, 1)}%")
|
||||
index += 1
|
||||
|
||||
# 用户消息渠道
|
||||
if channel_value:
|
||||
channel = next(
|
||||
(channel for channel in MessageChannel.__members__.values() if channel.value == channel_value), None)
|
||||
else:
|
||||
channel = None
|
||||
self.post_message(mtype=NotificationType.Download,
|
||||
channel=channel,
|
||||
title=title,
|
||||
text="\n".join(messages),
|
||||
userid=userid)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'seconds',
|
||||
'label': '执行间隔',
|
||||
'placeholder': '单位(秒)'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'adminuser',
|
||||
'label': '管理员用户',
|
||||
'placeholder': '多个用户,分割'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'type',
|
||||
'label': '推送类型',
|
||||
'items': [
|
||||
{'title': '管理员', 'value': 'admin'},
|
||||
{'title': '下载用户', 'value': 'user'},
|
||||
{'title': '管理员和下载用户', 'value': 'both'},
|
||||
{'title': '所有用户', 'value': 'all'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"seconds": 300,
|
||||
"adminuser": "",
|
||||
"type": "admin"
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
@@ -12,6 +12,9 @@ from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
|
||||
from app.core.event import eventmanager
|
||||
from app.db.models.site import Site
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
@@ -19,6 +22,7 @@ from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper
|
||||
from app.schemas import NotificationType
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -100,7 +104,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
self._cron = config.get("cron")
|
||||
self._token = config.get("token")
|
||||
self._downloaders = config.get("downloaders")
|
||||
self._sites = config.get("sites")
|
||||
self._sites = config.get("sites") or []
|
||||
self._notify = config.get("notify")
|
||||
self._nolabels = config.get("nolabels")
|
||||
self._nopaths = config.get("nopaths")
|
||||
@@ -109,6 +113,11 @@ class IYUUAutoSeed(_PluginBase):
|
||||
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
|
||||
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
self._sites = [site.get("id") for site in self.sites.get_indexers() if
|
||||
not site.get("public") and site.get("id") in self._sites]
|
||||
self.__update_config()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
@@ -166,8 +175,8 @@ class IYUUAutoSeed(_PluginBase):
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
# 站点的可选项
|
||||
site_options = [{"title": site.get("name"), "value": site.get("id")}
|
||||
for site in self.sites.get_indexers()]
|
||||
site_options = [{"title": site.name, "value": site.id}
|
||||
for site in Site.list_order_by_pri(self.db)]
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
@@ -425,10 +434,6 @@ class IYUUAutoSeed(_PluginBase):
|
||||
return
|
||||
logger.info("开始辅种任务 ...")
|
||||
|
||||
# 排除已删除站点
|
||||
self._sites = [site.get("id") for site in self.sites.get_indexers() if
|
||||
site.get("id") in self._sites]
|
||||
|
||||
# 计数器初始化
|
||||
self.total = 0
|
||||
self.realtotal = 0
|
||||
@@ -979,3 +984,31 @@ class IYUUAutoSeed(_PluginBase):
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
@eventmanager.register(EventType.SiteDeleted)
|
||||
def site_deleted(self, event):
|
||||
"""
|
||||
删除对应站点选中
|
||||
"""
|
||||
site_id = event.event_data.get("site_id")
|
||||
config = self.get_config()
|
||||
if config:
|
||||
sites = config.get("sites")
|
||||
if sites:
|
||||
if isinstance(sites, str):
|
||||
sites = [sites]
|
||||
|
||||
# 删除对应站点
|
||||
if site_id:
|
||||
sites = [site for site in sites if int(site) != int(site_id)]
|
||||
else:
|
||||
# 清空
|
||||
sites = []
|
||||
|
||||
# 若无站点,则停止
|
||||
if len(sites) == 0:
|
||||
self._enabled = False
|
||||
|
||||
self._sites = sites
|
||||
# 保存配置
|
||||
self.__update_config()
|
||||
|
||||
@@ -2,7 +2,6 @@ import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
@@ -10,11 +9,10 @@ from typing import List, Tuple, Dict, Any, Optional
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
@@ -23,14 +21,13 @@ from app.modules.themoviedb.tmdbv3api import Episode
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import NotificationType, EventType, MediaType
|
||||
from app.utils.path_utils import PathUtils
|
||||
|
||||
|
||||
class MediaSyncDel(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "媒体库同步删除"
|
||||
plugin_name = "媒体文件同步删除"
|
||||
# 插件描述
|
||||
plugin_desc = "媒体库删除媒体后同步删除历史记录、源文件和下载任务。"
|
||||
plugin_desc = "同步删除历史记录、源文件和下载任务。"
|
||||
# 插件图标
|
||||
plugin_icon = "mediasyncdel.png"
|
||||
# 主题色
|
||||
@@ -58,14 +55,16 @@ class MediaSyncDel(_PluginBase):
|
||||
_del_source = False
|
||||
_exclude_path = None
|
||||
_library_path = None
|
||||
_transferchain = None
|
||||
_transferhis = None
|
||||
_downloadhis = None
|
||||
qb = None
|
||||
tr = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._transferhis = TransferHistoryOper(self.db)
|
||||
self._downloadhis = DownloadHistoryOper(self.db)
|
||||
self._transferchain = TransferChain(self.db)
|
||||
self._transferhis = self._transferchain.transferhis
|
||||
self._downloadhis = self._transferchain.downloadhis
|
||||
self.episode = Episode()
|
||||
self.qb = Qbittorrent()
|
||||
self.tr = Transmission()
|
||||
@@ -188,9 +187,9 @@ class MediaSyncDel(_PluginBase):
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'sync_type',
|
||||
'label': '同步方式',
|
||||
'label': '媒体库同步方式',
|
||||
'items': [
|
||||
{'title': 'webhook', 'value': 'webhook'},
|
||||
{'title': 'Webhook', 'value': 'webhook'},
|
||||
{'title': '日志', 'value': 'log'},
|
||||
{'title': 'Scripter X', 'value': 'plugin'}
|
||||
]
|
||||
@@ -209,7 +208,7 @@ class MediaSyncDel(_PluginBase):
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'label': '日志检查周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
@@ -247,7 +246,7 @@ class MediaSyncDel(_PluginBase):
|
||||
'props': {
|
||||
'model': 'library_path',
|
||||
'rows': '2',
|
||||
'label': '媒体库路径',
|
||||
'label': '媒体库路径映射',
|
||||
'placeholder': '媒体服务器路径:MoviePilot路径(一行一个)'
|
||||
}
|
||||
}
|
||||
@@ -267,11 +266,11 @@ class MediaSyncDel(_PluginBase):
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'text': '同步方式分为webhook、日志同步和Scripter X。'
|
||||
'webhook需要Emby4.8.0.45及以上开启媒体删除的webhook'
|
||||
'(建议使用媒体库刮削插件覆盖元数据重新刮削剧集路径)。'
|
||||
'日志同步需要配置执行周期,默认30分钟执行一次。'
|
||||
'Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。'
|
||||
'text': '媒体库同步方式分为Webhook、日志同步和Scripter X:'
|
||||
'1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook。'
|
||||
'2、日志同步需要配置检查周期,默认30分钟执行一次。'
|
||||
'3、Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。'
|
||||
'4、启用该插件后,非媒体服务器触发的源文件删除,也会同步处理下载器中的下载任务。'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -534,7 +533,7 @@ class MediaSyncDel(_PluginBase):
|
||||
episode_num=episode_num)
|
||||
|
||||
def __sync_del(self, media_type: str, media_name: str, media_path: str,
|
||||
tmdb_id: int, season_num: int, episode_num: int):
|
||||
tmdb_id: int, season_num: str, episode_num: str):
|
||||
"""
|
||||
执行删除逻辑
|
||||
"""
|
||||
@@ -587,10 +586,7 @@ class MediaSyncDel(_PluginBase):
|
||||
if self._del_source:
|
||||
# 1、直接删除源文件
|
||||
if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT:
|
||||
source_name = os.path.basename(transferhis.src)
|
||||
source_path = str(transferhis.src).replace(source_name, "")
|
||||
self.delete_media_file(filedir=source_path,
|
||||
filename=source_name)
|
||||
self._transferchain.delete_files(Path(transferhis.src))
|
||||
if transferhis.download_hash:
|
||||
try:
|
||||
# 2、判断种子是否被删除完
|
||||
@@ -654,16 +650,20 @@ class MediaSyncDel(_PluginBase):
|
||||
self.save_data("history", history)
|
||||
|
||||
def __get_transfer_his(self, media_type: str, media_name: str, media_path: str,
|
||||
tmdb_id: int, season_num: int, episode_num: int):
|
||||
tmdb_id: int, season_num: str, episode_num: str):
|
||||
"""
|
||||
查询转移记录
|
||||
"""
|
||||
# 季数
|
||||
if season_num:
|
||||
if season_num and season_num.isdigit():
|
||||
season_num = str(season_num).rjust(2, '0')
|
||||
else:
|
||||
season_num = None
|
||||
# 集数
|
||||
if episode_num:
|
||||
if episode_num and episode_num.isdigit():
|
||||
episode_num = str(episode_num).rjust(2, '0')
|
||||
else:
|
||||
episode_num = None
|
||||
|
||||
# 类型
|
||||
mtype = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV
|
||||
@@ -718,19 +718,21 @@ class MediaSyncDel(_PluginBase):
|
||||
"""
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 媒体服务器类型
|
||||
media_server = settings.MEDIASERVER
|
||||
|
||||
last_time = self.get_data("last_time")
|
||||
del_medias = []
|
||||
if media_server == 'emby':
|
||||
del_medias = self.parse_emby_log(last_time)
|
||||
elif media_server == 'jellyfin':
|
||||
del_medias = self.parse_jellyfin_log(last_time)
|
||||
elif media_server == 'plex':
|
||||
# TODO plex解析日志
|
||||
|
||||
# 媒体服务器类型,多个以,分隔
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
media_servers = settings.MEDIASERVER.split(',')
|
||||
for media_server in media_servers:
|
||||
if media_server == 'emby':
|
||||
del_medias.extend(self.parse_emby_log(last_time))
|
||||
elif media_server == 'jellyfin':
|
||||
del_medias.extend(self.parse_jellyfin_log(last_time))
|
||||
elif media_server == 'plex':
|
||||
# TODO plex解析日志
|
||||
return
|
||||
|
||||
if not del_medias:
|
||||
logger.error("未解析到已删除媒体信息")
|
||||
@@ -825,10 +827,7 @@ class MediaSyncDel(_PluginBase):
|
||||
if self._del_source:
|
||||
# 1、直接删除源文件
|
||||
if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT:
|
||||
source_name = os.path.basename(transferhis.src)
|
||||
source_path = str(transferhis.src).replace(source_name, "")
|
||||
self.delete_media_file(filedir=source_path,
|
||||
filename=source_name)
|
||||
self._transferchain.delete_files(Path(transferhis.src))
|
||||
if transferhis.download_hash:
|
||||
try:
|
||||
# 2、判断种子是否被删除完
|
||||
@@ -1187,42 +1186,6 @@ class MediaSyncDel(_PluginBase):
|
||||
|
||||
return del_medias
|
||||
|
||||
@staticmethod
|
||||
def delete_media_file(filedir: str, filename: str):
|
||||
"""
|
||||
删除媒体文件,空目录也会被删除
|
||||
"""
|
||||
filedir = os.path.normpath(filedir).replace("\\", "/")
|
||||
file = os.path.join(filedir, filename)
|
||||
try:
|
||||
if not os.path.exists(file):
|
||||
return False, f"{file} 不存在"
|
||||
os.remove(file)
|
||||
nfoname = f"{os.path.splitext(filename)[0]}.nfo"
|
||||
nfofile = os.path.join(filedir, nfoname)
|
||||
if os.path.exists(nfofile):
|
||||
os.remove(nfofile)
|
||||
# 检查空目录并删除
|
||||
if re.findall(r"^S\d{2}|^Season", os.path.basename(filedir), re.I):
|
||||
# 当前是季文件夹,判断并删除
|
||||
seaon_dir = filedir
|
||||
if seaon_dir.count('/') > 1 and not PathUtils.get_dir_files(seaon_dir, exts=settings.RMT_MEDIAEXT):
|
||||
shutil.rmtree(seaon_dir)
|
||||
# 媒体文件夹
|
||||
media_dir = os.path.dirname(seaon_dir)
|
||||
else:
|
||||
media_dir = filedir
|
||||
# 检查并删除媒体文件夹,非根目录且目录大于二级,且没有媒体文件时才会删除
|
||||
if media_dir != '/' \
|
||||
and media_dir.count('/') > 1 \
|
||||
and not re.search(r'[a-zA-Z]:/$', media_dir) \
|
||||
and not PathUtils.get_dir_files(media_dir, exts=settings.RMT_MEDIAEXT):
|
||||
shutil.rmtree(media_dir)
|
||||
return True, f"{file} 删除成功"
|
||||
except Exception as e:
|
||||
logger.error("删除源文件失败:%s" % str(e))
|
||||
return True, f"{file} 删除失败"
|
||||
|
||||
def get_state(self):
|
||||
return self._enabled
|
||||
|
||||
@@ -1239,21 +1202,25 @@ class MediaSyncDel(_PluginBase):
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
|
||||
@eventmanager.register(EventType.MediaDeleted)
|
||||
def remote_sync_del(self, event: Event):
|
||||
@eventmanager.register(EventType.DownloadFileDeleted)
|
||||
def downloadfile_del_sync(self, event: Event):
|
||||
"""
|
||||
媒体库同步删除
|
||||
下载文件删除处理事件
|
||||
"""
|
||||
if event:
|
||||
logger.info("收到命令,开始执行媒体库同步删除 ...")
|
||||
self.post_message(channel=event.event_data.get("channel"),
|
||||
title="开始媒体库同步删除 ...",
|
||||
userid=event.event_data.get("user"))
|
||||
self.sync_del_by_log()
|
||||
|
||||
if event:
|
||||
self.post_message(channel=event.event_data.get("channel"),
|
||||
title="媒体库同步删除完成!", userid=event.event_data.get("user"))
|
||||
if not self._enabled:
|
||||
return
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data
|
||||
src = event_data.get("src")
|
||||
if not src:
|
||||
return
|
||||
# 查询下载hash
|
||||
download_hash = self._downloadhis.get_hash_by_fullpath(src)
|
||||
if download_hash:
|
||||
self.handle_torrent(src=src, torrent_hash=download_hash)
|
||||
else:
|
||||
logger.warn(f"未查询到文件 {src} 对应的下载记录")
|
||||
|
||||
@staticmethod
|
||||
def get_tmdbimage_url(path: str, prefix="w500"):
|
||||
|
||||
@@ -325,6 +325,9 @@ class MessageForward(_PluginBase):
|
||||
logger.info(f"转发消息 {title} 成功")
|
||||
return True
|
||||
else:
|
||||
if ret_json.get('errcode') == 81013:
|
||||
return False
|
||||
|
||||
logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}")
|
||||
if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
|
||||
logger.info("token已过期,正在重新刷新token重试")
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
@@ -157,15 +158,16 @@ class NAStoolSync(_PluginBase):
|
||||
|
||||
# 替换value
|
||||
if isinstance(plugin_value, str):
|
||||
plugin_value = json.loads(plugin_value)
|
||||
if str(plugin_value.get("to_download")).isdigit() and int(
|
||||
plugin_value.get("to_download")) == int(sub_downloaders[0]):
|
||||
plugin_value["to_download"] = sub_downloaders[1]
|
||||
_value: dict = json.loads(plugin_value)
|
||||
elif isinstance(plugin_value, dict):
|
||||
if str(plugin_value.get("to_download")).isdigit() and int(
|
||||
plugin_value.get("to_download")) == int(sub_downloaders[0]):
|
||||
plugin_value["to_download"] = sub_downloaders[1]
|
||||
|
||||
# 替换辅种记录
|
||||
if str(plugin_id) == "IYUUAutoSeed":
|
||||
if isinstance(plugin_value, str):
|
||||
plugin_value = json.loads(plugin_value)
|
||||
plugin_value: list = json.loads(plugin_value)
|
||||
if not isinstance(plugin_value, list):
|
||||
plugin_value = [plugin_value]
|
||||
for value in plugin_value:
|
||||
@@ -213,6 +215,7 @@ class NAStoolSync(_PluginBase):
|
||||
mtorrent = history[9]
|
||||
mdesc = history[10]
|
||||
msite = history[11]
|
||||
mdate = history[12]
|
||||
|
||||
# 处理站点映射
|
||||
if self._site:
|
||||
@@ -234,7 +237,9 @@ class NAStoolSync(_PluginBase):
|
||||
download_hash=mdownload_hash,
|
||||
torrent_name=mtorrent,
|
||||
torrent_description=mdesc,
|
||||
torrent_site=msite
|
||||
torrent_site=msite,
|
||||
userid=settings.SUPERUSER,
|
||||
date=mdate
|
||||
)
|
||||
cnt += 1
|
||||
if cnt % 100 == 0:
|
||||
@@ -358,7 +363,8 @@ class NAStoolSync(_PluginBase):
|
||||
DOWNLOAD_ID,
|
||||
TORRENT,
|
||||
DESC,
|
||||
SITE
|
||||
SITE,
|
||||
DATE
|
||||
FROM
|
||||
DOWNLOAD_HISTORY
|
||||
WHERE
|
||||
|
||||
204
app/plugins/personmeta/__init__.py
Normal file
204
app/plugins/personmeta/__init__.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from requests import RequestException
|
||||
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.helper.nfo import NfoReader
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import TransferInfo, MediaInfo
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class PersonMeta(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "演职人员刮削"
|
||||
# 插件描述
|
||||
plugin_desc = "刮削演职人员图片以及中文名称。"
|
||||
# 插件图标
|
||||
plugin_icon = "actor.png"
|
||||
# 主题色
|
||||
plugin_color = "#E66E72"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "personmeta_"
|
||||
# 加载顺序
|
||||
plugin_order = 24
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
tmdbchain = None
|
||||
_enabled = False
|
||||
_metadir = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.tmdbchain = TmdbChain(self.db)
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._metadir = config.get("metadir")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'metadir',
|
||||
'label': '人物元数据目录',
|
||||
'placeholder': '/metadata/people'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"metadir": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def scrap_rt(self, event: Event):
|
||||
"""
|
||||
根据事件实时刮削演员信息
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
# 下载人物头像
|
||||
if not self._metadir:
|
||||
logger.warning("人物元数据目录未配置,无法下载人物头像")
|
||||
return
|
||||
# 事件数据
|
||||
mediainfo: MediaInfo = event.event_data.get("mediainfo")
|
||||
transferinfo: TransferInfo = event.event_data.get("transferinfo")
|
||||
if not mediainfo or not transferinfo:
|
||||
return
|
||||
# 文件路径
|
||||
filepath = transferinfo.target_path
|
||||
if not filepath:
|
||||
return
|
||||
# 电影
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# nfo文件
|
||||
nfofile = filepath.with_name("movie.nfo")
|
||||
if not nfofile.exists():
|
||||
nfofile = filepath.parent / f"{filepath.stem}.nfo"
|
||||
if not nfofile.exists():
|
||||
logger.warning(f"电影nfo文件不存在:{nfofile}")
|
||||
return
|
||||
else:
|
||||
# nfo文件
|
||||
nfofile = filepath.parent.with_name("tvshow.nfo")
|
||||
if not nfofile.exists():
|
||||
logger.warning(f"剧集nfo文件不存在:{nfofile}")
|
||||
return
|
||||
# 读取nfo文件
|
||||
nfo = NfoReader(nfofile)
|
||||
# 读取演员信息
|
||||
actors = nfo.get_elements("actor") or []
|
||||
for actor in actors:
|
||||
# 演员ID
|
||||
actor_id = actor.find("id").text
|
||||
if not actor_id:
|
||||
continue
|
||||
# 演员名称
|
||||
actor_name = actor.find("name").text
|
||||
# 查询演员详情
|
||||
actor_info = self.tmdbchain.person_detail(actor_id)
|
||||
if not actor_info:
|
||||
continue
|
||||
# 演员头像
|
||||
actor_image = actor_info.get("profile_path")
|
||||
if not actor_image:
|
||||
continue
|
||||
# 计算保存路径
|
||||
image_path = Path(self._metadir) / f"{actor_name}-tmdb-{actor_id}" / f"folder{Path(actor_image).suffix}"
|
||||
if image_path.exists():
|
||||
continue
|
||||
# 下载图片
|
||||
self.download_image(f"https://image.tmdb.org/t/p/original{actor_image}", image_path)
|
||||
|
||||
@staticmethod
|
||||
@retry(RequestException, logger=logger)
|
||||
def download_image(image_url: str, path: Path):
|
||||
"""
|
||||
下载图片,保存到指定路径
|
||||
"""
|
||||
try:
|
||||
logger.info(f"正在下载演职人员图片:{image_url} ...")
|
||||
r = RequestUtils().get_res(url=image_url, raise_exception=True)
|
||||
if r:
|
||||
path.write_bytes(r.content)
|
||||
logger.info(f"图片已保存:{path}")
|
||||
else:
|
||||
logger.info(f"图片下载失败,请检查网络连通性:{image_url}")
|
||||
except RequestException as err:
|
||||
raise err
|
||||
except Exception as err:
|
||||
logger.error(f"图片下载失败:{err}")
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
@@ -549,7 +549,7 @@ class RssSubscribe(_PluginBase):
|
||||
logger.error(f"未获取到RSS数据:{url}")
|
||||
return
|
||||
# 过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
# 解析数据
|
||||
for result in results:
|
||||
try:
|
||||
@@ -593,7 +593,8 @@ class RssSubscribe(_PluginBase):
|
||||
if self._filter:
|
||||
result = self.chain.filter_torrents(
|
||||
rule_string=filter_rule,
|
||||
torrent_list=[torrentinfo]
|
||||
torrent_list=[torrentinfo],
|
||||
mediainfo=mediainfo
|
||||
)
|
||||
if not result:
|
||||
logger.info(f"{title} {description} 不匹配过滤规则")
|
||||
|
||||
@@ -15,6 +15,7 @@ from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event
|
||||
from app.core.event import eventmanager
|
||||
from app.db.models.site import Site
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
@@ -84,6 +85,11 @@ class SiteStatistic(_PluginBase):
|
||||
self._statistic_type = config.get("statistic_type") or "all"
|
||||
self._statistic_sites = config.get("statistic_sites") or []
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
self._statistic_sites = [site.get("id") for site in self.sites.get_indexers() if
|
||||
not site.get("public") and site.get("id") in self._statistic_sites]
|
||||
self.__update_config()
|
||||
|
||||
if self._enabled or self._onlyonce:
|
||||
# 加载模块
|
||||
self._site_schema = ModuleHelper.load('app.plugins.sitestatistic.siteuserinfo',
|
||||
@@ -177,8 +183,8 @@ class SiteStatistic(_PluginBase):
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
# 站点的可选项
|
||||
site_options = [{"title": site.get("name"), "value": site.get("id")}
|
||||
for site in self.sites.get_indexers()]
|
||||
site_options = [{"title": site.name, "value": site.id}
|
||||
for site in Site.list_order_by_pri(self.db)]
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
@@ -1047,10 +1053,6 @@ class SiteStatistic(_PluginBase):
|
||||
else:
|
||||
refresh_sites = [site for site in self.sites.get_indexers() if
|
||||
site.get("id") in self._statistic_sites]
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
self._statistic_sites = [site.get("id") for site in refresh_sites if site]
|
||||
self.__update_config()
|
||||
if not refresh_sites:
|
||||
return
|
||||
|
||||
|
||||
@@ -73,15 +73,19 @@ class SpeedLimiter(_PluginBase):
|
||||
try:
|
||||
# 总带宽
|
||||
self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000
|
||||
# 自动限速开关
|
||||
if self._bandwidth > 0:
|
||||
# 自动限速开关
|
||||
self._auto_limit = True
|
||||
else:
|
||||
self._auto_limit = False
|
||||
except Exception as e:
|
||||
logger.error(f"智能限速上行带宽设置错误:{str(e)}")
|
||||
self._bandwidth = 0
|
||||
|
||||
# 限速服务开关
|
||||
self._limit_enabled = True if self._play_up_speed or self._play_down_speed or self._auto_limit else False
|
||||
self._limit_enabled = True if (self._play_up_speed
|
||||
or self._play_down_speed
|
||||
or self._auto_limit) else False
|
||||
self._allocation_ratio = config.get("allocation_ratio") or ""
|
||||
# 不限速地址
|
||||
self._unlimited_ips["ipv4"] = config.get("ipv4") or ""
|
||||
@@ -383,78 +387,86 @@ class SpeedLimiter(_PluginBase):
|
||||
return
|
||||
# 当前播放的总比特率
|
||||
total_bit_rate = 0
|
||||
# 查询播放中会话
|
||||
playing_sessions = []
|
||||
if settings.MEDIASERVER == "emby":
|
||||
req_url = "{HOST}emby/Sessions?api_key={APIKEY}"
|
||||
try:
|
||||
res = Emby().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
sessions = res.json()
|
||||
for session in sessions:
|
||||
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
|
||||
playing_sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error(f"获取Emby播放会话失败:{str(e)}")
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
elif settings.MEDIASERVER == "jellyfin":
|
||||
req_url = "{HOST}Sessions?api_key={APIKEY}"
|
||||
try:
|
||||
res = Jellyfin().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
sessions = res.json()
|
||||
for session in sessions:
|
||||
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
|
||||
playing_sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error(f"获取Jellyfin播放会话失败:{str(e)}")
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
|
||||
for media_stream in media_streams:
|
||||
total_bit_rate += int(media_stream.get("BitRate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
|
||||
for media_stream in media_streams:
|
||||
total_bit_rate += int(media_stream.get("BitRate") or 0)
|
||||
elif settings.MEDIASERVER == "plex":
|
||||
_plex = Plex().get_plex()
|
||||
if _plex:
|
||||
sessions = _plex.sessions()
|
||||
for session in sessions:
|
||||
bitrate = sum([m.bitrate or 0 for m in session.media])
|
||||
playing_sessions.append({
|
||||
"type": session.TAG,
|
||||
"bitrate": bitrate,
|
||||
"address": session.player.address
|
||||
})
|
||||
# 媒体服务器类型,多个以,分隔
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
media_servers = settings.MEDIASERVER.split(',')
|
||||
# 查询所有媒体服务器状态
|
||||
for media_server in media_servers:
|
||||
# 查询播放中会话
|
||||
playing_sessions = []
|
||||
if media_server == "emby":
|
||||
req_url = "{HOST}emby/Sessions?api_key={APIKEY}"
|
||||
try:
|
||||
res = Emby().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
sessions = res.json()
|
||||
for session in sessions:
|
||||
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
|
||||
playing_sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error(f"获取Emby播放会话失败:{str(e)}")
|
||||
continue
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("address")) \
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
elif media_server == "jellyfin":
|
||||
req_url = "{HOST}Sessions?api_key={APIKEY}"
|
||||
try:
|
||||
res = Jellyfin().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
sessions = res.json()
|
||||
for session in sessions:
|
||||
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
|
||||
playing_sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error(f"获取Jellyfin播放会话失败:{str(e)}")
|
||||
continue
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
|
||||
for media_stream in media_streams:
|
||||
total_bit_rate += int(media_stream.get("BitRate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
|
||||
for media_stream in media_streams:
|
||||
total_bit_rate += int(media_stream.get("BitRate") or 0)
|
||||
elif media_server == "plex":
|
||||
_plex = Plex().get_plex()
|
||||
if _plex:
|
||||
sessions = _plex.sessions()
|
||||
for session in sessions:
|
||||
bitrate = sum([m.bitrate or 0 for m in session.media])
|
||||
playing_sessions.append({
|
||||
"type": session.TAG,
|
||||
"bitrate": bitrate,
|
||||
"address": session.player.address
|
||||
})
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("address")) \
|
||||
and session.get("type") == "Video":
|
||||
total_bit_rate += int(session.get("bitrate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("address")) \
|
||||
and session.get("type") == "Video":
|
||||
total_bit_rate += int(session.get("bitrate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("address")) \
|
||||
and session.get("type") == "Video":
|
||||
total_bit_rate += int(session.get("bitrate") or 0)
|
||||
|
||||
if total_bit_rate:
|
||||
# 开启智能限速计算上传限速
|
||||
@@ -471,17 +483,13 @@ class SpeedLimiter(_PluginBase):
|
||||
self.__set_limiter(limit_type="未播放", upload_limit=self._noplay_up_speed,
|
||||
download_limit=self._noplay_down_speed)
|
||||
|
||||
def __calc_limit(self, total_bit_rate):
|
||||
def __calc_limit(self, total_bit_rate: float) -> float:
|
||||
"""
|
||||
计算智能上传限速
|
||||
"""
|
||||
residual_bandwidth = (self._bandwidth - total_bit_rate)
|
||||
if residual_bandwidth < 0:
|
||||
play_up_speed = 10
|
||||
else:
|
||||
play_up_speed = round(residual_bandwidth / 8 / 1024, 2)
|
||||
|
||||
return play_up_speed
|
||||
if not self._bandwidth:
|
||||
return 10
|
||||
return round((self._bandwidth - total_bit_rate) / 8 / 1024, 2)
|
||||
|
||||
def __set_limiter(self, limit_type: str, upload_limit: float, download_limit: float):
|
||||
"""
|
||||
@@ -564,7 +572,7 @@ class SpeedLimiter(_PluginBase):
|
||||
logger.error(f"设置限速失败:{str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def __allow_access(allow_ips, ip):
|
||||
def __allow_access(allow_ips: dict, ip: str) -> bool:
|
||||
"""
|
||||
判断IP是否合法
|
||||
:param allow_ips: 充许的IP范围 {"ipv4":, "ipv6":}
|
||||
|
||||
@@ -644,7 +644,7 @@ class TorrentRemover(_PluginBase):
|
||||
return None
|
||||
if self._torrentstates and torrent.state not in self._torrentstates:
|
||||
return None
|
||||
if self._torrentcategorys and torrent.category not in self._torrentcategorys:
|
||||
if self._torrentcategorys and (not torrent.category or torrent.category not in self._torrentcategorys):
|
||||
return None
|
||||
return {
|
||||
"id": torrent.hash,
|
||||
@@ -731,20 +731,34 @@ class TorrentRemover(_PluginBase):
|
||||
remove_torrents.append(item)
|
||||
# 处理辅种
|
||||
if self._samedata and remove_torrents:
|
||||
remove_ids = [t.get("id") for t in remove_torrents]
|
||||
remove_torrents_plus = []
|
||||
for remove_torrent in remove_torrents:
|
||||
name = remove_torrent.get("name")
|
||||
size = remove_torrent.get("size")
|
||||
for torrent in torrents:
|
||||
if downloader == "qbittorrent":
|
||||
item_plus = self.__get_qb_torrent(torrent)
|
||||
plus_id = torrent.hash
|
||||
plus_name = torrent.name
|
||||
plus_size = torrent.size
|
||||
plus_site = StringUtils.get_url_sld(torrent.tracker)
|
||||
else:
|
||||
item_plus = self.__get_tr_torrent(torrent)
|
||||
if not item_plus:
|
||||
continue
|
||||
if item_plus.get("name") == name \
|
||||
and item_plus.get("size") == size \
|
||||
and item_plus.get("id") not in [t.get("id") for t in remove_torrents]:
|
||||
remove_torrents_plus.append(item_plus)
|
||||
remove_torrents.extend(remove_torrents_plus)
|
||||
plus_id = torrent.hashString
|
||||
plus_name = torrent.name
|
||||
plus_size = torrent.total_size
|
||||
plus_site = torrent.trackers[0].get("sitename") if torrent.trackers else ""
|
||||
# 比对名称和大小
|
||||
if plus_name == name \
|
||||
and plus_size == size \
|
||||
and plus_id not in remove_ids:
|
||||
remove_torrents_plus.append(
|
||||
{
|
||||
"id": plus_id,
|
||||
"name": plus_name,
|
||||
"site": plus_site,
|
||||
"size": plus_size
|
||||
}
|
||||
)
|
||||
if remove_torrents_plus:
|
||||
remove_torrents.extend(remove_torrents_plus)
|
||||
return remove_torrents
|
||||
|
||||
185
app/scheduler.py
185
app/scheduler.py
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
import pytz
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
@@ -40,57 +42,153 @@ class Scheduler(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
# 数据库连接
|
||||
self._db = SessionFactory()
|
||||
# 各服务的运行状态
|
||||
self._jobs = {
|
||||
"cookiecloud": {
|
||||
"func": CookieCloudChain(self._db).process,
|
||||
"running": False,
|
||||
},
|
||||
"mediaserver_sync": {
|
||||
"func": MediaServerChain(self._db).sync,
|
||||
"running": False,
|
||||
},
|
||||
"subscribe_tmdb": {
|
||||
"func": SubscribeChain(self._db).check,
|
||||
"running": False,
|
||||
},
|
||||
"subscribe_search": {
|
||||
"func": SubscribeChain(self._db).search,
|
||||
"running": False,
|
||||
},
|
||||
"subscribe_refresh": {
|
||||
"func": SubscribeChain(self._db).refresh,
|
||||
"running": False,
|
||||
},
|
||||
"transfer": {
|
||||
"func": TransferChain(self._db).process,
|
||||
"running": False,
|
||||
}
|
||||
}
|
||||
|
||||
# 调试模式不启动定时服务
|
||||
if settings.DEV:
|
||||
return
|
||||
|
||||
# CookieCloud定时同步
|
||||
if settings.COOKIECLOUD_INTERVAL:
|
||||
self._scheduler.add_job(CookieCloudChain(self._db).process,
|
||||
"interval",
|
||||
minutes=settings.COOKIECLOUD_INTERVAL,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
|
||||
name="同步CookieCloud站点")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="cookiecloud",
|
||||
name="同步CookieCloud站点",
|
||||
minutes=settings.COOKIECLOUD_INTERVAL,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
|
||||
kwargs={
|
||||
'job_id': 'cookiecloud'
|
||||
}
|
||||
)
|
||||
|
||||
# 媒体服务器同步
|
||||
if settings.MEDIASERVER_SYNC_INTERVAL:
|
||||
self._scheduler.add_job(MediaServerChain(self._db).sync, "interval",
|
||||
hours=settings.MEDIASERVER_SYNC_INTERVAL,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
|
||||
name="同步媒体服务器")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="mediaserver_sync",
|
||||
name="同步媒体服务器",
|
||||
hours=settings.MEDIASERVER_SYNC_INTERVAL,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
|
||||
kwargs={
|
||||
'job_id': 'mediaserver_sync'
|
||||
}
|
||||
)
|
||||
|
||||
# 新增订阅时搜索(5分钟检查一次)
|
||||
self._scheduler.add_job(SubscribeChain(self._db).search, "interval",
|
||||
minutes=5, kwargs={'state': 'N'})
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
minutes=5,
|
||||
kwargs={
|
||||
'job_id': 'subscribe_search',
|
||||
'state': 'N'
|
||||
}
|
||||
)
|
||||
|
||||
# 检查更新订阅TMDB数据(每隔6小时)
|
||||
self._scheduler.add_job(SubscribeChain(self._db).check, "interval", hours=6)
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_tmdb",
|
||||
name="订阅元数据更新",
|
||||
hours=6,
|
||||
kwargs={
|
||||
'job_id': 'subscribe_tmdb'
|
||||
}
|
||||
)
|
||||
|
||||
# 订阅状态每隔24小时搜索一次
|
||||
if settings.SUBSCRIBE_SEARCH:
|
||||
self._scheduler.add_job(SubscribeChain(self._db).search, "interval",
|
||||
hours=24, kwargs={'state': 'R'}, name="订阅搜索")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_search",
|
||||
name="订阅搜索",
|
||||
hours=24,
|
||||
kwargs={
|
||||
'job_id': 'subscribe_search',
|
||||
'state': 'R'
|
||||
}
|
||||
)
|
||||
|
||||
if settings.SUBSCRIBE_MODE == "spider":
|
||||
# 站点首页种子定时刷新模式
|
||||
triggers = TimerUtils.random_scheduler(num_executions=30)
|
||||
for trigger in triggers:
|
||||
self._scheduler.add_job(SubscribeChain(self._db).refresh, "cron",
|
||||
hour=trigger.hour, minute=trigger.minute, name="订阅刷新")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"cron",
|
||||
id=f"subscribe_refresh|{trigger.hour}:{trigger.minute}",
|
||||
name="订阅刷新",
|
||||
hour=trigger.hour,
|
||||
minute=trigger.minute,
|
||||
kwargs={
|
||||
'job_id': 'subscribe_refresh'
|
||||
})
|
||||
else:
|
||||
# RSS订阅模式
|
||||
if not settings.SUBSCRIBE_RSS_INTERVAL:
|
||||
settings.SUBSCRIBE_RSS_INTERVAL = 30
|
||||
elif settings.SUBSCRIBE_RSS_INTERVAL < 5:
|
||||
settings.SUBSCRIBE_RSS_INTERVAL = 5
|
||||
self._scheduler.add_job(SubscribeChain(self._db).refresh, "interval",
|
||||
minutes=settings.SUBSCRIBE_RSS_INTERVAL, name="订阅刷新")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_refresh",
|
||||
name="RSS订阅刷新",
|
||||
minutes=settings.SUBSCRIBE_RSS_INTERVAL,
|
||||
kwargs={
|
||||
'job_id': 'subscribe_refresh'
|
||||
}
|
||||
)
|
||||
|
||||
# 下载器文件转移(每5分钟)
|
||||
if settings.DOWNLOADER_MONITOR:
|
||||
self._scheduler.add_job(TransferChain(self._db).process, "interval", minutes=5, name="下载文件整理")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="transfer",
|
||||
name="下载文件整理",
|
||||
minutes=5,
|
||||
kwargs={
|
||||
'job_id': 'transfer'
|
||||
}
|
||||
)
|
||||
|
||||
# 公共定时服务
|
||||
self._scheduler.add_job(SchedulerChain(self._db).scheduler_job, "interval", minutes=10)
|
||||
self._scheduler.add_job(
|
||||
SchedulerChain(self._db).scheduler_job,
|
||||
"interval",
|
||||
minutes=10
|
||||
)
|
||||
|
||||
# 打印服务
|
||||
logger.debug(self._scheduler.print_jobs())
|
||||
@@ -98,11 +196,54 @@ class Scheduler(metaclass=Singleton):
|
||||
# 启动定时服务
|
||||
self._scheduler.start()
|
||||
|
||||
def list(self):
|
||||
def start(self, job_id: str, *args, **kwargs):
|
||||
"""
|
||||
启动定时服务
|
||||
"""
|
||||
# 处理job_id格式
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.get("running"):
|
||||
logger.warning(f"定时任务 {job_id} 正在运行 ...")
|
||||
return
|
||||
self._jobs[job_id]["running"] = True
|
||||
try:
|
||||
job["func"](*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务 {job_id} 执行失败:{e}")
|
||||
self._jobs[job_id]["running"] = False
|
||||
|
||||
def list(self) -> List[schemas.ScheduleInfo]:
|
||||
"""
|
||||
当前所有任务
|
||||
"""
|
||||
return self._scheduler.get_jobs()
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
job_id = job.id.split("|")[0]
|
||||
if not self._jobs.get(job_id):
|
||||
continue
|
||||
# 任务状态
|
||||
status = "正在运行" if self._jobs[job_id].get("running") else "等待"
|
||||
# 下次运行时间
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=job.name,
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
return schedulers
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
|
||||
@@ -36,6 +36,12 @@ class DownloadHistory(BaseModel):
|
||||
torrent_description: Optional[str] = None
|
||||
# 站点
|
||||
torrent_site: Optional[str] = None
|
||||
# 下载用户
|
||||
userid: Optional[str] = None
|
||||
# 下载渠道
|
||||
channel: Optional[str] = None
|
||||
# 创建时间
|
||||
date: Optional[str] = None
|
||||
# 备注
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
@@ -51,3 +51,5 @@ class NotificationSwitch(BaseModel):
|
||||
telegram: Optional[bool] = False
|
||||
# Slack开关
|
||||
slack: Optional[bool] = False
|
||||
# SynologyChat开关
|
||||
synologychat: Optional[bool] = False
|
||||
|
||||
@@ -35,6 +35,8 @@ class TransferInfo(BaseModel):
|
||||
"""
|
||||
文件转移结果信息
|
||||
"""
|
||||
# 是否成功标志
|
||||
success: bool = True
|
||||
# 转移⼁路径
|
||||
path: Optional[Path] = None
|
||||
# 转移后路径
|
||||
|
||||
@@ -34,8 +34,8 @@ class EventType(Enum):
|
||||
DownloadAdded = "download.added"
|
||||
# 删除历史记录
|
||||
HistoryDeleted = "history.deleted"
|
||||
# 删除媒体库文件
|
||||
MediaDeleted = "media.deleted"
|
||||
# 删除下载源文件
|
||||
DownloadFileDeleted = "downloadfile.deleted"
|
||||
# 用户外来消息
|
||||
UserMessage = "user.message"
|
||||
# 通知消息
|
||||
@@ -48,7 +48,7 @@ class SystemConfigKey(Enum):
|
||||
UserInstalledPlugins = "UserInstalledPlugins"
|
||||
# 搜索结果
|
||||
SearchResults = "SearchResults"
|
||||
# 索引站点范围
|
||||
# 搜索站点范围
|
||||
IndexerSites = "IndexerSites"
|
||||
# 订阅站点范围
|
||||
RssSites = "RssSites"
|
||||
@@ -60,10 +60,14 @@ class SystemConfigKey(Enum):
|
||||
CustomReleaseGroups = "CustomReleaseGroups"
|
||||
# 自定义识别词
|
||||
CustomIdentifiers = "CustomIdentifiers"
|
||||
# 过滤规则
|
||||
FilterRules = "FilterRules"
|
||||
# 搜索优先级规则
|
||||
SearchFilterRules = "SearchFilterRules"
|
||||
# 订阅优先级规则
|
||||
SubscribeFilterRules = "SubscribeFilterRules"
|
||||
# 洗版规则
|
||||
FilterRules2 = "FilterRules2"
|
||||
BestVersionFilterRules = "BestVersionFilterRules"
|
||||
# 默认过滤规则
|
||||
DefaultFilterRules = "DefaultFilterRules"
|
||||
# 转移屏蔽词
|
||||
TransferExcludeWords = "TransferExcludeWords"
|
||||
|
||||
@@ -105,3 +109,4 @@ class MessageChannel(Enum):
|
||||
Wechat = "微信"
|
||||
Telegram = "Telegram"
|
||||
Slack = "Slack"
|
||||
SynologyChat = "SynologyChat"
|
||||
|
||||
@@ -172,39 +172,3 @@ class RequestUtils:
|
||||
cookiesList.append(cookies)
|
||||
return cookiesList
|
||||
return cookie_dict
|
||||
|
||||
|
||||
class WebUtils:
|
||||
@staticmethod
|
||||
def get_location(ip: str):
|
||||
"""
|
||||
https://api.mir6.com/api/ip
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"ip": "240e:97c:2f:1::5c",
|
||||
"dec": "47925092370311863177116789888333643868",
|
||||
"country": "中国",
|
||||
"countryCode": "CN",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"districts": "",
|
||||
"idc": "",
|
||||
"isp": "中国电信",
|
||||
"net": "数据中心",
|
||||
"zipcode": "510000",
|
||||
"areacode": "020",
|
||||
"protocol": "IPv6",
|
||||
"location": "中国[CN] 广东省 广州市",
|
||||
"myip": "125.89.7.89",
|
||||
"time": "2023-09-01 17:28:23"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
r = RequestUtils().get_res(f"https://api.mir6.com/api/ip?ip={ip}&type=json")
|
||||
if r:
|
||||
return r.json().get("data", {}).get("location") or ''
|
||||
except Exception as err:
|
||||
return str(err)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
class PathUtils:
|
||||
|
||||
@staticmethod
|
||||
def get_dir_files(in_path, exts="", filesize=0, episode_format=None):
|
||||
"""
|
||||
获得目录下的媒体文件列表List ,按后缀、大小、格式过滤
|
||||
"""
|
||||
if not in_path:
|
||||
return []
|
||||
if not os.path.exists(in_path):
|
||||
return []
|
||||
ret_list = []
|
||||
if os.path.isdir(in_path):
|
||||
for root, dirs, files in os.walk(in_path):
|
||||
for file in files:
|
||||
cur_path = os.path.join(root, file)
|
||||
# 检查路径是否合法
|
||||
if PathUtils.is_invalid_path(cur_path):
|
||||
continue
|
||||
# 检查格式匹配
|
||||
if episode_format and not episode_format.match(file):
|
||||
continue
|
||||
# 检查后缀
|
||||
if exts and os.path.splitext(file)[-1].lower() not in exts:
|
||||
continue
|
||||
# 检查文件大小
|
||||
if filesize and os.path.getsize(cur_path) < filesize:
|
||||
continue
|
||||
# 命中
|
||||
if cur_path not in ret_list:
|
||||
ret_list.append(cur_path)
|
||||
else:
|
||||
# 检查路径是否合法
|
||||
if PathUtils.is_invalid_path(in_path):
|
||||
return []
|
||||
# 检查后缀
|
||||
if exts and os.path.splitext(in_path)[-1].lower() not in exts:
|
||||
return []
|
||||
# 检查格式
|
||||
if episode_format and not episode_format.match(os.path.basename(in_path)):
|
||||
return []
|
||||
# 检查文件大小
|
||||
if filesize and os.path.getsize(in_path) < filesize:
|
||||
return []
|
||||
ret_list.append(in_path)
|
||||
return ret_list
|
||||
|
||||
@staticmethod
|
||||
def get_dir_level1_files(in_path, exts=""):
|
||||
"""
|
||||
查询目录下的文件(只查询一级)
|
||||
"""
|
||||
ret_list = []
|
||||
if not os.path.exists(in_path):
|
||||
return []
|
||||
for file in os.listdir(in_path):
|
||||
path = os.path.join(in_path, file)
|
||||
if os.path.isfile(path):
|
||||
if not exts or os.path.splitext(file)[-1].lower() in exts:
|
||||
ret_list.append(path)
|
||||
return ret_list
|
||||
|
||||
@staticmethod
|
||||
def get_dir_level1_medias(in_path, exts=""):
|
||||
"""
|
||||
根据后缀,返回目录下所有的文件及文件夹列表(只查询一级)
|
||||
"""
|
||||
ret_list = []
|
||||
if not os.path.exists(in_path):
|
||||
return []
|
||||
if os.path.isdir(in_path):
|
||||
for file in os.listdir(in_path):
|
||||
path = os.path.join(in_path, file)
|
||||
if os.path.isfile(path):
|
||||
if not exts or os.path.splitext(file)[-1].lower() in exts:
|
||||
ret_list.append(path)
|
||||
else:
|
||||
ret_list.append(path)
|
||||
else:
|
||||
ret_list.append(in_path)
|
||||
return ret_list
|
||||
|
||||
@staticmethod
|
||||
def is_invalid_path(path):
|
||||
"""
|
||||
判断是否不能处理的路径
|
||||
"""
|
||||
if not path:
|
||||
return True
|
||||
if path.find('/@Recycle/') != -1 or path.find('/#recycle/') != -1 or path.find('/.') != -1 or path.find(
|
||||
'/@eaDir') != -1:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_path_in_path(path1, path2):
|
||||
"""
|
||||
判断两个路径是否包含关系 path1 in path2
|
||||
"""
|
||||
if not path1 or not path2:
|
||||
return False
|
||||
path1 = os.path.normpath(path1).replace("\\", "/")
|
||||
path2 = os.path.normpath(path2).replace("\\", "/")
|
||||
if path1 == path2:
|
||||
return True
|
||||
path = os.path.dirname(path2)
|
||||
while True:
|
||||
if path == path1:
|
||||
return True
|
||||
path = os.path.dirname(path)
|
||||
if path == os.path.dirname(path):
|
||||
break
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_bluray_dir(path):
|
||||
"""
|
||||
判断是否蓝光原盘目录,是则返回原盘的根目录,否则返回空
|
||||
"""
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
if os.path.isdir(path):
|
||||
if os.path.exists(os.path.join(path, "BDMV", "index.bdmv")):
|
||||
return path
|
||||
elif os.path.normpath(path).endswith("BDMV") \
|
||||
and os.path.exists(os.path.join(path, "index.bdmv")):
|
||||
return os.path.dirname(path)
|
||||
elif os.path.normpath(path).endswith("STREAM") \
|
||||
and os.path.exists(os.path.join(os.path.dirname(path), "index.bdmv")):
|
||||
return PathUtils.get_parent_paths(path, 2)
|
||||
else:
|
||||
# 电视剧原盘下会存在多个目录形如:Spider Man 2021/DIsc1, Spider Man 2021/Disc2
|
||||
for level1 in PathUtils.get_dir_level1_medias(path):
|
||||
if os.path.exists(os.path.join(level1, "BDMV", "index.bdmv")):
|
||||
return path
|
||||
return None
|
||||
else:
|
||||
if str(os.path.splitext(path)[-1]).lower() in [".m2ts", ".ts"] \
|
||||
and os.path.normpath(os.path.dirname(path)).endswith("STREAM") \
|
||||
and os.path.exists(os.path.join(PathUtils.get_parent_paths(path, 2), "index.bdmv")):
|
||||
return PathUtils.get_parent_paths(path, 3)
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_parent_paths(path, level: int = 1):
|
||||
"""
|
||||
获取父目录路径,level为向上查找的层数
|
||||
"""
|
||||
for lv in range(0, level):
|
||||
path = os.path.dirname(path)
|
||||
return path
|
||||
@@ -17,19 +17,18 @@ class SiteUtils:
|
||||
if html.xpath("//input[@type='password']"):
|
||||
return False
|
||||
# 是否存在登出和用户面板等链接
|
||||
xpaths = ['//a[contains(@href, "logout")'
|
||||
' or contains(@data-url, "logout")'
|
||||
' or contains(@href, "mybonus") '
|
||||
' or contains(@onclick, "logout")'
|
||||
' or contains(@href, "usercp")]',
|
||||
'//form[contains(@action, "logout")]']
|
||||
xpaths = [
|
||||
'//a[contains(@href, "logout")'
|
||||
' or contains(@data-url, "logout")'
|
||||
' or contains(@href, "mybonus") '
|
||||
' or contains(@onclick, "logout")'
|
||||
' or contains(@href, "usercp")]',
|
||||
'//form[contains(@action, "logout")]',
|
||||
'//div[@class="user-info-side"]'
|
||||
]
|
||||
for xpath in xpaths:
|
||||
if html.xpath(xpath):
|
||||
return True
|
||||
user_info_div = html.xpath('//div[@class="user-info-side"]')
|
||||
if user_info_div:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -61,7 +61,9 @@ class SystemUtils:
|
||||
移动
|
||||
"""
|
||||
try:
|
||||
# 当前目录改名
|
||||
temp = src.replace(src.parent / dest.name)
|
||||
# 移动到目标目录
|
||||
shutil.move(temp, dest)
|
||||
return 0, ""
|
||||
except Exception as err:
|
||||
@@ -74,7 +76,11 @@ class SystemUtils:
|
||||
硬链接
|
||||
"""
|
||||
try:
|
||||
dest.hardlink_to(src)
|
||||
# link到当前目录并改名
|
||||
tmp_path = (src.parent / dest.name).with_suffix(".mp")
|
||||
tmp_path.hardlink_to(src)
|
||||
# 移动到目标目录
|
||||
shutil.move(tmp_path, dest)
|
||||
return 0, ""
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
@@ -106,7 +112,7 @@ class SystemUtils:
|
||||
|
||||
if directory.is_file():
|
||||
return [directory]
|
||||
|
||||
|
||||
if not min_filesize:
|
||||
min_filesize = 0
|
||||
|
||||
@@ -122,6 +128,36 @@ class SystemUtils:
|
||||
|
||||
return files
|
||||
|
||||
@staticmethod
|
||||
def exits_files(directory: Path, extensions: list, min_filesize: int = 0) -> bool:
|
||||
"""
|
||||
判断目录下是否存在指定扩展名的文件
|
||||
:return True存在 False不存在
|
||||
"""
|
||||
|
||||
if not min_filesize:
|
||||
min_filesize = 0
|
||||
|
||||
if not directory.exists():
|
||||
return False
|
||||
|
||||
if directory.is_file():
|
||||
return True
|
||||
|
||||
if not min_filesize:
|
||||
min_filesize = 0
|
||||
|
||||
pattern = r".*(" + "|".join(extensions) + ")$"
|
||||
|
||||
# 遍历目录及子目录
|
||||
for path in directory.rglob('**/*'):
|
||||
if path.is_file() \
|
||||
and re.match(pattern, path.name, re.IGNORECASE) \
|
||||
and path.stat().st_size >= min_filesize * 1024 * 1024:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def list_sub_files(directory: Path, extensions: list) -> List[Path]:
|
||||
"""
|
||||
@@ -311,6 +347,7 @@ class SystemUtils:
|
||||
# 创建 Docker 客户端
|
||||
client = docker.DockerClient(base_url='tcp://127.0.0.1:38379')
|
||||
# 获取当前容器的 ID
|
||||
container_id = None
|
||||
with open('/proc/self/mountinfo', 'r') as f:
|
||||
data = f.read()
|
||||
index_resolv_conf = data.find("resolv.conf")
|
||||
@@ -318,6 +355,12 @@ class SystemUtils:
|
||||
index_second_slash = data.rfind("/", 0, index_resolv_conf)
|
||||
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
|
||||
container_id = data[index_first_slash:index_second_slash]
|
||||
if len(container_id) < 20:
|
||||
index_resolv_conf = data.find("/sys/fs/cgroup/devices")
|
||||
if index_resolv_conf != -1:
|
||||
index_second_slash = data.rfind(" ", 0, index_resolv_conf)
|
||||
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
|
||||
container_id = data[index_first_slash:index_second_slash]
|
||||
if not container_id:
|
||||
return False, "获取容器ID失败!"
|
||||
# 重启当前容器
|
||||
|
||||
90
app/utils/web.py
Normal file
90
app/utils/web.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class WebUtils:
|
||||
|
||||
@staticmethod
|
||||
def get_location(ip: str):
|
||||
"""
|
||||
查询IP所属地
|
||||
"""
|
||||
return WebUtils.get_location1(ip) or WebUtils.get_location2(ip)
|
||||
|
||||
@staticmethod
|
||||
def get_location1(ip: str):
|
||||
"""
|
||||
https://api.mir6.com/api/ip
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"ip": "240e:97c:2f:1::5c",
|
||||
"dec": "47925092370311863177116789888333643868",
|
||||
"country": "中国",
|
||||
"countryCode": "CN",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"districts": "",
|
||||
"idc": "",
|
||||
"isp": "中国电信",
|
||||
"net": "数据中心",
|
||||
"zipcode": "510000",
|
||||
"areacode": "020",
|
||||
"protocol": "IPv6",
|
||||
"location": "中国[CN] 广东省 广州市",
|
||||
"myip": "125.89.7.89",
|
||||
"time": "2023-09-01 17:28:23"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
r = RequestUtils().get_res(f"https://api.mir6.com/api/ip?ip={ip}&type=json")
|
||||
if r:
|
||||
return r.json().get("data", {}).get("location") or ''
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_location2(ip: str):
|
||||
"""
|
||||
https://whois.pconline.com.cn/ipJson.jsp?json=true&ip=
|
||||
{
|
||||
"ip": "122.8.12.22",
|
||||
"pro": "上海市",
|
||||
"proCode": "310000",
|
||||
"city": "上海市",
|
||||
"cityCode": "310000",
|
||||
"region": "",
|
||||
"regionCode": "0",
|
||||
"addr": "上海市 铁通",
|
||||
"regionNames": "",
|
||||
"err": ""
|
||||
}
|
||||
"""
|
||||
try:
|
||||
r = RequestUtils().get_res(f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip}")
|
||||
if r:
|
||||
return r.json().get("addr") or ''
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.2.1'
|
||||
APP_VERSION = 'v1.2.7'
|
||||
|
||||
Reference in New Issue
Block a user