mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-24 23:16:46 +00:00
941 lines
33 KiB
Python
941 lines
33 KiB
Python
import unittest
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
from app.core.config import settings
|
||
from app.core.context import MediaInfo
|
||
from app.core.meta import MetaVideo
|
||
from app.chain.transfer import JobManager, TransferChain
|
||
from app.modules.filemanager.transhandler import TransHandler
|
||
from app.schemas import EpisodeFormat, FileItem, TransferInfo, TransferTask
|
||
from app.schemas.types import EventType, MediaType
|
||
|
||
|
||
class FakeMeta:
|
||
def __init__(self, episode: int, season: int = 1):
|
||
self.name = "Test Show"
|
||
self.title = f"Test Show S{season:02d}E{episode:02d}"
|
||
self.year = "2026"
|
||
self.type = MediaType.TV
|
||
self.begin_season = season
|
||
self.end_season = None
|
||
self.total_season = 1
|
||
self.begin_episode = episode
|
||
self.end_episode = None
|
||
self.total_episode = 1
|
||
self.episode_list = [episode]
|
||
self.season_episode = f"S01E{episode:02d}"
|
||
self.part = None
|
||
|
||
@property
|
||
def season(self):
|
||
return f"S{self.begin_season:02d}"
|
||
|
||
@property
|
||
def episode(self):
|
||
return f"E{self.begin_episode:02d}"
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"title": self.title,
|
||
"name": self.name,
|
||
"year": self.year,
|
||
"type": self.type.value,
|
||
"begin_season": self.begin_season,
|
||
"end_season": self.end_season,
|
||
"total_season": self.total_season,
|
||
"begin_episode": self.begin_episode,
|
||
"end_episode": self.end_episode,
|
||
"total_episode": self.total_episode,
|
||
"season_episode": self.season_episode,
|
||
"episode_list": self.episode_list,
|
||
"part": self.part,
|
||
}
|
||
|
||
|
||
class FakeMedia:
|
||
def __init__(self, tmdb_id: int = 12345):
|
||
self.tmdb_id = tmdb_id
|
||
self.douban_id = None
|
||
self.type = MediaType.TV
|
||
self.title_year = "Test Show (2026)"
|
||
|
||
def clear(self):
|
||
pass
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"type": MediaType.TV.value,
|
||
"title": "Test Show",
|
||
"year": "2026",
|
||
"title_year": "Test Show (2026)",
|
||
"tmdb_id": self.tmdb_id,
|
||
"douban_id": self.douban_id,
|
||
}
|
||
|
||
|
||
def make_media_info() -> MediaInfo:
|
||
media = MediaInfo()
|
||
media.type = MediaType.TV
|
||
media.title = "Test Show"
|
||
media.title_year = "Test Show (2026)"
|
||
media.year = "2026"
|
||
media.tmdb_id = 12345
|
||
media.category = ""
|
||
media.actors = []
|
||
media.season_years = {}
|
||
media.vote_average = 0
|
||
return media
|
||
|
||
|
||
def make_task(episode: int, season: int = 1) -> TransferTask:
|
||
name = f"Test.Show.S{season:02d}E{episode:02d}.mkv"
|
||
return TransferTask(
|
||
fileitem=FileItem(
|
||
storage="local",
|
||
path=f"/downloads/Test Show/{name}",
|
||
type="file",
|
||
name=name,
|
||
basename=name.removesuffix(".mkv"),
|
||
extension="mkv",
|
||
size=1024,
|
||
),
|
||
meta=FakeMeta(episode),
|
||
)
|
||
|
||
|
||
def make_transfer_chain() -> TransferChain:
|
||
chain = object.__new__(TransferChain)
|
||
chain.jobview = JobManager()
|
||
chain._media_exts = settings.RMT_MEDIAEXT
|
||
chain._subtitle_exts = settings.RMT_SUBEXT
|
||
chain._audio_exts = settings.RMT_AUDIOEXT
|
||
chain._allowed_exts = (
|
||
chain._media_exts + chain._audio_exts + chain._subtitle_exts
|
||
)
|
||
chain._success_target_files = {}
|
||
chain._scrape_batches = {}
|
||
return chain
|
||
|
||
|
||
def make_fileitem(path: str, size: int = 1024) -> FileItem:
|
||
file_path = path
|
||
name = file_path.rsplit("/", 1)[-1]
|
||
suffix = name.rsplit(".", 1)[-1] if "." in name else ""
|
||
basename = name[: -(len(suffix) + 1)] if suffix else name
|
||
return FileItem(
|
||
storage="local",
|
||
path=file_path,
|
||
type="file",
|
||
name=name,
|
||
basename=basename,
|
||
extension=suffix,
|
||
size=size,
|
||
)
|
||
|
||
|
||
def migrate_to_media_job(jobview: JobManager, task: TransferTask):
|
||
task.mediainfo = FakeMedia()
|
||
jobview.migrate_task(task)
|
||
jobview.running_task(task)
|
||
jobview.finish_task(task)
|
||
jobview.try_remove_job(task)
|
||
|
||
|
||
class TransferJobManagerTest(unittest.TestCase):
|
||
def test_same_storage_success_uses_target_path_when_metadata_is_delayed(self):
|
||
"""
|
||
网盘操作已成功但目标元数据暂不可见时,整理结果应按成功路径落库。
|
||
"""
|
||
source_item = FileItem(
|
||
storage="alist",
|
||
path="/downloads/Test.Show.S01E01.mkv",
|
||
type="file",
|
||
name="Test.Show.S01E01.mkv",
|
||
basename="Test.Show.S01E01",
|
||
extension="mkv",
|
||
size=1024,
|
||
modify_time=1715939275.0,
|
||
)
|
||
target_path = Path(
|
||
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
|
||
)
|
||
target_folder = FileItem(
|
||
storage="alist",
|
||
path=f"{target_path.parent.as_posix()}/",
|
||
type="dir",
|
||
name=target_path.parent.name,
|
||
basename=target_path.parent.stem,
|
||
)
|
||
source_oper = SimpleNamespace(
|
||
is_support_transtype=lambda transfer_type: True,
|
||
move=lambda fileitem, path, name: True,
|
||
)
|
||
target_oper = SimpleNamespace(
|
||
get_folder=lambda path: target_folder,
|
||
get_item=lambda path: None,
|
||
)
|
||
|
||
new_item, errmsg = TransHandler._TransHandler__transfer_command(
|
||
fileitem=source_item,
|
||
target_storage="alist",
|
||
source_oper=source_oper,
|
||
target_oper=target_oper,
|
||
target_file=target_path,
|
||
transfer_type="move",
|
||
)
|
||
|
||
self.assertEqual("", errmsg)
|
||
self.assertIsNotNone(new_item)
|
||
self.assertEqual(target_path.as_posix(), new_item.path)
|
||
self.assertEqual("alist", new_item.storage)
|
||
self.assertEqual("file", new_item.type)
|
||
self.assertEqual(1024, new_item.size)
|
||
|
||
def test_transfer_media_uses_target_folder_returned_by_storage(self):
|
||
"""
|
||
整理成功时直接使用存储层返回的目标目录项,回调和事件不再二次拼装。
|
||
"""
|
||
handler = TransHandler()
|
||
source_item = FileItem(
|
||
storage="alist",
|
||
path="/downloads/Test.Show.S01E01.mkv",
|
||
type="file",
|
||
name="Test.Show.S01E01.mkv",
|
||
basename="Test.Show.S01E01",
|
||
extension="mkv",
|
||
size=1024,
|
||
modify_time=1715939275.0,
|
||
)
|
||
target_path = Path("/library")
|
||
target_file = Path(
|
||
"/library/Test.Show.S01E01.mkv"
|
||
)
|
||
target_folder = FileItem(
|
||
storage="alist",
|
||
type="dir",
|
||
path="/library/",
|
||
name="library",
|
||
basename="library",
|
||
)
|
||
target_item = FileItem(
|
||
storage="alist",
|
||
path=target_file.as_posix(),
|
||
type="file",
|
||
name=target_file.name,
|
||
basename=target_file.stem,
|
||
extension="mkv",
|
||
size=1024,
|
||
)
|
||
source_oper = SimpleNamespace(
|
||
is_support_transtype=lambda transfer_type: True,
|
||
move=lambda fileitem, path, name: True,
|
||
)
|
||
target_oper = SimpleNamespace(
|
||
get_folder=lambda path: target_folder,
|
||
get_item=lambda path: None,
|
||
)
|
||
|
||
with patch.object(
|
||
TransHandler, "get_rename_path", return_value=target_file
|
||
), patch(
|
||
"app.modules.filemanager.transhandler.DirectoryHelper.get_media_root_path",
|
||
return_value=Path("/library"),
|
||
), patch.object(
|
||
TransHandler,
|
||
"_TransHandler__transfer_command",
|
||
return_value=(target_item, ""),
|
||
), patch("app.modules.filemanager.transhandler.eventmanager") as eventmanager_mock:
|
||
eventmanager_mock.send_event.return_value = None
|
||
transferinfo = handler.transfer_media(
|
||
fileitem=source_item,
|
||
in_meta=MetaVideo("Test.Show.S01E01"),
|
||
mediainfo=make_media_info(),
|
||
target_storage="alist",
|
||
target_path=target_path,
|
||
transfer_type="move",
|
||
source_oper=source_oper,
|
||
target_oper=target_oper,
|
||
need_scrape=True,
|
||
need_notify=True,
|
||
)
|
||
|
||
self.assertTrue(transferinfo.success)
|
||
self.assertEqual(target_item, transferinfo.target_item)
|
||
self.assertEqual(target_folder, transferinfo.target_diritem)
|
||
|
||
def test_success_callback_uses_transfer_result_target_diritem(self):
|
||
"""
|
||
回调发送刮削事件时应直接使用整理结果里的目标目录项。
|
||
"""
|
||
chain = make_transfer_chain()
|
||
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="alist",
|
||
path="/library/Test Show (2026)/Season 1/",
|
||
type="dir",
|
||
name="Season 1",
|
||
)
|
||
target_item = FileItem(
|
||
storage="alist",
|
||
path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
|
||
type="file",
|
||
name="Test.Show.S01E01.mkv",
|
||
extension="mkv",
|
||
)
|
||
transferinfo = TransferInfo(
|
||
success=True,
|
||
fileitem=task.fileitem,
|
||
target_item=target_item,
|
||
target_diritem=target_diritem,
|
||
file_list_new=[target_item.path],
|
||
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)),
|
||
):
|
||
state, errmsg = chain._TransferChain__default_callback(task, transferinfo)
|
||
|
||
self.assertTrue(state)
|
||
self.assertEqual("", errmsg)
|
||
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([target_item.path], event_data["file_list"])
|
||
|
||
def test_manual_episode_offset_applies_once(self):
|
||
chain = make_transfer_chain()
|
||
source_fileitem = make_fileitem("/downloads/Test.Show.2026.S01E14.mkv")
|
||
planned_episodes = []
|
||
|
||
chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
|
||
(source_fileitem, False)
|
||
]
|
||
chain._TransferChain__put_to_jobview = lambda task: True
|
||
chain._TransferChain__register_scrape_batch_task = lambda task: None
|
||
chain._TransferChain__close_scrape_batch = lambda batch_id: None
|
||
|
||
def fake_handle_transfer(task, callback=None):
|
||
planned_episodes.append(task.meta.begin_episode)
|
||
return True, ""
|
||
|
||
chain._TransferChain__handle_transfer = fake_handle_transfer
|
||
|
||
transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
|
||
download_history_oper = SimpleNamespace(
|
||
get_by_hash=lambda download_hash: None,
|
||
get_file_by_fullpath=lambda fullpath: None,
|
||
get_files_by_savepath=lambda savepath: [],
|
||
get_by_path=lambda path: None,
|
||
)
|
||
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||
|
||
with patch("app.chain.transfer.TransferHistoryOper", return_value=transfer_history_oper), \
|
||
patch("app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper), \
|
||
patch("app.chain.transfer.SystemConfigOper", return_value=system_config_oper), \
|
||
patch("app.chain.transfer.MetaInfoPath", lambda *args, **kwargs: FakeMeta(14)):
|
||
state, errmsg = chain.do_transfer(
|
||
fileitem=source_fileitem,
|
||
mediainfo=FakeMedia(),
|
||
target_path=Path("/library"),
|
||
epformat=EpisodeFormat(offset="-1"),
|
||
background=False,
|
||
)
|
||
|
||
self.assertTrue(state, errmsg)
|
||
# 手动集数偏移只能应用一次,避免 E14 + (-1) 被二次处理成 E12。
|
||
self.assertEqual([13], planned_episodes)
|
||
|
||
def test_completed_media_job_is_removed_after_last_meta_task_fails(self):
|
||
jobview = JobManager()
|
||
tasks = [make_task(episode) for episode in range(1, 4)]
|
||
for task in tasks:
|
||
self.assertTrue(jobview.add_task(task))
|
||
|
||
migrate_to_media_job(jobview, tasks[0])
|
||
migrate_to_media_job(jobview, tasks[1])
|
||
|
||
# 还有一个 meta 任务未处理时,media 组虽然已完成也不能提前清理。
|
||
self.assertEqual(2, len(jobview.list_jobs()))
|
||
|
||
# 最后一个仍在 meta 组中的任务未识别,__handle_transfer 会直接 remove_task 后 return。
|
||
jobview.remove_task(tasks[2].fileitem)
|
||
jobview.try_remove_job(tasks[2])
|
||
|
||
self.assertEqual([], jobview.list_jobs())
|
||
|
||
def test_completed_media_job_is_removed_after_all_meta_tasks_migrate(self):
|
||
jobview = JobManager()
|
||
tasks = [make_task(episode) for episode in range(1, 3)]
|
||
for task in tasks:
|
||
self.assertTrue(jobview.add_task(task))
|
||
|
||
migrate_to_media_job(jobview, tasks[0])
|
||
self.assertEqual(2, len(jobview.list_jobs()))
|
||
|
||
migrate_to_media_job(jobview, tasks[1])
|
||
self.assertEqual([], jobview.list_jobs())
|
||
|
||
def test_exception_marks_unfinished_meta_task_failed_and_cleans_jobs(self):
|
||
jobview = JobManager()
|
||
tasks = [make_task(episode) for episode in range(1, 3)]
|
||
for task in tasks:
|
||
self.assertTrue(jobview.add_task(task))
|
||
|
||
migrate_to_media_job(jobview, tasks[0])
|
||
jobview.running_task(tasks[1])
|
||
|
||
jobview.fail_unfinished_task(tasks[1])
|
||
jobview.try_remove_job(tasks[1])
|
||
|
||
self.assertEqual([], jobview.list_jobs())
|
||
|
||
def test_exception_marks_unfinished_media_task_failed_and_cleans_jobs(self):
|
||
jobview = JobManager()
|
||
task = make_task(1)
|
||
self.assertTrue(jobview.add_task(task))
|
||
|
||
task.mediainfo = FakeMedia()
|
||
jobview.migrate_task(task)
|
||
jobview.running_task(task)
|
||
|
||
jobview.fail_unfinished_task(task)
|
||
jobview.try_remove_job(task)
|
||
|
||
self.assertEqual([], jobview.list_jobs())
|
||
|
||
def test_pre_recognized_jobs_with_same_meta_do_not_block_each_other(self):
|
||
jobview = JobManager()
|
||
task1 = make_task(1)
|
||
task2 = make_task(2)
|
||
task1.mediainfo = FakeMedia(100)
|
||
task2.mediainfo = FakeMedia(200)
|
||
|
||
self.assertTrue(jobview.add_task(task1))
|
||
self.assertTrue(jobview.add_task(task2))
|
||
|
||
jobview.running_task(task1)
|
||
jobview.finish_task(task1)
|
||
jobview.try_remove_job(task1)
|
||
|
||
jobs = jobview.list_jobs()
|
||
self.assertEqual(1, len(jobs))
|
||
self.assertEqual(task2.fileitem, jobs[0].tasks[0].fileitem)
|
||
|
||
def test_same_source_file_is_deduped_across_media_jobs(self):
|
||
"""
|
||
同一个源文件即使识别到不同媒体作业,也不能重复加入整理视图。
|
||
"""
|
||
jobview = JobManager()
|
||
task1 = make_task(1)
|
||
task2 = make_task(1)
|
||
task1.mediainfo = FakeMedia(100)
|
||
task2.mediainfo = FakeMedia(200)
|
||
|
||
self.assertTrue(jobview.add_task(task1))
|
||
self.assertFalse(jobview.add_task(task2))
|
||
|
||
jobs = jobview.list_jobs()
|
||
self.assertEqual(1, len(jobs))
|
||
self.assertEqual(task1.fileitem, jobs[0].tasks[0].fileitem)
|
||
|
||
def test_pre_recognized_migrations_with_same_meta_do_not_link_jobs(self):
|
||
jobview = JobManager()
|
||
task1 = make_task(1)
|
||
task2 = make_task(2)
|
||
task1.mediainfo = FakeMedia(100)
|
||
task2.mediainfo = FakeMedia(200)
|
||
|
||
self.assertTrue(jobview.add_task(task1))
|
||
self.assertTrue(jobview.add_task(task2))
|
||
|
||
self.assertTrue(jobview.migrate_task(task1))
|
||
self.assertTrue(jobview.migrate_task(task2))
|
||
jobview.running_task(task1)
|
||
jobview.finish_task(task1)
|
||
jobview.try_remove_job(task1)
|
||
|
||
jobs = jobview.list_jobs()
|
||
self.assertEqual(1, len(jobs))
|
||
self.assertEqual(task2.fileitem, jobs[0].tasks[0].fileitem)
|
||
|
||
def test_exception_failure_does_not_mark_downloader_without_history(self):
|
||
chain = make_transfer_chain()
|
||
completed = []
|
||
|
||
def fake_transfer_completed(hashs, downloader):
|
||
completed.append((hashs, downloader))
|
||
|
||
chain.transfer_completed = fake_transfer_completed
|
||
task = make_task(1)
|
||
task.downloader = "qbittorrent"
|
||
task.download_hash = "abc123"
|
||
self.assertTrue(chain.jobview.add_task(task))
|
||
chain.jobview.running_task(task)
|
||
|
||
chain._TransferChain__fail_transfer_task(task)
|
||
|
||
self.assertEqual([], completed)
|
||
self.assertEqual([], chain.jobview.list_jobs())
|
||
|
||
def test_successful_history_skip_marks_downloader_hash_completed(self):
|
||
chain = make_transfer_chain()
|
||
completed = []
|
||
|
||
def fake_transfer_completed(hashs, downloader):
|
||
completed.append((hashs, downloader))
|
||
|
||
chain.transfer_completed = fake_transfer_completed
|
||
chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
|
||
(fileitem, False)
|
||
]
|
||
|
||
fileitem = make_task(1).fileitem
|
||
history = SimpleNamespace(
|
||
status=True,
|
||
download_hash="abc123",
|
||
downloader="qbittorrent",
|
||
)
|
||
transfer_history_oper = SimpleNamespace(
|
||
get_by_src=lambda src, storage=None: history
|
||
)
|
||
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||
|
||
with patch(
|
||
"app.chain.transfer.TransferHistoryOper",
|
||
return_value=transfer_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.SystemConfigOper",
|
||
return_value=system_config_oper,
|
||
):
|
||
state, errmsg = TransferChain.do_transfer(
|
||
chain,
|
||
fileitem=fileitem,
|
||
downloader="qbittorrent",
|
||
download_hash="abc123",
|
||
background=False,
|
||
)
|
||
|
||
self.assertTrue(state)
|
||
self.assertEqual("Test.Show.S01E01.mkv 已整理过", errmsg)
|
||
self.assertEqual([("abc123", "qbittorrent")], completed)
|
||
|
||
def test_failed_history_skip_still_marks_downloader_hash_completed(self):
|
||
chain = make_transfer_chain()
|
||
completed = []
|
||
|
||
def fake_transfer_completed(hashs, downloader):
|
||
completed.append((hashs, downloader))
|
||
|
||
chain.transfer_completed = fake_transfer_completed
|
||
chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
|
||
(fileitem, False)
|
||
]
|
||
|
||
fileitem = make_task(1).fileitem
|
||
history = SimpleNamespace(
|
||
status=False,
|
||
download_hash="abc123",
|
||
downloader="qbittorrent",
|
||
)
|
||
transfer_history_oper = SimpleNamespace(
|
||
get_by_src=lambda src, storage=None: history
|
||
)
|
||
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||
|
||
with patch(
|
||
"app.chain.transfer.TransferHistoryOper",
|
||
return_value=transfer_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.SystemConfigOper",
|
||
return_value=system_config_oper,
|
||
):
|
||
state, errmsg = TransferChain.do_transfer(
|
||
chain,
|
||
fileitem=fileitem,
|
||
downloader="qbittorrent",
|
||
download_hash="abc123",
|
||
background=False,
|
||
)
|
||
|
||
self.assertFalse(state)
|
||
self.assertEqual("Test.Show.S01E01.mkv 已整理过", errmsg)
|
||
self.assertEqual([("abc123", "qbittorrent")], completed)
|
||
|
||
def test_unrecognized_task_marks_downloader_hash_completed(self):
|
||
chain = make_transfer_chain()
|
||
chain.post_message = lambda *_args, **_kwargs: None
|
||
completed = []
|
||
|
||
def fake_transfer_completed(hashs, downloader):
|
||
completed.append((hashs, downloader))
|
||
|
||
chain.transfer_completed = fake_transfer_completed
|
||
task = make_task(1)
|
||
task.downloader = "qbittorrent"
|
||
task.download_hash = "abc123"
|
||
self.assertTrue(chain.jobview.add_task(task))
|
||
|
||
transfer_history_oper = SimpleNamespace(
|
||
add_fail=lambda **kwargs: SimpleNamespace(id=1)
|
||
)
|
||
|
||
with patch(
|
||
"app.chain.transfer.TransferHistoryOper",
|
||
return_value=transfer_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.MediaChain"
|
||
) as media_chain_cls, patch(
|
||
"app.chain.transfer.settings.AI_AGENT_ENABLE", False
|
||
), patch(
|
||
"app.chain.transfer.settings.AI_AGENT_RETRY_TRANSFER", False
|
||
):
|
||
media_chain_cls.return_value.recognize_by_meta.return_value = None
|
||
state, errmsg = chain._TransferChain__handle_transfer(task)
|
||
|
||
self.assertFalse(state)
|
||
self.assertEqual("未识别到媒体信息", errmsg)
|
||
self.assertEqual([("abc123", "qbittorrent")], completed)
|
||
self.assertEqual([], chain.jobview.list_jobs())
|
||
|
||
def test_do_transfer_does_not_sync_extra_files_by_default(self):
|
||
chain = make_transfer_chain()
|
||
planned = []
|
||
main_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv"
|
||
)
|
||
subtitle_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E01.2026.zh-cn.srt"
|
||
)
|
||
|
||
chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
|
||
(main_fileitem, False)
|
||
]
|
||
chain._TransferChain__put_to_jobview = lambda task: True
|
||
chain._TransferChain__register_scrape_batch_task = lambda task: None
|
||
chain._TransferChain__close_scrape_batch = lambda batch_id: None
|
||
|
||
def fake_handle_transfer(task, callback=None):
|
||
planned.append(task.fileitem.path)
|
||
return True, ""
|
||
|
||
chain._TransferChain__handle_transfer = fake_handle_transfer
|
||
transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
|
||
download_history_oper = SimpleNamespace(
|
||
get_by_hash=lambda download_hash: None,
|
||
get_file_by_fullpath=lambda fullpath: None,
|
||
get_files_by_savepath=lambda savepath: [],
|
||
get_by_path=lambda path: None,
|
||
)
|
||
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||
storage_chain = SimpleNamespace(
|
||
get_parent_item=lambda fileitem: FileItem(
|
||
storage="local",
|
||
path="/downloads/Test Show (2026)/",
|
||
type="dir",
|
||
name="Test Show (2026)",
|
||
),
|
||
list_files=lambda fileitem, recursion=False: [
|
||
main_fileitem,
|
||
subtitle_fileitem,
|
||
],
|
||
)
|
||
|
||
with patch(
|
||
"app.chain.transfer.TransferHistoryOper",
|
||
return_value=transfer_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.DownloadHistoryOper",
|
||
return_value=download_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.SystemConfigOper",
|
||
return_value=system_config_oper,
|
||
), patch(
|
||
"app.chain.transfer.StorageChain",
|
||
return_value=storage_chain,
|
||
):
|
||
state, errmsg = TransferChain.do_transfer(
|
||
chain,
|
||
fileitem=main_fileitem,
|
||
background=False,
|
||
)
|
||
|
||
self.assertTrue(state)
|
||
self.assertEqual("", errmsg)
|
||
self.assertEqual([main_fileitem.path], planned)
|
||
|
||
def test_do_transfer_syncs_matching_extra_files_for_each_main_video(self):
|
||
chain = make_transfer_chain()
|
||
planned = []
|
||
main_ep1_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv"
|
||
)
|
||
main_ep2_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E02.2026.mkv"
|
||
)
|
||
ep1_subtitle_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E01.2026.zh-cn.srt"
|
||
)
|
||
ep1_audio_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E01.2026.commentary.mka"
|
||
)
|
||
ep2_subtitle_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Test.Show.S01E02.2026.zh-cn.srt"
|
||
)
|
||
other_title_fileitem = make_fileitem(
|
||
"/downloads/Test Show (2026)/Other.Show.S01E01.2026.zh-cn.srt"
|
||
)
|
||
parent_fileitem = FileItem(
|
||
storage="local",
|
||
path="/downloads/Test Show (2026)/",
|
||
type="dir",
|
||
name="Test Show (2026)",
|
||
)
|
||
|
||
chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
|
||
(main_ep1_fileitem, False),
|
||
(main_ep2_fileitem, False),
|
||
(ep1_subtitle_fileitem, False),
|
||
(ep1_audio_fileitem, False),
|
||
(ep2_subtitle_fileitem, False),
|
||
(other_title_fileitem, False),
|
||
]
|
||
chain._TransferChain__put_to_jobview = lambda task: True
|
||
chain._TransferChain__register_scrape_batch_task = lambda task: None
|
||
chain._TransferChain__close_scrape_batch = lambda batch_id: None
|
||
|
||
def fake_handle_transfer(task, callback=None):
|
||
planned.append((task.fileitem.path, task.meta.begin_episode))
|
||
return True, ""
|
||
|
||
chain._TransferChain__handle_transfer = fake_handle_transfer
|
||
transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
|
||
download_history_oper = SimpleNamespace(
|
||
get_by_hash=lambda download_hash: None,
|
||
get_file_by_fullpath=lambda fullpath: None,
|
||
get_files_by_savepath=lambda savepath: [],
|
||
get_by_path=lambda path: None,
|
||
)
|
||
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||
list_files_calls = []
|
||
|
||
def fake_list_files(fileitem, recursion=False):
|
||
list_files_calls.append((fileitem.path, recursion))
|
||
return [
|
||
main_ep1_fileitem,
|
||
main_ep2_fileitem,
|
||
ep1_subtitle_fileitem,
|
||
ep1_audio_fileitem,
|
||
ep2_subtitle_fileitem,
|
||
other_title_fileitem,
|
||
]
|
||
|
||
storage_chain = SimpleNamespace(
|
||
get_parent_item=lambda fileitem: parent_fileitem,
|
||
list_files=fake_list_files,
|
||
)
|
||
|
||
with patch(
|
||
"app.chain.transfer.TransferHistoryOper",
|
||
return_value=transfer_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.DownloadHistoryOper",
|
||
return_value=download_history_oper,
|
||
), patch(
|
||
"app.chain.transfer.SystemConfigOper",
|
||
return_value=system_config_oper,
|
||
), patch(
|
||
"app.chain.transfer.StorageChain",
|
||
return_value=storage_chain,
|
||
):
|
||
state, errmsg = TransferChain.do_transfer(
|
||
chain,
|
||
fileitem=parent_fileitem,
|
||
background=False,
|
||
sync_extra_files=True,
|
||
)
|
||
|
||
self.assertTrue(state)
|
||
self.assertEqual("", errmsg)
|
||
self.assertEqual(
|
||
[
|
||
(main_ep1_fileitem.path, 1),
|
||
(main_ep2_fileitem.path, 2),
|
||
(ep1_subtitle_fileitem.path, 1),
|
||
(ep1_audio_fileitem.path, 1),
|
||
(ep2_subtitle_fileitem.path, 2),
|
||
],
|
||
planned,
|
||
)
|
||
self.assertEqual([], list_files_calls)
|
||
|
||
def test_scrape_event_is_aggregated_by_transfer_batch_across_seasons(self):
|
||
chain = make_transfer_chain()
|
||
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 = make_transfer_chain()
|
||
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__":
|
||
unittest.main()
|