Compare commits

...

46 Commits

Author SHA1 Message Date
jxxghp
f32405b646 fix 下载器整理 2025-01-09 21:06:31 +08:00
jxxghp
13955dafe3 v2.2.0
- 分享订阅后立即刷新生效
- 认证站点新增支持`YemaPT`
- 问题修复与细节改进
2025-01-09 19:22:20 +08:00
jxxghp
eaca396a9f add rsa 2025-01-09 18:53:55 +08:00
jxxghp
fabd9f2f75 feat:分享订阅后清除缓存 2025-01-09 16:01:52 +08:00
jxxghp
0d8480769f feat:实时手动整理时不发消息 2025-01-09 12:58:09 +08:00
jxxghp
dc850f1c48 fix version 2025-01-09 12:32:46 +08:00
jxxghp
fb311f3d8a fix #3583 2025-01-09 07:59:17 +08:00
jxxghp
293d89510a fix bug 2025-01-08 12:28:53 +08:00
jxxghp
9446e88012 fix #3689 2025-01-08 11:37:58 +08:00
jxxghp
6f593beeed fix #3687 2025-01-07 20:58:27 +08:00
jxxghp
0dc20cd9b4 Merge pull request #3689 from InfinityPacer/feature/transfer 2025-01-07 20:40:47 +08:00
InfinityPacer
a0543e914e fix(transfer): switch downloader monitor to foreground 2025-01-07 19:54:53 +08:00
jxxghp
1435cd6526 Merge pull request #3686 from InfinityPacer/feature/recommend 2025-01-07 16:30:42 +08:00
jxxghp
7e24181c37 fix noqa 2025-01-07 14:44:44 +08:00
jxxghp
922c391ffc fix 2025-01-07 14:39:15 +08:00
jxxghp
39169e8faa fix 2025-01-07 14:38:26 +08:00
jxxghp
433712aa80 fix tvdbapi 2025-01-07 14:36:37 +08:00
jxxghp
23650657cd add noqa
fix #3670
2025-01-07 14:20:31 +08:00
jxxghp
b5d58b8a9e 更新 __init__.py 2025-01-07 07:19:04 +08:00
jxxghp
0514ff0189 更新 __init__.py 2025-01-07 07:06:40 +08:00
jxxghp
9a15e3f9b3 Merge pull request #3683 from InfinityPacer/feature/module 2025-01-07 06:56:43 +08:00
InfinityPacer
104113852a fix(recommend): add global exit handling 2025-01-07 02:04:02 +08:00
InfinityPacer
430702abd3 feat(transmission): add protocol support 2025-01-07 00:52:58 +08:00
jxxghp
d7300777cb 更新 version.py 2025-01-06 18:03:14 +08:00
jxxghp
4fd61a9c8d Merge pull request #3680 from InfinityPacer/feature/module 2025-01-06 17:58:33 +08:00
InfinityPacer
af2b4aa867 perf(log): optimize get_caller for improved performance 2025-01-06 17:46:35 +08:00
jxxghp
7e252f1692 fix bug 2025-01-06 13:34:51 +08:00
jxxghp
a7e7174cb2 v2.1.9
- 消息发送范围增加了`操作用户和管理员`选项,修复了入库消息不按规则发送的问题
- 修复了IOS桌面图标模式下,弹窗会导致底栏UI错位的问题
- 优化了刮削的处理逻辑
2025-01-06 12:00:38 +08:00
jxxghp
6e2d0c2aad fix #3674 2025-01-06 11:47:05 +08:00
jxxghp
aeb65d7cac fix #3618 2025-01-06 10:56:30 +08:00
jxxghp
e7c580d375 fix #3646 2025-01-06 10:28:26 +08:00
jxxghp
90fedade76 fix #3673 2025-01-06 10:08:46 +08:00
jxxghp
49d9715106 Merge pull request #3673 from Aqr-K/refactor/stringUtils
refactor(string): 优化 `compare_version` 方法
2025-01-06 10:04:41 +08:00
jxxghp
c194e8c59a fix scraping 2025-01-06 08:22:04 +08:00
jxxghp
b6f9315e2b Merge pull request #3675 from InfinityPacer/feature/recommend 2025-01-06 06:57:07 +08:00
InfinityPacer
f91f99de52 fix(log): update logger handlers without reset 2025-01-06 01:53:47 +08:00
InfinityPacer
3ad3a769ab fix(recommend): add global exit handling 2025-01-06 00:37:22 +08:00
Aqr-K
261bb5fa81 fix: 调整变量顺序,更加直观 2025-01-05 17:07:11 +08:00
Aqr-K
704dcf46d3 refactor(string): 调整 preprocess_versionconversion_version 2025-01-05 16:54:02 +08:00
Aqr-K
9fab50edb0 refactor(string): 优化 版本比较 方法 2025-01-05 16:22:28 +08:00
jxxghp
5d2a911849 feat:手动刮削时强制覆盖 2025-01-05 15:38:13 +08:00
jxxghp
89e96ee27a feat:消息支持管理员+操作用户同时发送 2025-01-05 13:21:41 +08:00
jxxghp
41636395ff fix 整理入库消息用户隔离 2025-01-05 12:35:21 +08:00
jxxghp
6f1f89ac26 Merge pull request #3669 from Aqr-K/feature/plugin 2025-01-05 09:47:46 +08:00
Aqr-K
3078c076dc fix(plugin): 调整判断顺序 2025-01-04 14:20:03 +08:00
Aqr-K
a7794fa2ad feat(plugin): feat(log): plugin monitor supports hot update. 2025-01-04 05:42:51 +08:00
40 changed files with 683 additions and 433 deletions

View File

@@ -17,7 +17,7 @@ router = APIRouter()
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
def list(
def current(
name: str = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""

View File

@@ -117,7 +117,7 @@ def scrape(fileitem: schemas.FileItem,
if not scrape_path.exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 手动刮削
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")

View File

@@ -19,7 +19,7 @@ class GzipRequest(Request):
body = await super().body()
if "gzip" in self.headers.getlist("Content-Encoding"):
body = gzip.decompress(body)
self._body = body
self._body = body # noqa
return self._body

View File

@@ -1,3 +1,4 @@
import copy
import gc
import pickle
import traceback
@@ -61,7 +62,7 @@ class ChainBase(metaclass=ABCMeta):
"""
try:
with open(settings.TEMP_PATH / filename, 'wb') as f:
pickle.dump(cache, f)
pickle.dump(cache, f) # noqa
except Exception as err:
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
finally:
@@ -488,32 +489,58 @@ class ChainBase(metaclass=ABCMeta):
f"title={message.title}, "
f"text={message.text}"
f"userid={message.userid}")
if not message.userid and message.mtype:
# 没有指定用户ID时按规则确定发送对象
# 默认发送全体
to_targets = None
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
if notify_action == "admin":
# 仅发送管理员
logger.info(f"已设置 {message.mtype} 的消息只发送给管理员")
to_targets = self.useroper.get_settings(settings.SUPERUSER)
elif notify_action == "user":
# 发送对应用户
if message.username:
logger.info(f"已设置 {message.mtype} 的消息只发送给用户 {message.username}")
to_targets = self.useroper.get_settings(message.username)
if not message.username or to_targets is None:
if message.username:
logger.info(f"没有 {message.username} 这个用户,该消息将发送给管理员")
# 回滚发送管理员
to_targets = self.useroper.get_settings(settings.SUPERUSER)
message.targets = to_targets
# 发送事件
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
# 保存消息
# 保存原消息
self.messagehelper.put(message, role="user", title=message.title)
self.messageoper.add(**message.dict())
# 发送
# 发送消息按设置隔离
if not message.userid and message.mtype:
# 消息隔离设置
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
# 是否已发送管理员标志
admin_sended = False
send_orignal = False
for action in actions:
send_message = copy.deepcopy(message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
admin_sended = True
elif action == "user" and send_message.username:
# 发送对应用户
logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}")
# 读取用户消息IDS
send_message.targets = self.useroper.get_settings(send_message.username)
if send_message.targets is None:
# 没有找到用户
if not admin_sended:
# 回滚发送管理员
logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员")
# 读取管理员消息IDS
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
admin_sended = True
else:
# 管理员发过了,此消息不发了
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
continue
else:
# 按原消息发送全体
if not admin_sended:
send_orignal = True
break
# 按设定发送
self.eventmanager.send_event(etype=EventType.NoticeMessage,
data={**send_message.dict(), "type": send_message.mtype})
self.run_module("post_message", message=send_message)
if not send_orignal:
return
# 发送消息事件
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
# 按原消息发送
self.run_module("post_message", message=message)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:

View File

@@ -307,6 +307,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
fileitem: FileItem = event_data.get("fileitem")
meta: MetaBase = event_data.get("meta")
mediainfo: MediaInfo = event_data.get("mediainfo")
overwrite = event_data.get("overwrite", False)
if not fileitem:
return
# 刮削锁
@@ -316,7 +317,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
scraping_files.append(fileitem.path)
try:
# 执行刮削
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
finally:
# 释放锁
with scraping_lock:
@@ -365,8 +366,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
"""
if not _fileitem or not _content or not _path:
return
# 保存文件到临时目录
tmp_file = settings.TEMP_PATH / _path.name
# 保存文件到临时目录,文件名随机
tmp_file = settings.TEMP_PATH / f"{_path.name}.{StringUtils.generate_random_str(10)}"
tmp_file.write_bytes(_content)
# 获取文件的父目录
try:
@@ -412,31 +413,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
if fileitem.type == "file":
# 是否已存在
nfo_path = filepath.with_suffix(".nfo")
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
# 电影文件
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if movie_nfo:
# 保存或上传nfo文件到上级目录
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
else:
logger.warn(f"{filepath.name} nfo文件生成失败")
else:
logger.info(f"已存在nfo文件{nfo_path}")
return
# 电影文件
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if not movie_nfo:
logger.warn(f"{filepath.name} nfo文件生成失败")
return
# 保存或上传nfo文件到上级目录
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
else:
# 电影目录
if is_bluray_folder(fileitem):
# 原盘目录
nfo_path = filepath / (filepath.name + ".nfo")
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
# 生成原盘nfo
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if movie_nfo:
# 保存或上传nfo文件到当前目录
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
else:
logger.warn(f"{filepath.name} nfo文件生成失败")
else:
logger.info(f"已存在nfo文件{nfo_path}")
return
# 生成原盘nfo
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if not movie_nfo:
logger.warn(f"{filepath.name} nfo文件生成失败")
return
# 保存或上传nfo文件到当前目录
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
else:
# 处理目录内的文件
files = __list_files(_fileitem=fileitem)
@@ -455,23 +456,18 @@ class MediaChain(ChainBase, metaclass=Singleton):
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
image_path = filepath / image_name
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
# 下载图片
content = __download_image(_url=attr_value)
# 写入图片到当前目录
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
else:
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(_url=attr_value)
# 写入图片到当前目录
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
else:
# 电视剧
if fileitem.type == "file":
# 是否已存在
nfo_path = filepath.with_suffix(".nfo")
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
logger.info(f"已存在nfo文件{nfo_path}")
return
# 重新识别季集
file_meta = MetaInfoPath(filepath)
if not file_meta.begin_episode:
@@ -481,33 +477,37 @@ class MediaChain(ChainBase, metaclass=Singleton):
if not file_mediainfo:
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
return
# 获取集的nfo文件
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
season=file_meta.begin_season, episode=file_meta.begin_episode)
if not episode_nfo:
logger.warn(f"{filepath.name} nfo生成失败")
return
# 保存或上传nfo文件到上级目录
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
# 是否已存在
nfo_path = filepath.with_suffix(".nfo")
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
# 获取集的nfo文件
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
season=file_meta.begin_season, episode=file_meta.begin_episode)
if episode_nfo:
# 保存或上传nfo文件到上级目录
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
else:
logger.warn(f"{filepath.name} nfo文件生成失败")
else:
logger.info(f"已存在nfo文件{nfo_path}")
# 获取集的图片
image_dict = self.metadata_img(mediainfo=file_mediainfo,
season=file_meta.begin_season, episode=file_meta.begin_episode)
if image_dict:
for episode, image_url in image_dict.items():
image_path = filepath.with_suffix(Path(image_url).suffix)
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
else:
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
else:
# 当前为目录,处理目录内的文件
files = __list_files(_fileitem=fileitem)
@@ -526,32 +526,33 @@ class MediaChain(ChainBase, metaclass=Singleton):
if season_meta.begin_season is not None:
# 是否已存在
nfo_path = filepath / "season.nfo"
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
# 当前目录有季号生成季nfo
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo,
season=season_meta.begin_season)
if season_nfo:
# 写入nfo到根目录
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
else:
logger.warn(f"无法生成电视剧季nfo文件{meta.name}")
else:
logger.info(f"已存在nfo文件{nfo_path}")
return
# 当前目录有季号生成季nfo
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_meta.begin_season)
if not season_nfo:
logger.warn(f"无法生成电视剧季nfo文件{meta.name}")
return
# 写入nfo到根目录
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
# TMDB季poster图片
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
if image_dict:
for image_name, image_url in image_dict.items():
image_path = filepath.with_name(image_name)
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到剧集目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
else:
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到剧集目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
# 额外fanart季图片poster thumb banner
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
@@ -563,32 +564,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
if image_season != str(season_meta.begin_season).rjust(2, '0'):
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
continue
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
else:
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
# 判断当前目录是不是剧集根目录
if not season_meta.season:
# 是否已存在
nfo_path = filepath / "tvshow.nfo"
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
# 当前目录有名称生成tvshow nfo 和 tv图片
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if tv_nfo:
# 写入tvshow nfo到根目录
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
else:
logger.warn(f"无法生成电视剧nfo文件{meta.name}")
else:
logger.info(f"已存在nfo文件{nfo_path}")
return
# 当前目录有名称生成tvshow nfo 和 tv图片
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if not tv_nfo:
logger.warn(f"无法生成电视剧nfo文件{meta.name}")
return
# 写入tvshow nfo到根目录
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
# 生成目录图片
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
@@ -597,14 +597,13 @@ class MediaChain(ChainBase, metaclass=Singleton):
if image_name.startswith("season"):
continue
image_path = filepath / image_name
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
else:
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
logger.info(f"{filepath.name} 刮削完成")

View File

@@ -13,7 +13,7 @@ from app.chain import ChainBase
from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.config import settings, global_vars
from app.log import logger
from app.schemas import MediaType
from app.utils.common import log_execution_time
@@ -105,6 +105,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
for page in range(1, self.cache_max_pages + 1):
for method in recommend_methods:
if global_vars.is_system_stopped:
return
if method in methods_finished:
continue
logger.debug(f"Fetch {method.__name__} data for page {page}.")
@@ -131,6 +133,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
return
for data in datas:
if global_vars.is_system_stopped:
return
poster_path = data.get("poster_path")
if poster_path:
poster_url = poster_path.replace("original", "w500")

View File

@@ -96,7 +96,7 @@ class SiteChain(ChainBase):
))
return userdata
def refresh_userdatas(self) -> Dict[str, SiteUserData]:
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
"""
刷新所有站点的用户数据
"""
@@ -105,7 +105,7 @@ class SiteChain(ChainBase):
result = {}
for site in sites:
if global_vars.is_system_stopped:
return
return None
if site.get("is_active"):
userdata = self.refresh_userdata(site)
if userdata:

View File

@@ -114,7 +114,7 @@ class StorageChain(ChainBase):
"""
return self.run_module("storage_usage", storage=storage)
def support_transtype(self, storage: str) -> Optional[str]:
def support_transtype(self, storage: str) -> Optional[dict]:
"""
获取支持的整理方式
"""

View File

@@ -6,6 +6,8 @@ import time
from datetime import datetime
from typing import Dict, List, Optional, Union, Tuple
from cachetools import TTLCache
from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
@@ -508,7 +510,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
return
# 记录重新识别过的种子
_recognize_cached = []
_recognize_cached = TTLCache(maxsize=1024, ttl=6 * 3600)
with self._rlock:
logger.debug(f"match lock acquired at {datetime.now()}")
@@ -572,14 +574,15 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
# 有自定义识别词时,需要判断是否需要重新识别
if subscribe.custom_words:
custom_words_list = subscribe.custom_words.split("\n")
_, apply_words = WordsMatcher().prepare(torrent_info.title,
custom_words=subscribe.custom_words.split("\n"))
custom_words=custom_words_list)
if apply_words:
logger.info(
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
# 重新识别元数据
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
custom_words=subscribe.custom_words)
custom_words=custom_words_list)
# 媒体信息需要重新识别
torrent_mediainfo = None
@@ -588,8 +591,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
# 避免重复处理
_cache_key = f"{torrent_meta.org_string}_{torrent_info.description}"
if _cache_key not in _recognize_cached:
_recognize_cached.append(_cache_key)
if not _recognize_cached.get(_cache_key):
_recognize_cached[_cache_key] = True
# 重新识别媒体信息
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
if torrent_mediainfo:

View File

@@ -1,5 +1,6 @@
import json
import re
from pathlib import Path
from typing import Union
from app.chain import ChainBase
@@ -161,4 +162,15 @@ class SystemChain(ChainBase, metaclass=Singleton):
"""
获取前端版本
"""
if SystemUtils.is_frozen() and SystemUtils.is_windows():
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
else:
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if version_file.exists():
try:
with open(version_file, 'r') as f:
version = str(f.read()).strip()
return version
except Exception as err:
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
return FRONTEND_VERSION

View File

@@ -414,6 +414,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
title=f"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败!",
text=f"原因:{transferinfo.message or '未知'}",
image=task.mediainfo.get_message_image(),
username=task.username,
link=settings.MP_DOMAIN('#/history')
))
# 整理失败
@@ -462,8 +463,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
self.storagechain.delete_media_file(t.fileitem, delete_self=False)
# 整理完成且有成功的任务时
if self.jobview.is_finished(task):
# 发送通知
if transferinfo.need_notify:
# 发送通知,实时手动整理时不发
if transferinfo.need_notify and (task.background or not task.manual):
se_str = None
if task.mediainfo.type == MediaType.TV:
season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)
@@ -479,7 +480,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
self.send_transfer_message(meta=task.meta,
mediainfo=task.mediainfo,
transferinfo=transferinfo,
season_episode=se_str)
season_episode=se_str,
username=task.username)
# 刮削事件
if transferinfo.need_scrape:
self.eventmanager.send_event(EventType.MetadataScrape, {
@@ -493,23 +495,28 @@ class TransferChain(ChainBase, metaclass=Singleton):
return True, ""
def put_to_queue(self, task: TransferTask, callback: Optional[Callable] = None):
def put_to_queue(self, task: TransferTask):
"""
添加到待整理队列
:param task: 任务信息
:param callback: 回调函数
"""
if not task:
return
# 维护整理任务视图
with task_lock:
self.jobview.add_task(task)
self.__put_to_jobview(task)
# 添加到队列
self._queue.put(TransferQueue(
task=task,
callback=callback or self.__default_callback
callback=self.__default_callback
))
def __put_to_jobview(self, task: TransferTask):
"""
添加到作业视图
"""
with task_lock:
self.jobview.add_task(task)
def remove_from_queue(self, fileitem: FileItem):
"""
从待整理队列移除
@@ -576,10 +583,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
self.progress.update(value=processed_num / total_num * 100,
text=__process_msg,
key=ProgressKey.FileTransfer)
# 移除已完成的任务
with task_lock:
if self.jobview.is_done(task):
self.jobview.remove_job(task)
except queue.Empty:
if not __queue_start:
# 结束进度
@@ -606,102 +609,116 @@ class TransferChain(ChainBase, metaclass=Singleton):
"""
处理整理任务
"""
# 识别
if not task.mediainfo:
download_history = task.download_history
# 识别媒体信息
if download_history and (download_history.tmdbid or download_history.doubanid):
# 下载记录中已存在识别信息
mediainfo: MediaInfo = self.recognize_media(mtype=MediaType(download_history.type),
tmdbid=download_history.tmdbid,
doubanid=download_history.doubanid)
try:
# 识别
if not task.mediainfo:
mediainfo = None
download_history = task.download_history
# 下载用户
if download_history:
task.username = download_history.username
# 识别媒体信息
if download_history.tmdbid or download_history.doubanid:
# 下载记录中已存在识别信息
mediainfo: Optional[MediaInfo] = self.recognize_media(mtype=MediaType(download_history.type),
tmdbid=download_history.tmdbid,
doubanid=download_history.doubanid)
if mediainfo:
# 更新自定义媒体类别
if download_history.media_category:
mediainfo.category = download_history.media_category
else:
# 识别媒体信息
mediainfo = self.mediachain.recognize_by_meta(task.meta)
# 更新媒体图片
if mediainfo:
# 更新自定义媒体类别
if download_history.media_category:
mediainfo.category = download_history.media_category
else:
# 识别媒体信息
mediainfo = self.mediachain.recognize_by_meta(task.meta)
# 更新媒体图片
if mediainfo:
self.obtain_images(mediainfo=mediainfo)
if not mediainfo:
# 新增整理失败历史记录
his = self.transferhis.add_fail(
fileitem=task.fileitem,
mode=task.transfer_type,
meta=task.meta,
downloader=task.downloader,
download_hash=task.download_hash
)
self.post_message(Notification(
mtype=NotificationType.Manual,
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别整理。",
link=settings.MP_DOMAIN('#/history')
))
# 任务失败直接移除task
self.jobview.remove_task(task.fileitem)
return False, "未识别到媒体信息"
self.obtain_images(mediainfo=mediainfo)
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
if not settings.SCRAP_FOLLOW_TMDB:
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
mtype=mediainfo.type.value)
if transfer_history:
mediainfo.title = transfer_history.title
if not mediainfo:
# 新增整理失败历史记录
his = self.transferhis.add_fail(
fileitem=task.fileitem,
mode=task.transfer_type,
meta=task.meta,
downloader=task.downloader,
download_hash=task.download_hash
)
self.post_message(Notification(
mtype=NotificationType.Manual,
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别整理。",
username=task.username,
link=settings.MP_DOMAIN('#/history')
))
# 任务失败直接移除task
self.jobview.remove_task(task.fileitem)
return False, "未识别到媒体信息"
# 获取集数据
if not task.episodes_info and mediainfo.type == MediaType.TV:
if task.meta.begin_season is None:
task.meta.begin_season = 1
mediainfo.season = mediainfo.season or task.meta.begin_season
task.episodes_info = self.tmdbchain.tmdb_episodes(
tmdbid=mediainfo.tmdb_id,
season=mediainfo.season
)
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
if not settings.SCRAP_FOLLOW_TMDB:
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
mtype=mediainfo.type.value)
if transfer_history:
mediainfo.title = transfer_history.title
# 更新任务信息
task.mediainfo = mediainfo
# 更新队列任务
curr_task = self.jobview.remove_task(task.fileitem)
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
# 获取集数据
if not task.episodes_info and mediainfo.type == MediaType.TV:
if task.meta.begin_season is None:
task.meta.begin_season = 1
mediainfo.season = mediainfo.season or task.meta.begin_season
task.episodes_info = self.tmdbchain.tmdb_episodes(
tmdbid=mediainfo.tmdb_id,
season=mediainfo.season
)
# 查询整理目标目录
if not task.target_directory:
if task.target_path:
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
dest_path=task.target_path,
target_storage=task.target_storage)
else:
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
storage=task.fileitem.storage,
src_path=Path(task.fileitem.path),
target_storage=task.target_storage)
# 更新任务信息
task.mediainfo = mediainfo
# 更新队列任务
curr_task = self.jobview.remove_task(task.fileitem)
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
# 执行整理
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
meta=task.meta,
mediainfo=task.mediainfo,
target_directory=task.target_directory,
target_storage=task.target_storage,
target_path=task.target_path,
transfer_type=task.transfer_type,
episodes_info=task.episodes_info,
scrape=task.scrape,
library_type_folder=task.library_type_folder,
library_category_folder=task.library_category_folder)
if not transferinfo:
logger.error("文件整理模块运行失败")
return False, "文件整理模块运行失败"
# 查询整理目标目录
if not task.target_directory:
if task.target_path:
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
dest_path=task.target_path,
target_storage=task.target_storage)
else:
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
storage=task.fileitem.storage,
src_path=Path(task.fileitem.path),
target_storage=task.target_storage)
# 回调,位置传参:任务、整理结果
if callback:
return callback(task, transferinfo)
# 执行整理
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
meta=task.meta,
mediainfo=task.mediainfo,
target_directory=task.target_directory,
target_storage=task.target_storage,
target_path=task.target_path,
transfer_type=task.transfer_type,
episodes_info=task.episodes_info,
scrape=task.scrape,
library_type_folder=task.library_type_folder,
library_category_folder=task.library_category_folder)
if not transferinfo:
logger.error("文件整理模块运行失败")
return False, "文件整理模块运行失败"
return transferinfo.success, transferinfo.message
# 回调,位置传参:任务、整理结果
if callback:
return callback(task, transferinfo)
return transferinfo.success, transferinfo.message
finally:
# 移除已完成的任务
with task_lock:
if self.jobview.is_done(task):
self.jobview.remove_job(task)
def get_queue_tasks(self) -> List[TransferJob]:
"""
@@ -783,11 +800,11 @@ class TransferChain(ChainBase, metaclass=Singleton):
# 非MoviePilot下载的任务按文件识别
mediainfo = None
# 执行整理,匹配源目录
# 执行实时整理,匹配源目录
state, errmsg = self.do_transfer(
fileitem=FileItem(
storage="local",
path=str(file_path),
path=str(file_path).replace("\\", "/"),
type="dir" if not file_path.is_file() else "file",
name=file_path.name,
size=file_path.stat().st_size,
@@ -795,7 +812,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
),
mediainfo=mediainfo,
downloader=torrent.downloader,
download_hash=torrent.hash
download_hash=torrent.hash,
background=False,
)
# 设置下载任务状态
@@ -885,7 +903,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
library_type_folder: bool = None, library_category_folder: bool = None,
season: int = None, epformat: EpisodeFormat = None, min_filesize: int = 0,
downloader: str = None, download_hash: str = None,
force: bool = False, background: bool = True) -> Tuple[bool, str]:
force: bool = False, background: bool = True,
manual: bool = False) -> Tuple[bool, str]:
"""
执行一个复杂目录的整理操作
:param fileitem: 文件项
@@ -905,6 +924,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
:param download_hash: 下载记录hash
:param force: 是否强制整理
:param background: 是否后台运行
:param manual: 是否手动整理
返回:成功标识,错误信息
"""
@@ -969,9 +989,11 @@ class TransferChain(ChainBase, metaclass=Singleton):
processed_num = 0
# 失败数量
fail_num = 0
logger.info(f"正在计划整理 {total_num} 个文件...")
# 启动进度
if not background:
# 启动进度
self.progress.start(ProgressKey.FileTransfer)
__process_msg = f"开始整理,共 {total_num} 个文件 ..."
logger.info(__process_msg)
@@ -980,6 +1002,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
key=ProgressKey.FileTransfer)
# 整理所有文件
transfer_tasks: List[TransferTask] = []
for file_item, bluray_dir in file_items:
if global_vars.is_system_stopped:
break
@@ -990,6 +1013,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
or file_item.path.find('/.') != -1 \
or file_item.path.find('/@eaDir') != -1:
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
fail_num += 1
continue
# 整理屏蔽词不处理
@@ -1017,14 +1041,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
fail_num += 1
continue
# 更新进度
if not background:
__process_msg = f"正在整理 {processed_num + 1}/{total_num}{file_item.name} ..."
logger.info(__process_msg)
self.progress.update(value=processed_num / total_num * 100,
text=__process_msg,
key=ProgressKey.FileTransfer)
if not meta:
# 文件元数据
file_meta = MetaInfoPath(file_path)
@@ -1082,28 +1098,43 @@ class TransferChain(ChainBase, metaclass=Singleton):
library_category_folder=library_category_folder,
downloader=downloader,
download_hash=download_hash,
download_history=download_history
download_history=download_history,
manual=manual,
background=background
)
if background:
self.put_to_queue(
task=transfer_task
)
self.put_to_queue(task=transfer_task)
logger.info(f"{file_path.name} 已添加到整理队列")
processed_num += 1
else:
# 加入列表
self.__put_to_jobview(transfer_task)
transfer_tasks.append(transfer_task)
# 实时整理
if not background:
for transfer_task in transfer_tasks:
if global_vars.is_system_stopped:
break
# 更新进度
__process_msg = f"正在整理 {processed_num + fail_num + 1}/{total_num}{transfer_task.fileitem.name} ..."
logger.info(__process_msg)
self.progress.update(value=(processed_num + fail_num) / total_num * 100,
text=__process_msg,
key=ProgressKey.FileTransfer)
state, err_msg = self.__handle_transfer(
task=transfer_task,
callback=self.__default_callback
)
if not state:
all_success = False
logger.warn(f"{file_path.name} {err_msg}")
err_msgs.append(f"{file_path.name} {err_msg}")
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}")
fail_num += 1
# 完成计数
processed_num += 1
else:
processed_num += 1
# 整理结束
if not background:
# 整理结束
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num}"
logger.info(__end_msg)
self.progress.update(value=100,
@@ -1198,7 +1229,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
mediainfo=mediainfo,
download_hash=history.download_hash,
force=True,
background=False)
background=False,
manual=True)
if not state:
return False, errmsg
@@ -1267,7 +1299,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
library_type_folder=library_type_folder,
library_category_folder=library_category_folder,
force=force,
background=background
background=background,
manual=True
)
if not state:
return False, errmsg
@@ -1288,11 +1321,12 @@ class TransferChain(ChainBase, metaclass=Singleton):
library_type_folder=library_type_folder,
library_category_folder=library_category_folder,
force=force,
background=background)
background=background,
manual=True)
return state, errmsg
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
transferinfo: TransferInfo, season_episode: str = None):
transferinfo: TransferInfo, season_episode: str = None, username: str = None):
"""
发送入库成功的消息
"""
@@ -1313,4 +1347,5 @@ class TransferChain(ChainBase, metaclass=Singleton):
self.post_message(Notification(
mtype=NotificationType.Organize,
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
username=username,
link=settings.MP_DOMAIN('#/history')))

View File

@@ -351,7 +351,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
return default, True
@validator('*', pre=True, always=True)
def generic_type_validator(cls, value: Any, field):
def generic_type_validator(cls, value: Any, field): # noqa
"""
通用校验器,尝试将配置值转换为期望的类型
"""
@@ -418,14 +418,21 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
更新多个配置项
"""
results = {}
log_updated = False
log_updated, plugin_monitor_updated = False, False
for k, v in env.items():
results[k] = self.update_setting(k, v)
if hasattr(log_settings, k):
log_updated = True
if k in ["PLUGIN_AUTO_RELOAD", "DEV"]:
plugin_monitor_updated = True
# 本次更新存在日志配置项更新,需要重新加载日志配置
if log_updated:
logger.update_loggers()
# 本次更新存在插件监控配置项更新,需要重新加载插件监控
if plugin_monitor_updated:
# 解决顶层循环导入问题
from app.core.plugin import PluginManager
PluginManager().reload_monitor()
return results
@property

View File

@@ -293,7 +293,7 @@ class EventManager(metaclass=Singleton):
# 对于类实例(实现了 __call__ 方法)
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
handler_cls = handler.__class__
handler_cls = handler.__class__ # noqa
return cls.__get_handler_identifier(handler_cls)
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息

View File

@@ -220,11 +220,23 @@ class PluginManager(metaclass=Singleton):
self._running_plugins = {}
logger.info("插件停止完成")
def reload_monitor(self):
"""
重新加载插件文件修改监测
"""
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
if self._observer and self._observer.is_alive():
logger.info("插件文件修改监测已经在运行中...")
else:
self.__start_monitor()
else:
self.stop_monitor()
def __start_monitor(self):
"""
开发者模式下监测插件文件修改
启用监测插件文件修改监测
"""
logger.info("发者模式下开始监测插件文件修改...")
logger.info("开始监测插件文件修改...")
monitor_handler = PluginMonitorHandler()
self._observer = Observer()
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
@@ -232,14 +244,16 @@ class PluginManager(metaclass=Singleton):
def stop_monitor(self):
"""
停止监测插件修改
停止监测插件文件修改监测
"""
# 停止监测
if self._observer:
if self._observer and self._observer.is_alive():
logger.info("正在停止插件文件修改监测...")
self._observer.stop()
self._observer.join()
logger.info("插件文件修改监测停止完成")
else:
logger.info("未启用插件文件修改监测,无需停止")
@staticmethod
def __stop_plugin(plugin: Any):
@@ -668,7 +682,7 @@ class PluginManager(metaclass=Singleton):
# 相同 ID 的插件保留版本号最大的版本
max_versions = {}
for p in all_plugins:
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
max_versions[p.id] = p.plugin_version
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
logger.info(f"共获取到 {len(result)} 个线上插件")
@@ -809,7 +823,7 @@ class PluginManager(metaclass=Singleton):
plugin.has_update = False
if plugin_static:
installed_version = getattr(plugin_static, "plugin_version")
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
# 需要更新
plugin.has_update = True
# 运行状态

View File

@@ -225,7 +225,7 @@ class Base:
return list(result)
def to_dict(self):
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
@declared_attr
def __tablename__(self) -> str:

View File

@@ -11,7 +11,7 @@ def init_db():
初始化数据库
"""
# 全量建表
Base.metadata.create_all(bind=Engine)
Base.metadata.create_all(bind=Engine) # noqa
def update_db():

View File

@@ -57,7 +57,7 @@ class MessageOper(DbOper):
# 从kwargs中去掉Message中没有的字段
for k in list(kwargs.keys()):
if k not in Message.__table__.columns.keys():
if k not in Message.__table__.columns.keys(): # noqa
kwargs.pop(k)
Message(**kwargs).create(self._db)

View File

@@ -70,7 +70,7 @@ class ResourceHelper(metaclass=Singleton):
local_version = self.siteshelper.indexer_version
else:
continue
if StringUtils.compare_version(version, local_version) > 0:
if StringUtils.compare_version(version, ">", local_version):
logger.info(f"{rname} 资源包有更新最新版本v{version}")
else:
continue

View File

@@ -30,6 +30,8 @@ class SubscribeHelper(metaclass=Singleton):
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
_shares_cache = TTLCache(maxsize=20, ttl=1800)
def __init__(self):
self.systemconfig = SystemConfigOper()
if settings.SUBSCRIBE_STATISTIC_SHARE:
@@ -136,6 +138,8 @@ class SubscribeHelper(metaclass=Singleton):
if res is None:
return False, "连接MoviePilot服务器失败"
if res.ok:
# 清除 get_shares 的缓存,以便实时看到结果
self._shares_cache.clear()
return True, ""
else:
return False, res.json().get("message")
@@ -156,7 +160,7 @@ class SubscribeHelper(metaclass=Singleton):
else:
return False, res.json().get("message")
@cached(cache=TTLCache(maxsize=20, ttl=1800))
@cached(cache=_shares_cache)
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
"""
获取订阅分享数据

View File

@@ -1,5 +1,6 @@
import inspect
import logging
import sys
import threading
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Dict, Any, Optional
@@ -95,6 +96,8 @@ class LoggerManager:
_loggers: Dict[str, Any] = {}
# 默认日志文件名称
_default_log_file = "moviepilot.log"
# 线程锁
_lock = threading.Lock()
@staticmethod
def __get_caller():
@@ -106,35 +109,53 @@ class LoggerManager:
caller_name = None
# 调用者插件名称
plugin_name = None
for i in inspect.stack()[3:]:
filepath = Path(i.filename)
try:
frame = sys._getframe(3) # noqa
except (AttributeError, ValueError):
# 如果无法获取帧,返回默认值
return "log.py", None
while frame:
filepath = Path(frame.f_code.co_filename)
parts = filepath.parts
# 设定调用者文件名称
if not caller_name:
# 设定调用者文件名称
if parts[-1] == "__init__.py":
if parts[-1] == "__init__.py" and len(parts) >= 2:
caller_name = parts[-2]
else:
caller_name = parts[-1]
# 设定调用者插件名称
if "app" in parts:
if not plugin_name and "plugins" in parts:
# 设定调用者插件名称
plugin_name = parts[parts.index("plugins") + 1]
if plugin_name == "__init__.py":
plugin_name = "plugin"
break
try:
plugins_index = parts.index("plugins")
if plugins_index + 1 < len(parts):
plugin_candidate = parts[plugins_index + 1]
if plugin_candidate == "__init__.py":
plugin_name = "plugin"
else:
plugin_name = plugin_candidate
break
except ValueError:
pass
if "main.py" in parts:
# 已经到达程序的入口
# 已经到达程序的入口,停止遍历
break
elif len(parts) != 1:
# 已经超出程序范围
# 已经超出程序范围,停止遍历
break
# 获取上一个帧
try:
frame = frame.f_back
except AttributeError:
break
return caller_name or "log.py", plugin_name
@staticmethod
def __setup_logger(log_file: str):
"""
设置日志
初始化日志实例
:param log_file日志文件相对路径
"""
log_file_path = log_settings.LOG_PATH / log_file
@@ -143,13 +164,8 @@ class LoggerManager:
# 创建新实例
_logger = logging.getLogger(log_file_path.stem)
if log_settings.DEBUG:
_logger.setLevel(logging.DEBUG)
# 全局日志等级
else:
loglevel = getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
_logger.setLevel(loglevel)
# 设置日志级别
_logger.setLevel(LoggerManager.__get_log_level())
# 移除已有的 handler避免重复添加
for handler in _logger.handlers:
@@ -179,16 +195,41 @@ class LoggerManager:
"""
更新日志实例
"""
_new_loggers: Dict[str, Any] = {}
for log_file, _logger in self._loggers.items():
# 移除已有的 handler避免重复添加
for handler in _logger.handlers:
_logger.removeHandler(handler)
# 重新设置日志实例
_new_logger = self.__setup_logger(log_file=log_file)
_new_loggers[log_file] = _new_logger
with LoggerManager._lock:
for _logger in self._loggers.values():
self.__update_logger_handlers(_logger)
self._loggers = _new_loggers
@staticmethod
def __update_logger_handlers(_logger: logging.Logger):
"""
更新 Logger 的 handler 配置
:param _logger: 需要更新的 Logger 实例
"""
# 更新现有 handler
for handler in _logger.handlers:
try:
if isinstance(handler, RotatingFileHandler):
# 更新最大文件大小和备份数量
handler.maxBytes = log_settings.LOG_MAX_FILE_SIZE_BYTES
handler.backupCount = log_settings.LOG_BACKUP_COUNT
# 更新日志文件输出格式
file_formatter = CustomFormatter(log_settings.LOG_FILE_FORMAT)
handler.setFormatter(file_formatter)
elif isinstance(handler, logging.StreamHandler):
# 更新控制台输出格式
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
handler.setFormatter(console_formatter)
except Exception as e:
logger.error(f"Failed to update handler: {handler}. Error: {e}")
# 更新日志级别
_logger.setLevel(LoggerManager.__get_log_level())
@staticmethod
def __get_log_level():
"""
获取当前日志级别
"""
return logging.DEBUG if log_settings.DEBUG else getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
def logger(self, method: str, msg: str, *args, **kwargs):
"""

View File

@@ -179,7 +179,7 @@ class DoubanCache(metaclass=Singleton):
return
with open(self._meta_path, 'wb') as f:
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
def _random_sample(self, new_meta_data: dict) -> bool:
"""

View File

@@ -28,7 +28,7 @@ class DoubanScraper:
# 电视剧元数据文件
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
if doc:
return doc.toprettyxml(indent=" ", encoding="utf-8")
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
return None

View File

@@ -391,7 +391,7 @@ class Emby:
year: str = None,
tmdb_id: int = None,
season: int = None
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
"""
根据标题和年份和季返回Emby中的剧集列表
:param item_id: Emby中的ID

View File

@@ -109,7 +109,7 @@ class FileManagerModule(_ModuleBase):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def support_transtype(self, storage: str) -> Optional[Dict[str, str]]:
def support_transtype(self, storage: str) -> Optional[dict]:
"""
支持的整理方式
"""
@@ -368,10 +368,7 @@ class FileManagerModule(_ModuleBase):
# 覆盖模式
overwrite_mode = target_directory.overwrite_mode
# 是否需要刮削
if scrape is None:
need_scrape = target_directory.scraping
else:
need_scrape = scrape
need_scrape = scrape or target_directory.scraping
# 目标存储类型
if not target_storage:
target_storage = target_directory.library_storage

View File

@@ -30,6 +30,7 @@ class AliPan(StorageBase, metaclass=Singleton):
# 支持的整理方式
transtype = {
"copy": "复制",
"move": "移动",
}
@@ -71,7 +72,7 @@ class AliPan(StorageBase, metaclass=Singleton):
refresh_token = self.__auth_params.get("refreshToken")
if refresh_token:
try:
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c,
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c, # noqa
name="MoviePilot V2", level=logging.ERROR, re_login=False)
except Exception as err:
logger.error(f"初始化阿里云盘失败:{str(err)}")
@@ -327,7 +328,7 @@ class AliPan(StorageBase, metaclass=Singleton):
return None
item = self.aligo.get_file_by_path(path=str(path))
if item:
return self.__get_fileitem(item, parent=path.parent)
return self.__get_fileitem(item, parent=str(path.parent))
return None
def delete(self, fileitem: schemas.FileItem) -> bool:

View File

@@ -553,7 +553,7 @@ class Alist(StorageBase, metaclass=Singleton):
:param new_name: 上传后文件名
:param task: 是否为任务默认为False避免未完成上传时对文件进行操作
"""
encoded_path = UrlUtils.quote(fileitem.path + path.name)
encoded_path = UrlUtils.quote((Path(fileitem.path) / path.name).as_posix())
headers = self.__get_header_with_token()
headers.setdefault("Content-Type", "application/octet-stream")
headers.setdefault("As-Task", str(task).lower())
@@ -569,7 +569,7 @@ class Alist(StorageBase, metaclass=Singleton):
return
new_item = self.get_item(Path(fileitem.path) / path.name)
if new_name and new_name != path.name:
if new_item and new_name and new_name != path.name:
if self.rename(new_item, new_name):
return self.get_item(Path(new_item.path).with_name(new_name))

View File

@@ -134,10 +134,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
category=category,
ignore_category_check=False
)
# 获取下载器全局设置
application = server.qbc.application.preferences
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
torrent_layout = application.get("torrent_content_layout", "Original")
torrent_layout = server.get_content_layout()
if not state:
# 读取种子的名称

View File

@@ -448,3 +448,14 @@ class Qbittorrent:
except Exception as err:
logger.error(f"修改tracker出错{str(err)}")
return False
def get_content_layout(self) -> Optional[str]:
"""
获取内容布局
"""
if not self.qbc:
return None
# 获取下载器全局设置
application = self.qbc.application.preferences
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
return application.get("torrent_content_layout", "Original")

View File

@@ -45,7 +45,7 @@ class TmdbScraper:
# 电视剧元数据文件
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
if doc:
return doc.toprettyxml(indent=" ", encoding="utf-8")
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
return None

View File

@@ -1,4 +1,3 @@
"""
Simple-to-use Python interface to The TVDB's API (thetvdb.com)
"""
@@ -6,19 +5,20 @@
__author__ = "dbr/Ben"
__version__ = "3.1.0"
import sys
import getpass
import hashlib
import logging
import os
import sys
import tempfile
import time
import types
import getpass
import tempfile
import warnings
import logging
import hashlib
from typing import Optional, Union
import requests
import requests_cache
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS # noqa
IS_PY2 = sys.version_info[0] == 2
@@ -176,7 +176,8 @@ class ConsoleUI(BaseUI):
"""Interactively allows the user to select a show from a console based UI
"""
def _displaySeries(self, allSeries, limit=6):
@staticmethod
def _displaySeries(allSeries, limit: Optional[int] = 6):
"""Helper function, lists series with corresponding ID
"""
if limit is not None:
@@ -267,6 +268,7 @@ class ShowContainer(dict):
"""
def __init__(self):
super().__init__()
self._stack = []
self._lastgc = time.time()
@@ -336,42 +338,6 @@ class Show(dict):
Search terms are converted to lower case (unicode) strings.
# Examples
These examples assume t is an instance of Tvdb():
>>> t = Tvdb()
>>>
To search for all episodes of Scrubs with a bit of data
containing "my first day":
>>> t['Scrubs'].search("my first day")
[<Episode 01x01 - u'My First Day'>]
>>>
Search for "My Name Is Earl" episode named "Faked His Own Death":
>>> t['My Name Is Earl'].search('Faked My Own Death', key='episodeName')
[<Episode 01x04 - u'Faked My Own Death'>]
>>>
To search Scrubs for all episodes with "mentor" in the episode name:
>>> t['scrubs'].search('mentor', key='episodeName')
[<Episode 01x02 - u'My Mentor'>, <Episode 03x15 - u'My Tormented Mentor'>]
>>>
# Using search results
>>> results = t['Scrubs'].search("my first")
>>> print results[0]['episodeName']
My First Day
>>> for x in results: print x['episodeName']
My First Day
My First Step
My First Kill
>>>
"""
results = []
for cur_season in self.values():
@@ -386,6 +352,7 @@ class Season(dict):
def __init__(self, show=None):
"""The show attribute points to the parent show
"""
super().__init__()
self.show = show
def __repr__(self):
@@ -420,6 +387,7 @@ class Episode(dict):
def __init__(self, season=None):
"""The season attribute points to the parent season
"""
super().__init__()
self.season = season
def __repr__(self):
@@ -540,7 +508,7 @@ class Tvdb:
self,
interactive=False,
select_first=False,
cache=True,
cache: Union[str, bool, requests.Session] = True,
banners=False,
actors=False,
custom_ui=None,
@@ -690,7 +658,7 @@ class Tvdb:
LOG.debug("Using specified requests.Session")
self.session = cache
try:
self.session.get
self.session.get # noqa
except AttributeError:
raise ValueError(
(
@@ -776,7 +744,7 @@ class Tvdb:
cache_key = self.session.cache.create_key(
fake_session_for_key.prepare_request(requests.Request('GET', url))
)
except Exception:
except Exception: # noqa
# FIXME: Can this just check for hasattr(self.session, "cache") instead?
pass
@@ -956,6 +924,7 @@ class Tvdb:
banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
banners = {}
for cur_banner in banners_resp.keys():
btype = None
banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
for banner_info in banners_info:
bid = banner_info.get('id')
@@ -981,32 +950,14 @@ class Tvdb:
LOG.debug("Transforming %s to %s" % (k, new_key))
new_url = self.config['url_artworkPrefix'] % v
banners[btype][btype2][bid][new_key] = new_url
banners[btype]['raw'] = banners_info
self._setShowData(sid, "_banners", banners)
if btype:
banners[btype]['raw'] = banners_info
self._setShowData(sid, "_banners", banners)
def _parseActors(self, sid):
"""Parsers actors XML, from
http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml
Actors are retrieved using t['show name]['_actors'], for example:
>>> t = Tvdb(actors = True)
>>> actors = t['scrubs']['_actors']
>>> type(actors)
<class 'tvdb_api.Actors'>
>>> type(actors[0])
<class 'tvdb_api.Actor'>
>>> actors[0]
<Actor u'John C. McGinley'>
>>> sorted(actors[0].keys())
[u'id', u'image', u'imageAdded', u'imageAuthor', u'lastUpdated', u'name', u'role',
u'seriesId', u'sortOrder']
>>> actors[0]['name']
u'John C. McGinley'
>>> actors[0]['image']
u'http://thetvdb.com/banners/actors/43638.jpg'
Any key starting with an underscore has been processed (not the raw
data from the XML)
"""

View File

@@ -1,14 +1,15 @@
from typing import Optional, Union, Tuple, List
from typing import Optional, Union, Tuple, List, Literal
import transmission_rpc
from transmission_rpc import Client, Torrent, File
from transmission_rpc.session import SessionStats, Session
from app.log import logger
from app.utils.string import StringUtils
from app.utils.url import UrlUtils
class Transmission:
_protocol: Literal["http", "https"] = "http"
_host: str = None
_port: int = None
_username: str = None
@@ -28,9 +29,14 @@ class Transmission:
若不设置参数,则创建配置文件设置的下载器
"""
if host and port:
self._host, self._port = host, port
self._protocol, self._host, self._port = kwargs.get("protocol", self._protocol), host, port
elif host:
self._host, self._port = StringUtils.get_domain_address(address=host, prefix=False)
result = UrlUtils.parse_url_params(url=host)
if result:
self._protocol, self._host, self._port, path = result
else:
logger.error("Transmission配置不正确")
return
else:
logger.error("Transmission配置不完整")
return
@@ -46,8 +52,9 @@ class Transmission:
"""
try:
# 登录
logger.info(f"正在连接 transmission{self._host}:{self._port}")
trt = transmission_rpc.Client(host=self._host,
logger.info(f"正在连接 transmission{self._protocol}://{self._host}:{self._port}")
trt = transmission_rpc.Client(protocol=self._protocol,
host=self._host,
port=self._port,
username=self._username,
password=self._password,

View File

@@ -252,7 +252,7 @@ class Monitor(metaclass=Singleton):
self.transferchain.do_transfer(
fileitem=FileItem(
storage=storage,
path=str(event_path),
path=str(event_path).replace("\\", "/"),
type="file",
name=event_path.name,
basename=event_path.stem,

View File

@@ -587,6 +587,6 @@ class Scheduler(metaclass=Singleton):
else:
self._auth_count += 1
logger.error(f"用户认证失败{msg},共失败 {self._auth_count}")
logger.error(f"用户认证失败{msg},共失败 {self._auth_count}")
if self._auth_count >= __max_try__:
logger.error("用户认证失败次数过多,将不再尝试认证!")

View File

@@ -50,7 +50,7 @@ class AuthCredentials(ChainEventData):
service: Optional[str] = Field(default=None, description="服务名称")
@root_validator(pre=True)
def check_fields_based_on_grant_type(cls, values):
def check_fields_based_on_grant_type(cls, values): # noqa
grant_type = values.get("grant_type")
if not grant_type:
values["grant_type"] = "password"

View File

@@ -3,10 +3,11 @@ from typing import Optional, List, Any, Callable
from pydantic import BaseModel, Field
from app.schemas import TmdbEpisode, DownloadHistory
from app.schemas.tmdb import TmdbEpisode
from app.schemas.history import DownloadHistory
from app.schemas.context import MetaInfo, MediaInfo
from app.schemas.file import FileItem
from app.schemas.system import TransferDirectoryConf
from schemas import MediaInfo, MetaInfo
class TransferTorrent(BaseModel):
@@ -58,9 +59,12 @@ class TransferTask(BaseModel):
library_type_folder: Optional[bool] = False
library_category_folder: Optional[bool] = False
episodes_info: Optional[List[TmdbEpisode]] = None
username: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
download_history: Optional[DownloadHistory] = None
manual: Optional[bool] = False
background: Optional[bool] = True
def to_dict(self):
"""

View File

@@ -88,7 +88,7 @@ def user_auth():
if status:
logger.info(f"{msg} 用户认证成功")
else:
logger.info(f"用户认证失败{msg}")
logger.info(f"用户认证失败{msg}")
def check_auth():

View File

@@ -17,6 +17,11 @@ _special_domains = [
'pt.ecust.pp.ua',
]
# 内置版本号转换字典
_version_map = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
# 不符合的版本号
_other_version = -5
class StringUtils:
@@ -222,7 +227,7 @@ class StringUtils:
size = float(size)
d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]
s = [x[0] for x in d]
index = bisect.bisect_left(s, size) - 1
index = bisect.bisect_left(s, size) - 1 # noqa
if index == -1:
return str(size) + "B"
else:
@@ -740,27 +745,122 @@ class StringUtils:
return ''.join(common_prefix)
@staticmethod
def compare_version(v1: str, v2: str) -> int:
def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \
-> Tuple[Optional[bool], str | Exception] | Optional[bool]:
"""
比较两个版本号的大小v1 > v2时返回1v1 < v2时返回-1v1 = v2时返回0
比较两个版本号的大小
:param v1: 比对的来源版本号
:param v2: 比对的目标版本号
:param verbose: 是否输出比对结果的时候输出详细消息,默认 False 不输出
:param compare_type: 识别模式。支持直接使用符号进行比对
'ge' or '>=' :来源 >= 目标
'le' or '<=' :来源 <= 目标
'eq' or '==' :来源 == 目标
'gt' or '>' :来源 > 目标
'lt' or '<' :来源 < 目标
:return
"""
if not v1 or not v2:
return 0
v1 = v1.replace('v', '')
v2 = v2.replace('v', '')
v1 = [int(x) for x in v1.split('.')]
v2 = [int(x) for x in v2.split('.')]
for i in range(min(len(v1), len(v2))):
if v1[i] > v2[i]:
return 1
elif v1[i] < v2[i]:
return -1
if len(v1) > len(v2):
return 1
elif len(v1) < len(v2):
return -1
else:
return 0
def __preprocess_version(version: str) -> list:
"""
预处理版本号去除首尾空字符串与换行符去除开头大小写v并拆分版本号
"""
return re.split(r'[.-]', version.strip().lstrip('vV'))
def __conversion_version(version_list) -> list:
"""
英文字符转换为数字
:param version_list : 版本号列表,格式:['1', '2', '3', 'beta']
"""
result = []
for item in version_list:
# stable = -1rc = -2beta = -3alpha = -4
if item.isdigit():
result.append(int(item))
# 其余不符合的,都为-5
else:
value = _version_map.get(item, _other_version)
result.append(value)
return result
try:
if not v1 or not v2:
raise ValueError("要比较的版本号不全")
if not compare_type:
raise ValueError("缺少比对模式,无法比对")
if compare_type not in {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}:
raise ValueError(f"设置的版本比对模式 {compare_type} 不是有效的模式!")
# 拆分获取版本号各个分段值做成列表
v1_list = __conversion_version(__preprocess_version(version=v1))
v2_list = __conversion_version(__preprocess_version(version=v2))
# 补全版本号位置,保持长度一致
max_length = max(len(v1_list), len(v2_list))
v1_list += [0] * (max_length - len(v1_list))
v2_list += [0] * (max_length - len(v2_list))
ver_comparison, ver_comparison_err = None, None
for v1_value, v2_value in zip(v1_list, v2_list):
# 来源==目标
if compare_type in {"eq", "=="}:
if v1_value != v2_value:
ver_comparison, ver_comparison_err = None, "不等于"
break
else:
ver_comparison, ver_comparison_err = "等于", None
# 来源>=目标
elif compare_type in {"ge", ">="}:
if v1_value > v2_value:
ver_comparison, ver_comparison_err = "大于", None
break
elif v1_value < v2_value:
ver_comparison, ver_comparison_err = None, "小于"
break
else:
ver_comparison, ver_comparison_err = "等于", None
# 来源>目标
elif compare_type in {"gt", ">"}:
if v1_value > v2_value:
ver_comparison, ver_comparison_err = "大于", None
break
elif v1_value < v2_value:
ver_comparison, ver_comparison_err = None, "小于"
break
else:
ver_comparison, ver_comparison_err = None, "等于"
# 来源<=目标
elif compare_type in {"le", "<="}:
if v1_value > v2_value:
ver_comparison, ver_comparison_err = None, "大于"
break
elif v1_value < v2_value:
ver_comparison, ver_comparison_err = "小于", None
break
else:
ver_comparison, ver_comparison_err = "等于", None
# 来源<目标
elif compare_type in {"lt", "<"}:
if v1_value > v2_value:
ver_comparison, ver_comparison_err = None, "大于"
break
elif v1_value < v2_value:
ver_comparison, ver_comparison_err = "小于", None
break
else:
ver_comparison, ver_comparison_err = None, "等于"
msg = f"版本号 {v1} {ver_comparison if ver_comparison else ver_comparison_err} 目标版本号 {v2} "
return (True if ver_comparison else False, msg) if verbose else True if ver_comparison else False
except Exception as e:
return (None, e) if verbose else None
@staticmethod
def diff_time_str(time_str: str):

View File

@@ -1,6 +1,6 @@
import mimetypes
from pathlib import Path
from typing import Optional, Union
from typing import Optional, Union, Tuple
from urllib import parse
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
@@ -27,7 +27,7 @@ class UrlUtils:
@staticmethod
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
"""
基于传入的host适配请求的URL确保每个请求的URL是完整的用于在发送请求前自动处理和修正请求的URL
基于传入的host适配请求的URL确保每个请求的URL是完整的用于在发送请求前自动处理和修正请求的URL
:param host: 主机头
:param endpoint: 端点
:return: 完整的请求URL字符串
@@ -42,7 +42,7 @@ class UrlUtils:
@staticmethod
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
"""
使用给定的主机头、路径和查询参数组合生成完整的URL
使用给定的主机头、路径和查询参数组合生成完整的URL
:param host: str, 主机头,例如 https://example.com
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
@@ -101,9 +101,42 @@ class UrlUtils:
def quote(s: str) -> str:
"""
将字符串编码为 URL 安全的格式
这将确保路径中的特殊字符(如空格、中文字符等)被正确编码,以便在 URL 中传输
:param s: 要编码的字符串
:return: 编码后的字符串
"""
return parse.quote(s)
@staticmethod
def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:
"""
解析给定的 URL并提取协议、主机名、端口和路径信息
:param url: str
需要解析的 URL 字符串
可以是完整的 URL例如"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234"
:return: Optional[Tuple[str, str, int, str]]
- str: 协议(例如:"http", "https"
- str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1"
- int: 端口号例如80, 443
- str: URL 的路径部分(例如:"/", "/path"
如果输入地址无效或无法解析,则返回 None
"""
try:
if not url:
return None
url = UrlUtils.standardize_base_url(host=url)
parsed = urlparse(url)
if not parsed.hostname:
return None
protocol = parsed.scheme
hostname = parsed.hostname
port = parsed.port or (443 if protocol == "https" else 80)
path = parsed.path or "/"
return protocol, hostname, port, path
except Exception as e:
logger.debug(f"Error parse_url_params: {e}")
return None

View File

@@ -63,4 +63,5 @@ p115client==0.0.3.8.3.3
python-cookietools==0.0.2.1
aligo~=6.2.4
aiofiles~=24.1.0
jieba~=0.42.1
jieba~=0.42.1
rsa~=4.9

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.1.8'
FRONTEND_VERSION = 'v2.1.8'
APP_VERSION = 'v2.2.0'
FRONTEND_VERSION = 'v2.2.0'