This commit is contained in:
jxxghp
2025-06-27 07:53:10 +08:00
parent 8068523d88
commit 4dd5ea8e2f
8 changed files with 220 additions and 188 deletions
+2
View File
@@ -264,7 +264,9 @@ class SearchChain(ChainBase):
meta_info=t[1]) for t in _match_torrents] meta_info=t[1]) for t in _match_torrents]
finally: finally:
torrents.clear() torrents.clear()
del torrents
_match_torrents.clear() _match_torrents.clear()
del _match_torrents
# 排序 # 排序
progress.update(value=99, progress.update(value=99,
+201 -188
View File
@@ -364,32 +364,36 @@ class SubscribeChain(ChainBase):
# 过滤搜索结果 # 过滤搜索结果
matched_contexts = [] matched_contexts = []
for context in contexts: try:
if global_vars.is_system_stopped: for context in contexts:
break if global_vars.is_system_stopped:
torrent_meta = context.meta_info break
torrent_info = context.torrent_info torrent_meta = context.meta_info
torrent_mediainfo = context.media_info torrent_info = context.torrent_info
torrent_mediainfo = context.media_info
# 洗版 # 洗版
if subscribe.best_version: if subscribe.best_version:
# 洗版时,非整季不要 # 洗版时,非整季不要
if torrent_mediainfo.type == MediaType.TV: if torrent_mediainfo.type == MediaType.TV:
if torrent_meta.episode_list: if torrent_meta.episode_list:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季') logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 洗版时,优先级小于等于已下载优先级的不要
if subscribe.current_priority \
and torrent_info.pri_order <= subscribe.current_priority:
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue continue
# 洗版时,优先级小于等于已下载优先级的不要 # 更新订阅自定义属性
if subscribe.current_priority \ if subscribe.media_category:
and torrent_info.pri_order <= subscribe.current_priority: torrent_mediainfo.category = subscribe.media_category
logger.info( if subscribe.episode_group:
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级') torrent_mediainfo.episode_group = subscribe.episode_group
continue matched_contexts.append(context)
# 更新订阅自定义属性 finally:
if subscribe.media_category: contexts.clear()
torrent_mediainfo.category = subscribe.media_category del contexts
if subscribe.episode_group:
torrent_mediainfo.episode_group = subscribe.episode_group
matched_contexts.append(context)
if not matched_contexts: if not matched_contexts:
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源') logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
@@ -432,6 +436,7 @@ class SubscribeChain(ChainBase):
logger.debug(f"search Lock released at {datetime.now()}") logger.debug(f"search Lock released at {datetime.now()}")
finally: finally:
subscribes.clear() subscribes.clear()
del subscribes
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase, def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
mediainfo: MediaInfo, downloads: Optional[List[Context]]): mediainfo: MediaInfo, downloads: Optional[List[Context]]):
@@ -552,32 +557,32 @@ class SubscribeChain(ChainBase):
return return
with self._rlock: with self._rlock:
try: logger.debug(f"match lock acquired at {datetime.now()}")
logger.debug(f"match lock acquired at {datetime.now()}")
# 预识别所有未识别的种子 # 预识别所有未识别的种子
processed_torrents: Dict[str, List[Context]] = {} processed_torrents: Dict[str, List[Context]] = {}
for domain, contexts in torrents.items(): for domain, contexts in torrents.items():
if global_vars.is_system_stopped:
break
processed_torrents[domain] = []
for context in contexts:
if global_vars.is_system_stopped: if global_vars.is_system_stopped:
break break
processed_torrents[domain] = [] # 如果种子未识别,尝试识别
for context in contexts: if not context.media_info or (not context.media_info.tmdb_id
if global_vars.is_system_stopped: and not context.media_info.douban_id):
break re_mediainfo = self.recognize_media(meta=context.meta_info)
# 如果种子未识别,尝试识别 if re_mediainfo:
if not context.media_info or (not context.media_info.tmdb_id # 清理多余信息
and not context.media_info.douban_id): re_mediainfo.clear()
re_mediainfo = self.recognize_media(meta=context.meta_info) # 更新种子缓存
if re_mediainfo: context.media_info = re_mediainfo
# 清理多余信息 # 添加已预处理
re_mediainfo.clear() processed_torrents[domain].append(context)
# 更新种子缓存
context.media_info = re_mediainfo
# 添加已预处理
processed_torrents[domain].append(context)
# 所有订阅 # 所有订阅
subscribes = SubscribeOper().list(self.get_states_for_search('R')) subscribes = SubscribeOper().list(self.get_states_for_search('R'))
try:
for subscribe in subscribes: for subscribe in subscribes:
if global_vars.is_system_stopped: if global_vars.is_system_stopped:
break break
@@ -628,155 +633,163 @@ class SubscribeChain(ChainBase):
torrenthelper = TorrentHelper() torrenthelper = TorrentHelper()
systemconfig = SystemConfigOper() systemconfig = SystemConfigOper()
wordsmatcher = WordsMatcher() wordsmatcher = WordsMatcher()
for domain, contexts in processed_torrents.items(): try:
if global_vars.is_system_stopped: for domain, contexts in processed_torrents.items():
break
if domains and domain not in domains:
continue
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
for context in contexts:
if global_vars.is_system_stopped: if global_vars.is_system_stopped:
break break
# 提取信息 if domains and domain not in domains:
_context = copy.copy(context)
torrent_meta = _context.meta_info
torrent_mediainfo = _context.media_info
torrent_info = _context.torrent_info
# 不在订阅站点范围的不处理
sub_sites = self.get_sub_sites(subscribe)
if sub_sites and torrent_info.site not in sub_sites:
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
continue continue
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
try:
for context in contexts:
if global_vars.is_system_stopped:
break
# 提取信息
_context = copy.copy(context)
torrent_meta = _context.meta_info
torrent_mediainfo = _context.media_info
torrent_info = _context.torrent_info
# 有自定义识别词时,需要判断是否需要重新识别 # 不在订阅站点范围的不处理
if custom_words_list: sub_sites = self.get_sub_sites(subscribe)
# 使用org_string,应用一次后理论上不能再次应用 if sub_sites and torrent_info.site not in sub_sites:
_, apply_words = wordsmatcher.prepare(torrent_meta.org_string, logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
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=custom_words_list)
# 更新元数据缓存
_context.meta_info = torrent_meta
# 重新识别媒体信息
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
episode_group=subscribe.episode_group)
if torrent_mediainfo:
# 清理多余信息
torrent_mediainfo.clear()
# 更新种子缓存
_context.media_info = torrent_mediainfo
# 如果仍然没有识别到媒体信息,尝试标题匹配
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
logger.info(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
# 匹配成功
logger.info(
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
torrent_mediainfo = mediainfo
# 更新种子缓存
_context.media_info = mediainfo
else:
continue
# 直接比对媒体信息
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
continue
if torrent_mediainfo.douban_id \
and torrent_mediainfo.douban_id != mediainfo.douban_id:
continue
logger.info(
f'{mediainfo.title_year} 通过媒体信ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
else:
continue
# 如果是电视剧
if torrent_mediainfo.type == MediaType.TV:
# 有多季的不要
if len(torrent_meta.season_list) > 1:
logger.debug(f'{torrent_info.title} 有多季,不处理')
continue
# 比对季
if torrent_meta.begin_season:
if meta.begin_season != torrent_meta.begin_season:
logger.debug(f'{torrent_info.title} 季不匹配')
continue continue
elif meta.begin_season != 1:
logger.debug(f'{torrent_info.title} 季不匹配') # 有自定义识别词时,需要判断是否需要重新识别
continue if custom_words_list:
# 非洗版 # 使用org_string,应用一次后理论上不能再次应用
if not subscribe.best_version: _, apply_words = wordsmatcher.prepare(torrent_meta.org_string,
# 不是缺失的剧集不要 custom_words=custom_words_list)
if no_exists and no_exists.get(mediakey): if apply_words:
# 缺失集 logger.info(
no_exists_info = no_exists.get(mediakey).get(subscribe.season) f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
if no_exists_info: # 重新识别元数据
# 是否有交集 torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
if no_exists_info.episodes and \ custom_words=custom_words_list)
torrent_meta.episode_list and \ # 更新元数据缓存
not set(no_exists_info.episodes).intersection( _context.meta_info = torrent_meta
set(torrent_meta.episode_list) # 重新识别媒体信息
): torrent_mediainfo = self.recognize_media(meta=torrent_meta,
logger.debug( episode_group=subscribe.episode_group)
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集' if torrent_mediainfo:
) # 清理多余信息
continue torrent_mediainfo.clear()
else: # 更新种子缓存
# 洗版时,非整季不要 _context.media_info = torrent_mediainfo
if meta.type == MediaType.TV:
if torrent_meta.episode_list: # 如果仍然没有识别到媒体信息,尝试标题匹配
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季') if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
logger.info(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
# 匹配成功
logger.info(
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
torrent_mediainfo = mediainfo
# 更新种子缓存
_context.media_info = mediainfo
else:
continue continue
# 匹配订阅附加参数 # 直接比对媒体信息
if not torrenthelper.filter_torrent(torrent_info=torrent_info, if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
filter_params=self.get_params(subscribe)): if torrent_mediainfo.type != mediainfo.type:
continue continue
if torrent_mediainfo.tmdb_id \
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
continue
if torrent_mediainfo.douban_id \
and torrent_mediainfo.douban_id != mediainfo.douban_id:
continue
logger.info(
f'{mediainfo.title_year} 通过媒体信ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
else:
continue
# 优先级过滤规则 # 如果是电视剧
if subscribe.best_version: if torrent_mediainfo.type == MediaType.TV:
rule_groups = subscribe.filter_groups \ # 有多季的不要
or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups) if len(torrent_meta.season_list) > 1:
else: logger.debug(f'{torrent_info.title} 有多季,不处理')
rule_groups = subscribe.filter_groups \ continue
or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups) # 比对季
result: List[TorrentInfo] = self.filter_torrents( if torrent_meta.begin_season:
rule_groups=rule_groups, if meta.begin_season != torrent_meta.begin_season:
torrent_list=[torrent_info], logger.debug(f'{torrent_info.title} 季不匹配')
mediainfo=torrent_mediainfo) continue
if result is not None and not result: elif meta.begin_season != 1:
# 不符合过滤规则 logger.debug(f'{torrent_info.title} 季不匹配')
logger.debug(f"{torrent_info.title} 不匹配过滤规则") continue
continue # 非洗版
if not subscribe.best_version:
# 不是缺失的剧集不要
if no_exists and no_exists.get(mediakey):
# 缺失集
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
# 是否有交集
if no_exists_info.episodes and \
torrent_meta.episode_list and \
not set(no_exists_info.episodes).intersection(
set(torrent_meta.episode_list)
):
logger.debug(
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
)
continue
else:
# 洗版时,非整季不要
if meta.type == MediaType.TV:
if torrent_meta.episode_list:
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 洗版时,优先级小于已下载优先级的不要 # 匹配订阅附加参数
if subscribe.best_version: if not torrenthelper.filter_torrent(torrent_info=torrent_info,
if subscribe.current_priority \ filter_params=self.get_params(subscribe)):
and torrent_info.pri_order <= subscribe.current_priority: continue
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue
# 匹配成功 # 优先级过滤规则
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}') if subscribe.best_version:
# 自定义属性 rule_groups = subscribe.filter_groups \
if subscribe.media_category: or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
torrent_mediainfo.category = subscribe.media_category else:
if subscribe.episode_group: rule_groups = subscribe.filter_groups \
torrent_mediainfo.episode_group = subscribe.episode_group or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
_match_context.append(_context) result: List[TorrentInfo] = self.filter_torrents(
rule_groups=rule_groups,
torrent_list=[torrent_info],
mediainfo=torrent_mediainfo)
if result is not None and not result:
# 不符合过滤规则
logger.debug(f"{torrent_info.title} 不匹配过滤规则")
continue
# 洗版时,优先级小于已下载优先级的不要
if subscribe.best_version:
if subscribe.current_priority \
and torrent_info.pri_order <= subscribe.current_priority:
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue
# 匹配成功
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
# 自定义属性
if subscribe.media_category:
torrent_mediainfo.category = subscribe.media_category
if subscribe.episode_group:
torrent_mediainfo.episode_group = subscribe.episode_group
_match_context.append(_context)
finally:
contexts.clear()
del contexts
finally:
processed_torrents.clear()
del processed_torrents
if not _match_context: if not _match_context:
# 未匹配到资源 # 未匹配到资源
@@ -802,11 +815,11 @@ class SubscribeChain(ChainBase):
if subscribe: if subscribe:
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
downloads=downloads, lefts=lefts) downloads=downloads, lefts=lefts)
logger.debug(f"match Lock released at {datetime.now()}")
finally: finally:
subscribes.clear() subscribes.clear()
processed_torrents.clear() del subscribes
logger.debug(f"match Lock released at {datetime.now()}")
def check(self): def check(self):
""" """
+2
View File
@@ -131,6 +131,7 @@ class TorrentsChain(ChainBase):
ret_torrents.append(torrentinfo) ret_torrents.append(torrentinfo)
finally: finally:
rss_items.clear() rss_items.clear()
del rss_items
return ret_torrents return ret_torrents
def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]: def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]:
@@ -220,6 +221,7 @@ class TorrentsChain(ChainBase):
torrents_cache[domain] = torrents_cache[domain][-settings.CONF["torrents"]:] torrents_cache[domain] = torrents_cache[domain][-settings.CONF["torrents"]:]
finally: finally:
torrents.clear() torrents.clear()
del torrents
else: else:
logger.info(f'{indexer.get("name")} 没有获取到种子') logger.info(f'{indexer.get("name")} 没有获取到种子')
+3
View File
@@ -864,6 +864,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader) self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
finally: finally:
torrents.clear() torrents.clear()
del torrents
# 结束 # 结束
logger.info("所有下载器中下载完成的文件已整理完成") logger.info("所有下载器中下载完成的文件已整理完成")
@@ -1144,6 +1145,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
transfer_tasks.append(transfer_task) transfer_tasks.append(transfer_task)
finally: finally:
file_items.clear() file_items.clear()
del file_items
# 实时整理 # 实时整理
if transfer_tasks: if transfer_tasks:
@@ -1187,6 +1189,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
processed_num += 1 processed_num += 1
finally: finally:
transfer_tasks.clear() transfer_tasks.clear()
del transfer_tasks
# 整理结束 # 整理结束
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num}" __end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num}"
+5
View File
@@ -182,6 +182,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在" return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
finally: finally:
torrents.clear() torrents.clear()
del torrents
return None, None, None, f"添加种子任务失败:{content}" return None, None, None, f"添加种子任务失败:{content}"
else: else:
# 获取种子Hash # 获取种子Hash
@@ -211,6 +212,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
sucess_epidised = list(set(sucess_epidised).union(set(meta_info.episode_list))) sucess_epidised = list(set(sucess_epidised).union(set(meta_info.episode_list)))
finally: finally:
torrent_files.clear() torrent_files.clear()
del torrent_files
if sucess_epidised and file_ids: if sucess_epidised and file_ids:
# 选择文件 # 选择文件
server.set_files(torrent_hash=torrent_hash, file_ids=file_ids, priority=0) server.set_files(torrent_hash=torrent_hash, file_ids=file_ids, priority=0)
@@ -269,6 +271,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
)) ))
finally: finally:
torrents.clear() torrents.clear()
del torrents
elif status == TorrentStatus.TRANSFER: elif status == TorrentStatus.TRANSFER:
# 获取已完成且未整理的 # 获取已完成且未整理的
for name, server in servers.items(): for name, server in servers.items():
@@ -293,6 +296,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
)) ))
finally: finally:
torrents.clear() torrents.clear()
del torrents
elif status == TorrentStatus.DOWNLOADING: elif status == TorrentStatus.DOWNLOADING:
# 获取正在下载的任务 # 获取正在下载的任务
for name, server in servers.items(): for name, server in servers.items():
@@ -319,6 +323,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
)) ))
finally: finally:
torrents.clear() torrents.clear()
del torrents
else: else:
return None return None
return ret_torrents # noqa return ret_torrents # noqa
+1
View File
@@ -105,6 +105,7 @@ class Qbittorrent:
results.append(torrent) results.append(torrent)
finally: finally:
torrents.clear() torrents.clear()
del torrents
return results, False return results, False
return torrents or [], False return torrents or [], False
except Exception as err: except Exception as err:
+5
View File
@@ -184,6 +184,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在" return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
finally: finally:
torrents.clear() torrents.clear()
del torrents
return None, None, None, f"添加种子任务失败:{content}" return None, None, None, f"添加种子任务失败:{content}"
else: else:
torrent_hash = torrent.hashString torrent_hash = torrent.hashString
@@ -215,6 +216,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
server.start_torrents(torrent_hash) server.start_torrents(torrent_hash)
finally: finally:
torrent_files.clear() torrent_files.clear()
del torrent_files
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功" return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功"
else: else:
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功" return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功"
@@ -255,6 +257,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
)) ))
finally: finally:
torrents.clear() torrents.clear()
del torrents
elif status == TorrentStatus.TRANSFER: elif status == TorrentStatus.TRANSFER:
# 获取已完成且未整理的 # 获取已完成且未整理的
for name, server in servers.items(): for name, server in servers.items():
@@ -281,6 +284,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
)) ))
finally: finally:
torrents.clear() torrents.clear()
del torrents
elif status == TorrentStatus.DOWNLOADING: elif status == TorrentStatus.DOWNLOADING:
# 获取正在下载的任务 # 获取正在下载的任务
for name, server in servers.items(): for name, server in servers.items():
@@ -306,6 +310,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
)) ))
finally: finally:
torrents.clear() torrents.clear()
del torrents
else: else:
return None return None
return ret_torrents # noqa return ret_torrents # noqa
+1
View File
@@ -106,6 +106,7 @@ class Transmission:
ret_torrents.append(torrent) ret_torrents.append(torrent)
finally: finally:
torrents.clear() torrents.clear()
del torrents
return ret_torrents, False return ret_torrents, False
def get_completed_torrents(self, ids: Union[str, list] = None, def get_completed_torrents(self, ids: Union[str, list] = None,