"""下载后处理在独立账本和临时媒体上的失败与重试。""" import os import tempfile import time import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch from peach import download_postprocess as post from peach import ledger_backups from peach.downloads import DownloadStore, DownloadTask, INGESTED, POSTPROCESS_PENDING from peach.ingest_notices import IngestNotices from peach.jobs import JobDeferred from peach.repository import LedgerDatabase from support.ledger import fresh_ledger class DownloadPostprocessTests(unittest.TestCase): def test_isolated_ads_record_the_junk_reason(self): with self.database.write_transaction() as connection: connection.execute("UPDATE asset SET medium='other',name='推广.url',path=? WHERE id=?", (str(self.root / '推广.url'), self.asset_id)) self.assertEqual(post._isolate_ads(self.contract, -1, [self.asset_id]), 1) with self.database.read_connection() as connection: self.assertEqual(tuple(connection.execute("SELECT disposal,disposal_reason FROM asset WHERE id=?", (self.asset_id,)).fetchone()), ("trash", "junk")) def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name).resolve() self.database = LedgerDatabase(fresh_ledger(self.root)) self.source = self.root / 'incoming' / 'ABC-123.mp4' self.target = self.root / 'organized' / self.source.name self.source.parent.mkdir() self.source.write_bytes(b'main video') with self.database.write_transaction() as connection: self.asset_id = connection.execute( 'INSERT INTO asset(location,path,name,medium,size,code) VALUES(?,?,?,?,?,?)', ('115', str(self.source), self.source.name, 'video', 10, 'ABC-123')).lastrowid self.store = DownloadStore(self.database) self.task = self.store.save(DownloadTask( id=0, info_hash='a' * 40, provider='115', source_uri='', display_name='ABC-123', target='/115/incoming', state=INGESTED, code='ABC-123', ledger_path=str(self.source.parent), asset_id=self.asset_id, postprocess_state=POSTPROCESS_PENDING)) self.contract = SimpleNamespace( database=self.database, db_path=self.database.db_path, downloads=SimpleNamespace(wake=Mock(), clock=time.time), candidate_root=self.root / 'generated', download_organize_templates=lambda: {'115': {'file': '{code}'}}, cache_bust=lambda: None) self.handle = SimpleNamespace(progress=lambda **_: None) plan = {'rows': [{'asset_id': self.asset_id, 'action': 'move', 'current_path': str(self.source), 'target_path': str(self.target)}], 'reasons': {}} self.plan = patch.object(post.organize, 'build_plan', return_value=plan) self.plan.start() self.addCleanup(self.plan.stop) self.collect_source = post._collect collect = patch.object(post, '_collect', return_value={'candidates': 0}) collect.start() self.addCleanup(collect.stop) probe = patch.object(post, '_probe_folder', return_value=0) probe.start() self.addCleanup(probe.stop) def run_task(self): return post.run(self.contract, post.followup_key(self.task.id), self.handle) def test_temporary_failure_is_scheduled_and_only_three_retries_are_allowed(self): from peach.downloads import parse_iso from peach.task_retry import RETRY_DELAYS started = 1_800_000_000.0 self.contract.downloads.clock = lambda: started with patch.object(post, '_collect', side_effect=TimeoutError('来源连接超时')): for attempt, delay in enumerate(RETRY_DELAYS, 1): with self.assertRaises(TimeoutError): self.run_task() task = self.store.get(self.task.id) self.assertEqual((task.postprocess_state, task.postprocess_retries), ('pending', attempt)) self.assertEqual(parse_iso(task.next_check_at), started + delay) self.assertEqual(self.store.pending_postprocess(started), []) self.assertEqual([row.id for row in self.store.pending_postprocess(parse_iso(task.next_check_at))], [task.id]) started += delay with self.assertRaises(TimeoutError): self.run_task() task = self.store.get(self.task.id) self.assertEqual(task.postprocess_state, 'failed') self.assertIsNone(task.next_check_at) self.assertIn('自动重试 3 次', task.postprocess_detail) def test_offline_mount_retries_but_target_collision_stops_immediately(self): with patch.object(post.organize, 'build_plan', return_value={ 'rows': [{'action': 'skip', 'reason': post.organize.SKIP_OFFLINE}], 'reasons': {post.organize.SKIP_OFFLINE: 1}}), self.assertRaises(RuntimeError): self.run_task() self.assertEqual(self.store.get(self.task.id).postprocess_state, 'pending') with patch.object(post.organize, 'build_plan', return_value={ 'rows': [{'action': 'skip', 'reason': post.organize.SKIP_TARGET_EXISTS}], 'reasons': {post.organize.SKIP_TARGET_EXISTS: 1}}), self.assertRaises(RuntimeError): self.run_task() self.assertEqual(self.store.get(self.task.id).postprocess_state, 'failed') self.assertIsNone(self.store.get(self.task.id).next_check_at) def test_collection_retry_uses_structured_failure_instead_of_the_error_message(self): log = self.root / 'issues.jsonl' import json state = {'issues_log': str(log), 'issue_count': 1} issue = {'asset_id': self.asset_id, 'severity': 'error', 'retryable': True, 'diagnostics': {'failures': [{'automatic_retry_delay': 900.0}]}} log.write_text(json.dumps(issue) + '\n', encoding='utf-8') self.assertEqual(post._collection_retry_delay(state, self.asset_id), 900) issue['diagnostics'] = {'failures': [{'automatic_retry_delay': None}]} log.write_text(json.dumps(issue) + '\n', encoding='utf-8') self.assertIsNone(post._collection_retry_delay(state, self.asset_id)) def test_existing_unrelated_foreign_key_violation_is_reported_without_failing_the_move(self): with self.database.write_transaction() as connection: connection.execute("INSERT INTO media_binding(asset_id,backend,external_id) VALUES(99999,'test','x')") result = self.run_task() self.assertEqual((result['moved'], result['existing_foreign_keys']), (1, 1)) self.assertEqual(self.store.get(self.task.id).postprocess_state, 'complete') self.assertTrue(self.target.exists()) def test_organize_backup_lands_in_the_organize_folder_and_old_ones_are_pruned(self): folder = ledger_backups.organize_dir(self.contract.candidate_root) folder.mkdir(parents=True) old = [] for index in range(ledger_backups.ORGANIZE_KEEP_RECENT + 2): path = folder / f'ledger.pre-download-{index}-20260801-000000.db' path.write_bytes(b'backup') moment = time.time() - 30 * 86400 + index os.utime(path, (moment, moment)) old.append(path) result = post._organize(self.contract, self.task, self.asset_id) backup = Path(result['backup']) self.assertEqual(backup.parent, folder) self.assertEqual(ledger_backups.list_backups(folder, ledger_backups.ORGANIZE_PATTERN), [*old[3:], backup]) def test_retry_uses_the_bound_asset_after_a_move(self): post._organize(self.contract, self.task, self.asset_id) main, path, _ = post._bind_main(self.contract, self.store.get(self.task.id)) self.assertEqual((main, path), (self.asset_id, str(self.target))) def test_cloud_library_channel_binds_assets_by_media_source(self): self.task.provider = 'clouddrive:guangya:test-library' with self.database.write_transaction() as connection: connection.execute("UPDATE asset SET location='guangya' WHERE id=?", (self.asset_id,)) main, path, ids = post._bind_main(self.contract, self.task) self.assertEqual((main, path, ids), (self.asset_id, str(self.source), [self.asset_id])) self.assertEqual(self.task.payload()['provider_label'], '光鸭云盘') def test_occupied_target_is_a_retryable_failure(self): self.target.parent.mkdir() self.target.write_bytes(b'existing file') with self.assertRaisesRegex(RuntimeError, '整理'): self.run_task() self.assertEqual(self.store.get(self.task.id).postprocess_state, 'failed') self.assertTrue(self.source.exists()) self.assertEqual(self.target.read_bytes(), b'existing file') def test_new_foreign_key_violation_is_rejected(self): original = post.organize.apply_plan def corrupt(connection, rows, **options): result = original(connection, rows, **options) connection.execute("INSERT INTO media_binding(asset_id,backend,external_id) VALUES(99999,'test','x')") connection.commit() return result with patch.object(post.organize, 'apply_plan', side_effect=corrupt), \ self.assertRaisesRegex(RuntimeError, '校验'): self.run_task() self.assertEqual(self.store.get(self.task.id).postprocess_state, 'failed') def test_a_collision_found_in_the_plan_is_a_failure(self): with patch.object(post.organize, 'build_plan', return_value={ 'rows': [{'action': 'skip', 'reason': post.organize.SKIP_TARGET_EXISTS}], 'reasons': {post.organize.SKIP_TARGET_EXISTS: 1}}), \ self.assertRaisesRegex(RuntimeError, '整理'): self.run_task() self.assertTrue(self.source.exists()) def test_followups_are_declared_only_after_successful_organization(self): events = [] self.handle.run_id = 123 self.contract.task_runs = Mock() rows = [{'task_key': 'avatar', 'followup_key': 'asset:1'}] self.contract.task_runs.enqueue_followups.side_effect = lambda *_: events.append('followup') original = post._organize def organize(*args): events.append('organize') return original(*args) notices = IngestNotices() with patch.object(post, '_isolate_ads', side_effect=lambda *_: events.append('check') or 0), \ patch.object(post, '_probe_folder', side_effect=lambda *_, **options: events.append('probe') or 1), \ patch.object(post, '_collect', side_effect=lambda *_: events.append('collect') or {'followups': rows}), \ patch.object(post, '_organize', side_effect=organize), \ patch.object(post, 'declared_rows', return_value=rows), patch.object(post, 'NOTICES', notices): result = self.run_task() self.assertEqual(self.run_task(), {'task_id': self.task.id, 'already_complete': True}) self.assertEqual(events, ['check', 'probe', 'check', 'collect', 'check', 'organize', 'followup']) self.assertEqual(result['probed'], 1) self.contract.task_runs.enqueue_followups.assert_called_once() # 整理完成记一条入库通知,页面据此弹 toast;已完成的再跑一次不重复记。 [notice] = notices.snapshot()['items'] self.assertEqual((notice['kind'], notice['asset_id']), ('download', result['asset_id'])) def test_collection_that_steps_aside_stays_queued_instead_of_failed(self): self.handle.run_id = 123 self.contract.task_runs = Mock() with patch.object(post, '_collect', side_effect=JobDeferred('等待扫描与采集完成后继续')): with self.assertRaises(JobDeferred): self.run_task() task = self.store.get(self.task.id) self.assertEqual((task.postprocess_state, task.postprocess_detail), ('queued', '等待扫描与采集完成后继续')) self.contract.task_runs.enqueue_followups.assert_not_called() def test_collection_held_only_by_paused_sources_still_organizes_the_file(self): """MIAB-086:其余资料已落库,只差一站拒绝访问;文件照常整理,详情写明哪项等来源恢复。""" held = {'status': 'failed', 'error': '1 项都卡在来源限流上,等一会儿再跑一次。', 'issue_count': 1, 'paused_count': 1, 'issue_preview': [{'message': 'JavDB 标签未取得:来源拒绝访问'}]} # 账本就在临时根下,配置的各目录都指回它。 config = SimpleNamespace(configured=True, directory=lambda name: self.root) self.contract.cover_root = self.root / 'covers' with patch.object(post.settings_file, 'active', return_value=config), \ patch.object(post, 'process_library', return_value=held): state = self.collect_source(self.contract, self.asset_id, self.handle) self.assertEqual(state['paused_pending'], ['JavDB 标签未取得:来源拒绝访问']) with patch.object(post, '_collect', return_value=state): result = self.run_task() self.assertEqual(result['moved'], 1) task = self.store.get(self.task.id) self.assertEqual(task.postprocess_state, 'complete') self.assertIn('JavDB 标签未取得:来源拒绝访问', task.postprocess_detail) # 混有来源限流以外的问题时照旧失败,等人处理。 with patch.object(post.settings_file, 'active', return_value=config), \ patch.object(post, 'process_library', return_value={**held, 'issue_count': 2}): with self.assertRaisesRegex(RuntimeError, '来源限流'): self.collect_source(self.contract, self.asset_id, self.handle) def test_metadata_or_organization_failure_never_dispatches_followups(self): self.handle.run_id = 123 self.contract.task_runs = Mock() notices = IngestNotices() for failed_stage in ('_collect', '_organize'): with self.subTest(stage=failed_stage), patch.object(post, failed_stage, side_effect=RuntimeError('模拟阶段失败')), \ patch.object(post, 'NOTICES', notices): with self.assertRaisesRegex(RuntimeError, '模拟阶段失败'): self.run_task() self.assertEqual(self.store.get(self.task.id).postprocess_state, 'failed') self.contract.task_runs.enqueue_followups.assert_not_called() self.assertEqual(notices.snapshot()['items'], [])