Compare commits

...

20 Commits

Author SHA1 Message Date
jxxghp
73eba90f2f 更新 version.py 2025-06-24 10:34:42 +08:00
jxxghp
62e74f6fd1 fix 2025-06-24 08:19:10 +08:00
jxxghp
4375e48840 Merge pull request #4476 from Miralia/v2 2025-06-23 20:52:15 +08:00
Miralia
a1d6e94e90 feat(meta): 新增 WEB 平台来源识别并支持更多音视频格式。 2025-06-23 20:36:58 +08:00
jxxghp
1f44e13ff0 add reload logging 2025-06-23 10:14:22 +08:00
jxxghp
d2992f9ced fix plugin load 2025-06-23 09:31:56 +08:00
jxxghp
950337bccc fix plugin load 2025-06-23 08:19:22 +08:00
jxxghp
757c3be359 更新 version.py 2025-06-22 10:08:17 +08:00
jxxghp
269ab9adfc fix:删除消息能力 2025-06-22 10:04:21 +08:00
jxxghp
bd241a5164 feat:删除消息能力 2025-06-22 09:37:01 +08:00
jxxghp
3d92b57f24 fix 2025-06-22 09:04:03 +08:00
jxxghp
70d8cb3697 fix #4461 2025-06-22 08:51:29 +08:00
jxxghp
9e4ec5841c fix #4470 2025-06-22 08:47:43 +08:00
jxxghp
682f4fe608 fix message cache 2025-06-20 17:33:08 +08:00
jxxghp
ce8a077e07 优化按钮回调数据,简化为仅使用索引值 2025-06-19 15:54:07 +08:00
jxxghp
d5f63bcdb3 remove Commands DEV flag 2025-06-18 13:33:37 +08:00
jxxghp
5c3756fd1b v2.5.7-1 2025-06-17 20:02:45 +08:00
jxxghp
99939e1a3d fix 2025-06-17 19:42:16 +08:00
jxxghp
56742ace11 fix:带UA下载图片 2025-06-17 19:27:53 +08:00
jxxghp
742cb7a8da 更新 version.py 2025-06-17 18:56:47 +08:00
32 changed files with 493 additions and 368 deletions

View File

@@ -22,7 +22,7 @@ from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
WebhookEventInfo, TmdbEpisode, MediaPerson, FileItem, TransferDirectoryConf
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType, MessageChannel
from app.utils.object import ObjectUtils
@@ -641,6 +641,19 @@ class ChainBase(metaclass=ABCMeta):
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents,
immediately=True if message.userid else False)
def delete_message(self, channel: MessageChannel, source: str,
message_id: Union[str, int], chat_id: Optional[Union[str, int]] = None) -> bool:
"""
删除消息
:param channel: 消息渠道
:param source: 消息源(指定特定的消息模块)
:param message_id: 消息ID
:param chat_id: 聊天ID如群组ID
:return: 删除是否成功
"""
return self.run_module("delete_message", channel=channel, source=source,
message_id=message_id, chat_id=chat_id)
def metadata_img(self, mediainfo: MediaInfo,
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:
"""

View File

@@ -427,7 +427,7 @@ class MediaChain(ChainBase):
"""
try:
logger.info(f"正在下载图片:{_url} ...")
r = RequestUtils(proxies=settings.PROXY).get_res(url=_url)
r = RequestUtils(proxies=settings.PROXY, ua=settings.USER_AGENT).get_res(url=_url)
if r:
return r.content
else:
@@ -506,7 +506,9 @@ class MediaChain(ChainBase):
# 根据图片类型检查开关
if 'poster' in image_name.lower():
should_scrape = scraping_switchs.get('movie_poster', True)
elif 'backdrop' in image_name.lower() or 'fanart' in image_name.lower():
elif ('backdrop' in image_name.lower()
or 'fanart' in image_name.lower()
or 'background' in image_name.lower()):
should_scrape = scraping_switchs.get('movie_backdrop', True)
elif 'logo' in image_name.lower():
should_scrape = scraping_switchs.get('movie_logo', True)
@@ -700,7 +702,9 @@ class MediaChain(ChainBase):
# 根据电视剧图片类型检查开关
if 'poster' in image_name.lower():
should_scrape = scraping_switchs.get('tv_poster', True)
elif 'backdrop' in image_name.lower() or 'fanart' in image_name.lower():
elif ('backdrop' in image_name.lower()
or 'fanart' in image_name.lower()
or 'background' in image_name.lower()):
should_scrape = scraping_switchs.get('tv_backdrop', True)
elif 'banner' in image_name.lower():
should_scrape = scraping_switchs.get('tv_banner', True)

View File

@@ -1,4 +1,3 @@
import gc
import re
from typing import Any, Optional, Dict, Union, List
@@ -133,10 +132,10 @@ class MessageChain(ChainBase):
"""
# 申明全局变量
global _current_page, _current_meta, _current_media
# 加载缓存
user_cache: Dict[str, dict] = self.load_cache(self._cache_file) or {}
# 处理消息
logger.info(f'收到用户消息内容,用户:{userid},内容:{text}')
# 加载缓存
user_cache: Dict[str, dict] = self.load_cache(self._cache_file) or {}
# 保存消息
if not text.startswith('CALLBACK:'):
self.messagehelper.put(
@@ -272,6 +271,18 @@ class MessageChain(ChainBase):
"type": "Torrent",
"items": contexts
}
_current_page = 0
# 保存缓存
self.save_cache(user_cache, self._cache_file)
# 删除原消息
if (original_message_id and original_chat_id and
ChannelCapabilityManager.supports_deletion(channel)):
self.delete_message(
channel=channel,
source=source,
message_id=original_message_id,
chat_id=original_chat_id
)
# 发送种子数据
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
self.__post_torrents_message(channel=channel,
@@ -279,9 +290,7 @@ class MessageChain(ChainBase):
title=mediainfo.title,
items=contexts[:self._page_size],
userid=userid,
total=len(contexts),
original_message_id=original_message_id,
original_chat_id=original_chat_id)
total=len(contexts))
elif cache_type in ["Subscribe", "ReSubscribe"]:
# 订阅或洗版媒体
@@ -467,10 +476,12 @@ class MessageChain(ChainBase):
logger.info(f"搜索到 {len(medias)} 条相关媒体信息")
# 记录当前状态
_current_meta = meta
# 保存缓存
user_cache[userid] = {
'type': action,
'items': medias
}
self.save_cache(user_cache, self._cache_file)
_current_page = 0
_current_media = None
# 发送媒体列表
@@ -478,9 +489,7 @@ class MessageChain(ChainBase):
source=source,
title=meta.name,
items=medias[:self._page_size],
userid=userid, total=len(medias),
original_message_id=original_message_id,
original_chat_id=original_chat_id)
userid=userid, total=len(medias))
else:
# 广播事件
self.eventmanager.send_event(
@@ -493,15 +502,6 @@ class MessageChain(ChainBase):
}
)
# 保存缓存
self.save_cache(user_cache, self._cache_file)
# 清理内存
user_cache.clear()
del user_cache
gc.collect()
def _handle_callback(self, text: str, channel: MessageChannel, source: str,
userid: Union[str, int], username: str,
original_message_id: Optional[Union[str, int]] = None,
@@ -509,6 +509,9 @@ class MessageChain(ChainBase):
"""
处理按钮回调
"""
global _current_media
# 提取回调数据
callback_data = text[9:] # 去掉 "CALLBACK:" 前缀
logger.info(f"处理按钮回调:{callback_data}")
@@ -533,101 +536,20 @@ class MessageChain(ChainBase):
return
# 解析系统回调数据
if callback_data.startswith("page_"):
# 翻页操作
self._handle_page_callback(callback_data=callback_data, channel=channel,
source=source, userid=userid,
original_message_id=original_message_id, original_chat_id=original_chat_id)
elif callback_data.startswith("select_"):
# 选择操作或翻页操作
if callback_data in ["select_p", "select_n"]:
# 翻页操作
page_text = callback_data.split("_")[1] # 提取 "p" 或 "n"
self.handle_message(channel=channel, source=source,
userid=userid, username=username,
text=page_text,
original_message_id=original_message_id, original_chat_id=original_chat_id)
else:
# 选择操作
self._handle_select_callback(callback_data=callback_data, channel=channel,
source=source, userid=userid, username=username)
elif callback_data.startswith("download_"):
# 下载操作
self._handle_download_callback(callback_data=callback_data, channel=channel,
source=source, userid=userid, username=username)
elif callback_data.startswith("subscribe_"):
# 订阅操作
self._handle_subscribe_callback(callback_data=callback_data, channel=channel,
source=source, userid=userid, username=username)
else:
# 其他自定义回调
logger.info(f"未知的回调数据:{callback_data}")
def _handle_page_callback(self, callback_data: str, channel: MessageChannel, source: str,
userid: Union[str, int], original_message_id: Optional[Union[str, int]],
original_chat_id: Optional[str]):
"""
处理翻页回调
"""
try:
page = int(callback_data.split("_")[1])
# 获取当前页面
global _current_page
# 判断是上一页还是下一页
if page < _current_page:
# 上一页,调用原来的 "p" 逻辑
self.handle_message(channel=channel, source=source, userid=userid,
username="", text="p",
original_message_id=original_message_id, original_chat_id=original_chat_id)
elif page > _current_page:
# 下一页,调用原来的 "n" 逻辑
self.handle_message(channel=channel, source=source, userid=userid,
username="", text="n",
original_message_id=original_message_id, original_chat_id=original_chat_id)
except (ValueError, IndexError) as e:
logger.error(f"处理翻页回调失败:{e}")
def _handle_select_callback(self, callback_data: str, channel: MessageChannel, source: str,
userid: Union[str, int], username: str) -> None:
"""
处理选择回调
"""
try:
index = int(callback_data.split("_")[1])
# 调用原有的数字选择逻辑
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=str(index + 1))
except (ValueError, IndexError) as e:
logger.error(f"处理选择回调失败:{e}")
def _handle_download_callback(self, callback_data: str, channel: MessageChannel, source: str,
userid: Union[str, int], username: str) -> None:
"""
处理下载回调
"""
try:
if callback_data == "download_auto":
# 自动选择下载
self.handle_message(channel=channel, source=source, userid=userid, username=username, text="0")
else:
index = int(callback_data.split("_")[1])
self.handle_message(channel=channel, source=source, userid=userid, username=username,
text=str(index + 1))
except (ValueError, IndexError) as e:
logger.error(f"处理下载回调失败:{e}")
def _handle_subscribe_callback(self, callback_data: str, channel: MessageChannel, source: str,
userid: Union[str, int], username: str) -> None:
"""
处理订阅回调
"""
try:
index = int(callback_data.split("_")[1])
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=str(index + 1))
except (ValueError, IndexError) as e:
logger.error(f"处理订阅回调失败:{e}")
page_text = callback_data.split("_", 1)[1]
self.handle_message(channel=channel, source=source, userid=userid, username=username,
text=page_text,
original_message_id=original_message_id, original_chat_id=original_chat_id)
except IndexError:
logger.error(f"回调数据格式错误:{callback_data}")
self.post_message(Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="回调数据格式错误,请检查!"
))
def __auto_download(self, channel: MessageChannel, source: str, cache_list: list[Context],
userid: Union[str, int], username: str,
@@ -742,7 +664,7 @@ class MessageChain(ChainBase):
buttons.append([{
"text": button_text,
"callback_data": f"select_{_current_page * self._page_size + i}"
"callback_data": f"select_{i + 1}"
}])
else:
# 多按钮一行的情况,使用简化文本
@@ -750,7 +672,7 @@ class MessageChain(ChainBase):
current_row.append({
"text": button_text,
"callback_data": f"select_{_current_page * self._page_size + i}"
"callback_data": f"select_{i + 1}"
})
# 如果当前行已满或者是最后一个按钮,添加到按钮列表
@@ -762,9 +684,9 @@ class MessageChain(ChainBase):
if total > self._page_size:
page_buttons = []
if _current_page > 0:
page_buttons.append({"text": "⬅️ 上一页", "callback_data": "select_p"})
page_buttons.append({"text": "⬅️ 上一页", "callback_data": "page_p"})
if (_current_page + 1) * self._page_size < total:
page_buttons.append({"text": "下一页 ➡️", "callback_data": "select_n"})
page_buttons.append({"text": "下一页 ➡️", "callback_data": "page_n"})
if page_buttons:
buttons.append(page_buttons)
@@ -821,7 +743,7 @@ class MessageChain(ChainBase):
max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel)
# 自动选择按钮
buttons.append([{"text": "🤖 自动选择下载", "callback_data": "download_auto"}])
buttons.append([{"text": "🤖 自动选择下载", "callback_data": "download_0"}])
# 为每个种子项创建下载按钮
current_row = []
@@ -837,7 +759,7 @@ class MessageChain(ChainBase):
buttons.append([{
"text": button_text,
"callback_data": f"download_{_current_page * self._page_size + i}"
"callback_data": f"download_{i + 1}"
}])
else:
# 多按钮一行的情况,使用简化文本
@@ -845,7 +767,7 @@ class MessageChain(ChainBase):
current_row.append({
"text": button_text,
"callback_data": f"download_{_current_page * self._page_size + i}"
"callback_data": f"download_{i + 1}"
})
# 如果当前行已满或者是最后一个按钮,添加到按钮列表
@@ -857,9 +779,9 @@ class MessageChain(ChainBase):
if total > self._page_size:
page_buttons = []
if _current_page > 0:
page_buttons.append({"text": "⬅️ 上一页", "callback_data": "select_p"})
page_buttons.append({"text": "⬅️ 上一页", "callback_data": "page_p"})
if (_current_page + 1) * self._page_size < total:
page_buttons.append({"text": "下一页 ➡️", "callback_data": "select_n"})
page_buttons.append({"text": "下一页 ➡️", "callback_data": "page_n"})
if page_buttons:
buttons.append(page_buttons)

View File

@@ -9,7 +9,6 @@ from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.system import SystemChain
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.event import Event as ManagerEvent, eventmanager, Event
from app.core.plugin import PluginManager
from app.helper.message import MessageHelper
@@ -162,10 +161,6 @@ class Command(metaclass=Singleton):
"""
初始化菜单命令
"""
if settings.DEV:
logger.debug("Development mode active. Skipping command initialization.")
return
# 使用线程池提交后台任务,避免引起阻塞
ThreadHelper().submit(self.__init_commands_background, pid)

View File

@@ -10,6 +10,7 @@ from app.core.meta.releasegroup import ReleaseGroupsMatcher
from app.schemas.types import MediaType
from app.utils.string import StringUtils
from app.utils.tokens import Tokens
from app.core.meta.streamingplatform import StreamingPlatforms
class MetaVideo(MetaBase):
@@ -31,7 +32,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$|^HDR10(\+|Plus)$"
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$|^EDR$|^HQ$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕"
@@ -51,7 +52,7 @@ class MetaVideo(MetaBase):
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
_resources_pix_re2 = r"(^[248]+K)"
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$"
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$|^AV[3S]A$"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""
@@ -66,6 +67,8 @@ class MetaVideo(MetaBase):
original_title = title
self._source = ""
self._effect = []
self.web_source = None
self._index = 0
# 判断是否纯数字命名
if isfile \
and title.isdigit() \
@@ -93,9 +96,12 @@ class MetaVideo(MetaBase):
# 拆分tokens
tokens = Tokens(title)
self.tokens = tokens
# 实例化StreamingPlatforms对象
streaming_platforms = StreamingPlatforms()
# 解析名称、年份、季、集、资源类型、分辨率等
token = tokens.get_next()
while token:
self._index += 1 # 更新当前处理的token索引
# Part
self.__init_part(token)
# 标题
@@ -116,6 +122,9 @@ class MetaVideo(MetaBase):
# 资源类型
if self._continue_flag:
self.__init_resource_type(token)
# 流媒体平台
if self._continue_flag:
self.__init_web_source(token, streaming_platforms)
# 视频编码
if self._continue_flag:
self.__init_video_encode(token)
@@ -131,6 +140,9 @@ class MetaVideo(MetaBase):
self.resource_effect = " ".join(self._effect)
if self._source:
self.resource_type = self._source.strip()
# 添加流媒体平台
if self.web_source:
self.resource_type = f"{self.web_source} {self.resource_type}"
# 提取原盘DIY
if self.resource_type and "BluRay" in self.resource_type:
if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \
@@ -574,6 +586,57 @@ class MetaVideo(MetaBase):
self._effect.append(effect)
self._last_token = effect.upper()
def __init_web_source(self, token: str, streaming_platforms: StreamingPlatforms):
"""
识别流媒体平台
"""
if not self.name:
return
platform_name = None
query_range = 1
prev_token = None
prev_idx = self._index - 2
if 0 <= prev_idx < len(self.tokens.tokens):
prev_token = self.tokens.tokens[prev_idx]
next_token = self.tokens.peek()
if streaming_platforms.is_streaming_platform(token):
platform_name = streaming_platforms.get_streaming_platform_name(token)
else:
for adjacent_token, is_next in [(prev_token, False), (next_token, True)]:
if not adjacent_token or platform_name:
continue
for separator in [" ", "-"]:
if is_next:
combined_token = f"{token}{separator}{adjacent_token}"
else:
combined_token = f"{adjacent_token}{separator}{token}"
if streaming_platforms.is_streaming_platform(combined_token):
platform_name = streaming_platforms.get_streaming_platform_name(combined_token)
query_range = 2
if is_next:
self.tokens.get_next()
break
if not platform_name:
return
web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"]
match_start_idx = self._index - query_range
match_end_idx = self._index - 1
start_index = max(0, match_start_idx - query_range)
end_index = min(len(self.tokens.tokens), match_end_idx + 1 + query_range)
tokens_to_check = self.tokens.tokens[start_index:end_index]
if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):
self.web_source = platform_name
self._continue_flag = False
def __init_video_encode(self, token: str):
"""
识别视频编码

View File

@@ -0,0 +1,104 @@
from typing import Optional, List, Tuple
from app.utils.singleton import Singleton
class StreamingPlatforms(metaclass=Singleton):
"""
流媒体平台简称与全称。
"""
STREAMING_PLATFORMS: List[Tuple[str, str]] = [
("AMZN", "Amazon"),
("NF", "Netflix"),
("ATVP", "Apple TV+"),
("iT", "iTunes"),
("DSNP", "Disney+"),
("HS", "Hotstar"),
("APPS", "Disney+ MENA"),
("PMTP", "Paramount+"),
("HMAX", "Max"),
("", "Max"),
("HULU", "Hulu"),
("MA", "Movies Anywhere"),
("BCORE", "Bravia Core"),
("MS", "Microsoft Store"),
("SHO", "Showtime"),
("STAN", "Stan"),
("PCOK", "Peacock"),
("SKST", "SkyShowtime"),
("NOW", "Now TV"),
("FXTL", "Foxtel Now"),
("BNGE", "Binge"),
("CRKL", "Crackle"),
("RKTN", "Rakuten TV"),
("ALL4", "All 4"),
("AS", "Adult Swim"),
("BRTB", "Brtb TV"),
("CNLP", "Canal+"),
("CRIT", "Criterion Channel"),
("DSCP", "Discovery+"),
("", "ESPN"),
("FOOD", "Food Network"),
("MUBI", "Mubi"),
("PLAY", "Google Play"),
("YT", "YouTube"),
("", "friDay"),
("", "KKTV"),
("", "ofiii"),
("", "LiTV"),
("", "MyVideo"),
("Hami", "Hami Video"),
("", "meWATCH"),
("CATCHPLAY", "CATCHPLAY+"),
("", "LINE TV"),
("VIU", "Viu"),
("IQ", ""),
("", "WeTV"),
("ABMA", "Abema"),
("ADN", ""),
("AT-X", ""),
("Baha", ""),
("BG", "B-Global"),
("CR", "Crunchyroll"),
("", "DMM"),
("FOD", ""),
("FUNi", "Funimation"),
("HIDI", "HIDIVE"),
("UNXT", "U-NEXT"),
]
def __init__(self):
"""初始化流媒体平台匹配器"""
self._lookup_cache = {}
self._build_cache()
def _build_cache(self) -> None:
"""
构建查询缓存。
"""
self._lookup_cache.clear()
for short_name, full_name in self.STREAMING_PLATFORMS:
canonical_name = full_name or short_name
if not canonical_name:
continue
aliases = {short_name, full_name}
for alias in aliases:
if alias:
self._lookup_cache[alias.upper()] = canonical_name
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
"""
根据流媒体平台简称或全称获取标准名称。
"""
if platform_code is None:
return None
return self._lookup_cache.get(platform_code.upper())
def is_streaming_platform(self, name: str) -> bool:
"""
判断给定的字符串是否为已知的流媒体平台代码或名称。
"""
if name is None:
return False
return name.upper() in self._lookup_cache

View File

@@ -19,7 +19,6 @@ from app.core.config import settings
from app.core.event import eventmanager, Event
from app.db.plugindata_oper import PluginDataOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.module import ModuleHelper
from app.helper.plugin import PluginHelper
from app.helper.sites import SitesHelper
from app.log import logger
@@ -124,19 +123,8 @@ class PluginManager(metaclass=Singleton):
# 已安装插件
installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
# 扫描插件目录
if pid:
# 加载指定插件
plugins = ModuleHelper.load_with_pre_filter(
"app.plugins",
filter_func=lambda name, obj: check_module(obj) and name == pid
)
else:
# 加载已安装插件
plugins = ModuleHelper.load(
"app.plugins",
filter_func=lambda name, obj: check_module(obj) and name in installed_plugins
)
# 扫描插件目录,只加载符合条件的插件
plugins = self._load_selective_plugins(pid, installed_plugins, check_module)
# 排序
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
for plugin in plugins:
@@ -216,6 +204,80 @@ class PluginManager(metaclass=Singleton):
self._running_plugins = {}
logger.info("插件停止完成")
@staticmethod
def _load_selective_plugins(pid: Optional[str], installed_plugins: List[str],
check_module_func: Callable) -> List[Any]:
"""
选择性加载插件只import符合条件的插件
:param pid: 指定插件ID为空则加载所有已安装插件
:param installed_plugins: 已安装插件列表
:param check_module_func: 模块检查函数
:return: 插件类列表
"""
import importlib
plugins = []
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
if not plugins_dir.exists():
logger.warning(f"插件目录不存在:{plugins_dir}")
return plugins
# 确定需要加载的插件目录名称列表
if pid:
# 加载指定插件
target_plugins = [pid.lower()]
else:
# 加载已安装插件
target_plugins = [plugin_id.lower() for plugin_id in installed_plugins]
if not target_plugins:
logger.debug("没有需要加载的插件")
return plugins
# 扫描plugins目录
_loaded_modules = set()
for plugin_dir in plugins_dir.iterdir():
if not plugin_dir.is_dir() or plugin_dir.name.startswith('_'):
continue
# 检查是否是需要加载的插件
if plugin_dir.name not in target_plugins:
logger.debug(f"跳过插件目录:{plugin_dir.name}(不在加载列表中)")
continue
# 检查__init__.py是否存在
init_file = plugin_dir / "__init__.py"
if not init_file.exists():
logger.debug(f"跳过插件目录:{plugin_dir.name}缺少__init__.py")
continue
try:
# 构建模块名
module_name = f"app.plugins.{plugin_dir.name}"
logger.debug(f"正在导入插件模块:{module_name}")
# 导入模块
module = importlib.import_module(module_name)
importlib.reload(module)
# 检查模块中的类
for name, obj in module.__dict__.items():
if name.startswith('_') or not isinstance(obj, type):
continue
if name in _loaded_modules:
continue
if check_module_func(obj):
_loaded_modules.add(name)
plugins.append(obj)
logger.debug(f"找到符合条件的插件类:{name}")
break
except Exception as err:
logger.error(f"加载插件 {plugin_dir.name} 失败:{str(err)} - {traceback.format_exc()}")
return plugins
@property
def running_plugins(self) -> Dict[str, Any]:
"""
@@ -243,6 +305,7 @@ class PluginManager(metaclass=Singleton):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in ['DEV', 'PLUGIN_AUTO_RELOAD']:
return
logger.info("配置变更,重新加载插件文件修改监测...")
self.reload_monitor()
def reload_monitor(self):
@@ -350,8 +413,7 @@ class PluginManager(metaclass=Singleton):
# 确定需要安装的插件
plugins_to_install = [
plugin for plugin in online_plugins
if plugin.id in install_plugins
and not self.is_plugin_exists(plugin.id, plugin.plugin_version)
if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id, plugin.plugin_version)
]
if not plugins_to_install:

View File

@@ -68,6 +68,7 @@ def enable_doh(enable: bool):
else:
socket.getaddrinfo = _orig_getaddrinfo
class DohHelper(metaclass=Singleton):
def __init__(self):
enable_doh(settings.DOH_ENABLE)

View File

@@ -32,6 +32,7 @@ class SystemHelper:
if event_data.key not in ['DEBUG', 'LOG_LEVEL', 'LOG_MAX_FILE_SIZE', 'LOG_BACKUP_COUNT',
'LOG_FILE_FORMAT', 'LOG_CONSOLE_FORMAT']:
return
logger.info("配置变更,更新日志设置...")
logger.update_loggers()
@staticmethod

View File

@@ -29,6 +29,7 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.MediaServers.value]:
return
logger.info("配置变更重新初始化Emby模块...")
self.init_module()
@staticmethod

View File

@@ -399,28 +399,30 @@ class FanartModule(_ModuleBase):
if not mediainfo.get_image(season_image):
mediainfo.set_image(season_image, image_obj.get('url'))
else:
# 其他图片优先环境变量指定语言再like最多
def pick_best_image(images):
def __pick_best_image(_images):
lang_env = settings.FANART_LANG
if lang_env:
langs = [lang.strip() for lang in lang_env.split(",") if lang.strip()]
for lang in langs:
lang_images = [img for img in images if img.get('lang') == lang]
lang_images = [img for img in _images if img.get('lang') == lang]
if lang_images:
lang_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
return lang_images[0]
# 没设置或没找到,按原逻辑 zh、en、like最多
zh_images = [img for img in images if img.get('lang') == 'zh']
zh_images = [img for img in _images if img.get('lang') == 'zh']
if zh_images:
zh_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
return zh_images[0]
en_images = [img for img in images if img.get('lang') == 'en']
en_images = [img for img in _images if img.get('lang') == 'en']
if en_images:
en_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
return en_images[0]
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
return images[0]
image_obj = pick_best_image(images)
_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
return _images[0]
image_obj = __pick_best_image(images)
# 设置图片,没有图片才设置
if not mediainfo.get_image(image_name):
mediainfo.set_image(image_name, image_obj.get('url'))

View File

@@ -30,6 +30,7 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.MediaServers.value]:
return
logger.info("配置变更重新初始化Jellyfin模块...")
self.init_module()
@staticmethod

View File

@@ -30,6 +30,7 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.MediaServers.value]:
return
logger.info("配置变更重新初始化Plex模块...")
self.init_module()
@staticmethod

View File

@@ -36,6 +36,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Downloaders.value]:
return
logger.info("配置变更重新加载Qbittorrent模块...")
self.init_module()
@staticmethod

View File

@@ -32,6 +32,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Notifications.value]:
return
logger.info("配置变更重新加载Slack模块...")
self.init_module()
@staticmethod
@@ -222,13 +223,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
# 使用CALLBACK前缀标识按钮回调
text = f"CALLBACK:{callback_data}"
username = msg_json.get("user", {}).get("name")
# 获取原消息信息用于编辑
message_info = msg_json.get("message", {})
# Slack消息的时间戳作为消息ID
message_ts = message_info.get("ts")
channel_id = msg_json.get("channel", {}).get("id") or msg_json.get("container", {}).get("channel_id")
logger.info(f"收到来自 {client_config.name} 的Slack按钮回调"
f"userid={userid}, username={username}, callback_data={callback_data}")
@@ -320,3 +321,26 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid=message.userid, buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
def delete_message(self, channel: MessageChannel, source: str,
message_id: str, chat_id: Optional[str] = None) -> bool:
"""
删除消息
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: 消息IDSlack中为时间戳
:param chat_id: 聊天ID频道ID
:return: 删除是否成功
"""
success = False
for conf in self.get_configs().values():
if channel != self._channel:
break
if source != conf.name:
continue
client: Slack = self.get_instance(conf.name)
if client:
result = client.delete_msg(message_id=message_id, chat_id=chat_id)
if result:
success = True
return success

View File

@@ -13,18 +13,16 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.string import StringUtils
lock = Lock()
class Slack:
_client: WebClient = None
_service: SocketModeHandler = None
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_channel = ""
def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None,
def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None,
SLACK_CHANNEL: Optional[str] = None, **kwargs):
if not SLACK_OAUTH_TOKEN or not SLACK_APP_TOKEN:
@@ -160,7 +158,7 @@ class Slack:
"emoji": True
},
"url": button["url"],
"action_id": f"actionId-url-{len(elements)}"
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
})
else:
# 回调按钮
@@ -197,7 +195,7 @@ class Slack:
}
]
})
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
# 编辑消息
@@ -258,7 +256,7 @@ class Slack:
"type": "divider"
})
index = 1
# 如果有自定义按钮,先添加所有媒体项,然后添加统一的按钮
if buttons:
# 添加媒体列表(不带单独的选择按钮)
@@ -288,7 +286,7 @@ class Slack:
}
)
index += 1
# 添加统一的自定义按钮(在所有媒体项之后)
for button_row in buttons:
elements = []
@@ -302,7 +300,7 @@ class Slack:
"emoji": True
},
"url": button["url"],
"action_id": f"actionId-url-{len(elements)}"
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
})
else:
elements.append({
@@ -366,7 +364,7 @@ class Slack:
}
)
index += 1
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
# 编辑消息
@@ -423,7 +421,7 @@ class Slack:
}]
# 列表
index = 1
# 如果有自定义按钮,先添加种子列表,然后添加统一的按钮
if buttons:
# 添加种子列表(不带单独的选择按钮)
@@ -433,9 +431,9 @@ class Slack:
meta = MetaInfo(torrent.title, torrent.description)
link = torrent.page_url
title_text = f"{meta.season_episode} " \
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
title_text = re.sub(r"\s+", " ", title_text).strip()
free = torrent.volume_factor
seeder = f"{torrent.seeders}"
@@ -453,7 +451,7 @@ class Slack:
}
)
index += 1
# 添加统一的自定义按钮
for button_row in buttons:
elements = []
@@ -467,7 +465,7 @@ class Slack:
"emoji": True
},
"url": button["url"],
"action_id": f"actionId-url-{len(elements)}"
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
})
else:
elements.append({
@@ -493,9 +491,9 @@ class Slack:
meta = MetaInfo(torrent.title, torrent.description)
link = torrent.page_url
title_text = f"{meta.season_episode} " \
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
title_text = re.sub(r"\s+", " ", title_text).strip()
free = torrent.volume_factor
seeder = f"{torrent.seeders}"
@@ -530,7 +528,7 @@ class Slack:
}
)
index += 1
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
# 编辑消息
@@ -552,6 +550,43 @@ class Slack:
logger.error(f"Slack消息发送失败: {msg_e}")
return False
def delete_msg(self, message_id: str, chat_id: Optional[str] = None) -> Optional[bool]:
"""
删除Slack消息
:param message_id: 消息时间戳Slack消息ID
:param chat_id: 频道ID
:return: 删除是否成功
"""
if not self._client:
return None
try:
# 确定要删除消息的频道ID
if chat_id:
target_channel = chat_id
else:
target_channel = self.__find_public_channel()
if not target_channel:
logger.error("无法确定要删除消息的Slack频道")
return False
# 删除消息
result = self._client.chat_delete(
channel=target_channel,
ts=message_id
)
if result.get("ok"):
logger.info(f"成功删除Slack消息: channel={target_channel}, ts={message_id}")
return True
else:
logger.error(f"删除Slack消息失败: {result.get('error', 'unknown error')}")
return False
except Exception as e:
logger.error(f"删除Slack消息异常: {str(e)}")
return False
def __find_public_channel(self):
"""
查找公共频道

View File

@@ -30,6 +30,7 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Notifications.value]:
return
logger.info("配置变更重新加载SynologyChat模块...")
self.init_module()
@staticmethod

View File

@@ -36,6 +36,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Notifications.value]:
return
logger.info("配置变更重新加载Telegram模块...")
self.init_module()
@staticmethod
@@ -286,6 +287,29 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
def delete_message(self, channel: MessageChannel, source: str,
message_id: int, chat_id: Optional[int] = None) -> bool:
"""
删除消息
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: 消息ID
:param chat_id: 聊天ID
:return: 删除是否成功
"""
success = False
for conf in self.get_configs().values():
if channel != self._channel:
break
if source != conf.name:
continue
client: Telegram = self.get_instance(conf.name)
if client:
result = client.delete_msg(message_id=message_id, chat_id=chat_id)
if result:
success = True
return success
def register_commands(self, commands: Dict[str, dict]):
"""
注册命令,实现这个函数接收系统可用的命令菜单

View File

@@ -336,6 +336,35 @@ class Telegram:
logger.error(f"回应回调查询失败:{str(e)}")
return False
def delete_msg(self, message_id: int, chat_id: Optional[int] = None) -> Optional[bool]:
"""
删除Telegram消息
:param message_id: 消息ID
:param chat_id: 聊天ID
:return: 删除是否成功
"""
if not self._telegram_token or not self._telegram_chat_id:
return None
try:
# 确定要删除消息的聊天ID
if chat_id:
target_chat_id = chat_id
else:
target_chat_id = self._telegram_chat_id
# 删除消息
result = self._bot.delete_message(chat_id=target_chat_id, message_id=int(message_id))
if result:
logger.info(f"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}")
return True
else:
logger.error(f"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}")
return False
except Exception as e:
logger.error(f"删除Telegram消息异常: {str(e)}")
return False
def __edit_message(self, chat_id: str, message_id: int, text: str,
buttons: Optional[List[List[dict]]] = None,
image: Optional[str] = None) -> Optional[bool]:
@@ -352,7 +381,7 @@ class Telegram:
return None
try:
# 创建按钮键盘
reply_markup = None
if buttons:

View File

@@ -36,6 +36,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Downloaders.value]:
return
logger.info("配置变更重新加载Transmission模块...")
self.init_module()
@staticmethod

View File

@@ -34,6 +34,7 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
event_data: schemas.ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.MediaServers.value]:
return
logger.info("配置变更,重新加载飞牛影视模块...")
self.init_module()
@staticmethod

View File

@@ -31,6 +31,7 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Notifications.value]:
return
logger.info("配置变更重新加载VoceChat模块...")
self.init_module()
@staticmethod

View File

@@ -31,6 +31,7 @@ class WebPushModule(_ModuleBase, _MessageBase):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Notifications.value]:
return
logger.info("配置变更重新加载WebPush模块...")
self.init_module()
@staticmethod

View File

@@ -35,6 +35,7 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Notifications.value]:
return
logger.info("配置变更重新加载Wechat模块...")
self.init_module()
@staticmethod

View File

@@ -91,6 +91,7 @@ class Monitor(metaclass=Singleton):
event_data: ConfigChangeEventData = event.event_data
if event_data.key not in [SystemConfigKey.Directories.value]:
return
logger.info("配置变更事件触发,重新初始化目录监控...")
self.init()
def init(self):

View File

@@ -68,6 +68,7 @@ class Scheduler(metaclass=Singleton):
if event_data.key not in ['DEV', 'COOKIECLOUD_INTERVAL', 'MEDIASERVER_SYNC_INTERVAL', 'SUBSCRIBE_SEARCH',
'SUBSCRIBE_MODE', 'SUBSCRIBE_RSS_INTERVAL', 'SITEDATA_REFRESH_INTERVAL']:
return
logger.info(f"配置项 {event_data.key} 变更,重新初始化定时服务...")
self.init()
def init(self):

View File

@@ -145,6 +145,8 @@ class ChannelCapability(Enum):
MENU_COMMANDS = "menu_commands"
# 支持消息编辑
MESSAGE_EDITING = "message_editing"
# 支持消息删除
MESSAGE_DELETION = "message_deletion"
# 支持回调查询
CALLBACK_QUERIES = "callback_queries"
# 支持富文本
@@ -182,6 +184,7 @@ class ChannelCapabilityManager:
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MENU_COMMANDS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MESSAGE_DELETION,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
@@ -205,6 +208,8 @@ class ChannelCapabilityManager:
channel=MessageChannel.Slack,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MESSAGE_DELETION,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
@@ -290,6 +295,13 @@ class ChannelCapabilityManager:
"""
return cls.supports_capability(channel, ChannelCapability.MESSAGE_EDITING)
@classmethod
def supports_deletion(cls, channel: MessageChannel) -> bool:
"""
检查渠道是否支持消息删除
"""
return cls.supports_capability(channel, ChannelCapability.MESSAGE_DELETION)
@classmethod
def get_max_buttons_per_row(cls, channel: MessageChannel) -> int:
"""

View File

@@ -36,3 +36,7 @@ class Tokens:
return None
else:
return self._tokens[index]
@property
def tokens(self):
return self._tokens

View File

@@ -153,7 +153,7 @@ meta_cases = [{
"part": "",
"season": "S01",
"episode": "E02",
"restype": "WEB-DL",
"restype": "B-Global WEB-DL",
"pix": "1080p",
"video_codec": "x264",
"audio_codec": "AAC"
@@ -569,7 +569,7 @@ meta_cases = [{
"part": "",
"season": "S02",
"episode": "E05",
"restype": "WEB-DL",
"restype": "Crunchyroll WEB-DL",
"pix": "1080p",
"video_codec": "x264",
"audio_codec": "AAC"
@@ -649,7 +649,7 @@ meta_cases = [{
"part": "",
"season": "",
"episode": "",
"restype": "WEBRip",
"restype": "Netflix WEBRip",
"pix": "1080p",
"video_codec": "H264",
"audio_codec": "DDP 5.1"
@@ -681,7 +681,7 @@ meta_cases = [{
"part": "",
"season": "S01",
"episode": "E16",
"restype": "WEB-DL",
"restype": "KKTV WEB-DL",
"pix": "1080p",
"video_codec": "x264",
"audio_codec": "AAC"
@@ -921,7 +921,7 @@ meta_cases = [{
"part": "",
"season": "S06",
"episode": "E06",
"restype": "WEBRip",
"restype": "Max WEBRip",
"pix": "1080p",
"video_codec": "x264",
"audio_codec": "DD 5.1"
@@ -937,7 +937,7 @@ meta_cases = [{
"part": "",
"season": "S06",
"episode": "E05",
"restype": "WEBRip",
"restype": "Max WEBRip",
"pix": "1080p",
"video_codec": "x264",
"audio_codec": "DD 5.1"
@@ -969,7 +969,7 @@ meta_cases = [{
"part": "",
"season": "S02",
"episode": "",
"restype": "WEB-DL",
"restype": "Netflix WEB-DL",
"pix": "2160p",
"video_codec": "H265",
"audio_codec": "DDP 5.1 Atmos"

View File

@@ -1,6 +1,5 @@
import unittest
from tests.test_bluray import BluRayTest
from tests.test_metainfo import MetaInfoTest
if __name__ == '__main__':
@@ -10,9 +9,6 @@ if __name__ == '__main__':
suite.addTest(MetaInfoTest('test_metainfo'))
suite.addTest(MetaInfoTest('test_emby_format_ids'))
# 测试蓝光目录识别
suite.addTest(BluRayTest())
# 运行测试
runner = unittest.TextTestRunner()
runner.run(suite)

View File

@@ -1,178 +0,0 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
from pathlib import Path
from typing import List, Optional
from unittest import TestCase
from app import schemas
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.db.models.transferhistory import TransferHistory
from app.db.systemconfig_oper import SystemConfigOper
from app.db.transferhistory_oper import TransferHistoryOper
from tests.cases.files import bluray_files
class MockTransferHistoryOper(TransferHistoryOper):
def __init__(self):
# pylint: disable=super-init-not-called
self.history = []
def get_by_src(self, src, storage=None):
self.history.append(src)
return TransferHistory()
class MockStorage(StorageChain):
def __init__(self, files: list):
# pylint: disable=super-init-not-called
self.__root = schemas.FileItem(
path="/", name="", type="dir", extension="", size=0
)
self.__all = {self.__root.path: self.__root}
def __build_child(parent: schemas.FileItem, files: list[dict]):
parent.children = []
for item in files:
children = item.get("children")
sep = "" if parent.path.endswith("/") else "/"
name: str = item["name"]
file_item = schemas.FileItem(
path=f"{parent.path}{sep}{name}",
name=name,
extension=Path(name).suffix[1:],
basename=Path(name).stem,
type="file" if children is None else "dir",
size=item.get("size", 0),
)
parent.children.append(file_item)
self.__all[file_item.path] = file_item
if children is not None:
__build_child(file_item, children)
__build_child(self.__root, files)
def list_files(
self, fileitem: schemas.FileItem, recursion: bool = False
) -> Optional[List[schemas.FileItem]]:
if fileitem.type != "dir":
return None
if recursion:
result = []
file_path = f"{fileitem.path}/"
for path, item in self.__all.items():
if path.startswith(file_path):
result.append(item)
return result
else:
return fileitem.children
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
"""
根据路径获取文件项
"""
path_posix = path.as_posix()
return self.__all.get(path_posix)
class MockTransferChain(TransferChain):
def __init__(self, storage: MockStorage):
# pylint: disable=super-init-not-called
self.transferhis = MockTransferHistoryOper()
self.systemconfig = SystemConfigOper()
self.storagechain = storage
def test(self, path: str):
self.transferhis.history.clear()
self.do_transfer(
force=False,
background=False,
fileitem=self.storagechain.get_file_item(None, Path(path)),
)
return self.transferhis.history
class BluRayTest(TestCase):
def __init__(self, methodName="test"):
super().__init__(methodName)
def setUp(self) -> None:
pass
def tearDown(self) -> None:
pass
def test(self):
transfer = MockTransferChain(MockStorage(bluray_files))
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2055)",
"/FOLDER/Digimon/Digimon (2099)",
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
],
transfer.test("/FOLDER/Digimon"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2055)",
],
transfer.test("/FOLDER/Digimon/Digimon (2055)"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2055)",
],
transfer.test("/FOLDER/Digimon/Digimon (2055)/BDMV"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2055)",
],
transfer.test("/FOLDER/Digimon/Digimon (2055)/BDMV/STREAM"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2055)",
],
transfer.test("/FOLDER/Digimon/Digimon (2055)/BDMV/STREAM/00001.m2ts"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
],
transfer.test("/FOLDER/Digimon/Digimon (2199)"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
],
transfer.test("/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4"),
)
self.assertEqual(
[
"/FOLDER/Pokemon.2029.mp4",
],
transfer.test("/FOLDER/Pokemon.2029.mp4"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2055)",
"/FOLDER/Digimon/Digimon (2099)",
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
"/FOLDER/Pokemon (2016)",
"/FOLDER/Pokemon (2021)",
"/FOLDER/Pokemon (2028)/Pokemon.2028.mkv",
"/FOLDER/Pokemon.2029.mp4",
],
transfer.test("/FOLDER"),
)

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.5.7'
FRONTEND_VERSION = 'v2.5.7'
APP_VERSION = 'v2.5.8'
FRONTEND_VERSION = 'v2.5.8'