Compare commits

...

94 Commits

Author SHA1 Message Date
jxxghp
7dab7fbe66 更新 transhandler.py 2025-05-30 21:42:50 +08:00
jxxghp
62c06b6593 fix #4216 2025-05-30 17:32:37 +08:00
jxxghp
000b62969f v2.5.2 2025-05-30 17:06:21 +08:00
jxxghp
b4473bb4a7 fix 插件分身服务注册 2025-05-30 16:59:54 +08:00
jxxghp
2c0e06d599 fix 插件分身服务注册 2025-05-30 13:37:40 +08:00
jxxghp
d2c55e8ed3 Merge remote-tracking branch 'origin/v2' into v2 2025-05-30 08:07:57 +08:00
jxxghp
714abaa25a fix rename 2025-05-30 08:07:53 +08:00
jxxghp
0017eb987b Merge pull request #4365 from Aqr-K/fix-modules/thetvdb 2025-05-29 21:17:38 +08:00
Aqr-K
e5a0894692 fix(tvdb): 解决无网络环境时,tvdb 模块初始化时,仍然会进入超长等待的问题
- 改为惰性初始化,启动时不再执行 `auth` ,调用方法时,再进行 `auth` (保留 auth_token 过期检查重新 `auth` 的功能);
- 使用 双重检查锁定 的方式,保证线程安全;
- 统一通过一个 `timeout` 值进行设置,默认值从30秒降为15秒,保持与tmdb相同。
2025-05-29 20:04:18 +08:00
jxxghp
a8e00e9f0f fix apis 2025-05-29 13:35:01 +08:00
jxxghp
77a4c271ae Merge pull request #4361 from madrays/v2
增加缓存管理页面
2025-05-29 09:21:45 +08:00
jxxghp
014b77c3c7 v2.5.1-1 2025-05-29 08:30:31 +08:00
jxxghp
076e241056 fix tvdb 2025-05-29 08:30:14 +08:00
jxxghp
7ce57cc67a fix 2025-05-29 08:22:45 +08:00
jxxghp
da0343283a 支持在插件文件夹中管理分身插件的添加与移除 2025-05-29 08:16:54 +08:00
jxxghp
d5f7f1ba91 fix tvdb api 2025-05-29 08:03:12 +08:00
jxxghp
8761c82afe fix TVDB代理与SSL校验 #4356 2025-05-29 07:14:42 +08:00
madrays
13023141bc 增加缓存管理页面 2025-05-29 00:46:11 +08:00
jxxghp
4dd2038625 Merge pull request #4360 from cddjr/fix_TransHandler 2025-05-29 00:06:32 +08:00
景大侠
06a32b0e9d fix: TransHandler误报success的bug 2025-05-28 23:52:23 +08:00
jxxghp
c91ab7a76b 添加新的设定项 2025-05-28 21:05:29 +08:00
jxxghp
0344aa6a49 更新 version.py 2025-05-28 20:34:59 +08:00
jxxghp
a748c9d750 修复:更新壁纸助手以支持更多图片格式 2025-05-28 08:26:44 +08:00
jxxghp
038dc372b7 更新 config.py 2025-05-28 07:03:22 +08:00
jxxghp
bc8198fb8a Merge pull request #4356 from TimoYoung/v2 2025-05-27 21:06:54 +08:00
TimoYoung
f42275bd83 Merge remote-tracking branch 'origin/v2' into v2 2025-05-27 18:02:21 +08:00
TimoYoung
6bd86a724e fix:区分series和movie id 2025-05-27 17:58:37 +08:00
TimoYoung
fc96cfe8a0 feat:tvdb模块重写,更换tvdbv4 api,增加搜索能力
sonarr /series/lookup接口重写,直接用标题在tvdb查询剧集
2025-05-27 17:32:25 +08:00
jxxghp
a9f25fe7d6 fix bug 2025-05-27 12:31:43 +08:00
jxxghp
f740fed5f2 fix bug 2025-05-26 13:30:30 +08:00
jxxghp
a6d1bd12a2 fix:优化插件分身性能
feat:分身插件删除时清理文件
2025-05-26 13:21:47 +08:00
jxxghp
e8ab20acf2 Merge pull request #4351 from madrays/v2 2025-05-26 11:08:30 +08:00
madrays
ccfe193800 增加插件分身功能 2025-05-26 10:55:40 +08:00
jxxghp
bdccedca59 更新 system.py 2025-05-26 07:45:21 +08:00
DDSRem
9abb1488df Merge pull request #4348 from Aqr-K/fix-sh
fix(sh): 引号格式问题
2025-05-25 23:48:45 +08:00
Aqr-K
195fc1bdc3 fix(sh): 引号格式问题 2025-05-25 23:47:23 +08:00
jxxghp
2a9129f470 更新 version.py 2025-05-25 20:15:44 +08:00
jxxghp
acbfc0cc6e Merge pull request #4343 from Aqr-K/fix-sh 2025-05-25 19:53:58 +08:00
Aqr-K
bfb0c75e95 fix(sh): 补全调用 2025-05-25 18:50:41 +08:00
jxxghp
161a2ddae8 Merge pull request #4344 from DDS-Derek/dev 2025-05-25 18:32:29 +08:00
Aqr-K
99621cfd66 fix(config): 强制指定 quote_mode ,避免后续依赖升级,默认值不再是 always 2025-05-25 18:30:00 +08:00
DDSRem
e6e7234215 fix(u115): get information directly through id 2025-05-25 18:27:06 +08:00
DDSRem
5b7b329279 fix(docker): repair restart judgment
当 DOCKER_CLIENT_API 不等于默认值时代表外部调用重启,无需再映射 `/var/run/docker.sock`
2025-05-25 18:20:04 +08:00
Aqr-K
3abb2c8674 fix(sh): 重启时,无法同时结合 系统变量 与 env 文件,进行变量读取的问题。 2025-05-25 18:15:35 +08:00
jxxghp
39de89254f add Docker Client API地址 2025-05-25 14:55:51 +08:00
jxxghp
ac941968cb 更新 plugin.py 2025-05-25 11:22:08 +08:00
jxxghp
96f603bfd1 Merge pull request #4339 from jtcymc/v2 2025-05-25 08:01:00 +08:00
shaw
677e38c62d fix(SearchChain): with 关闭线程池
- 使用 with 语句管理 ThreadPoolExecutor,确保线程池正确关闭
2025-05-25 00:44:19 +08:00
jxxghp
72fce20905 feat:整理后记录字幕和音频文件 2025-05-24 20:58:46 +08:00
jxxghp
1eb41c20d5 fix TransferInfo 2025-05-24 15:40:03 +08:00
DDSRem
dd0c1d331f Merge pull request #4334 from DDS-Derek/dev
fix(plugin): dependency dynamic refresh
2025-05-24 09:24:41 +08:00
DDSRem
12760a70a1 fix(plugin): dependency dynamic refresh 2025-05-24 09:23:47 +08:00
jxxghp
525d17270f fix #4332 2025-05-24 06:37:59 +08:00
jxxghp
bc9959f5ab Merge pull request #4333 from Aqr-K/fix-log 2025-05-24 06:31:41 +08:00
jxxghp
94a8cd5128 Merge pull request #4331 from madrays/v2 2025-05-24 06:30:59 +08:00
Aqr-K
5a1b2c4938 fix(log): 区分 主程序日志 与 插件日志 2025-05-24 06:20:41 +08:00
madrays
851a2ac03a Delete requirements.in 2025-05-24 04:12:53 +08:00
madrays
34d7707f53 Delete config/plugins/twofahelper/twofahelper_sites.json 2025-05-24 04:12:13 +08:00
madrays
0aac7f62a3 Delete config/app.env 2025-05-24 04:11:54 +08:00
madrays
34379b92d0 重构插件页面,增加文件夹功能 2025-05-24 03:57:04 +08:00
DDSRem
250999f9f5 Merge pull request #4330 from Aqr-K/patch-1
fix(log): 修复 docker 环境下,重复打印日志的问题
2025-05-24 01:18:59 +08:00
Aqr-K
2b3832222b fix(log): 修复 docker 环境下,重复打印日志的问题 2025-05-24 01:16:14 +08:00
jxxghp
c5f6d0e721 更新 config.py 2025-05-23 21:05:50 +08:00
jxxghp
dbb0cf15b8 fix 最新入库条目 2025-05-23 07:12:47 +08:00
jxxghp
ab202ba951 Merge pull request #4324 from wumode/fix_typo 2025-05-23 06:45:55 +08:00
wumode
e2c13aa7ed fix: 确保名称识别正确兜底 2025-05-23 00:23:45 +08:00
jxxghp
c1ab19f3cf 更新 version.py 2025-05-21 21:42:42 +08:00
jxxghp
beebfb2e19 fix 2025-05-21 08:39:04 +08:00
jxxghp
cfca90aa7d fix delay get_item 2025-05-19 20:06:46 +08:00
jxxghp
19fe0a32c8 fix #4308 2025-05-19 12:53:55 +08:00
jxxghp
76659f8837 fix #4308 2025-05-19 12:51:34 +08:00
jxxghp
2254715190 Merge pull request #4308 from k1z/v2
修复重复识别缓存种子的bug
2025-05-19 12:29:13 +08:00
jxxghp
ae1a5460d4 fix FetchMedias Action 2025-05-19 12:26:27 +08:00
k1z
27d9f910ff 修复重复识别缓存种子的bug 2025-05-19 10:35:09 +08:00
k1z
28db4881d7 修复重复识别缓存种子的bug 2025-05-19 10:05:39 +08:00
jxxghp
7c76c3ccd6 rollback #4296 2025-05-18 21:40:06 +08:00
jxxghp
007bd24374 fix message link check 2025-05-18 15:25:45 +08:00
jxxghp
c8dc30287c fix #4294 x26[45] 调整为小写x 2025-05-18 15:15:01 +08:00
jxxghp
360184bbd1 fix 2025-05-18 13:50:43 +08:00
jxxghp
e8ed2454a1 feat:消息为链接时,交由第三方处理 2025-05-18 13:22:42 +08:00
jxxghp
923ecf29b8 fix #4294 2025-05-18 13:16:06 +08:00
jxxghp
a8f8bf5872 增强MetaBase类以支持tmdbid和doubanid的赋值,并为Emby格式ID识别添加测试用例。 2025-05-18 13:03:35 +08:00
jxxghp
bedcd94020 优化find_metainfo函数,增加对Emby格式ID标签的支持,并添加相应的测试用例以验证不同ID格式的识别。 2025-05-18 12:55:25 +08:00
jxxghp
959d4da1f8 Merge pull request #4300 from DDS-Derek/dev 2025-05-18 10:05:14 +08:00
DDSRem
861453c1a8 fix(u115): refresh delay 2025-05-18 10:03:36 +08:00
jxxghp
2f4072da0d Merge pull request #4297 from wikrin/v2 2025-05-17 20:20:30 +08:00
Attente
411b5e0ca6 fix(database): 将下载模板中的 title 变量更改为 torrent_title 2025-05-17 19:45:49 +08:00
Attente
3f03963811 fix(themoviedb): 直接在 API 层次处理剧集组集号
- 移除 season_group_details 中的冗余集号处理
2025-05-17 19:45:49 +08:00
jxxghp
d43f81e118 Merge pull request #4296 from Pollo3470/fix-bluray-match 2025-05-17 18:11:27 +08:00
Pollo
b97dbd2515 fix: 优化 Blu-ray 匹配规则 2025-05-17 17:56:05 +08:00
jxxghp
c6a20a9ed3 Merge pull request #4294 from Miralia/v2 2025-05-16 21:57:19 +08:00
Miralia
27f0f29eef fix(meta): 修复部分格式识别问题 2025-05-16 20:49:23 +08:00
jxxghp
223508ae72 Merge pull request #4292 from Seed680/v2 2025-05-16 15:55:31 +08:00
qiaoyun680
bce0a4b8cd bugfix:如果自定义壁纸API是图片地址,应该返回请求地址 2025-05-16 15:48:37 +08:00
54 changed files with 3400 additions and 2394 deletions

View File

@@ -40,54 +40,67 @@ class FetchMediasAction(BaseAction):
{
"func": RecommendChain().tmdb_trending,
"name": '流行趋势',
"api_path": "recommend/tmdb_trending"
},
{
"func": RecommendChain().douban_movie_showing,
"name": '正在热映',
"api_path": "recommend/douban_showing"
},
{
"func": RecommendChain().bangumi_calendar,
"name": 'Bangumi每日放送',
"api_path": "recommend/bangumi_calendar"
},
{
"func": RecommendChain().tmdb_movies,
"name": 'TMDB热门电影',
"api_path": "recommend/tmdb_movies"
},
{
"func": RecommendChain().tmdb_tvs,
"name": 'TMDB热门电视剧',
"api_path": "recommend/tmdb_tvs?with_original_language=zh|en|ja|ko"
},
{
"func": RecommendChain().douban_movie_hot,
"name": '豆瓣热门电影',
"api_path": "recommend/douban_movie_hot"
},
{
"func": RecommendChain().douban_tv_hot,
"name": '豆瓣热门电视剧',
"api_path": "recommend/douban_tv_hot"
},
{
"func": RecommendChain().douban_tv_animation,
"name": '豆瓣热门动漫',
"api_path": "recommend/douban_tv_animation"
},
{
"func": RecommendChain().douban_movies,
"name": '豆瓣最新电影',
"api_path": "recommend/douban_movies"
},
{
"func": RecommendChain().douban_tvs,
"name": '豆瓣最新电视剧',
"api_path": "recommend/douban_tvs"
},
{
"func": RecommendChain().douban_movie_top250,
"name": '豆瓣电影TOP250',
"api_path": "recommend/douban_movie_top250"
},
{
"func": RecommendChain().douban_tv_weekly_chinese,
"name": '豆瓣国产剧集榜',
"api_path": "recommend/douban_tv_weekly_chinese"
},
{
"func": RecommendChain().douban_tv_weekly_global,
"name": '豆瓣全球剧集榜',
"api_path": "recommend/douban_tv_weekly_global"
}
]
@@ -124,7 +137,7 @@ class FetchMediasAction(BaseAction):
获取数据源
"""
for s in self.__inner_sources:
if s['name'] == source:
if s['api_path'] == source:
return s
return None
@@ -135,13 +148,14 @@ class FetchMediasAction(BaseAction):
params = FetchMediasParams(**params)
try:
if params.source_type == "ranking":
for name in params.sources:
for api_path in params.sources:
if global_vars.is_workflow_stopped(workflow_id):
break
source = self.__get_source(name)
source = self.__get_source(api_path)
if not source:
continue
logger.info(f"获取媒体数据 {source} ...")
name = source.get("name")
results = []
if source.get("func"):
results = source['func']()

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
transfer, mediaserver, bangumi, storage, discover, recommend, workflow
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -27,3 +27,4 @@ api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
api_router.include_router(discover.router, prefix="/discover", tags=["discover"])
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"])

View File

@@ -149,11 +149,12 @@ def seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any
"""
查询媒体剧集组列表themoviedb
"""
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)
if not mediainfo:
return []
return mediainfo.episode_groups
@router.get("/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason])
def seasons(mediaid: Optional[str] = None,
title: Optional[str] = None,
@@ -219,14 +220,13 @@ def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str
)
event = eventmanager.send_event(ChainEventType.MediaRecognizeConvert, event_data)
# 使用事件返回的上下文数据
if event and event.event_data:
if event and event.event_data and event.event_data.media_dict:
event_data: MediaRecognizeConvertEventData = event.event_data
if event_data.media_dict:
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
mediainfo = MediaChain().recognize_media(tmdbid=new_id, mtype=mtype)
elif event_data.convert_type == "douban":
mediainfo = MediaChain().recognize_media(doubanid=new_id, mtype=mtype)
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
mediainfo = MediaChain().recognize_media(tmdbid=new_id, mtype=mtype)
elif event_data.convert_type == "douban":
mediainfo = MediaChain().recognize_media(doubanid=new_id, mtype=mtype)
elif title:
# 使用名称识别兜底
meta = MetaInfo(title)

View File

@@ -121,7 +121,7 @@ def not_exists(media_in: schemas.MediaInfo,
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
def latest(server: str, count: Optional[int] = 18,
def latest(server: str, count: Optional[int] = 20,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器最新入库条目

View File

@@ -1,4 +1,5 @@
import mimetypes
import shutil
from typing import Annotated, Any, List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException
@@ -144,11 +145,11 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
local_plugins = PluginManager().get_local_plugins()
# 已安装插件
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
# 未安装的本地插件
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
if state == "installed":
return installed_plugins
# 未安装的本地插件
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
# 在线插件
online_plugins = PluginManager().get_online_plugins()
if not online_plugins:
@@ -177,6 +178,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
if state == "market":
# 返回未安装的插件
return market_plugins
# 返回所有插件
return installed_plugins + market_plugins
@@ -330,10 +332,11 @@ def reset_plugin(plugin_id: str,
"""
根据插件ID重置插件配置及数据
"""
plugin_manager = PluginManager()
# 删除配置
PluginManager().delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_config(plugin_id)
# 删除插件所有数据
PluginManager().delete_plugin_data(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
# 重新加载插件
reload_plugin(plugin_id)
return schemas.Response(success=True)
@@ -374,6 +377,71 @@ def plugin_static_file(plugin_id: str, filepath: str):
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.get("/folders", summary="获取插件文件夹配置", response_model=dict)
def get_plugin_folders(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
"""
获取插件文件夹分组配置
"""
try:
result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
return result
except Exception as e:
logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}")
return {}
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
def save_plugin_folders(folders: dict, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
"""
保存插件文件夹分组配置
"""
try:
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(success=True)
except Exception as e:
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
return schemas.Response(success=False, message=str(e))
@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response)
def create_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
"""
创建新的插件文件夹
"""
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
if folder_name not in folders:
folders[folder_name] = []
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 创建成功")
else:
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在")
@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response)
def delete_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
"""
删除插件文件夹
"""
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
if folder_name in folders:
del folders[folder_name]
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 删除成功")
else:
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在")
@router.put("/folders/{folder_name}/plugins", summary="更新文件夹中的插件", response_model=schemas.Response)
def update_folder_plugins(folder_name: str, plugin_ids: List[str], _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
"""
更新指定文件夹中的插件列表
"""
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
folders[folder_name] = plugin_ids
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新")
@router.get("/{plugin_id}", summary="获取插件配置")
def plugin_config(plugin_id: str,
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
@@ -389,10 +457,11 @@ def set_plugin_config(plugin_id: str, conf: dict,
"""
更新插件配置
"""
plugin_manager = PluginManager()
# 保存配置
PluginManager().save_plugin_config(plugin_id, conf)
plugin_manager.save_plugin_config(plugin_id, conf)
# 重新生效插件
PluginManager().init_plugin(plugin_id, conf)
plugin_manager.init_plugin(plugin_id, conf)
# 注册插件服务
register_plugin(plugin_id)
return schemas.Response(success=True)
@@ -404,18 +473,153 @@ def uninstall_plugin(plugin_id: str,
"""
卸载插件
"""
config_oper = SystemConfigOper()
# 删除已安装信息
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
for plugin in install_plugins:
if plugin == plugin_id:
install_plugins.remove(plugin)
break
# 保存
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 移除插件API
remove_plugin_api(plugin_id)
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
# 判断是否为分身
plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id)
if getattr(plugin_class, "is_clone", False):
# 如果是分身插件,则删除分身数据和配置
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
# 删除分身文件
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
if plugin_base_dir.exists():
try:
shutil.rmtree(plugin_base_dir)
plugin_manager.plugins.pop(plugin_id, None)
except Exception as e:
logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}")
# 从插件文件夹中移除该插件
_remove_plugin_from_folders(plugin_id)
# 移除插件
PluginManager().remove_plugin(plugin_id)
plugin_manager.remove_plugin(plugin_id)
return schemas.Response(success=True)
@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response)
def clone_plugin(plugin_id: str,
clone_data: dict,
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
"""
创建插件分身
"""
try:
success, message = PluginManager().clone_plugin(
plugin_id=plugin_id,
suffix=clone_data.get("suffix", ""),
name=clone_data.get("name", ""),
description=clone_data.get("description", ""),
version=clone_data.get("version", ""),
icon=clone_data.get("icon", "")
)
if success:
# 注册插件服务
reload_plugin(message)
# 将分身插件添加到原插件所在的文件夹中
_add_clone_to_plugin_folder(plugin_id, message)
return schemas.Response(success=True, message="插件分身创建成功")
else:
return schemas.Response(success=False, message=message)
except Exception as e:
logger.error(f"创建插件分身失败:{str(e)}")
return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}")
def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
"""
将分身插件添加到原插件所在的文件夹中
:param original_plugin_id: 原插件ID
:param clone_plugin_id: 分身插件ID
"""
try:
config_oper = SystemConfigOper()
# 获取插件文件夹配置
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
# 查找原插件所在的文件夹
target_folder = None
for folder_name, folder_data in folders.items():
if isinstance(folder_data, dict) and 'plugins' in folder_data:
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
if original_plugin_id in folder_data['plugins']:
target_folder = folder_name
break
elif isinstance(folder_data, list):
# 旧格式:直接是插件列表
if original_plugin_id in folder_data:
target_folder = folder_name
break
# 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中
if target_folder:
folder_data = folders[target_folder]
if isinstance(folder_data, dict) and 'plugins' in folder_data:
# 新格式
if clone_plugin_id not in folder_data['plugins']:
folder_data['plugins'].append(clone_plugin_id)
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}'")
elif isinstance(folder_data, list):
# 旧格式
if clone_plugin_id not in folder_data:
folder_data.append(clone_plugin_id)
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}'")
# 保存更新后的文件夹配置
config_oper.set(SystemConfigKey.PluginFolders, folders)
else:
logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立")
except Exception as e:
logger.error(f"处理插件文件夹时出错:{str(e)}")
# 文件夹处理失败不影响插件分身创建的整体流程
def _remove_plugin_from_folders(plugin_id: str):
"""
从所有文件夹中移除指定的插件
:param plugin_id: 要移除的插件ID
"""
try:
config_oper = SystemConfigOper()
# 获取插件文件夹配置
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
# 标记是否有修改
modified = False
# 遍历所有文件夹,移除指定插件
for folder_name, folder_data in folders.items():
if isinstance(folder_data, dict) and 'plugins' in folder_data:
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
if plugin_id in folder_data['plugins']:
folder_data['plugins'].remove(plugin_id)
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
modified = True
elif isinstance(folder_data, list):
# 旧格式:直接是插件列表
if plugin_id in folder_data:
folder_data.remove(plugin_id)
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
modified = True
# 如果有修改,保存更新后的文件夹配置
if modified:
config_oper.set(SystemConfigKey.PluginFolders, folders)
else:
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
except Exception as e:
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
# 文件夹处理失败不影响插件卸载的整体流程

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from starlette.background import BackgroundTasks
from app import schemas
from app.api.endpoints.plugin import register_plugin_api
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
from app.command import Command
@@ -17,13 +18,13 @@ from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
from app.utils.string import StringUtils
from startup.plugins_initializer import register_plugin_api
router = APIRouter()
@@ -395,6 +396,21 @@ def auth_site(
return schemas.Response(success=status, message=msg)
@router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response)
def site_mapping(_: User = Depends(get_current_active_superuser)):
"""
获取站点域名到名称的映射关系
"""
try:
sites = SiteOper().list()
mapping = {}
for site in sites:
mapping[site.domain] = site.name
return schemas.Response(success=True, data=mapping)
except Exception as e:
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
def read_site(
site_id: int,

View File

@@ -162,47 +162,50 @@ def rename(fileitem: schemas.FileItem,
"""
if not new_name:
return schemas.Response(success=False, message="新名称为空")
# 重命名目录内文件
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper()
progress.start(ProgressKey.BatchRename)
total = len(sub_files)
handled = 0
for sub_file in sub_files:
handled += 1
progress.update(value=handled / total * 100,
text=f"正在处理 {sub_file.name} ...",
key=ProgressKey.BatchRename)
if sub_file.type == "dir":
continue
if not sub_file.extension:
continue
if f".{sub_file.extension.lower()}" not in media_exts:
continue
sub_path = Path(f"{fileitem.path}{sub_file.name}")
meta = MetaInfoPath(sub_path)
mediainfo = transferchain.recognize_media(meta)
if not mediainfo:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
if not new_path:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
ret: schemas.Response = rename(fileitem=sub_file,
new_name=Path(new_path).name,
recursive=False)
if not ret.success:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end(ProgressKey.BatchRename)
# 重命名自己
result = StorageChain().rename_file(fileitem, new_name)
if result:
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper()
progress.start(ProgressKey.BatchRename)
total = len(sub_files)
handled = 0
for sub_file in sub_files:
handled += 1
progress.update(value=handled / total * 100,
text=f"正在处理 {sub_file.name} ...",
key=ProgressKey.BatchRename)
if sub_file.type == "dir":
continue
if not sub_file.extension:
continue
if f".{sub_file.extension.lower()}" not in media_exts:
continue
sub_path = Path(f"{fileitem.path}{sub_file.name}")
meta = MetaInfoPath(sub_path)
mediainfo = transferchain.recognize_media(meta)
if not mediainfo:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
if not new_path:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
ret: schemas.Response = rename(fileitem=sub_file,
new_name=Path(new_path).name,
recursive=False)
if not ret.success:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=True)
return schemas.Response(success=False)

View File

@@ -29,6 +29,7 @@ from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
from app.helper.sites import SitesHelper
from app.helper.subscribe import SubscribeHelper
from app.helper.system import SystemHelper
from app.log import logger
from app.monitor import Monitor
from app.scheduler import Scheduler
@@ -36,7 +37,6 @@ from app.schemas.types import SystemConfigKey
from app.utils.crypto import HashUtils
from app.utils.http import RequestUtils
from app.utils.security import SecurityUtils
from app.utils.system import SystemUtils
from app.utils.url import UrlUtils
from version import APP_VERSION
@@ -474,12 +474,12 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
"""
重启系统(仅管理员)
"""
if not SystemUtils.can_restart():
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
# 标识停止事件
global_vars.stop_system()
# 执行重启
ret, msg = SystemUtils.restart()
ret, msg = SystemHelper.restart()
return schemas.Response(success=ret, message=msg)

View File

@@ -0,0 +1,199 @@
from typing import Optional
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.media import MediaChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.db.models import User
from app.db.user_oper import get_current_active_superuser
from app.utils.crypto import HashUtils
router = APIRouter()
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
def torrents_cache(_: User = Depends(get_current_active_superuser)):
"""
获取当前种子缓存数据
"""
torrents_chain = TorrentsChain()
# 获取spider和rss两种缓存
if settings.SUBSCRIBE_MODE == "rss":
cache_info = torrents_chain.get_torrents("rss")
else:
cache_info = torrents_chain.get_torrents("spider")
# 统计信息
torrent_count = sum(len(torrents) for torrents in cache_info.values())
# 转换为前端需要的格式
torrent_data = []
for domain, contexts in cache_info.items():
for context in contexts:
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
torrent_data.append({
"hash": torrent_hash,
"domain": domain,
"title": context.torrent_info.title,
"description": context.torrent_info.description,
"size": context.torrent_info.size,
"pubdate": context.torrent_info.pubdate,
"site_name": context.torrent_info.site_name,
"media_name": context.media_info.title if context.media_info else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"season_episode": context.meta_info.season_episode if context.meta_info else "",
"resource_term": context.meta_info.resource_term if context.meta_info else "",
"enclosure": context.torrent_info.enclosure,
"page_url": context.torrent_info.page_url,
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
})
return schemas.Response(success=True, data={
"count": torrent_count,
"sites": len(cache_info),
"data": torrent_data
})
@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存",
response_model=schemas.Response)
def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser)):
"""
删除指定的种子缓存
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找并删除指定种子
original_count = len(cache_data[domain])
cache_data[domain] = [
context for context in cache_data[domain]
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash
]
if len(cache_data[domain]) == original_count:
return schemas.Response(success=False, message="未找到指定的种子")
# 保存更新后的缓存
torrents_chain.save_cache(cache_data, torrents_chain.cache_file)
return schemas.Response(success=True, message="种子删除成功")
except Exception as e:
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
def clear_cache(_: User = Depends(get_current_active_superuser)):
"""
清理所有种子缓存
"""
torrents_chain = TorrentsChain()
try:
torrents_chain.clear_torrents()
return schemas.Response(success=True, message="种子缓存清理完成")
except Exception as e:
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
def refresh_cache(_: User = Depends(get_current_active_superuser)):
"""
刷新种子缓存
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
try:
result = torrents_chain.refresh()
# 统计刷新结果
total_count = sum(len(torrents) for torrents in result.values())
sites_count = len(result)
return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子")
except Exception as e:
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response)
def reidentify_cache(domain: str, torrent_hash: str,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
_: User = Depends(get_current_active_superuser)):
"""
重新识别指定的种子
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param tmdbid: 手动指定的TMDB ID
:param doubanid: 手动指定的豆瓣ID
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
media_chain = MediaChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找指定种子
target_context = None
for context in cache_data[domain]:
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash:
target_context = context
break
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
# 重新识别
meta = MetaInfo(title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description)
if tmdbid or doubanid:
# 手动指定媒体信息
mediainfo = MediaChain().recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
else:
# 自动重新识别
mediainfo = media_chain.recognize_by_meta(meta)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
else:
# 清理多余数据
mediainfo.clear()
# 更新上下文中的媒体信息
target_context.media_info = mediainfo
# 保存更新后的缓存
torrents_chain.save_cache(cache_data, TorrentsChain().cache_file)
return schemas.Response(success=True, message="重新识别完成", data={
"media_name": mediainfo.title if mediainfo else "",
"media_year": mediainfo.year if mediainfo else "",
"media_type": mediainfo.type.value if mediainfo and mediainfo.type else ""
})
except Exception as e:
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.tvdb import TvdbChain
from app.chain.subscribe import SubscribeChain
from app.core.metainfo import MetaInfo
from app.core.security import verify_apikey
@@ -520,87 +521,87 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db:
"""
# 季信息
seas: List[int] = []
# tvdbid 列表
tvdbids: List[int] = []
# 获取TVDBID
if not term.startswith("tvdb:"):
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
mtype=MediaType.TV)
if not mediainfo:
return [SonarrSeries()]
# 季信息
if mediainfo.seasons:
seas = list(mediainfo.seasons)
title = term.replace("+", " ")
tvdbids = TvdbChain().get_tvdbid_by_name(title=title)
else:
tvdbid = int(term.replace("tvdb:", ""))
tvdbids.append(tvdbid)
sonarr_series_list = []
for tvdbid in tvdbids:
# 查询TVDB信息
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
if not tvdbinfo:
return [SonarrSeries()]
continue
# 季信息
sea_num = tvdbinfo.get('season')
# 季信息(只取默认季类型,排除特别季)
sea_num = len([season for season in tvdbinfo.get('seasons') if
season['type']['id'] == tvdbinfo.get('defaultSeasonType') and season['number'] > 0])
if sea_num:
seas = list(range(1, int(sea_num) + 1))
# 根据TVDB查询媒体信息
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')),
mtype=MediaType.TV)
if not mediainfo:
continue
# 查询是否存在
exists = MediaChain().media_exists(mediainfo)
if exists:
hasfile = True
else:
hasfile = False
# 查询是否存在
exists = MediaChain().media_exists(mediainfo)
if exists:
hasfile = True
else:
hasfile = False
# 查询订阅信息
seasons: List[dict] = []
subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)
if subscribes:
# 已监控
monitored = True
# 已监控季
sub_seas = [sub.season for sub in subscribes]
for sea in seas:
if sea in sub_seas:
seasons.append({
"seasonNumber": sea,
"monitored": True,
})
else:
# 查询订阅信息
seasons: List[dict] = []
subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)
if subscribes:
# 已监控
monitored = True
# 已监控季
sub_seas = [sub.season for sub in subscribes]
for sea in seas:
if sea in sub_seas:
seasons.append({
"seasonNumber": sea,
"monitored": True,
})
else:
seasons.append({
"seasonNumber": sea,
"monitored": False,
})
subid = subscribes[-1].id
else:
subid = None
monitored = False
for sea in seas:
seasons.append({
"seasonNumber": sea,
"monitored": False,
})
subid = subscribes[-1].id
else:
subid = None
monitored = False
for sea in seas:
seasons.append({
"seasonNumber": sea,
"monitored": False,
})
sonarr_series = SonarrSeries(
id=subid,
title=mediainfo.title,
seasonCount=len(seasons),
seasons=seasons,
remotePoster=mediainfo.get_poster_image(),
year=mediainfo.year,
tmdbId=mediainfo.tmdb_id,
tvdbId=tvdbid,
imdbId=mediainfo.imdb_id,
profileId=1,
languageProfileId=1,
monitored=monitored,
hasFile=hasfile,
)
sonarr_series_list.append(sonarr_series)
return [SonarrSeries(
id=subid,
title=mediainfo.title,
seasonCount=len(seasons),
seasons=seasons,
remotePoster=mediainfo.get_poster_image(),
year=mediainfo.year,
tmdbId=mediainfo.tmdb_id,
tvdbId=mediainfo.tvdb_id,
imdbId=mediainfo.imdb_id,
profileId=1,
languageProfileId=1,
qualityProfileId=1,
isAvailable=True,
monitored=monitored,
hasFile=hasfile
)]
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
@arr_router.get("/series/{tid}", summary="剧集详情")

View File

@@ -67,7 +67,7 @@ class ChainBase(metaclass=ABCMeta):
"""
try:
with open(settings.TEMP_PATH / filename, 'wb') as f:
pickle.dump(cache, f) # noqa
pickle.dump(cache, f) # noqa
except Exception as err:
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
finally:
@@ -374,7 +374,7 @@ class ChainBase(metaclass=ABCMeta):
return self.run_module("search_torrents", site=site, keywords=keywords,
mtype=mtype, page=page)
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:
"""
获取站点最新一页的种子,多个站点需要多线程处理
@@ -543,12 +543,12 @@ class ChainBase(metaclass=ABCMeta):
return self.run_module("media_files", mediainfo=mediainfo)
def post_message(self,
message: Optional[Notification] = None,
meta: Optional[MetaBase] = None,
mediainfo: Optional[MediaInfo] = None,
torrentinfo: Optional[TorrentInfo] = None,
transferinfo: Optional[TransferInfo] = None,
**kwargs) -> None:
message: Optional[Notification] = None,
meta: Optional[MetaBase] = None,
mediainfo: Optional[MediaInfo] = None,
torrentinfo: Optional[TorrentInfo] = None,
transferinfo: Optional[TransferInfo] = None,
**kwargs) -> None:
"""
发送消息
:param message: Notification实例
@@ -561,7 +561,7 @@ class ChainBase(metaclass=ABCMeta):
"""
# 渲染消息
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
# 保存消息
self.messagehelper.put(message, role="user", title=message.title)
self.messageoper.add(**message.dict())
@@ -643,7 +643,7 @@ class ChainBase(metaclass=ABCMeta):
self.messageoper.add(**message.dict(), note=note_list)
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents)
def metadata_img(self, mediainfo: MediaInfo,
def metadata_img(self, mediainfo: MediaInfo,
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:
"""
获取图片名称和url

View File

@@ -422,13 +422,17 @@ class MessageChain(ChainBase):
or text.find("继续") != -1:
# 聊天
content = text
action = "chat"
action = "Chat"
elif StringUtils.is_link(text):
# 链接
content = text
action = "Link"
else:
# 搜索
content = text
action = "Search"
if action != "chat":
if action in ["Search", "ReSearch", "Subscribe", "ReSubscribe"]:
# 搜索
meta, medias = self.mediachain.search(content)
# 识别

View File

@@ -329,36 +329,36 @@ class SearchChain(ChainBase):
self.progress.update(value=0,
text=f"开始搜索,共 {total_num} 个站点 ...",
key=ProgressKey.Search)
# 多线程
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
all_task = []
for site in indexer_sites:
if area == "imdbid":
# 搜索IMDBID
task = executor.submit(self.search_torrents, site=site,
keywords=[mediainfo.imdb_id] if mediainfo else None,
mtype=mediainfo.type if mediainfo else None,
page=page)
else:
# 搜索标题
task = executor.submit(self.search_torrents, site=site,
keywords=keywords,
mtype=mediainfo.type if mediainfo else None,
page=page)
all_task.append(task)
# 结果集
results = []
for future in as_completed(all_task):
if global_vars.is_system_stopped:
break
finish_count += 1
result = future.result()
if result:
results.extend(result)
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
self.progress.update(value=finish_count / total_num * 100,
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
key=ProgressKey.Search)
# 多线程
with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor:
all_task = []
for site in indexer_sites:
if area == "imdbid":
# 搜索IMDBID
task = executor.submit(self.search_torrents, site=site,
keywords=[mediainfo.imdb_id] if mediainfo else None,
mtype=mediainfo.type if mediainfo else None,
page=page)
else:
# 搜索标题
task = executor.submit(self.search_torrents, site=site,
keywords=keywords,
mtype=mediainfo.type if mediainfo else None,
page=page)
all_task.append(task)
for future in as_completed(all_task):
if global_vars.is_system_stopped:
break
finish_count += 1
result = future.result()
if result:
results.extend(result)
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
self.progress.update(value=finish_count / total_num * 100,
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
key=ProgressKey.Search)
# 计算耗时
end_time = datetime.now()
# 更新进度

View File

@@ -561,6 +561,26 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
logger.debug(f"match lock acquired at {datetime.now()}")
# 所有订阅
subscribes = self.subscribeoper.list(self.get_states_for_search('R'))
# 预识别所有未识别的种子
processed_torrents = {}
for domain, contexts in torrents.items():
processed_torrents[domain] = []
for context in contexts:
# 复制上下文避免修改原始数据
_context = copy.deepcopy(context)
torrent_meta = _context.meta_info
torrent_mediainfo = _context.media_info
# 如果种子未识别,尝试识别
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
if torrent_mediainfo:
# 更新种子缓存
context.media_info = torrent_mediainfo
# 添加已预处理
processed_torrents[domain].append(_context)
# 遍历订阅
for subscribe in subscribes:
if global_vars.is_system_stopped:
@@ -604,9 +624,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
else:
custom_words_list = None
# 遍历缓存种子
# 遍历预识别后的种子
_match_context = []
for domain, contexts in torrents.items():
for domain, contexts in processed_torrents.items():
if global_vars.is_system_stopped:
break
if domains and domain not in domains:
@@ -638,32 +658,28 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
custom_words=custom_words_list)
# 更新元数据缓存
context.meta_info = torrent_meta
# 媒体信息需要重新识别
torrent_mediainfo = None
# 先判断是否有没识别的种子,否则重新识别
if not torrent_mediainfo \
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
# 重新识别媒体信息
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
episode_group=subscribe.episode_group)
if torrent_mediainfo:
# 更新种子缓存
context.media_info = torrent_mediainfo
else:
# 通过标题匹配兜底
logger.warn(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
# 匹配成功
logger.info(
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
torrent_mediainfo = mediainfo
# 重新识别媒体信息
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
episode_group=subscribe.episode_group)
if torrent_mediainfo:
# 更新种子缓存
context.media_info = torrent_mediainfo
else:
continue
# 如果仍然没有识别到媒体信息,尝试标题匹配
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
logger.warn(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
# 匹配成功
logger.info(
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
torrent_mediainfo = mediainfo
# 更新种子缓存
context.media_info = mediainfo
else:
continue
# 直接比对媒体信息
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):

View File

@@ -10,6 +10,7 @@ from app.schemas import Notification, MessageChannel
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.system import SystemUtils
from helper.system import SystemHelper
from version import FRONTEND_VERSION, APP_VERSION
@@ -45,7 +46,8 @@ class SystemChain(ChainBase, metaclass=Singleton):
"channel": channel.value,
"userid": userid
}, self._restart_file)
SystemUtils.restart()
# 重启
SystemHelper.restart()
def __get_version_message(self) -> str:
"""

View File

@@ -38,6 +38,15 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
self.mediachain = MediaChain()
self.torrenthelper = TorrentHelper()
@property
def cache_file(self) -> str:
"""
返回缓存文件列表
"""
if settings.SUBSCRIBE_MODE == 'spider':
return self._spider_file
return self._rss_file
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
远程刷新订阅,发送消息

13
app/chain/tvdb.py Normal file
View File

@@ -0,0 +1,13 @@
from typing import List
from app.chain import ChainBase
from app.utils.singleton import Singleton
class TvdbChain(ChainBase, metaclass=Singleton):
"""
Tvdb处理链单例运行
"""
def get_tvdbid_by_name(self, title: str) -> List[int]:
tvdb_info_list = self.run_module("search_tvdb", title=title)
return [int(item["tvdb_id"]) for item in tvdb_info_list]

View File

@@ -6,7 +6,7 @@ import secrets
import sys
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Type
from typing import Any, Dict, List, Optional, Tuple, Type, Union
from dotenv import set_key
from pydantic import BaseModel, BaseSettings, validator, Field
@@ -86,7 +86,7 @@ class ConfigModel(BaseModel):
AUXILIARY_AUTH_ENABLE: bool = False
# API密钥需要更换
API_TOKEN: Optional[str] = None
# 网络代理 IP:PORT
# 网络代理服务器地址
PROXY_HOST: Optional[str] = None
# 登录页面电影海报,tmdb/bing/mediaserver
WALLPAPER: str = "tmdb"
@@ -111,7 +111,8 @@ class ConfigModel(BaseModel):
# TMDB API Key
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
# TVDB API Key
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
TVDB_V4_API_KEY: str = "ed2aa66b-7899-4677-92a7-67bc9ce3d93a"
TVDB_V4_API_PIN: str = ""
# Fanart开关
FANART_ENABLE: bool = True
# Fanart API Key
@@ -126,6 +127,8 @@ class ConfigModel(BaseModel):
ANIME_GENREIDS = [16]
# 用户认证站点
AUTH_SITE: str = ""
# 重启自动升级
MOVIEPILOT_AUTO_UPDATE: str = 'release'
# 自动检查和更新站点资源包(站点索引、认证等)
AUTO_UPDATE_RESOURCE: bool = True
# 是否启用DOH解析域名
@@ -229,7 +232,8 @@ class ConfigModel(BaseModel):
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
"https://github.com/Aqr-K/MoviePilot-Plugins,"
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
"https://github.com/gxterry/MoviePilot-Plugins")
"https://github.com/gxterry/MoviePilot-Plugins,"
"https://github.com/DzAvril/MoviePilot-Plugins")
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
# 是否开启插件热加载
@@ -279,6 +283,8 @@ class ConfigModel(BaseModel):
TOKENIZED_SEARCH: bool = False
# 为指定默认字幕添加.default后缀
DEFAULT_SUB: Optional[str] = "zh-cn"
# Docker Client API地址
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
class Settings(BaseSettings, ConfigModel, LogConfigModel):
@@ -426,7 +432,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
else:
value_to_write = str(converted_value) if converted_value is not None else ""
set_key(SystemUtils.get_env_path(), field.name, value_to_write)
set_key(dotenv_path=SystemUtils.get_env_path(), key_to_set=field.name, value_to_set=value_to_write,
quote_mode="always")
if is_converted:
logger.info(f"配置项 '{field.name}' 已自动修正并写入到 'app.env' 文件")
return True, message

View File

@@ -582,6 +582,12 @@ class MetaBase(object):
# Part
if not self.part:
self.part = meta.part
# tmdbid
if not self.tmdbid and meta.tmdbid:
self.tmdbid = meta.tmdbid
# doubanid
if not self.doubanid and meta.doubanid:
self.doubanid = meta.doubanid
def to_dict(self):
"""

View File

@@ -31,7 +31,7 @@ class MetaVideo(MetaBase):
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$"
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕"
@@ -592,12 +592,12 @@ class MetaVideo(MetaBase):
self._stop_name_flag = True
self._last_token_type = "videoencode"
if not self.video_encode:
if re_res.group(1):
self.video_encode = re_res.group(1).upper()
elif re_res.group(2):
self.video_encode = re_res.group(2).lower()
if re_res.group(2):
self.video_encode = re_res.group(2).upper()
elif re_res.group(3):
self.video_encode = re_res.group(3).lower()
else:
self.video_encode = re_res.group(0).upper()
self.video_encode = re_res.group(1).upper()
self._last_token = self.video_encode
elif self.video_encode == "10bit":
self.video_encode = f"{re_res.group(1).upper()} 10bit"

View File

@@ -120,41 +120,69 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
return title, metainfo
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title)
if not results:
return title, metainfo
for result in results:
# 查找tmdbid信息
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
if tmdbid and tmdbid[0].isdigit():
metainfo['tmdbid'] = tmdbid[0]
# 查找豆瓣id信息
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
if doubanid and doubanid[0].isdigit():
metainfo['doubanid'] = doubanid[0]
# 查找媒体类型
mtype = re.findall(r'(?<=type=)\w+', result)
if mtype:
if mtype[0] == "movies":
metainfo['type'] = MediaType.MOVIE
elif mtype[0] == "tv":
metainfo['type'] = MediaType.TV
# 查找季信息
begin_season = re.findall(r'(?<=s=)\d+', result)
if begin_season and begin_season[0].isdigit():
metainfo['begin_season'] = int(begin_season[0])
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
if end_season and end_season[0].isdigit():
metainfo['end_season'] = int(end_season[0])
# 查找集信息
begin_episode = re.findall(r'(?<=e=)\d+', result)
if begin_episode and begin_episode[0].isdigit():
metainfo['begin_episode'] = int(begin_episode[0])
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
if end_episode and end_episode[0].isdigit():
metainfo['end_episode'] = int(end_episode[0])
# 去除title中该部分
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
title = title.replace(f"{{[{result}]}}", '')
if results:
for result in results:
# 查找tmdbid信息
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
if tmdbid and tmdbid[0].isdigit():
metainfo['tmdbid'] = tmdbid[0]
# 查找豆瓣id信息
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
if doubanid and doubanid[0].isdigit():
metainfo['doubanid'] = doubanid[0]
# 查找媒体类型
mtype = re.findall(r'(?<=type=)\w+', result)
if mtype:
if mtype[0] == "movies":
metainfo['type'] = MediaType.MOVIE
elif mtype[0] == "tv":
metainfo['type'] = MediaType.TV
# 查找季信息
begin_season = re.findall(r'(?<=s=)\d+', result)
if begin_season and begin_season[0].isdigit():
metainfo['begin_season'] = int(begin_season[0])
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
if end_season and end_season[0].isdigit():
metainfo['end_season'] = int(end_season[0])
# 查找集信息
begin_episode = re.findall(r'(?<=e=)\d+', result)
if begin_episode and begin_episode[0].isdigit():
metainfo['begin_episode'] = int(begin_episode[0])
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
if end_episode and end_episode[0].isdigit():
metainfo['end_episode'] = int(end_episode[0])
# 去除title中该部分
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
title = title.replace(f"{{[{result}]}}", '')
# 支持Emby格式的ID标签
# 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式
tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title)
if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip()
# 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式
if not metainfo['tmdbid']:
tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title)
if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip()
# 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式
if not metainfo['tmdbid']:
tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title)
if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip()
# 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式
if not metainfo['tmdbid']:
tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title)
if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip()
# 计算季集总数
if metainfo.get('begin_season') and metainfo.get('end_season'):
if metainfo['begin_season'] > metainfo['end_season']:
@@ -169,3 +197,67 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
elif metainfo.get('begin_episode') and not metainfo.get('end_episode'):
metainfo['total_episode'] = 1
return title, metainfo
def test_find_metainfo():
"""
测试find_metainfo函数的各种ID识别格式
"""
test_cases = [
# 测试 [tmdbid=xxxx] 格式
("The Vampire Diaries (2009) [tmdbid=18165]", "18165"),
# 测试 [tmdbid-xxxx] 格式
("Inception (2010) [tmdbid-27205]", "27205"),
# 测试 [tmdb=xxxx] 格式
("Breaking Bad (2008) [tmdb=1396]", "1396"),
# 测试 [tmdb-xxxx] 格式
("Interstellar (2014) [tmdb-157336]", "157336"),
# 测试 {tmdbid=xxxx} 格式
("Stranger Things (2016) {tmdbid=66732}", "66732"),
# 测试 {tmdbid-xxxx} 格式
("The Matrix (1999) {tmdbid-603}", "603"),
# 测试 {tmdb=xxxx} 格式
("Game of Thrones (2011) {tmdb=1399}", "1399"),
# 测试 {tmdb-xxxx} 格式
("Avatar (2009) {tmdb-19995}", "19995"),
]
for title, expected_tmdbid in test_cases:
cleaned_title, metainfo = find_metainfo(title)
found_tmdbid = metainfo.get('tmdbid')
print(f"原标题: {title}")
print(f"清理后标题: {cleaned_title}")
print(f"期望的tmdbid: {expected_tmdbid}")
print(f"识别的tmdbid: {found_tmdbid}")
print(f"结果: {'通过' if found_tmdbid == expected_tmdbid else '失败'}")
print("-" * 50)
def test_meta_info_path():
"""
测试MetaInfoPath函数
"""
# 测试文件路径
path_tests = [
# 文件名中包含tmdbid
Path("/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv"),
# 目录名中包含tmdbid
Path("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv"),
# 父目录名中包含tmdbid
Path("/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv"),
# 祖父目录名中包含tmdbid
Path("/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv"),
]
for path in path_tests:
meta = MetaInfoPath(path)
print(f"测试路径: {path}")
print(f"识别结果: tmdbid={meta.tmdbid}")
print("-" * 50)
if __name__ == "__main__":
# 运行测试函数
# test_find_metainfo()
test_meta_info_path()

View File

@@ -226,13 +226,21 @@ class PluginManager(metaclass=Singleton):
logger.info("插件停止完成")
@property
def running_plugins(self):
def running_plugins(self) -> Dict[str, Any]:
"""
获取运行态插件列表
:return: 运行态插件列表
"""
return self._running_plugins
@property
def plugins(self) -> Dict[str, Any]:
"""
获取插件列表
:return: 插件列表
"""
return self._plugins
def reload_monitor(self):
"""
重新加载插件文件修改监测
@@ -399,7 +407,8 @@ class PluginManager(metaclass=Singleton):
"""
if not self._plugins.get(pid):
return False
return self.systemconfig.set(self._config_key % pid, conf)
self.systemconfig.set(self._config_key % pid, conf)
return True
def delete_plugin_config(self, pid: str) -> bool:
"""
@@ -1016,3 +1025,333 @@ class PluginManager(metaclass=Singleton):
except Exception as e:
logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}")
return None
def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str,
version: str = None, icon: str = None) -> Tuple[bool, str]:
"""
创建插件分身
:param plugin_id: 原插件ID
:param suffix: 分身后缀
:param name: 分身名称
:param description: 分身描述
:param version: 自定义版本号
:param icon: 自定义图标URL
:return: (是否成功, 错误信息)
"""
try:
# 验证参数
if not plugin_id or not suffix:
return False, "插件ID和分身后缀不能为空"
# 检查原插件是否存在
if plugin_id not in self._plugins:
return False, f"原插件 {plugin_id} 不存在"
# 生成分身插件ID
clone_id = f"{plugin_id}{suffix.lower()}"
# 检查分身插件是否已存在
if self.is_plugin_exists(clone_id):
return False, f"分身插件 {clone_id} 已存在"
# 获取原插件目录
original_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
if not original_plugin_dir.exists():
return False, f"原插件目录 {original_plugin_dir} 不存在"
# 创建分身插件目录
clone_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / clone_id.lower()
# 复制插件目录
import shutil
shutil.copytree(original_plugin_dir, clone_plugin_dir)
logger.info(f"已复制插件目录:{original_plugin_dir} -> {clone_plugin_dir}")
# 修改插件文件内容
success, msg = self._modify_plugin_files(
plugin_dir=clone_plugin_dir,
original_id=plugin_id,
suffix=suffix,
name=name,
description=description,
version=version,
icon=icon
)
if not success:
# 如果修改失败,清理已创建的目录
if clone_plugin_dir.exists():
shutil.rmtree(clone_plugin_dir)
return False, msg
# 将分身插件添加到已安装列表
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
if clone_id not in installed_plugins:
installed_plugins.append(clone_id)
self.systemconfig.set(SystemConfigKey.UserInstalledPlugins, installed_plugins)
# 为分身插件创建初始配置(从原插件复制配置)
logger.info(f"正在为分身插件 {clone_id} 创建初始配置...")
original_config = self.get_plugin_config(plugin_id)
if original_config:
# 复制原插件配置作为分身插件的初始配置
clone_config = original_config.copy()
# 可以在这里修改一些默认值,比如禁用分身插件
# 默认禁用分身插件,让用户手动配置
clone_config['enable'] = False
clone_config['enabled'] = False
self.save_plugin_config(clone_id, clone_config)
logger.info(f"已为分身插件 {clone_id} 设置初始配置")
else:
logger.info(f"原插件 {plugin_id} 没有配置,分身插件 {clone_id} 将使用默认配置")
# 注册分身插件的API和服务
logger.info(f"正在注册分身插件 {clone_id} ...")
PluginManager().reload_plugin(clone_id)
# 确保分身插件正确初始化配置
if clone_id in self._running_plugins:
clone_instance = self._running_plugins[clone_id]
clone_config = self.get_plugin_config(clone_id)
if clone_config:
logger.info(f"正在为分身插件 {clone_id} 重新初始化配置...")
clone_instance.init_plugin(clone_config)
logger.info(f"分身插件 {clone_id} 配置重新初始化完成")
logger.info(f"插件分身 {clone_id} 创建成功")
return True, clone_id
except Exception as e:
logger.error(f"创建插件分身失败:{str(e)}")
return False, f"创建插件分身失败:{str(e)}"
def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str,
name: str, description: str, version: str = None,
icon: str = None) -> Tuple[bool, str]:
"""
修改插件文件中的类名和相关信息
:param plugin_dir: 插件目录
:param original_id: 原插件ID
:param suffix: 分身后缀
:param name: 分身名称
:param description: 分身描述
:param version: 自定义版本号
:param icon: 自定义图标URL
:return: (是否成功, 错误信息)
"""
try:
# 获取原插件类
original_plugin_class = self._plugins.get(original_id)
if not original_plugin_class:
return False, f"无法获取原插件类 {original_id}"
# 获取原类名
original_class_name = original_plugin_class.__name__
clone_class_name = f"{original_class_name}{suffix}"
# 修改 __init__.py 文件
init_file = plugin_dir / "__init__.py"
if init_file.exists():
success, msg = self._modify_python_file(
file_path=init_file,
original_class_name=original_class_name,
clone_class_name=clone_class_name,
name=name,
description=description,
version=version,
icon=icon
)
if not success:
return False, msg
# 检查是否为联邦插件存在dist目录
dist_dir = plugin_dir / "dist"
if dist_dir.exists():
success, msg = self._modify_federation_files(
dist_dir=dist_dir,
original_class_name=original_class_name,
clone_class_name=clone_class_name
)
if not success:
return False, msg
return True, "文件修改成功"
except Exception as e:
logger.error(f"修改插件文件失败:{str(e)}")
return False, f"修改插件文件失败:{str(e)}"
@staticmethod
def _modify_python_file(file_path: Path, original_class_name: str,
clone_class_name: str, name: str, description: str,
version: str = None, icon: str = None) -> Tuple[bool, str]:
"""
修改Python文件中的类名和插件信息
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 替换类名
content = content.replace(f"class {original_class_name}", f"class {clone_class_name}")
# 替换插件名称和描述
import re
# 替换 plugin_name
if name:
content = re.sub(
r'plugin_name\s*=\s*["\'][^"\']*["\']',
f'plugin_name = "{name}"',
content
)
# 替换 plugin_desc
if description:
content = re.sub(
r'plugin_desc\s*=\s*["\'][^"\']*["\']',
f'plugin_desc = "{description}"',
content
)
# 替换 plugin_config_prefix如果存在
content = re.sub(
r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']',
f'plugin_config_prefix = "{clone_class_name.lower()}_"',
content
)
# 替换 plugin_version如果提供了自定义版本
if version:
content = re.sub(
r'plugin_version\s*=\s*["\'][^"\']*["\']',
f'plugin_version = "{version}"',
content
)
# 替换 plugin_icon如果提供了自定义图标
if icon and icon.strip():
old_content = content
content = re.sub(
r'plugin_icon\s*=\s*["\'][^"\']*["\']',
f'plugin_icon = "{icon}"',
content
)
if old_content != content:
logger.info(f"已替换插件图标为: {icon}")
else:
logger.warning(f"插件图标替换失败,未找到匹配的图标设置")
else:
logger.info("未提供自定义图标,保持原插件图标")
# 添加分身标志
if "def init_plugin(self" in content:
init_index = content.index("def init_plugin(self")
# 在 def init_plugin(self 前添加 is_clone = True
content = content[:init_index] + "is_clone = True\n\n " + content[init_index:]
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.debug(f"已修改Python文件{file_path}")
return True, "Python文件修改成功"
except Exception as e:
logger.error(f"修改Python文件失败{str(e)}")
return False, f"修改Python文件失败{str(e)}"
def _modify_federation_files(self, dist_dir: Path, original_class_name: str,
clone_class_name: str) -> Tuple[bool, str]:
"""
修改联邦插件的前端文件
"""
try:
# 获取原始插件名(从类名推导)
original_plugin_name = original_class_name
clone_plugin_name = clone_class_name
# 遍历dist目录下的所有文件
for file_path in dist_dir.rglob("*"):
if not file_path.is_file():
continue
# 处理JS文件
if file_path.suffix == '.js':
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 替换类名引用(精确匹配)
content = content.replace(original_class_name, clone_class_name)
# 替换插件名引用(如果存在)
content = content.replace(f'"{original_plugin_name}"', f'"{clone_plugin_name}"')
content = content.replace(f"'{original_plugin_name}'", f"'{clone_plugin_name}'")
# 替换CSS key中的类名联邦插件特有
content = content.replace(f'css__{original_class_name}__', f'css__{clone_class_name}__')
# 替换可能的小写类名引用
content = content.replace(original_class_name.lower(), clone_class_name.lower())
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.debug(f"已修改联邦插件JS文件{file_path}")
except Exception as e:
logger.warning(f"修改联邦插件文件 {file_path} 失败:{str(e)}")
continue
# 处理CSS文件
elif file_path.suffix == '.css':
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 替换CSS中可能的类名引用
content = content.replace(original_class_name.lower(), clone_class_name.lower())
content = content.replace(original_class_name, clone_class_name)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.debug(f"已修改联邦插件CSS文件{file_path}")
except Exception as e:
logger.warning(f"修改联邦插件CSS文件 {file_path} 失败:{str(e)}")
continue
# 重命名构建文件(如果需要)
self._rename_federation_assets(dist_dir, original_class_name, clone_class_name)
return True, "联邦插件文件修改完成"
except Exception as e:
logger.error(f"修改联邦插件文件失败:{str(e)}")
return False, f"修改联邦插件文件失败:{str(e)}"
@staticmethod
def _rename_federation_assets(dist_dir: Path, original_class_name: str, clone_class_name: str):
"""
重命名联邦插件的资源文件,避免文件名冲突
"""
try:
# 查找包含原类名的文件并重命名
for file_path in dist_dir.glob("*"):
if not file_path.is_file():
continue
file_name = file_path.name
# 如果文件名包含原类名,则重命名
if original_class_name.lower() in file_name.lower():
new_name = file_name.replace(
original_class_name.lower(),
clone_class_name.lower()
)
new_path = file_path.parent / new_name
# 避免重命名冲突
if not new_path.exists():
file_path.rename(new_path)
logger.debug(f"重命名联邦插件文件:{file_name} -> {new_name}")
except Exception as e:
# 重命名失败不影响整体流程
logger.warning(f"重命名联邦插件资源文件失败:{str(e)}")

View File

@@ -2,6 +2,8 @@ import sys
import json
import shutil
import traceback
import site
import importlib
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Set
@@ -452,7 +454,7 @@ class PluginHelper(metaclass=Singleton):
@staticmethod
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
"""
使用自动降级策略PIP 安装依赖,优先级依次为镜像站、代理、直连
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
:param requirements_file: 依赖的 requirements.txt 文件路径
:return: (是否成功, 错误信息)
"""
@@ -466,12 +468,25 @@ class PluginHelper(metaclass=Singleton):
strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST]))
strategies.append(("直连", base_cmd))
# 记录当前已安装的包,以便后续刷新
before_installation = set(sys.modules.keys())
# 遍历策略进行安装
for strategy_name, pip_command in strategies:
logger.debug(f"[PIP] 尝试使用策略:{strategy_name} 安装依赖,命令:{' '.join(pip_command)}")
success, message = SystemUtils.execute_with_subprocess(pip_command)
if success:
logger.debug(f"[PIP] 策略:{strategy_name} 安装依赖成功,输出:{message}")
# 安装成功后刷新Python的模块系统
importlib.reload(site)
# 获取新安装的模块
current_modules = set(sys.modules.keys())
new_modules = current_modules - before_installation
# 重新加载新安装的模块
for module in new_modules:
if module in sys.modules:
del sys.modules[module]
logger.debug(f"[PIP] 已刷新导入系统,新加载的模块: {new_modules}")
return True, message
else:
logger.error(f"[PIP] 策略:{strategy_name} 安装依赖失败,错误信息:{message}")

View File

@@ -8,6 +8,7 @@ from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
from helper.system import SystemHelper
class ResourceHelper(metaclass=Singleton):
@@ -32,80 +33,80 @@ class ResourceHelper(metaclass=Singleton):
检测是否有更新,如有则下载安装
"""
if not settings.AUTO_UPDATE_RESOURCE:
return
return None
if SystemUtils.is_frozen():
return
return None
logger.info("开始检测资源包版本...")
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
if res:
try:
resource_info = json.loads(res.text)
online_version = resource_info.get("version")
if online_version:
logger.info(f"最新资源包版本v{online_version}")
# 需要更新的资源包
need_updates = {}
# 资源明细
resources: dict = resource_info.get("resources") or {}
for rname, resource in resources.items():
rtype = resource.get("type")
platform = resource.get("platform")
target = resource.get("target")
version = resource.get("version")
# 判断平台
if platform and platform != SystemUtils.platform():
continue
# 判断版本号
if rtype == "auth":
# 站点认证资源
local_version = self.siteshelper.auth_version
elif rtype == "sites":
# 站点索引资源
local_version = self.siteshelper.indexer_version
else:
continue
if StringUtils.compare_version(version, ">", local_version):
logger.info(f"{rname} 资源包有更新最新版本v{version}")
else:
continue
# 需要安装
need_updates[rname] = target
if need_updates:
# 下载文件信息列表
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=30).get_res(self._files_api)
if r and not r.ok:
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
elif not r:
return None, "连接仓库失败"
files_info = r.json()
for item in files_info:
save_path = need_updates.get(item.get("name"))
if not save_path:
continue
if item.get("download_url"):
logger.info(f"开始更新资源文件:{item.get('name')} ...")
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载资源文件
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(download_url)
if not res:
logger.error(f"文件 {item.get('name')} 下载失败!")
elif res.status_code != 200:
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
# 创建插件文件夹
file_path = self._base_dir / save_path / item.get("name")
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
file_path.write_bytes(res.content)
logger.info("资源包更新完成,开始重启服务...")
SystemHelper.restart()
else:
logger.info("所有资源已最新,无需更新")
except json.JSONDecodeError:
logger.error("资源包仓库数据解析失败!")
return
return None
else:
logger.warn("无法连接资源包仓库!")
return
online_version = resource_info.get("version")
if online_version:
logger.info(f"最新资源包版本v{online_version}")
# 需要更新的资源包
need_updates = {}
# 资源明细
resources: dict = resource_info.get("resources") or {}
for rname, resource in resources.items():
rtype = resource.get("type")
platform = resource.get("platform")
target = resource.get("target")
version = resource.get("version")
# 判断平台
if platform and platform != SystemUtils.platform():
continue
# 判断版本号
if rtype == "auth":
# 站点认证资源
local_version = self.siteshelper.auth_version
elif rtype == "sites":
# 站点索引资源
local_version = self.siteshelper.indexer_version
else:
continue
if StringUtils.compare_version(version, ">", local_version):
logger.info(f"{rname} 资源包有更新最新版本v{version}")
else:
continue
# 需要安装
need_updates[rname] = target
if need_updates:
# 下载文件信息列表
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=30).get_res(self._files_api)
if r and not r.ok:
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
elif not r:
return None, "连接仓库失败"
files_info = r.json()
for item in files_info:
save_path = need_updates.get(item.get("name"))
if not save_path:
continue
if item.get("download_url"):
logger.info(f"开始更新资源文件:{item.get('name')} ...")
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载资源文件
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(download_url)
if not res:
logger.error(f"文件 {item.get('name')} 下载失败!")
elif res.status_code != 200:
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
# 创建插件文件夹
file_path = self._base_dir / save_path / item.get("name")
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
file_path.write_bytes(res.content)
logger.info("资源包更新完成,开始重启服务...")
SystemUtils.restart()
else:
logger.info("所有资源已最新,无需更新")
return None

55
app/helper/system.py Normal file
View File

@@ -0,0 +1,55 @@
from pathlib import Path
from typing import Tuple
import docker
from app.core.config import settings
from app.utils.system import SystemUtils
class SystemHelper:
@staticmethod
def can_restart() -> bool:
"""
判断是否可以内部重启
"""
return (
Path("/var/run/docker.sock").exists()
or settings.DOCKER_CLIENT_API != "tcp://127.0.0.1:38379"
)
@staticmethod
def restart() -> Tuple[bool, str]:
"""
执行Docker重启操作
"""
if not SystemUtils.is_docker():
return False, "非Docker环境无法重启"
try:
# 创建 Docker 客户端
client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API)
# 获取当前容器的 ID
container_id = None
with open("/proc/self/mountinfo", "r") as f:
data = f.read()
index_resolv_conf = data.find("resolv.conf")
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 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失败"
# 重启当前容器
client.containers.get(container_id.strip()).restart()
return True, ""
except Exception as err:
print(str(err))
return False, f"重启时发生错误:{str(err)}"

View File

@@ -91,8 +91,8 @@ class WallpaperHelper(metaclass=Singleton):
if resp and resp.status_code == 200:
# 如果返回的是图片格式
content_type = resp.headers.get('Content-Type')
if content_type and content_type.lower() == 'image/jpeg':
wallpaper_list.append(resp.url)
if content_type and content_type.lower().startswith('image/'):
wallpaper_list.append(settings.CUSTOMIZE_WALLPAPER_API_URL)
else:
try:
result = resp.json()

View File

@@ -30,7 +30,7 @@ class LogConfigModel(BaseModel):
# 备份的日志文件数量
LOG_BACKUP_COUNT: int = 3
# 控制台日志格式
LOG_CONSOLE_FORMAT: str = "%(leveltext)s%(message)s"
LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s"
# 文件日志格式
LOG_FILE_FORMAT: str = "%(levelname)s%(asctime)s - %(message)s"
@@ -189,6 +189,9 @@ class LoggerManager:
file_handler.setFormatter(file_formatter)
_logger.addHandler(file_handler)
# 禁止向父级log传递
_logger.propagate = False
return _logger
def update_loggers(self):

File diff suppressed because it is too large Load Diff

View File

@@ -435,6 +435,17 @@ class AliPan(StorageBase, metaclass=Singleton):
break
return items
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
自动延迟重试 get_item 模块
"""
for _ in range(2):
time.sleep(2)
fileitem = self.get_item(path)
if fileitem:
return fileitem
return None
def create_folder(self, parent_item: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
"""
创建目录
@@ -457,7 +468,7 @@ class AliPan(StorageBase, metaclass=Singleton):
# 缓存新目录
new_path = Path(parent_item.path) / name
self._id_cache[str(new_path)] = (resp.get("drive_id"), resp.get("file_id"))
return self.get_item(new_path)
return self._delay_get_item(new_path)
@staticmethod
def _calculate_pre_hash(file_path: Path):
@@ -676,7 +687,7 @@ class AliPan(StorageBase, metaclass=Singleton):
chunk_size=chunk_size)
if create_res.get('rapid_upload', False):
logger.info(f"【阿里云盘】{target_name} 秒传完成!")
return self.get_item(target_path)
return self._delay_get_item(target_path)
if create_res.get("exist", False):
logger.info(f"【阿里云盘】{target_name} 已存在")
@@ -919,7 +930,7 @@ class AliPan(StorageBase, metaclass=Singleton):
return False
# 重命名
new_path = Path(path) / fileitem.name
new_file = self.get_item(new_path)
new_file = self._delay_get_item(new_path)
self.rename(new_file, new_name)
# 更新缓存
del self._id_cache[fileitem.path]

View File

@@ -306,6 +306,17 @@ class U115Pan(StorageBase, metaclass=Singleton):
sha1.update(chunk)
return sha1.hexdigest()
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
自动延迟重试 get_item 模块
"""
for _ in range(2):
time.sleep(2)
fileitem = self.get_item(path)
if fileitem:
return fileitem
return None
def init_storage(self):
pass
@@ -513,7 +524,32 @@ class U115Pan(StorageBase, metaclass=Singleton):
# Step 3: 秒传
if init_result.get("status") == 2:
logger.info(f"【115】{target_name} 秒传成功")
return self.get_item(target_path)
file_id = init_result.get("file_id", None)
if file_id:
logger.debug(f"【115】{target_name} 使用秒传返回ID获取文件信息")
time.sleep(2)
info_resp = self._request_api(
"GET",
"/open/folder/get_info",
"data",
params={
"file_id": int(file_id)
}
)
if info_resp:
return schemas.FileItem(
storage=self.schema.value,
fileid=str(info_resp["file_id"]),
path=str(target_path) + ("/" if info_resp["file_category"] == "0" else ""),
type="file" if info_resp["file_category"] == "1" else "dir",
name=info_resp["file_name"],
basename=Path(info_resp["file_name"]).stem,
extension=Path(info_resp["file_name"]).suffix[1:] if info_resp["file_category"] == "1" else None,
pickcode=info_resp["pick_code"],
size=StringUtils.num_filesize(info_resp['size']) if info_resp["file_category"] == "1" else None,
modify_time=info_resp["utime"]
)
return self._delay_get_item(target_path)
# Step 4: 获取上传凭证
token_resp = self._request_api(
@@ -618,7 +654,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
logger.error(f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}")
return None
# 返回结果
return self.get_item(target_path)
return self._delay_get_item(target_path)
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
"""
@@ -783,7 +819,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
return False
if resp["state"]:
new_path = Path(path) / fileitem.name
new_item = self.get_item(new_path)
new_item = self._delay_get_item(new_path)
self.rename(new_item, new_name)
# 更新缓存
del self._id_cache[fileitem.path]
@@ -811,7 +847,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
return False
if resp["state"]:
new_path = Path(path) / fileitem.name
new_file = self.get_item(new_path)
new_file = self._delay_get_item(new_path)
self.rename(new_file, new_name)
# 更新缓存
del self._id_cache[fileitem.path]

View File

@@ -0,0 +1,921 @@
import re
from pathlib import Path
from threading import Lock
from typing import Optional, List, Tuple
from jinja2 import Template
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfoPath
from app.helper.message import TemplateHelper
from app.log import logger
from app.modules.filemanager.storages import StorageBase
from app.schemas import TransferInfo, TmdbEpisode, TransferDirectoryConf, FileItem, TransferInterceptEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.system import SystemUtils
from app.schemas import TransferRenameEventData
lock = Lock()
class TransHandler:
"""
文件转移整理类
"""
result: Optional[TransferInfo] = None
inner_lock: Lock = Lock()
def __init__(self):
self.__reset_result()
def __reset_result(self):
"""
重置结果
"""
self.result = TransferInfo()
def __set_result(self, **kwargs):
"""
设置结果
"""
with self.inner_lock:
# 设置值
for key, value in kwargs.items():
if hasattr(self.result, key):
current_value = getattr(self.result, key)
if current_value is None:
current_value = value
elif isinstance(current_value, list):
if isinstance(value, list):
current_value.extend(value)
else:
current_value.append(value)
elif isinstance(current_value, dict):
if isinstance(value, dict):
current_value.update(value)
else:
current_value[key] = value
elif isinstance(current_value, bool):
current_value = value
elif isinstance(current_value, int):
current_value += (value or 0)
else:
current_value = value
setattr(self.result, key, current_value)
def transfer_media(self,
fileitem: FileItem,
in_meta: MetaBase,
mediainfo: MediaInfo,
target_storage: str,
target_path: Path,
transfer_type: str,
source_oper: StorageBase,
target_oper: StorageBase,
need_scrape: Optional[bool] = False,
need_rename: Optional[bool] = True,
need_notify: Optional[bool] = True,
overwrite_mode: Optional[str] = None,
episodes_info: List[TmdbEpisode] = None
) -> TransferInfo:
"""
识别并整理一个文件或者一个目录下的所有文件
:param fileitem: 整理的文件对象,可能是一个文件也可以是一个目录
:param in_meta预识别元数据
:param mediainfo: 媒体信息
:param target_storage: 目标存储
:param target_path: 目标路径
:param transfer_type: 文件整理方式
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param need_scrape: 是否需要刮削
:param need_rename: 是否需要重命名
:param need_notify: 是否需要通知
:param overwrite_mode: 覆盖模式
:param episodes_info: 当前季的全部集信息
:return: TransferInfo、错误信息
"""
# 重置结果
self.__reset_result()
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
# 判断是否为文件夹
if fileitem.type == "dir":
# 整理整个目录,一般为蓝光原盘
if need_rename:
new_path = self.get_rename_path(
path=target_path,
template_string=rename_format,
rename_dict=self.get_naming_dict(meta=in_meta,
mediainfo=mediainfo)
).parent
else:
new_path = target_path / fileitem.name
# 整理目录
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
mediainfo=mediainfo,
source_oper=source_oper,
target_oper=target_oper,
target_storage=target_storage,
target_path=new_path,
transfer_type=transfer_type)
if not new_diritem:
logger.error(f"文件夹 {fileitem.path} 整理失败:{errmsg}")
self.__set_result(success=False,
message=errmsg,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
logger.info(f"文件夹 {fileitem.path} 整理成功")
# 计算目录下所有文件大小
total_size = sum(file.stat().st_size for file in Path(fileitem.path).rglob('*') if file.is_file())
# 返回整理后的路径
self.__set_result(success=True,
fileitem=fileitem,
target_item=new_diritem,
target_diritem=new_diritem,
total_size=total_size,
need_scrape=need_scrape,
need_notify=need_notify,
transfer_type=transfer_type)
return self.result
else:
# 整理单个文件
if mediainfo.type == MediaType.TV:
# 电视剧
if in_meta.begin_episode is None:
logger.warn(f"文件 {fileitem.path} 整理失败:未识别到文件集数")
self.__set_result(success=False,
message=f"未识别到文件集数",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
# 文件结束季为空
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
# 目的文件名
if need_rename:
new_file = self.get_rename_path(
path=target_path,
template_string=rename_format,
rename_dict=self.get_naming_dict(
meta=in_meta,
mediainfo=mediainfo,
episodes_info=episodes_info,
file_ext=f".{fileitem.extension}"
)
)
else:
new_file = target_path / fileitem.name
# 判断是否要覆盖
overflag = False
# 计算重命名中的文件夹层级
rename_format_level = len(rename_format.split("/")) - 1
folder_path = new_file.parents[rename_format_level - 1]
# 目标目录
target_diritem = target_oper.get_folder(folder_path)
if not target_diritem:
logger.error(f"目标目录 {folder_path} 获取失败")
self.__set_result(success=False,
message=f"目标目录 {folder_path} 获取失败",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
# 目标文件
target_item = target_oper.get_item(new_file)
if target_item:
# 目标文件已存在
target_file = new_file
if target_storage == "local" and new_file.is_symlink():
target_file = new_file.readlink()
if not target_file.exists():
overflag = True
if not overflag:
# 目标文件已存在
logger.info(f"目的文件系统中已经存在同名文件 {target_file},当前整理覆盖模式设置为 {overwrite_mode}")
if overwrite_mode == 'always':
# 总是覆盖同名文件
overflag = True
elif overwrite_mode == 'size':
# 存在时大覆盖小
if target_item.size < fileitem.size:
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
overflag = True
else:
self.__set_result(success=False,
message=f"媒体库存在同名文件,且质量更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
elif overwrite_mode == 'never':
# 存在不覆盖
self.__set_result(success=False,
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
elif overwrite_mode == 'latest':
# 仅保留最新版本
logger.info(f"当前整理覆盖模式设置为仅保留最新版本,将覆盖:{new_file}")
overflag = True
else:
if overwrite_mode == 'latest':
# 文件不存在,但仅保留最新版本
logger.info(f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
self.__delete_version_files(target_oper, new_file)
# 整理文件
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
mediainfo=mediainfo,
target_storage=target_storage,
target_file=new_file,
transfer_type=transfer_type,
over_flag=overflag,
source_oper=source_oper,
target_oper=target_oper)
if not new_item:
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
self.__set_result(success=False,
message=err_msg,
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
logger.info(f"文件 {fileitem.path} 整理成功")
self.__set_result(success=True,
fileitem=fileitem,
target_item=new_item,
target_diritem=target_diritem,
need_scrape=need_scrape,
transfer_type=transfer_type,
need_notify=need_notify)
return self.result
@staticmethod
def __transfer_command(fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str,
) -> Tuple[Optional[FileItem], str]:
"""
处理单个文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标文件路径
:param transfer_type: 整理方式
"""
def __get_targetitem(_path: Path) -> FileItem:
"""
获取文件信息
"""
return FileItem(
storage=target_storage,
path=str(_path).replace("\\", "/"),
name=_path.name,
basename=_path.stem,
type="file",
size=_path.stat().st_size,
extension=_path.suffix.lstrip('.'),
modify_time=_path.stat().st_mtime
)
if (fileitem.storage != target_storage
and fileitem.storage != "local" and target_storage != "local"):
return None, f"不支持 {fileitem.storage}{target_storage} 的文件整理"
# 加锁
with lock:
if fileitem.storage == "local" and target_storage == "local":
# 创建目录
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True)
# 本地到本地
if transfer_type == "copy":
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
elif transfer_type == "move":
state = source_oper.move(fileitem, target_file.parent, target_file.name)
elif transfer_type == "link":
state = source_oper.link(fileitem, target_file)
elif transfer_type == "softlink":
state = source_oper.softlink(fileitem, target_file)
else:
return None, f"不支持的整理方式:{transfer_type}"
if state:
return __get_targetitem(target_file), ""
else:
return None, f"{fileitem.path} {transfer_type} 失败"
elif fileitem.storage == "local" and target_storage != "local":
# 本地到网盘
filepath = Path(fileitem.path)
if not filepath.exists():
return None, f"文件 {filepath} 不存在"
if transfer_type == "copy":
# 复制
# 根据目的路径创建文件夹
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
if new_item:
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动
# 根据目的路径获取文件夹
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
if new_item:
# 删除源文件
source_oper.delete(fileitem)
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif fileitem.storage != "local" and target_storage == "local":
# 网盘到本地
if target_file.exists():
logger.warn(f"文件已存在:{target_file}")
return __get_targetitem(target_file), ""
# 网盘到本地
if transfer_type in ["copy", "move"]:
# 下载
tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent)
if tmp_file:
# 创建目录
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True)
# 将tmp_file移动后target_file
SystemUtils.move(tmp_file, target_file)
if transfer_type == "move":
# 删除源文件
source_oper.delete(fileitem)
return __get_targetitem(target_file), ""
else:
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
elif fileitem.storage == target_storage:
# 同一网盘
if transfer_type == "copy":
# 复制文件到新目录
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 复制文件失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动文件到新目录
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 移动文件失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
else:
return None, f"不支持的整理方式:{transfer_type}"
return None, "未知错误"
def __transfer_other_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理其他相关文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
# 整理字幕
state, errmsg = self.__transfer_subtitles(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
if not state:
return False, errmsg
# 整理音轨文件
state, errmsg = self.__transfer_audio_track_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
return state, errmsg
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理对应字幕文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
# 字幕正则式
_zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&]?(cn|ch[si]|sg|zho?|eng)" \
r"|简[体中]?)[.\])])" \
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
r"|简体|简中|JPSC|sc_jp" \
r"|(?<![a-z0-9])gb(?![a-z0-9])"
_zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \
r"|(cht|eng)[-_&]?(cht|eng)" \
r"|繁[体中]?)[.\])])" \
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp" \
r"|(?<![a-z0-9])big5(?![a-z0-9])"
_eng_sub_re = r"[.\[(]eng[.\])]"
# 比对文件名并整理字幕
org_path = Path(fileitem.path)
# 查找上级文件项
parent_item: FileItem = source_oper.get_parent(fileitem)
if not parent_item:
return False, f"{org_path} 上级目录获取失败"
# 字幕文件列表
file_list: List[FileItem] = source_oper.list(parent_item) or []
file_list = [f for f in file_list if f.type == "file" and f.extension
and f".{f.extension.lower()}" in settings.RMT_SUBEXT]
if len(file_list) == 0:
logger.info(f"{parent_item.path} 目录下没有找到字幕文件...")
else:
logger.info(f"字幕文件清单:{[f.name for f in file_list]}")
# 识别文件名
metainfo = MetaInfoPath(org_path)
for sub_item in file_list:
# 识别字幕文件名
sub_file_name = re.sub(_zhtw_sub_re,
".",
re.sub(_zhcn_sub_re,
".",
sub_item.name,
flags=re.I),
flags=re.I)
sub_file_name = re.sub(_eng_sub_re, ".", sub_file_name, flags=re.I)
sub_metainfo = MetaInfoPath(Path(sub_item.path))
# 匹配字幕文件名
if (org_path.stem == Path(sub_file_name).stem) or \
(sub_metainfo.cn_name and sub_metainfo.cn_name == metainfo.cn_name) \
or (sub_metainfo.en_name and sub_metainfo.en_name == metainfo.en_name):
if metainfo.part and metainfo.part != sub_metainfo.part:
continue
if metainfo.season \
and metainfo.season != sub_metainfo.season:
continue
if metainfo.episode \
and metainfo.episode != sub_metainfo.episode:
continue
new_file_type = ""
# 兼容jellyfin字幕识别(多重识别), emby则会识别最后一个后缀
if re.search(_zhcn_sub_re, sub_item.name, re.I):
new_file_type = ".chi.zh-cn"
elif re.search(_zhtw_sub_re, sub_item.name,
re.I):
new_file_type = ".zh-tw"
elif re.search(_eng_sub_re, sub_item.name, re.I):
new_file_type = ".eng"
# 通过对比字幕文件大小 尽量整理所有存在的字幕
file_ext = f".{sub_item.extension}"
new_sub_tag_dict = {
".eng": ".英文",
".chi.zh-cn": ".简体中文",
".zh-tw": ".繁体中文"
}
new_sub_tag_list = [
(".default" + new_file_type if (
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") or
(settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") or
(settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
) else new_file_type) if t == 0 else "%s%s(%s)" % (new_file_type,
new_sub_tag_dict.get(
new_file_type, ""
),
t) for t in range(6)
]
for new_sub_tag in new_sub_tag_list:
new_file: Path = target_file.with_name(target_file.stem + new_sub_tag + file_ext)
# 如果字幕文件不存在, 直接整理字幕, 并跳出循环
try:
logger.debug(f"正在处理字幕:{sub_item.name}")
new_item, errmsg = self.__transfer_command(fileitem=sub_item,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_file,
transfer_type=transfer_type)
if new_item:
logger.info(f"字幕 {sub_item.name} 整理完成")
self.__set_result(
subtitle_list=[sub_item.path],
subtitle_list_new=[new_item.path],
)
break
else:
logger.error(f"字幕 {sub_item.name} 整理失败:{errmsg}")
return False, errmsg
except Exception as error:
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
return True, ""
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理对应音轨文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
org_path = Path(fileitem.path)
# 查找上级文件项
parent_item: FileItem = source_oper.get_parent(fileitem)
if not parent_item:
return False, f"{org_path} 上级目录获取失败"
file_list: List[FileItem] = source_oper.list(parent_item)
# 匹配音轨文件
pending_file_list: List[FileItem] = [file for file in file_list
if Path(file.name).stem == org_path.stem
and file.type == "file" and file.extension
and f".{file.extension.lower()}" in settings.RMT_AUDIOEXT]
if len(pending_file_list) == 0:
return True, f"{parent_item.path} 目录下没有找到匹配的音轨文件"
logger.debug("音轨文件清单:" + str(pending_file_list))
for track_file in pending_file_list:
track_ext = f".{track_file.extension}"
new_track_file = target_file.with_name(target_file.stem + track_ext)
try:
logger.info(f"正在整理音轨文件:{track_file}{new_track_file}")
new_item, errmsg = self.__transfer_command(fileitem=track_file,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_track_file,
transfer_type=transfer_type)
if new_item:
logger.info(f"音轨文件 {org_path.name} 整理完成")
self.__set_result(
audio_list=[track_file.path],
audio_list_new=[new_item.path],
)
else:
logger.error(f"音轨文件 {org_path.name} 整理失败:{errmsg}")
except Exception as error:
logger.error(f"音轨文件 {org_path.name} 整理失败:{str(error)}")
return True, ""
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,
source_oper: StorageBase, target_oper: StorageBase,
transfer_type: str, target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
"""
整理整个文件夹
:param fileitem: 源文件
:param mediainfo: 媒体信息
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param transfer_type: 整理方式
:param target_storage: 目标存储
:param target_path: 目标路径
"""
logger.info(f"正在整理目录:{fileitem.path}{target_path}")
target_item = target_oper.get_folder(target_path)
if not target_item:
return None, f"获取目标目录失败:{target_path}"
event_data = TransferInterceptEventData(
fileitem=fileitem,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type
)
event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)
if event and event.event_data:
event_data = event.event_data
# 如果事件被取消,跳过文件整理
if event_data.cancel:
logger.debug(
f"Transfer dir canceled by event: {event_data.source},"
f"Reason: {event_data.reason}")
return None, event_data.reason
# 处理所有文件
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_path=target_path,
transfer_type=transfer_type)
if state:
return target_item, errmsg
else:
return None, errmsg
def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
transfer_type: str, target_path: Path) -> Tuple[bool, str]:
"""
按目录结构整理目录下所有文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_path: 目标路径
:param transfer_type: 整理方式
"""
file_list: List[FileItem] = source_oper.list(fileitem)
# 整理文件
for item in file_list:
if item.type == "dir":
# 递归整理目录
new_path = target_path / item.name
state, errmsg = self.__transfer_dir_files(fileitem=item,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
transfer_type=transfer_type,
target_path=new_path)
if not state:
return False, errmsg
else:
# 整理文件
new_file = target_path / item.name
new_item, errmsg = self.__transfer_command(fileitem=item,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_file,
transfer_type=transfer_type)
if not new_item:
return False, errmsg
self.__set_result(
file_list=[item.path],
file_list_new=[new_item.path],
)
# 返回成功
return True, ""
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo,
source_oper: StorageBase, target_oper: StorageBase,
target_storage: str, target_file: Path,
transfer_type: str, over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
"""
整理一个文件,同时处理其他相关文件
:param fileitem: 原文件
:param mediainfo: 媒体信息
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_storage: 目标存储
:param target_file: 新文件
:param transfer_type: 整理方式
:param over_flag: 是否覆盖为True时会先删除再整理
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
"""
logger.info(f"正在整理文件:【{fileitem.storage}{fileitem.path} 到 【{target_storage}{target_file}"
f"操作类型:{transfer_type}")
event_data = TransferInterceptEventData(
fileitem=fileitem,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_file,
transfer_type=transfer_type,
options={
"over_flag": over_flag
}
)
event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)
if event and event.event_data:
event_data = event.event_data
# 如果事件被取消,跳过文件整理
if event_data.cancel:
logger.debug(
f"Transfer file canceled by event: {event_data.source},"
f"Reason: {event_data.reason}")
return None, event_data.reason
if target_storage == "local" and (target_file.exists() or target_file.is_symlink()):
if not over_flag:
logger.warn(f"文件已存在:{target_file}")
return None, f"{target_file} 已存在"
else:
logger.info(f"正在删除已存在的文件:{target_file}")
target_file.unlink()
else:
exists_item = target_oper.get_item(target_file)
if exists_item:
if not over_flag:
logger.warn(f"文件已存在:【{target_storage}{target_file}")
return None, f"{target_storage}{target_file} 已存在"
else:
logger.info(f"正在删除已存在的文件:【{target_storage}{target_file}")
target_oper.delete(exists_item)
# 执行文件整理命令
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
if new_item:
self.__set_result(
file_list=[fileitem.path],
file_list_new=[new_item.path],
file_count=1,
total_size=fileitem.size,
)
# 处理其他相关文件
self.__transfer_other_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
return new_item, errmsg
return None, errmsg
@staticmethod
def get_dest_path(mediainfo: MediaInfo, target_path: Path,
need_type_folder: Optional[bool] = False, need_category_folder: Optional[bool] = False):
"""
获取目标路径
"""
if need_type_folder:
target_path = target_path / mediainfo.type.value
if need_category_folder and mediainfo.category:
target_path = target_path / mediainfo.category
return target_path
@staticmethod
def get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf,
need_type_folder: Optional[bool] = None, need_category_folder: Optional[bool] = None) -> Path:
"""
根据设置并装媒体库目录
:param mediainfo: 媒体信息
:param target_dir: 媒体库根目录
:param need_type_folder: 是否需要按媒体类型创建目录
:param need_category_folder: 是否需要按媒体类别创建目录
"""
if need_type_folder is None:
need_type_folder = target_dir.library_type_folder
if need_category_folder is None:
need_category_folder = target_dir.library_category_folder
if not target_dir.media_type and need_type_folder:
# 一级自动分类
library_dir = Path(target_dir.library_path) / mediainfo.type.value
elif target_dir.media_type and need_type_folder:
# 一级手动分类
library_dir = Path(target_dir.library_path) / target_dir.media_type
else:
library_dir = Path(target_dir.library_path)
if not target_dir.media_category and need_category_folder and mediainfo.category:
# 二级自动分类
library_dir = library_dir / mediainfo.category
elif target_dir.media_category and need_category_folder:
# 二级手动分类
library_dir = library_dir / target_dir.media_category
return library_dir
@staticmethod
def get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: Optional[str] = None,
episodes_info: List[TmdbEpisode] = None) -> dict:
"""
根据媒体信息返回Format字典
:param meta: 文件元数据
:param mediainfo: 识别的媒体信息
:param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息
"""
return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo,
file_extension=file_ext, episodes_info=episodes_info)
@staticmethod
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
"""
删除目录下的所有版本文件
:param storage_oper: 存储操作对象
:param path: 目录路径
"""
# 存储
if not storage_oper:
return False
# 识别文件中的季集信息
meta = MetaInfoPath(path)
season = meta.season
episode = meta.episode
logger.warn(f"正在删除目标目录中其它版本的文件:{path.parent}")
# 获取父目录
parent_item = storage_oper.get_item(path.parent)
if not parent_item:
logger.warn(f"目录 {path.parent} 不存在")
return False
# 检索媒体文件
media_files = storage_oper.list(parent_item)
if not media_files:
logger.info(f"目录 {path.parent} 中没有文件")
return False
# 删除文件
for media_file in media_files:
media_path = Path(media_file.path)
if media_path == path:
continue
if media_file.type != "file":
continue
if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT:
continue
# 识别文件中的季集信息
filemeta = MetaInfoPath(media_path)
# 相同季集的文件才删除
if filemeta.season != season or filemeta.episode != episode:
continue
logger.info(f"正在删除文件:{media_file.name}")
storage_oper.delete(media_file)
return True
@staticmethod
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
"""
生成重命名后的完整路径,支持智能重命名事件
:param template_string: Jinja2 模板字符串
:param rename_dict: 渲染上下文,用于替换模板中的变量
:param path: 可选的基础路径,如果提供,将在其基础上拼接生成的路径
:return: 生成的完整路径
"""
# 创建jinja2模板对象
template = Template(template_string)
# 渲染生成的字符串
render_str = template.render(rename_dict)
logger.debug(f"Initial render string: {render_str}")
# 发送智能重命名事件
event_data = TransferRenameEventData(
template_string=template_string,
rename_dict=rename_dict,
render_str=render_str,
path=path
)
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
# 检查事件返回的结果
if event and event.event_data:
event_data: TransferRenameEventData = event.event_data
if event_data.updated and event_data.updated_str:
logger.debug(f"Render string updated by event: "
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})")
render_str = event_data.updated_str
# 目的路径
if path:
return path / render_str
else:
return Path(render_str)

View File

@@ -21,8 +21,8 @@ class FilterModule(_ModuleBase):
rule_set: Dict[str, dict] = {
# 蓝光原盘
"BLU": {
"include": [r'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD'],
"exclude": [r'[Hx].?264|[Hx].?265|WEB-?DL|WEB-?RIP|REMUX']
"include": [r'(?i)(\bBlu-?Ray\b.*\b(?:VC-?1|AVC|MPEG-?2)\b|\b(?:UHD|4K|2160p)\b(?:.*Blu-?Ray)?.*\b(?:HEVC|H\.?265)\b|\bBlu-?Ray\b.*\b(?:UHD|4K|2160p)\b.*\b(?:HEVC|H\.?265)\b|\b(?:COMPLETE|FULL)\b.*\b(?:(?:UHD|4K|2160p)\b.*)?Blu-?Ray\b|\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\b)'],
"exclude": [r'(?i)(\b[XH]\.?264\b|\b[XH]\.?265\b|\bWEB-?DL\b|\bWEB-?RIP\b|\bHDTV(?:RIP)?\b|\bREMUX\b|\bBDRip\b|\bBRRip\b|\bHDRip\b|\bENCODE\b|\b(?<!WEB-|HDTV)RIP\b)']
},
# 4K
"4K": {

View File

@@ -1334,7 +1334,18 @@ class TmdbApi:
return []
try:
logger.debug(f"正在获取剧集组:{group_id}...")
return self.tv.group_episodes(group_id) or []
group_seasons = self.tv.group_episodes(group_id) or []
return [
{
**group_season,
"episodes": [
{**ep, "episode_number": idx}
# 剧集组中每个季的episode_number从1开始
for idx, ep in enumerate(group_season.get("episodes", []), start=1)
]
}
for group_season in group_seasons
]
except Exception as e:
logger.error(str(e))
return []
@@ -1348,9 +1359,6 @@ class TmdbApi:
return {}
for group_season in group_seasons:
if group_season.get('order') == season:
# 剧集组中每个季的episode_number从1开始
for i, e in enumerate(group_season.get('episodes', []), start=1):
e['episode_number'] = i
return group_season
return {}

View File

@@ -1,21 +1,92 @@
from threading import Lock
from typing import Optional, Tuple, Union
from app.core.config import settings
from app.log import logger
from app.modules import _ModuleBase
from app.modules.thetvdb import tvdbapi
from app.modules.thetvdb import tvdb_v4_official
from app.schemas.types import ModuleType, MediaRecognizeType
from app.utils.http import RequestUtils
class TheTvDbModule(_ModuleBase):
tvdb: tvdbapi.Tvdb = None
"""
TVDB媒体信息匹配
"""
__timeout: int = 15
tvdb: Optional[tvdb_v4_official.TVDB] = None
__auth_lock = Lock()
def init_module(self) -> None:
self.tvdb = tvdbapi.Tvdb(apikey=settings.TVDB_API_KEY,
cache=False,
select_first=True,
proxies=settings.PROXY)
pass
def _initialize_tvdb_session(self, is_retry: bool = False) -> None:
"""
创建或刷新 TVDB 登录会话。
:param is_retry: 是否是由于token失效后的重试登录
"""
action = "刷新" if is_retry else "创建"
logger.info(f"开始{action}TVDB登录会话...")
try:
if not settings.TVDB_V4_API_KEY:
raise ConnectionError("TVDB API Key 未配置,无法初始化会话。")
self.tvdb = tvdb_v4_official.TVDB(apikey=settings.TVDB_V4_API_KEY,
pin=settings.TVDB_V4_API_PIN,
proxy=settings.PROXY,
timeout=self.__timeout)
if self.tvdb:
logger.info(f"TVDB登录会话{action}成功。")
else:
raise ValueError(f"TVDB登录会话{action}后实例仍为None。")
except Exception as e:
self.tvdb = None
raise ConnectionError(f"TVDB登录会话{action}失败: {str(e)}") from e
def _ensure_tvdb_session(self, is_retry: bool = False) -> None:
"""
确保TVDB会话存在。如果不存在或需要强制重新初始化则进行初始化。
:param is_retry: 是否重新初始化例如token失效时
"""
# 第一次检查 (无锁),提高性能,避免不必要锁竞争
if not self.tvdb or is_retry:
with self.__auth_lock:
# 第二次检查 (有锁),防止多个线程都通过第一次检查后重复初始化
if not self.tvdb or is_retry:
self._initialize_tvdb_session(is_retry=is_retry)
def _handle_tvdb_call(self, method_name: str, *args, **kwargs):
"""
包裹 TVDB 调用,处理 token 失效情况并尝试重新初始化
:param method_name: 要在 self.tvdb 实例上调用的方法的名称 (字符串)
"""
try:
self._ensure_tvdb_session()
actual_method = getattr(self.tvdb, method_name)
return actual_method(*args, **kwargs)
except ValueError as e:
if "Unauthorized" in str(e):
logger.warning("TVDB Token 可能已失效,正在尝试重新登录...")
try:
self._ensure_tvdb_session(is_retry=True)
actual_method = getattr(self.tvdb, method_name)
return actual_method(*args, **kwargs)
except ConnectionError as conn_err:
logger.error(f"TVDB Token失效后重新登录失败: {conn_err}")
raise
elif "NotFoundException" in str(e) or "ID not found" in str(e):
logger.warning(f"TVDB 资源未找到 (调用 {method_name}): {e}")
return None
else:
logger.error(f"TVDB 调用 ({method_name}) 时发生未处理的 ValueError: {str(e)}")
raise
except ConnectionError as e:
logger.error(f"TVDB 连接会话错误: {str(e)}")
raise
except AttributeError as e:
logger.error(f"TVDB 实例上没有方法 '{method_name}': {e}")
raise
except Exception as e:
logger.error(f"TVDB 调用时发生未知错误: {str(e)}", exc_info=True)
raise
@staticmethod
def get_name() -> str:
@@ -43,18 +114,19 @@ class TheTvDbModule(_ModuleBase):
return 4
def stop(self):
self.tvdb.close()
logger.info("TheTvDbModule 停止。正在清除 TVDB 会话。")
with self.__auth_lock:
self.tvdb = None
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.thetvdb.com/series/81189")
if ret and ret.status_code == 200:
try:
self._handle_tvdb_call("get_series", 81189)
return True, ""
elif ret:
return False, f"无法连接 api.thetvdb.com错误码{ret.status_code}"
return False, "api.thetvdb.com 网络连接失败"
except Exception as e:
return False, str(e)
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@@ -67,6 +139,26 @@ class TheTvDbModule(_ModuleBase):
"""
try:
logger.info(f"开始获取TVDB信息: {tvdbid} ...")
return self.tvdb[tvdbid].data
return self._handle_tvdb_call("get_series_extended", tvdbid)
except Exception as err:
logger.error(f"获取TVDB信息失败: {str(err)}")
return None
def search_tvdb(self, title: str) -> list:
"""
用标题搜索TVDB剧集
:param title: 标题
:return: TVDB信息
"""
try:
logger.info(f"开始用标题搜索TVDB剧集: {title} ...")
res = self._handle_tvdb_call("search", title)
if res is None:
return []
if not isinstance(res, list):
logger.warning(f"TVDB 搜索 '{title}' 未返回列表:{type(res)}")
return []
return [item for item in res if isinstance(item, dict) and item.get("type") == "series"]
except Exception as err:
logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}")
return []

View File

@@ -0,0 +1,615 @@
"""Official python package for using the tvdb v4 api"""
__author__ = "Weylin Wagnon"
__version__ = "1.0.12"
import json
import urllib.parse
from http import HTTPStatus
from app.utils.http import RequestUtils
class Auth:
"""
TVDB认证类
"""
def __init__(self, url, apikey, pin="", proxy=None, timeout: int = 15):
login_info = {"apikey": apikey}
if pin != "":
login_info["pin"] = pin
login_info_bytes = json.dumps(login_info, indent=2)
try:
# 使用项目统一的RequestUtils类
req_utils = RequestUtils(proxies=proxy, timeout=timeout)
response = req_utils.post_res(
url=url,
data=login_info_bytes,
headers={"Content-Type": "application/json"}
)
if response and response.status_code == 200:
result = response.json()
self.token = result["data"]["token"]
else:
error_msg = f"登录失败,状态码: {response.status_code if response else 'None'}"
if response:
try:
error_data = response.json()
error_msg = f"Code: {response.status_code}, {error_data.get('message', '未知错误')}"
except Exception as err:
error_msg = f"Code: {response.status_code}, 响应解析失败:{err}"
raise Exception(error_msg)
except Exception as e:
raise Exception(f"TVDB认证失败: {str(e)}")
def get_token(self):
"""
获取认证token
"""
return self.token
class Request:
"""
请求处理类
"""
def __init__(self, auth_token, proxy=None, timeout=15):
self.auth_token = auth_token
self.links = None
self.proxy = proxy
self.timeout = timeout
def make_request(self, url, if_modified_since=None):
"""
向指定的 URL 发起请求并返回数据
"""
headers = {"Authorization": f"Bearer {self.auth_token}"}
if if_modified_since:
headers["If-Modified-Since"] = str(if_modified_since)
try:
# 使用项目统一的RequestUtils类
req_utils = RequestUtils(proxies=self.proxy, timeout=self.timeout)
response = req_utils.get_res(url=url, headers=headers)
if response is None:
raise ValueError(f"获取 {url} 失败\n 网络连接失败")
if response.status_code == HTTPStatus.NOT_MODIFIED:
return {
"code": HTTPStatus.NOT_MODIFIED.real,
"message": "Not-Modified",
}
if response.status_code == 200:
result = response.json()
data = result.get("data", None)
if data is not None and result.get("status", "failure") != "failure":
self.links = result.get("links", None)
return data
msg = result.get("message", "未知错误")
raise ValueError(f"获取 {url} 失败\n {str(msg)}")
else:
# 处理其他HTTP错误状态码
try:
error_data = response.json()
msg = error_data.get("message", f"HTTP {response.status_code}")
except Exception as err:
msg = f"HTTP {response.status_code} {err}"
raise ValueError(f"获取 {url} 失败\n {str(msg)}")
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"获取 {url} 失败\n {str(e)}")
class Url:
"""
URL构建类
"""
def __init__(self):
self.base_url = "https://api4.thetvdb.com/v4/"
def construct(self, url_sect, url_id=None, url_subsect=None, url_lang=None, **kwargs):
"""
构建API URL
"""
url = self.base_url + url_sect
if url_id:
url += "/" + str(url_id)
if url_subsect:
url += "/" + url_subsect
if url_lang:
url += "/" + url_lang
if kwargs:
params = {var: val for var, val in kwargs.items() if val is not None}
if params:
url += "?" + urllib.parse.urlencode(params)
return url
class TVDB:
"""
TVDB API主类
"""
def __init__(self, apikey: str, pin="", proxy=None, timeout: int = 15):
self.url = Url()
login_url = self.url.construct("login")
self.auth = Auth(login_url, apikey, pin, proxy, timeout)
auth_token = self.auth.get_token()
self.request = Request(auth_token, proxy, timeout)
def get_req_links(self) -> dict:
"""
获取上一次请求返回的链接信息(例如分页链接)
"""
return self.request.links
def get_artwork_statuses(self, meta=None, if_modified_since=None) -> list:
"""
返回艺术图状态列表
"""
url = self.url.construct("artwork/statuses", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_artwork_types(self, meta=None, if_modified_since=None) -> list:
"""
返回艺术图类型列表
"""
url = self.url.construct("artwork/types", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_artwork(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个艺术图信息的字典
"""
url = self.url.construct("artwork", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_artwork_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个艺术图的扩展信息字典
"""
url = self.url.construct("artwork", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_awards(self, meta=None, if_modified_since=None) -> list:
"""
返回奖项列表
"""
url = self.url.construct("awards", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_award(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个奖项信息的字典
"""
url = self.url.construct("awards", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_award_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个奖项的扩展信息字典
"""
url = self.url.construct("awards", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_award_categories(self, meta=None, if_modified_since=None) -> list:
"""
返回奖项类别列表
"""
url = self.url.construct("awards/categories", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_award_category(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个奖项类别信息的字典
"""
url = self.url.construct("awards/categories", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_award_category_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个奖项类别的扩展信息字典
"""
url = self.url.construct("awards/categories", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_content_ratings(self, meta=None, if_modified_since=None) -> list:
"""
返回内容分级列表
"""
url = self.url.construct("content/ratings", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_countries(self, meta=None, if_modified_since=None) -> list:
"""
返回国家列表
"""
url = self.url.construct("countries", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_companies(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回公司列表 (可分页)
"""
url = self.url.construct("companies", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_company_types(self, meta=None, if_modified_since=None) -> list:
"""
返回公司类型列表
"""
url = self.url.construct("companies/types", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_company(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个公司信息的字典
"""
url = self.url.construct("companies", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_series(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回剧集列表 (可分页)
"""
url = self.url.construct("series", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_series(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个剧集信息的字典
"""
url = self.url.construct("series", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_series_by_slug(self, slug: str, meta=None, if_modified_since=None) -> dict:
"""
通过 slug (别名) 返回单个剧集信息的字典
"""
url = self.url.construct("series/slug", slug, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_series_extended(self, id: int, meta=None, short=False, if_modified_since=None) -> dict:
"""
返回单个剧集的扩展信息字典
"""
url = self.url.construct("series", id, "extended", meta=meta, short=short)
return self.request.make_request(url, if_modified_since)
def get_series_episodes(self, id: int, season_type: str = "default", page: int = 0,
lang: str = None, meta=None, if_modified_since=None, **kwargs) -> dict:
"""
返回指定剧集和季类型的各集信息字典 (可分页,可指定语言)
"""
url = self.url.construct(
"series", id, "episodes/" + season_type, lang, page=page, meta=meta, **kwargs
)
return self.request.make_request(url, if_modified_since)
def get_series_translation(self, id: int, lang: str, meta=None, if_modified_since=None) -> dict:
"""
返回剧集的指定语言翻译信息字典
"""
url = self.url.construct("series", id, "translations", lang, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_series_artworks(self, id: int, lang: str, type=None, if_modified_since=None) -> dict:
"""
返回包含艺术图数组的剧集记录 (可指定语言和类型)
"""
url = self.url.construct("series", id, "artworks", lang=lang, type=type)
return self.request.make_request(url, if_modified_since)
def get_series_next_aired(self, id: int, if_modified_since=None) -> dict:
"""
返回剧集的下一播出信息字典
"""
url = self.url.construct("series", id, "nextAired")
return self.request.make_request(url, if_modified_since)
def get_all_movies(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回电影列表 (可分页)
"""
url = self.url.construct("movies", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_movie(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个电影信息的字典
"""
url = self.url.construct("movies", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_movie_by_slug(self, slug: str, meta=None, if_modified_since=None) -> dict:
"""
通过 slug (别名) 返回单个电影信息的字典
"""
url = self.url.construct("movies/slug", slug, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_movie_extended(self, id: int, meta=None, short=False, if_modified_since=None) -> dict:
"""
返回电影的扩展信息字典
"""
url = self.url.construct("movies", id, "extended", meta=meta, short=short)
return self.request.make_request(url, if_modified_since)
def get_movie_translation(self, id: int, lang: str, meta=None, if_modified_since=None) -> dict:
"""
返回电影的指定语言翻译信息字典
"""
url = self.url.construct("movies", id, "translations", lang, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_seasons(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回季列表 (可分页)
"""
url = self.url.construct("seasons", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_season(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单季信息的字典
"""
url = self.url.construct("seasons", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_season_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单季的扩展信息字典
"""
url = self.url.construct("seasons", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_season_types(self, meta=None, if_modified_since=None) -> list:
"""
返回季类型列表
"""
url = self.url.construct("seasons/types", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_season_translation(self, id: int, lang: str, meta=None, if_modified_since=None) -> dict:
"""
返回季的指定语言翻译信息字典
"""
url = self.url.construct("seasons", id, "translations", lang, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_episodes(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回集列表 (可分页)
"""
url = self.url.construct("episodes", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_episode(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单集信息的字典
"""
url = self.url.construct("episodes", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_episode_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单集的扩展信息字典
"""
url = self.url.construct("episodes", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_episode_translation(self, id: int, lang: str, meta=None, if_modified_since=None) -> dict:
"""
返回单集的指定语言翻译信息字典
"""
url = self.url.construct("episodes", id, "translations", lang, meta=meta)
return self.request.make_request(url, if_modified_since)
# 兼容旧函数名。
get_episodes_translation = get_episode_translation
def get_all_genders(self, meta=None, if_modified_since=None) -> list:
"""
返回性别列表
"""
url = self.url.construct("genders", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_genres(self, meta=None, if_modified_since=None) -> list:
"""
返回类型(流派)列表
"""
url = self.url.construct("genres", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_genre(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个类型(流派)信息的字典
"""
url = self.url.construct("genres", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_languages(self, meta=None, if_modified_since=None) -> list:
"""
返回语言列表
"""
url = self.url.construct("languages", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_people(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回人物列表 (可分页)
"""
url = self.url.construct("people", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_person(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个人物信息的字典
"""
url = self.url.construct("people", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_person_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个人物的扩展信息字典
"""
url = self.url.construct("people", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_person_translation(self, id: int, lang: str, meta=None, if_modified_since=None) -> dict:
"""
返回人物的指定语言翻译信息字典
"""
url = self.url.construct("people", id, "translations", lang, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_character(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回角色信息的字典
"""
url = self.url.construct("characters", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_people_types(self, meta=None, if_modified_since=None) -> list:
"""
返回人物类型列表
"""
url = self.url.construct("people/types", meta=meta)
return self.request.make_request(url, if_modified_since)
# 兼容旧函数名
get_all_people_types = get_people_types
def get_source_types(self, meta=None, if_modified_since=None) -> list:
"""
返回来源类型列表
"""
url = self.url.construct("sources/types", meta=meta)
return self.request.make_request(url, if_modified_since)
# 兼容旧函数名
get_all_sourcetypes = get_source_types
def get_updates(self, since: int, **kwargs) -> list:
"""
返回更新列表
"""
url = self.url.construct("updates", since=since, **kwargs)
return self.request.make_request(url)
def get_all_tag_options(self, page=None, meta=None, if_modified_since=None) -> list:
"""
返回标签选项列表 (可分页)
"""
url = self.url.construct("tags/options", page=page, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_tag_option(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个标签选项信息的字典
"""
url = self.url.construct("tags/options", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_all_lists(self, page=None, meta=None) -> dict:
"""
返回所有公开的列表信息 (可分页)
"""
url = self.url.construct("lists", page=page, meta=meta)
return self.request.make_request(url)
def get_list(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个列表信息的字典
"""
url = self.url.construct("lists", id, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_list_by_slug(self, slug: str, meta=None, if_modified_since=None) -> dict:
"""
通过 slug (别名) 返回单个列表信息的字典
"""
url = self.url.construct("lists/slug", slug, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_list_extended(self, id: int, meta=None, if_modified_since=None) -> dict:
"""
返回单个列表的扩展信息字典
"""
url = self.url.construct("lists", id, "extended", meta=meta)
return self.request.make_request(url, if_modified_since)
def get_list_translation(self, id: int, lang: str, meta=None, if_modified_since=None) -> dict:
"""
返回列表的指定语言翻译信息字典
"""
url = self.url.construct("lists", id, "translations", lang, meta=meta)
return self.request.make_request(url, if_modified_since)
def get_inspiration_types(self, meta=None, if_modified_since=None) -> dict:
"""
返回灵感类型列表
"""
url = self.url.construct("inspiration/types", meta=meta)
return self.request.make_request(url, if_modified_since)
def search(self, query: str, **kwargs) -> list:
"""
根据查询字符串进行搜索,并返回结果列表
"""
url = self.url.construct("search", query=query, **kwargs)
return self.request.make_request(url)
def search_by_remote_id(self, remoteid: str) -> list:
"""
通过外部 ID 精确匹配搜索,并返回结果列表
"""
url = self.url.construct("search/remoteid", remoteid)
return self.request.make_request(url)
def get_tags(self, slug: str, if_modified_since=None) -> dict:
"""
返回具有指定 slug 的标签实体信息字典 (此方法基于的 /entities/{slug} 端点非标准,请谨慎使用)
"""
url = self.url.construct("entities", url_subsect=slug)
return self.request.make_request(url, if_modified_since)
def get_entities_types(self, if_modified_since=None) -> dict:
"""
返回可用的实体类型列表
"""
url = self.url.construct("entities")
return self.request.make_request(url, if_modified_since)
def get_user_by_id(self, id: int) -> dict:
"""
通过用户 ID 返回用户信息字典
"""
url = self.url.construct("user", id)
return self.request.make_request(url)
def get_user(self) -> dict:
"""
返回当前认证的用户信息字典
"""
url = self.url.construct("user")
return self.request.make_request(url)
def get_user_favorites(self) -> dict:
"""
返回当前认证用户的收藏夹信息字典
"""
url = self.url.construct('user/favorites')
return self.request.make_request(url)

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,8 @@ class _PluginBase(metaclass=ABCMeta):
plugin_desc: Optional[str] = ""
# 插件顺序
plugin_order: Optional[int] = 9999
# 是否为插件分身
is_clone: bool = False
def __init__(self):
# 插件数据

View File

@@ -540,17 +540,18 @@ class Scheduler(metaclass=Singleton):
self.remove_plugin_job(pid)
# 获取插件服务列表
with self._lock:
plugin_manager = PluginManager()
try:
plugin_services = PluginManager().get_plugin_services(pid=pid)
plugin_services = plugin_manager.get_plugin_services(pid=pid)
except Exception as e:
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
return
# 获取插件名称
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
plugin_name = plugin_manager.get_plugin_attr(pid, "plugin_name")
# 开始注册插件服务
for service in plugin_services:
try:
sid = f"{service['id']}"
sid = f"{pid}_{service['id']}"
job_id = sid.split("|")[0]
self.remove_plugin_job(pid, job_id)
self._jobs[job_id] = {
@@ -599,8 +600,8 @@ class Scheduler(metaclass=Singleton):
name = service.get("name")
provider_name = service.get("provider_name")
if service.get("running") and name and provider_name:
if name not in added:
added.append(name)
if job_id not in added:
added.append(job_id)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=name,
@@ -609,11 +610,11 @@ class Scheduler(metaclass=Singleton):
))
# 获取其他待执行任务
for job in jobs:
if job.name not in added:
added.append(job.name)
job_id = job.id.split("|")[0]
if job_id not in added:
added.append(job_id)
else:
continue
job_id = job.id.split("|")[0]
service = self._jobs.get(job_id)
if not service:
continue

View File

@@ -306,7 +306,7 @@ class MediaRecognizeConvertEventData(ChainEventData):
convert_type: str = Field(..., description="转换类型themoviedb/douban")
# 输出参数
media_dict: dict = Field(default=dict, description="转换后的媒体信息TheMovieDb/豆瓣)")
media_dict: dict = Field(default_factory=dict, description="转换后的媒体信息TheMovieDb/豆瓣)")
class StorageOperSelectionEventData(ChainEventData):

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
class FileItem(BaseModel):
# 存储类型
storage: Optional[str] = "local"
storage: Optional[str] = Field(default="local")
# 类型 dir/file
type: Optional[str] = None
# 文件路径

View File

@@ -115,15 +115,23 @@ class TransferInfo(BaseModel):
# 整理方式
transfer_type: Optional[str] = None
# 处理文件数
file_count: Optional[int] = 0
file_count: Optional[int] = Field(default=0)
# 处理文件清单
file_list: Optional[list] = Field(default_factory=list)
# 目标文件清单
file_list_new: Optional[list] = Field(default_factory=list)
# 总文件大小
total_size: Optional[float] = 0.0
total_size: Optional[int] = Field(default=0)
# 失败清单
fail_list: Optional[list] = Field(default_factory=list)
# 处理字幕文件清单
subtitle_list: Optional[list] = Field(default_factory=list)
# 目标字幕文件清单
subtitle_list_new: Optional[list] = Field(default_factory=list)
# 处理音频文件清单
audio_list: Optional[list] = Field(default_factory=list)
# 目标音频文件清单
audio_list_new: Optional[list] = Field(default_factory=list)
# 错误信息
message: Optional[str] = None
# 是否需要刮削

View File

@@ -141,6 +141,8 @@ class SystemConfigKey(Enum):
UserInstalledPlugins = "UserInstalledPlugins"
# 插件安装统计
PluginInstallReport = "PluginInstallReport"
# 插件文件夹分组配置
PluginFolders = "PluginFolders"
# 默认电影订阅规则
DefaultMovieSubscribeConfig = "DefaultMovieSubscribeConfig"
# 默认电视剧订阅规则

View File

@@ -908,3 +908,20 @@ class StringUtils:
:return: 如果elem有效非None且长度大于0返回True否则返回False
"""
return elem is not None and len(elem) > 0
@staticmethod
def is_link(text: str) -> bool:
"""
检查文件是否为链接地址,支持各类协议
:param text: 要检查的文本
:return: 如果URL有效返回True否则返回False
"""
if not text:
return False
# 检查是否以http、https、ftp等协议开头
if re.match(r'^(http|https|ftp|ftps|sftp|ws|wss)://', text):
return True
# 检查是否为IP地址或域名
if re.match(r'^[a-zA-Z0-9.-]+(\.[a-zA-Z]{2,})?$', text):
return True
return False

View File

@@ -11,7 +11,6 @@ from glob import glob
from pathlib import Path
from typing import List, Optional, Tuple, Union
import docker
import psutil
from app import schemas
@@ -439,47 +438,6 @@ class SystemUtils:
"""
return [psutil.virtual_memory().used, int(psutil.virtual_memory().percent)]
@staticmethod
def can_restart() -> bool:
"""
判断是否可以内部重启
"""
return Path("/var/run/docker.sock").exists()
@staticmethod
def restart() -> Tuple[bool, str]:
"""
执行Docker重启操作
"""
if not SystemUtils.is_docker():
return False, "非Docker环境无法重启"
try:
# 创建 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")
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 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失败"
# 重启当前容器
client.containers.get(container_id.strip()).restart()
return True, ""
except Exception as err:
print(str(err))
return False, f"重启时发生错误:{str(err)}"
@staticmethod
def is_hardlink(src: Path, dest: Path) -> bool:
"""

View File

@@ -12,7 +12,7 @@ class Tokens:
self.load_text(text)
def load_text(self, text):
splitted_text = re.split(r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/||;|&|\||#|_|「|」|~", text)
splitted_text = re.split(r"\.|\s+|\(|\)|\[|]|-|【|】|/||;|&|\||#|_|「|」|~", text)
for sub_text in splitted_text:
if sub_text:
self._tokens.append(sub_text)

View File

@@ -5,8 +5,12 @@
HOST=0.0.0.0
# 【*】超级管理员,设置后一但重启将固化到数据库中,修改将无效(初始化超级管理员密码仅会生成一次,请在日志中查看并自行登录系统修改)
SUPERUSER=admin
# 重启自动升级 release/dev/true/false
MOVIEPILOT_AUTO_UPDATE=release
# 自动检查和更新站点资源包(索引、认证等)
AUTO_UPDATE_RESOURCE=true
# 网络代理服务器地址 http(s)://ip:port、socks5://user:pass@host:port、socks5h://user:pass@host:port
PROXY_HOST=
# 媒体识别来源 themoviedb/douban使用themoviedb时需要确保能正常连接api.themoviedb.org使用douban时不支持二级分类
RECOGNIZE_SOURCE=themoviedb
# OCR服务器地址

View File

@@ -37,7 +37,7 @@ def upgrade() -> None:
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if resource_term %}\\n质量{{ resource_term }}{% endif %}'
'{% if size %}\\n大小{{ size }}{% endif %}'
'{% if title %}\\n种子{{ title }}{% endif %}'
'{% if torrent_title %}\\n种子{{ torrent_title }}{% endif %}'
'{% if pubdate %}\\n发布时间{{ pubdate }}{% endif %}'
'{% if freedate %}\\n免费时间{{ freedate }}{% endif %}'
'{% if seeders %}\\n做种数{{ seeders }}{% endif %}'

View File

@@ -7,10 +7,7 @@ ENV LANG="C.UTF-8" \
DISPLAY=:987 \
PUID=0 \
PGID=0 \
UMASK=000 \
PORT=3001 \
NGINX_PORT=3000 \
MOVIEPILOT_AUTO_UPDATE=release
UMASK=000
WORKDIR "/app"
RUN apt-get update -y \
&& apt-get upgrade -y \
@@ -87,5 +84,5 @@ RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
&& rm -rf /tmp/*
EXPOSE 3000
VOLUME [ "/config" ]
VOLUME [ "${CONFIG_DIR}" ]
ENTRYPOINT [ "/entrypoint.sh" ]

View File

@@ -20,6 +20,148 @@ function WARN() {
echo -e "${WARN} ${1}"
}
# 校正设置目录
CONFIG_DIR="${CONFIG_DIR:-/config}"
# 记录非系统环境docker容器表提供的变量
declare -ga VARS_SET_BY_SCRIPT=()
# 环境变量补全
# 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值
# 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义)
function load_config_from_app_env() {
local env_file="${CONFIG_DIR}/app.env"
# 定义 ["变量名"]="预设默认值"
# 禁止填入 CONFIG_DIR 变量ACME_ENV_ 开头的变量暂时不处理,还是交由 cert.sh 处理
declare -A vars_and_default_values=(
# update.sh
["PIP_PROXY"]=""
["GITHUB_PROXY"]=""
["PROXY_HOST"]=""
["GITHUB_TOKEN"]=""
["MOVIEPILOT_AUTO_UPDATE"]="release"
# cert
["ENABLE_SSL"]="false"
["SSL_DOMAIN"]=""
["NGINX_PORT"]="3000"
["PORT"]="3001"
["NGINX_CLIENT_MAX_BODY_SIZE"]="10m"
)
INFO "开始加载配置 (配置文件: ${env_file})..."
shopt -s extglob
declare -A values_from_env_file
if [ -f "${env_file}" ]; then
INFO "检测到 ${env_file} 文件,尝试解析..."
while IFS= read -r line || [ -n "$line" ]; do
if [[ "$line" =~ ^[[:space:]]*# || -z "$line" ]]; then
continue
fi
local key_in_file value_raw_in_file
if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*) ]]; then
key_in_file="${BASH_REMATCH[1]}"
value_raw_in_file="${BASH_REMATCH[2]}"
if [[ -n "${vars_and_default_values[$key_in_file]+_}" ]]; then
local temp_val_after_initial_trim
temp_val_after_initial_trim="${value_raw_in_file#"${value_raw_in_file%%[![:space:]]*}"}"
temp_val_after_initial_trim="${temp_val_after_initial_trim%"${temp_val_after_initial_trim##*[![:space:]]}"}"
local val_before_quote_check="${temp_val_after_initial_trim}"
if [[ ! ("${temp_val_after_initial_trim:0:1}" == "'" && "${temp_val_after_initial_trim: -1}" == "'") ]]; then
if [[ "${temp_val_after_initial_trim}" =~ ^(.*)[[:space:]]+# ]]; then
val_before_quote_check="${BASH_REMATCH[1]}"
val_before_quote_check="${val_before_quote_check%%+([[:space:]])}"
elif [[ "${temp_val_after_initial_trim:0:1}" == "#" ]]; then
val_before_quote_check=""
fi
fi
local parsed_value_from_file
if [[ "${val_before_quote_check:0:1}" == "'" && "${val_before_quote_check: -1}" == "'" && ${#val_before_quote_check} -ge 2 ]]; then
parsed_value_from_file="${val_before_quote_check:1:${#val_before_quote_check}-2}"
parsed_value_from_file="${parsed_value_from_file//\\\'/__MP_PARSER_SQUOTE__}"
parsed_value_from_file="${parsed_value_from_file//__MP_PARSER_SQUOTE__/\'}"
elif [ -z "${val_before_quote_check}" ]; then
parsed_value_from_file=""
else
WARN "位于 ${env_file} 中的键 ${key_in_file} 对应值 ${val_before_quote_check} 未按规范使用单引号包裹,将采用字面量解析。"
parsed_value_from_file="${val_before_quote_check}"
fi
values_from_env_file["${key_in_file}"]="${parsed_value_from_file}"
fi
else
WARN "跳过 ${env_file} 中格式不正确的行: $line"
fi
done < <(sed -e '1s/^\xEF\xBB\xBF//' -e 's/\r$//g' "${env_file}")
INFO "${env_file} 解析完毕。"
else
INFO "${env_file} 文件不存在,跳过文件加载。"
fi
INFO "正在根据优先级确定并导出配置值..."
for var_name in "${!vars_and_default_values[@]}"; do
local fallback_value="${vars_and_default_values[$var_name]}"
local final_value
local value_source="未设置"
# 标志变量是否来自初始环境
local set_by_initial_env=false
# 检查变量是否在环境中已设置(可能为空)
if eval "[ -n \"\${${var_name}+x}\" ]"; then
# 获取其值
final_value="$(eval echo \"\$"${var_name}"\")"
value_source="系统环境变量"
set_by_initial_env=true
elif [[ -n "${values_from_env_file["${var_name}"]+_}" ]]; then
final_value="${values_from_env_file["${var_name}"]}"
value_source=".env 文件"
else
final_value="${fallback_value}"
value_source="内置默认值"
fi
# 不论来源如何,都导出变量,以便脚本的其余部分和子进程使用
# (例如 envsubst, mp_update.sh, cert.sh)
if declare -gx "${var_name}=${final_value}"; then
if [ -z "${final_value}" ]; then
INFO "变量 ${var_name}, 值为空, 来源: ${value_source})。"
else
INFO "变量 ${var_name}, 值: ${final_value} , (来源: ${value_source})。"
fi
# 如果变量不是来自初始环境变量,则记录下来以便稍后 unset
if ! ${set_by_initial_env}; then
# 检查是否已在数组中,避免重复添加
local found_in_script_vars=false
for item in "${VARS_SET_BY_SCRIPT[@]}"; do
if [[ "$item" == "$var_name" ]]; then
found_in_script_vars=true
break
fi
done
if ! ${found_in_script_vars}; then
VARS_SET_BY_SCRIPT+=("${var_name}")
fi
fi
else
ERROR "导出变量 ${var_name} (值: '${final_value}', 来源: ${value_source}) 失败。"
fi
done
shopt -u extglob
INFO "配置加载流程执行完毕。"
}
# 使用env配置
load_config_from_app_env
# 生成HTTPS配置块
if [ "${ENABLE_SSL}" = "true" ]; then
export HTTPS_SERVER_CONF=$(cat <<EOF
@@ -32,8 +174,8 @@ if [ "${ENABLE_SSL}" = "true" ]; then
server_name ${SSL_DOMAIN:-moviepilot};
# SSL证书路径
ssl_certificate /config/certs/latest/fullchain.pem;
ssl_certificate_key /config/certs/latest/privkey.pem;
ssl_certificate ${CONFIG_DIR}/certs/latest/fullchain.pem;
ssl_certificate_key ${CONFIG_DIR}/certs/latest/privkey.pem;
# SSL安全配置
ssl_protocols TLSv1.2 TLSv1.3;
@@ -52,7 +194,6 @@ else
fi
# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值
export NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-10m}
envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}${ENABLE_SSL}${HTTPS_SERVER_CONF}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
# 自动更新
cd /
@@ -66,7 +207,7 @@ chown -R moviepilot:moviepilot \
"${HOME}" \
/app \
/public \
/config \
"${CONFIG_DIR}" \
/var/lib/nginx \
/var/log/nginx
chown moviepilot:moviepilot /etc/hosts /tmp
@@ -92,6 +233,23 @@ if [ -S "/var/run/docker.sock" ]; then
fi
# 设置后端服务权限掩码
umask "${UMASK}"
# 清除非系统环境导入的变量,保证转移到 dumb-init 的时候,不会带入不必要的环境变量
INFO "准备为 Python 应用清理的非系统环境导入的变量..."
if [ ${#VARS_SET_BY_SCRIPT[@]} -gt 0 ]; then
for var_to_unset in "${VARS_SET_BY_SCRIPT[@]}"; do
# 再次确认变量确实存在于当前环境中(虽然理论上应该存在)
if eval "[ -n \"\${${var_to_unset}+x}\" ]"; then
INFO "取消设置环境变量: ${var_to_unset}"
unset "${var_to_unset}"
else
WARN "变量 ${var_to_unset} 已不存在,无需取消设置。"
fi
done
else
INFO "没有由非系统环境导入的变量需要清理。"
fi
# 启动后端服务
INFO "→ 启动后端服务..."
exec dumb-init gosu moviepilot:moviepilot python3 app/main.py

View File

@@ -668,7 +668,7 @@ meta_cases = [{
"restype": "UHD BluRay DoVi",
"pix": "1080p",
"video_codec": "X265",
"audio_codec": "DD 7.1"
"audio_codec": "DD+ 7.1"
}
}, {
"title": "Childhood.In.A.Capsule.S01E16.2022.1080p.KKTV.WEB-DL.X264.AAC-ADWeb.mkv",
@@ -968,7 +968,7 @@ meta_cases = [{
"year": "2023",
"part": "",
"season": "S02",
"episode": "E01-E08",
"episode": "",
"restype": "WEB-DL",
"pix": "2160p",
"video_codec": "H265",
@@ -1016,7 +1016,7 @@ meta_cases = [{
"year": "2019",
"part": "",
"season": "S01",
"episode": "E01-E36",
"episode": "",
"restype": "WEB-DL",
"pix": "2160p",
"video_codec": "H265",
@@ -1037,4 +1037,84 @@ meta_cases = [{
"video_codec": "",
"audio_codec": ""
}
}, {
"path": "/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv",
"target": {
"type": "电视剧",
"cn_name": "",
"en_name": "The Vampire Diaries",
"year": "2009",
"part": "",
"season": "S01",
"episode": "E01",
"restype": "",
"pix": "1080p",
"video_codec": "",
"audio_codec": "",
"tmdbid": 18165
}
}, {
"path": "/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv",
"target": {
"type": "未知",
"cn_name": "",
"en_name": "Inception",
"year": "2010",
"part": "",
"season": "",
"episode": "",
"restype": "",
"pix": "1080p",
"video_codec": "",
"audio_codec": "",
"tmdbid": 27205
}
}, {
"path": "/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv",
"target": {
"type": "电视剧",
"cn_name": "",
"en_name": "Breaking Bad",
"year": "2008",
"part": "",
"season": "S01",
"episode": "E01",
"restype": "",
"pix": "1080p",
"video_codec": "",
"audio_codec": "",
"tmdbid": 1396
}
}, {
"path": "/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv",
"target": {
"type": "电视剧",
"cn_name": "",
"en_name": "Game Of Thrones",
"year": "2011",
"part": "",
"season": "S01",
"episode": "E01",
"restype": "",
"pix": "1080p",
"video_codec": "",
"audio_codec": "",
"tmdbid": 1399
}
}, {
"path": "/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv",
"target": {
"type": "未知",
"cn_name": "",
"en_name": "Avatar",
"year": "2009",
"part": "",
"season": "",
"episode": "",
"restype": "",
"pix": "1080p",
"video_codec": "",
"audio_codec": "",
"tmdbid": 19995
}
}]

View File

@@ -7,6 +7,7 @@ if __name__ == '__main__':
# 测试名称识别
suite.addTest(MetaInfoTest('test_metainfo'))
suite.addTest(MetaInfoTest('test_emby_format_ids'))
# 运行测试
runner = unittest.TextTestRunner()

View File

@@ -32,4 +32,32 @@ class MetaInfoTest(TestCase):
"video_codec": meta_info.video_encode or "",
"audio_codec": meta_info.audio_encode or ""
}
# 检查tmdbid
if info.get("target").get("tmdbid"):
target["tmdbid"] = meta_info.tmdbid
self.assertEqual(target, info.get("target"))
def test_emby_format_ids(self):
"""
测试Emby格式ID识别
"""
# 测试文件路径
test_paths = [
# 文件名中包含tmdbid
("/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", 18165),
# 目录名中包含tmdbid
("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", 27205),
# 父目录名中包含tmdbid
("/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", 1396),
# 祖父目录名中包含tmdbid
("/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", 1399),
# 测试{tmdb-xxx}格式
("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995),
]
for path_str, expected_tmdbid in test_paths:
meta = MetaInfoPath(Path(path_str))
self.assertEqual(meta.tmdbid, expected_tmdbid,
f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}")

View File

@@ -2,6 +2,7 @@ from unittest import TestCase
from tests.cases.groups import release_group_cases
from app.core.meta.releasegroup import ReleaseGroupsMatcher
class MetaInfoTest(TestCase):
def test_release_group(self):
for info in release_group_cases:

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.4.8'
FRONTEND_VERSION = 'v2.4.8'
APP_VERSION = 'v2.5.2'
FRONTEND_VERSION = 'v2.5.2'