mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix: aggregate metadata scrape events
This commit is contained in:
+7
-4
@@ -750,7 +750,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
# 媒体根目录
|
# 媒体根目录
|
||||||
fileitem: FileItem = event_data.get("fileitem")
|
fileitem: FileItem = event_data.get("fileitem")
|
||||||
# 媒体文件列表
|
# 媒体文件列表
|
||||||
file_list: List[str] = event_data.get("file_list", [])
|
file_list: List[str] = list(dict.fromkeys(event_data.get("file_list") or []))
|
||||||
# 媒体元数据
|
# 媒体元数据
|
||||||
meta: MetaBase = event_data.get("meta")
|
meta: MetaBase = event_data.get("meta")
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
@@ -793,7 +793,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 1. 收集fileitem和file_list中每个文件之间所有子目录
|
# 1. 收集fileitem和file_list中每个文件之间所有子目录
|
||||||
all_dirs = set()
|
all_dirs: set[Path] = set()
|
||||||
root_path = Path(fileitem.path)
|
root_path = Path(fileitem.path)
|
||||||
|
|
||||||
logger.debug(f"开始收集目录,根目录:{root_path}")
|
logger.debug(f"开始收集目录,根目录:{root_path}")
|
||||||
@@ -815,7 +815,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
logger.debug(f"共收集到 {len(all_dirs)} 个目录")
|
logger.debug(f"共收集到 {len(all_dirs)} 个目录")
|
||||||
|
|
||||||
# 2. 初始化一遍子目录,但不处理文件
|
# 2. 初始化一遍子目录,但不处理文件
|
||||||
for sub_dir in all_dirs:
|
for sub_dir in sorted(
|
||||||
|
all_dirs,
|
||||||
|
key=lambda item: (len(item.parts), item.as_posix()),
|
||||||
|
):
|
||||||
sub_dir_item = self.storagechain.get_file_item(
|
sub_dir_item = self.storagechain.get_file_item(
|
||||||
storage=fileitem.storage, path=sub_dir
|
storage=fileitem.storage, path=sub_dir
|
||||||
)
|
)
|
||||||
@@ -834,7 +837,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
# 3. 刮削每个文件
|
# 3. 刮削每个文件
|
||||||
logger.info(f"开始刮削 {len(file_list)} 个文件")
|
logger.info(f"开始刮削 {len(file_list)} 个文件")
|
||||||
for sub_file_path in file_list:
|
for sub_file_path in sorted(file_list):
|
||||||
sub_file_item = self.storagechain.get_file_item(
|
sub_file_item = self.storagechain.get_file_item(
|
||||||
storage=fileitem.storage, path=Path(sub_file_path)
|
storage=fileitem.storage, path=Path(sub_file_path)
|
||||||
)
|
)
|
||||||
|
|||||||
+188
-22
@@ -3,9 +3,10 @@ import queue
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
|
import uuid
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple, Union, Dict, Callable
|
from typing import List, Optional, Tuple, Union, Dict, Callable, Any
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.agent import ReplyMode, prompt_manager, agent_manager
|
from app.agent import ReplyMode, prompt_manager, agent_manager
|
||||||
@@ -730,6 +731,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self.retry_scheduler = FailedRetryScheduler()
|
self.retry_scheduler = FailedRetryScheduler()
|
||||||
# 转移成功的文件清单
|
# 转移成功的文件清单
|
||||||
self._success_target_files: Dict[str, List[str]] = {}
|
self._success_target_files: Dict[str, List[str]] = {}
|
||||||
|
# 批次级刮削缓冲,避免同一批多文件入库重复触发目录刮削
|
||||||
|
self._scrape_batches: Dict[str, Dict[str, Any]] = {}
|
||||||
# 整理进度进度
|
# 整理进度进度
|
||||||
self._progress = ProgressHelper(ProgressKey.FileTransfer)
|
self._progress = ProgressHelper(ProgressKey.FileTransfer)
|
||||||
# 队列相关状态
|
# 队列相关状态
|
||||||
@@ -796,7 +799,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return StorageChain().is_bluray_folder(fileitem)
|
return StorageChain().is_bluray_folder(fileitem)
|
||||||
if not fileitem.extension:
|
if not fileitem.extension:
|
||||||
return False
|
return False
|
||||||
return True if f".{fileitem.extension.lower()}" in self._media_exts else False
|
media_exts = (
|
||||||
|
self._media_exts if hasattr(self, "_media_exts") else settings.RMT_MEDIAEXT
|
||||||
|
)
|
||||||
|
return True if f".{fileitem.extension.lower()}" in media_exts else False
|
||||||
|
|
||||||
def __is_allowed_file(self, fileitem: FileItem) -> bool:
|
def __is_allowed_file(self, fileitem: FileItem) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -831,7 +837,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
def __notify():
|
def __notify():
|
||||||
"""
|
"""
|
||||||
完成时发送消息、刮削事件、移除任务等
|
完成时发送消息、移除任务等
|
||||||
"""
|
"""
|
||||||
# 更新文件数量
|
# 更新文件数量
|
||||||
transferinfo.file_count = (
|
transferinfo.file_count = (
|
||||||
@@ -842,12 +848,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self.jobview.size(task.mediainfo, task.meta.begin_season)
|
self.jobview.size(task.mediainfo, task.meta.begin_season)
|
||||||
or task.fileitem.size
|
or task.fileitem.size
|
||||||
)
|
)
|
||||||
# 更新文件清单
|
|
||||||
with job_lock:
|
|
||||||
transferinfo.file_list_new = self._success_target_files.pop(
|
|
||||||
transferinfo.target_diritem.path, []
|
|
||||||
)
|
|
||||||
|
|
||||||
# 发送通知,实时手动整理时不发
|
# 发送通知,实时手动整理时不发
|
||||||
if transferinfo.need_notify and (task.background or not task.manual):
|
if transferinfo.need_notify and (task.background or not task.manual):
|
||||||
se_str = None
|
se_str = None
|
||||||
@@ -868,19 +868,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
username=task.username,
|
username=task.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 刮削事件
|
|
||||||
if transferinfo.need_scrape and self.__is_media_file(task.fileitem):
|
|
||||||
self.eventmanager.send_event(
|
|
||||||
EventType.MetadataScrape,
|
|
||||||
{
|
|
||||||
"meta": task.meta,
|
|
||||||
"mediainfo": task.mediainfo,
|
|
||||||
"fileitem": transferinfo.target_diritem,
|
|
||||||
"file_list": transferinfo.file_list_new,
|
|
||||||
"overwrite": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
transferhis = TransferHistoryOper()
|
transferhis = TransferHistoryOper()
|
||||||
|
|
||||||
# 转移失败
|
# 转移失败
|
||||||
@@ -1070,9 +1057,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
# 设置任务成功
|
# 设置任务成功
|
||||||
self.jobview.finish_task(task)
|
self.jobview.finish_task(task)
|
||||||
|
|
||||||
|
# 登记批次级刮削目标
|
||||||
|
self.__record_scrape_target(task, transferinfo)
|
||||||
|
|
||||||
# 全部整理完成且有成功的任务时,发送消息和事件
|
# 全部整理完成且有成功的任务时,发送消息和事件
|
||||||
if self.jobview.is_finished(task):
|
if self.jobview.is_finished(task):
|
||||||
|
# 更新文件清单
|
||||||
|
with job_lock:
|
||||||
|
transferinfo.file_list_new = self._success_target_files.pop(
|
||||||
|
transferinfo.target_diritem.path, []
|
||||||
|
)
|
||||||
__notify()
|
__notify()
|
||||||
|
if not task.transfer_batch_id:
|
||||||
|
self.__send_metadata_scrape_event(task, transferinfo)
|
||||||
|
|
||||||
# 只要该种子的所有任务都已整理完成,则设置种子状态为已整理
|
# 只要该种子的所有任务都已整理完成,则设置种子状态为已整理
|
||||||
self.__mark_torrent_completed_if_done(task.download_hash, task.downloader)
|
self.__mark_torrent_completed_if_done(task.download_hash, task.downloader)
|
||||||
@@ -1122,6 +1119,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
# 维护整理任务视图,如果任务已存在则不添加到队列
|
# 维护整理任务视图,如果任务已存在则不添加到队列
|
||||||
if not self.__put_to_jobview(task):
|
if not self.__put_to_jobview(task):
|
||||||
return False
|
return False
|
||||||
|
self.__register_scrape_batch_task(task)
|
||||||
# 添加到队列
|
# 添加到队列
|
||||||
self._queue.put(TransferQueue(task=task, callback=self.__default_callback))
|
self._queue.put(TransferQueue(task=task, callback=self.__default_callback))
|
||||||
return True
|
return True
|
||||||
@@ -1149,6 +1147,168 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
):
|
):
|
||||||
self.transfer_completed(hashs=download_hash, downloader=downloader)
|
self.transfer_completed(hashs=download_hash, downloader=downloader)
|
||||||
|
|
||||||
|
def __send_metadata_scrape_event(
|
||||||
|
self, task: TransferTask, transferinfo: TransferInfo
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
发送元数据刮削事件,保持对外事件载荷兼容。
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
not task
|
||||||
|
or not transferinfo
|
||||||
|
or not transferinfo.need_scrape
|
||||||
|
or not transferinfo.target_diritem
|
||||||
|
or not self.__is_media_file(task.fileitem)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.eventmanager.send_event(
|
||||||
|
EventType.MetadataScrape,
|
||||||
|
{
|
||||||
|
"meta": task.meta,
|
||||||
|
"mediainfo": task.mediainfo,
|
||||||
|
"fileitem": transferinfo.target_diritem,
|
||||||
|
"file_list": transferinfo.file_list_new,
|
||||||
|
"overwrite": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __register_scrape_batch_task(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
登记批次任务。刮削事件只在批次关闭且任务全部完成后统一发送。
|
||||||
|
"""
|
||||||
|
if not task or not task.transfer_batch_id:
|
||||||
|
return
|
||||||
|
if not hasattr(self, "_scrape_batches"):
|
||||||
|
self._scrape_batches = {}
|
||||||
|
with job_lock:
|
||||||
|
batch = self._scrape_batches.setdefault(
|
||||||
|
task.transfer_batch_id,
|
||||||
|
{
|
||||||
|
"pending": set(),
|
||||||
|
"targets": {},
|
||||||
|
"closed": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
batch["pending"].add(task.fileitem.path)
|
||||||
|
|
||||||
|
def __close_scrape_batch(self, batch_id: Optional[str]):
|
||||||
|
"""
|
||||||
|
标记批次不再接收新任务,并尝试发送已聚合的刮削事件。
|
||||||
|
"""
|
||||||
|
if not batch_id:
|
||||||
|
return
|
||||||
|
if not hasattr(self, "_scrape_batches"):
|
||||||
|
self._scrape_batches = {}
|
||||||
|
with job_lock:
|
||||||
|
batch = self._scrape_batches.setdefault(
|
||||||
|
batch_id,
|
||||||
|
{
|
||||||
|
"pending": set(),
|
||||||
|
"targets": {},
|
||||||
|
"closed": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
batch["closed"] = True
|
||||||
|
self.__flush_scrape_batch_if_ready(batch_id)
|
||||||
|
|
||||||
|
def __record_scrape_target(self, task: TransferTask, transferinfo: TransferInfo):
|
||||||
|
"""
|
||||||
|
记录批次内需要刮削的目标文件,按目标媒体根目录聚合。
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
not task
|
||||||
|
or not task.transfer_batch_id
|
||||||
|
or not transferinfo
|
||||||
|
or not transferinfo.need_scrape
|
||||||
|
or not transferinfo.target_diritem
|
||||||
|
or not self.__is_media_file(task.fileitem)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not hasattr(self, "_scrape_batches"):
|
||||||
|
self._scrape_batches = {}
|
||||||
|
target_diritem = transferinfo.target_diritem
|
||||||
|
target_files = transferinfo.file_list_new or []
|
||||||
|
target_key = (target_diritem.storage, target_diritem.path)
|
||||||
|
with job_lock:
|
||||||
|
batch = self._scrape_batches.setdefault(
|
||||||
|
task.transfer_batch_id,
|
||||||
|
{
|
||||||
|
"pending": set(),
|
||||||
|
"targets": {},
|
||||||
|
"closed": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
target = batch["targets"].setdefault(
|
||||||
|
target_key,
|
||||||
|
{
|
||||||
|
"fileitem": target_diritem,
|
||||||
|
"meta": task.meta,
|
||||||
|
"mediainfo": task.mediainfo,
|
||||||
|
"files": [],
|
||||||
|
"overwrite": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not target.get("meta"):
|
||||||
|
target["meta"] = task.meta
|
||||||
|
if not target.get("mediainfo"):
|
||||||
|
target["mediainfo"] = task.mediainfo
|
||||||
|
for target_file in target_files:
|
||||||
|
if target_file and target_file not in target["files"]:
|
||||||
|
target["files"].append(target_file)
|
||||||
|
|
||||||
|
def __finish_scrape_batch_task(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
标记批次内单个任务已结束。
|
||||||
|
"""
|
||||||
|
if not task or not task.transfer_batch_id:
|
||||||
|
return
|
||||||
|
if not hasattr(self, "_scrape_batches"):
|
||||||
|
self._scrape_batches = {}
|
||||||
|
with job_lock:
|
||||||
|
batch = self._scrape_batches.get(task.transfer_batch_id)
|
||||||
|
if not batch:
|
||||||
|
return
|
||||||
|
batch["pending"].discard(task.fileitem.path)
|
||||||
|
self.__flush_scrape_batch_if_ready(task.transfer_batch_id)
|
||||||
|
|
||||||
|
def __flush_scrape_batch_if_ready(self, batch_id: Optional[str]):
|
||||||
|
"""
|
||||||
|
批次任务全部结束后发送聚合后的刮削事件。
|
||||||
|
"""
|
||||||
|
if not batch_id:
|
||||||
|
return
|
||||||
|
if not hasattr(self, "_scrape_batches"):
|
||||||
|
self._scrape_batches = {}
|
||||||
|
|
||||||
|
with job_lock:
|
||||||
|
batch = self._scrape_batches.get(batch_id)
|
||||||
|
if (
|
||||||
|
not batch
|
||||||
|
or not batch.get("closed")
|
||||||
|
or batch.get("pending")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
targets = list(batch.get("targets", {}).values())
|
||||||
|
self._scrape_batches.pop(batch_id, None)
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
fileitem = target.get("fileitem")
|
||||||
|
if not fileitem:
|
||||||
|
continue
|
||||||
|
file_list = list(dict.fromkeys(target.get("files") or []))
|
||||||
|
self.eventmanager.send_event(
|
||||||
|
EventType.MetadataScrape,
|
||||||
|
{
|
||||||
|
"meta": target.get("meta"),
|
||||||
|
"mediainfo": target.get("mediainfo"),
|
||||||
|
"fileitem": fileitem,
|
||||||
|
"file_list": file_list,
|
||||||
|
"overwrite": target.get("overwrite", False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def remove_from_queue(self, fileitem: FileItem):
|
def remove_from_queue(self, fileitem: FileItem):
|
||||||
"""
|
"""
|
||||||
从待整理队列移除
|
从待整理队列移除
|
||||||
@@ -1163,6 +1323,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
self.jobview.fail_unfinished_task(task)
|
self.jobview.fail_unfinished_task(task)
|
||||||
self.jobview.try_remove_job(task)
|
self.jobview.try_remove_job(task)
|
||||||
|
self.__finish_scrape_batch_task(task)
|
||||||
|
|
||||||
def __start_transfer(self):
|
def __start_transfer(self):
|
||||||
"""
|
"""
|
||||||
@@ -1487,6 +1648,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
finally:
|
finally:
|
||||||
# 移除已完成的任务
|
# 移除已完成的任务
|
||||||
self.jobview.try_remove_job(task)
|
self.jobview.try_remove_job(task)
|
||||||
|
self.__finish_scrape_batch_task(task)
|
||||||
|
|
||||||
def get_queue_tasks(self) -> List[TransferJob]:
|
def get_queue_tasks(self) -> List[TransferJob]:
|
||||||
"""
|
"""
|
||||||
@@ -1903,6 +2065,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
# 是否全部成功
|
# 是否全部成功
|
||||||
all_success = True
|
all_success = True
|
||||||
|
transfer_batch_id = str(uuid.uuid4())
|
||||||
if preview:
|
if preview:
|
||||||
# 预览模式始终同步执行,避免进入异步队列
|
# 预览模式始终同步执行,避免进入异步队列
|
||||||
background = False
|
background = False
|
||||||
@@ -2086,6 +2249,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
downloader=_downloader,
|
downloader=_downloader,
|
||||||
download_hash=_download_hash,
|
download_hash=_download_hash,
|
||||||
download_history=download_history,
|
download_history=download_history,
|
||||||
|
transfer_batch_id=transfer_batch_id,
|
||||||
manual=manual,
|
manual=manual,
|
||||||
background=background,
|
background=background,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
@@ -2098,6 +2262,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
# 加入列表
|
# 加入列表
|
||||||
if self.__put_to_jobview(transfer_task):
|
if self.__put_to_jobview(transfer_task):
|
||||||
|
self.__register_scrape_batch_task(transfer_task)
|
||||||
transfer_tasks.append(transfer_task)
|
transfer_tasks.append(transfer_task)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"{file_path.name} 已在整理列表中,跳过")
|
logger.debug(f"{file_path.name} 已在整理列表中,跳过")
|
||||||
@@ -2106,6 +2271,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
finally:
|
finally:
|
||||||
file_items.clear()
|
file_items.clear()
|
||||||
del file_items
|
del file_items
|
||||||
|
self.__close_scrape_batch(transfer_batch_id)
|
||||||
|
|
||||||
# 实时整理
|
# 实时整理
|
||||||
preview_items: List[dict] = []
|
preview_items: List[dict] = []
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class TransferTask(BaseModel):
|
|||||||
downloader: Optional[str] = None
|
downloader: Optional[str] = None
|
||||||
download_hash: Optional[str] = None
|
download_hash: Optional[str] = None
|
||||||
download_history: Optional[DownloadHistory] = None
|
download_history: Optional[DownloadHistory] = None
|
||||||
|
transfer_batch_id: Optional[str] = None
|
||||||
manual: Optional[bool] = False
|
manual: Optional[bool] = False
|
||||||
background: Optional[bool] = True
|
background: Optional[bool] = True
|
||||||
preview: Optional[bool] = False
|
preview: Optional[bool] = False
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from app.chain.transfer import JobManager, TransferChain
|
from app.chain.transfer import JobManager, TransferChain
|
||||||
from app.schemas import FileItem, TransferTask
|
from app.schemas import FileItem, TransferInfo, TransferTask
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import EventType, MediaType
|
||||||
|
|
||||||
|
|
||||||
class FakeMeta:
|
class FakeMeta:
|
||||||
def __init__(self, episode: int):
|
def __init__(self, episode: int, season: int = 1):
|
||||||
self.name = "Test Show"
|
self.name = "Test Show"
|
||||||
self.title = f"Test Show S01E{episode:02d}"
|
self.title = f"Test Show S{season:02d}E{episode:02d}"
|
||||||
self.year = "2026"
|
self.year = "2026"
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self.begin_season = 1
|
self.begin_season = season
|
||||||
self.end_season = None
|
self.end_season = None
|
||||||
self.total_season = 1
|
self.total_season = 1
|
||||||
self.begin_episode = episode
|
self.begin_episode = episode
|
||||||
@@ -25,7 +25,7 @@ class FakeMeta:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def season(self):
|
def season(self):
|
||||||
return "S01"
|
return f"S{self.begin_season:02d}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def episode(self):
|
def episode(self):
|
||||||
@@ -53,6 +53,8 @@ class FakeMedia:
|
|||||||
def __init__(self, tmdb_id: int = 12345):
|
def __init__(self, tmdb_id: int = 12345):
|
||||||
self.tmdb_id = tmdb_id
|
self.tmdb_id = tmdb_id
|
||||||
self.douban_id = None
|
self.douban_id = None
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self.title_year = "Test Show (2026)"
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
pass
|
pass
|
||||||
@@ -68,8 +70,8 @@ class FakeMedia:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_task(episode: int) -> TransferTask:
|
def make_task(episode: int, season: int = 1) -> TransferTask:
|
||||||
name = f"Test.Show.S01E{episode:02d}.mkv"
|
name = f"Test.Show.S{season:02d}E{episode:02d}.mkv"
|
||||||
return TransferTask(
|
return TransferTask(
|
||||||
fileitem=FileItem(
|
fileitem=FileItem(
|
||||||
storage="local",
|
storage="local",
|
||||||
@@ -299,6 +301,7 @@ class TransferJobManagerTest(unittest.TestCase):
|
|||||||
chain = object.__new__(TransferChain)
|
chain = object.__new__(TransferChain)
|
||||||
chain.jobview = JobManager()
|
chain.jobview = JobManager()
|
||||||
chain.post_message = lambda *_args, **_kwargs: None
|
chain.post_message = lambda *_args, **_kwargs: None
|
||||||
|
chain._scrape_batches = {}
|
||||||
completed = []
|
completed = []
|
||||||
|
|
||||||
def fake_transfer_completed(hashs, downloader):
|
def fake_transfer_completed(hashs, downloader):
|
||||||
@@ -332,6 +335,161 @@ class TransferJobManagerTest(unittest.TestCase):
|
|||||||
self.assertEqual([("abc123", "qbittorrent")], completed)
|
self.assertEqual([("abc123", "qbittorrent")], completed)
|
||||||
self.assertEqual([], chain.jobview.list_jobs())
|
self.assertEqual([], chain.jobview.list_jobs())
|
||||||
|
|
||||||
|
def test_scrape_event_is_aggregated_by_transfer_batch_across_seasons(self):
|
||||||
|
chain = object.__new__(TransferChain)
|
||||||
|
chain.jobview = JobManager()
|
||||||
|
chain._success_target_files = {}
|
||||||
|
chain._scrape_batches = {}
|
||||||
|
chain.eventmanager = MagicMock()
|
||||||
|
chain.transfer_completed = lambda *args, **kwargs: None
|
||||||
|
|
||||||
|
tasks = [make_task(1, season=1), make_task(1, season=2)]
|
||||||
|
target_diritem = FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/library/Test Show (2026)",
|
||||||
|
type="dir",
|
||||||
|
name="Test Show (2026)",
|
||||||
|
)
|
||||||
|
batch_id = "batch-tv-multi-season"
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
task.mediainfo = FakeMedia()
|
||||||
|
task.transfer_batch_id = batch_id
|
||||||
|
task.background = False
|
||||||
|
task.manual = True
|
||||||
|
self.assertTrue(chain._TransferChain__put_to_jobview(task))
|
||||||
|
chain._TransferChain__register_scrape_batch_task(task)
|
||||||
|
|
||||||
|
chain._TransferChain__close_scrape_batch(batch_id)
|
||||||
|
|
||||||
|
transferinfos = [
|
||||||
|
TransferInfo(
|
||||||
|
success=True,
|
||||||
|
fileitem=tasks[0].fileitem,
|
||||||
|
target_diritem=target_diritem,
|
||||||
|
target_item=FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Test.Show.S01E01.mkv",
|
||||||
|
extension="mkv",
|
||||||
|
),
|
||||||
|
file_list_new=[
|
||||||
|
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
|
||||||
|
],
|
||||||
|
transfer_type="copy",
|
||||||
|
need_scrape=True,
|
||||||
|
need_notify=False,
|
||||||
|
),
|
||||||
|
TransferInfo(
|
||||||
|
success=True,
|
||||||
|
fileitem=tasks[1].fileitem,
|
||||||
|
target_diritem=target_diritem,
|
||||||
|
target_item=FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/library/Test Show (2026)/Season 2/Test.Show.S02E01.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Test.Show.S02E01.mkv",
|
||||||
|
extension="mkv",
|
||||||
|
),
|
||||||
|
file_list_new=[
|
||||||
|
"/library/Test Show (2026)/Season 2/Test.Show.S02E01.mkv"
|
||||||
|
],
|
||||||
|
transfer_type="copy",
|
||||||
|
need_scrape=True,
|
||||||
|
need_notify=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferHistoryOper",
|
||||||
|
return_value=SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)),
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.StorageChain"
|
||||||
|
) as storage_chain_cls:
|
||||||
|
storage_chain_cls.return_value.is_bluray_folder.return_value = False
|
||||||
|
for task, transferinfo in zip(tasks, transferinfos):
|
||||||
|
chain._TransferChain__default_callback(task, transferinfo)
|
||||||
|
chain._TransferChain__finish_scrape_batch_task(task)
|
||||||
|
|
||||||
|
metadata_calls = [
|
||||||
|
call
|
||||||
|
for call in chain.eventmanager.send_event.call_args_list
|
||||||
|
if call.args[0] == EventType.MetadataScrape
|
||||||
|
]
|
||||||
|
self.assertEqual(1, len(metadata_calls))
|
||||||
|
event_data = metadata_calls[0].args[1]
|
||||||
|
self.assertEqual(target_diritem, event_data["fileitem"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
|
||||||
|
"/library/Test Show (2026)/Season 2/Test.Show.S02E01.mkv",
|
||||||
|
],
|
||||||
|
event_data["file_list"],
|
||||||
|
)
|
||||||
|
self.assertEqual({}, chain._scrape_batches)
|
||||||
|
|
||||||
|
def test_scrape_event_keeps_immediate_behavior_without_transfer_batch(self):
|
||||||
|
chain = object.__new__(TransferChain)
|
||||||
|
chain.jobview = JobManager()
|
||||||
|
chain._success_target_files = {}
|
||||||
|
chain._scrape_batches = {}
|
||||||
|
chain.eventmanager = MagicMock()
|
||||||
|
chain.transfer_completed = lambda *args, **kwargs: None
|
||||||
|
|
||||||
|
task = make_task(1)
|
||||||
|
task.mediainfo = FakeMedia()
|
||||||
|
task.background = False
|
||||||
|
task.manual = True
|
||||||
|
self.assertTrue(chain._TransferChain__put_to_jobview(task))
|
||||||
|
|
||||||
|
target_diritem = FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/library/Test Show (2026)",
|
||||||
|
type="dir",
|
||||||
|
name="Test Show (2026)",
|
||||||
|
)
|
||||||
|
transferinfo = TransferInfo(
|
||||||
|
success=True,
|
||||||
|
fileitem=task.fileitem,
|
||||||
|
target_diritem=target_diritem,
|
||||||
|
target_item=FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Test.Show.S01E01.mkv",
|
||||||
|
extension="mkv",
|
||||||
|
),
|
||||||
|
file_list_new=[
|
||||||
|
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
|
||||||
|
],
|
||||||
|
transfer_type="copy",
|
||||||
|
need_scrape=True,
|
||||||
|
need_notify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferHistoryOper",
|
||||||
|
return_value=SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)),
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.StorageChain"
|
||||||
|
) as storage_chain_cls:
|
||||||
|
storage_chain_cls.return_value.is_bluray_folder.return_value = False
|
||||||
|
chain._TransferChain__default_callback(task, transferinfo)
|
||||||
|
|
||||||
|
metadata_calls = [
|
||||||
|
call
|
||||||
|
for call in chain.eventmanager.send_event.call_args_list
|
||||||
|
if call.args[0] == EventType.MetadataScrape
|
||||||
|
]
|
||||||
|
self.assertEqual(1, len(metadata_calls))
|
||||||
|
event_data = metadata_calls[0].args[1]
|
||||||
|
self.assertEqual(target_diritem, event_data["fileitem"])
|
||||||
|
self.assertEqual(
|
||||||
|
["/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"],
|
||||||
|
event_data["file_list"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user