import importlib import shutil from pathlib import Path from .exception_service import ExceptionService from .library_postprocess import ( OrganizeItemError, _build_organize_plan, _build_prefixed_name, _build_quality_breakdown, _build_unique_destination, _serialize_compared_candidate ) from .metadata_normalization import ( MetadataNormalizationService, can_ingest_metadata, merge_metadata_layers ) from .matcher import Matcher from .preprocessor import PreprocessItemError, Preprocessor from .task_constants import ( STAGE_STATUS_COMPLETED, STAGE_STATUS_FAILED, STAGE_STATUS_RUNNING, TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, TASK_STATUS_RUNNING, current_timestamp, create_empty_repair_stats, create_pending_repair_stage_states ) class RepairExecutionError(Exception): def __init__(self, reason: str, message: str): super().__init__(message) self.reason = reason self.message = message class MetadataPatchService: def apply(self, item: dict, metadata_patch: dict) -> tuple[dict, bool]: patch = {key: value for key, value in (metadata_patch or {}).items() if value is not None} if not patch: return item, False normalized_metadata = self.metadata_normalizer.normalize_item(item, patch) merged_tags = dict(item.get('original_tags_json') or {}) merged_tags.update({key: value for key, value in normalized_metadata.items() if key in self.writable_keys}) file_path = Path(item['current_file_path']) if file_path.exists(): self._write_tags(file_path, merged_tags) updated_item = self.task_store.update_task_item( item['id'], original_tags_json=merged_tags, matched_metadata_json=normalized_metadata ) return updated_item, True def __init__(self, task_store, metadata_normalizer: MetadataNormalizationService): self.task_store = task_store self.metadata_normalizer = metadata_normalizer self.writable_keys = {'title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics'} def _write_tags(self, file_path: Path, tags: dict): mutagen = importlib.import_module('mutagen') tags_file = mutagen.File(str(file_path), easy=True) if tags_file is None: raise RepairExecutionError('metadata_write_failed', f'无法写入标签: {file_path}') key_mapping = { 'title': 'title', 'artist': 'artist', 'album': 'album', 'album_artist': 'albumartist', 'track_number': 'tracknumber', 'disc_number': 'discnumber', 'year': 'date', 'lyrics': 'lyrics' } for source_key, target_key in key_mapping.items(): if source_key not in tags: continue value = tags[source_key] if value in (None, ''): continue tags_file[target_key] = [str(value)] tags_file.save() class OrganizeService: def __init__(self, task_store, metadata_normalizer: MetadataNormalizationService): self.task_store = task_store self.metadata_normalizer = metadata_normalizer def plan(self, item: dict, output_root: str, override_relative_path: str | None = None) -> dict: root = Path(output_root).expanduser().resolve(strict=False) if override_relative_path: planned_relative_path = Path(override_relative_path).as_posix().lstrip('/') return {'output_root': root, 'planned_relative_path': planned_relative_path} normalized_item = { **item, 'matched_metadata_json': self.metadata_normalizer.normalize_item(item) } return _build_organize_plan(root, normalized_item) def resolve_destination(self, desired_path: Path, source_path: Path) -> tuple[Path, int]: candidate = desired_path collision_index = 1 while candidate.exists(): if candidate.resolve(strict=False) == source_path.resolve(strict=False): return candidate, collision_index collision_index += 1 candidate = candidate.with_name( f'{desired_path.stem} ({collision_index}){desired_path.suffix}' ) return candidate, collision_index def move_to_review_trash( self, *, trash_root: str, task_id: str, item_id: int | None, source_path: str, reason: str ) -> str: source = Path(source_path) if not source.exists(): raise RepairExecutionError('source_missing', f'源文件不存在: {source}') destination = _build_unique_destination( Path(trash_root) / reason / task_id, _build_prefixed_name(item_id, source.name) ) destination.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(source), str(destination)) return str(destination.resolve(strict=False)) def organize_item( self, item: dict, *, output_root: str, override_relative_path: str | None = None ) -> tuple[dict, dict]: normalized_metadata = self.metadata_normalizer.normalize_item(item) if normalized_metadata != (item.get('matched_metadata_json') or {}): item = self.task_store.update_task_item(item['id'], matched_metadata_json=normalized_metadata) plan = self.plan(item, output_root, override_relative_path) source_path = Path(item['current_file_path']) if not source_path.exists(): raise RepairExecutionError('source_missing', f'源文件不存在: {source_path}') desired_path = Path(plan['output_root']) / plan['planned_relative_path'] final_path, collision_count = self.resolve_destination(desired_path, source_path) final_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(source_path), str(final_path)) final_relative_path = final_path.relative_to(plan['output_root']).as_posix() updated_item = self.task_store.update_task_item( item['id'], current_file_path=str(final_path.resolve(strict=False)), filename=final_path.name, organize_status='organized', organize_reason=None, organize_message='已按标准路径入库', library_relative_path=final_relative_path, library_file_path=str(final_path.resolve(strict=False)), organize_decision_json={ 'source_path': item['current_file_path'], 'planned_relative_path': plan['planned_relative_path'], 'final_relative_path': final_relative_path, 'collision_strategy': 'suffix' if collision_count > 1 else 'none', 'trashed_on_failure': None, 'final_action': 'organized' } ) return updated_item, { 'planned_relative_path': plan['planned_relative_path'], 'final_relative_path': final_relative_path, 'collision_count': collision_count } class MatchRetryService: def __init__(self, task_store, matcher: Matcher, metadata_normalizer: MetadataNormalizationService): self.task_store = task_store self.matcher = matcher self.metadata_normalizer = metadata_normalizer def retry_match(self, item: dict, config_snapshot: dict, providers: list[str] | None = None) -> dict: match_config = dict(config_snapshot) if providers: match_config['repair_provider_scope'] = providers result = self.matcher.match_item(item, [item], match_config) matched_metadata = self.metadata_normalizer.normalize_item( { **item, 'matched_metadata_json': result['matched_metadata_json'] } ) return self.task_store.update_task_item( item['id'], match_status=result['status'], match_reason=result['reason'], match_message=result['message'], match_source=result['source'], match_confidence=result['confidence'], match_is_authoritative=1 if result['is_authoritative'] else 0, matched_metadata_json=matched_metadata, match_candidates_json=result['match_candidates_json'], match_enrichment_json=result['match_enrichment_json'] ) def select_candidate(self, item: dict, candidate_index: int) -> dict: candidates = item.get('match_candidates_json') or [] if candidate_index < 0 or candidate_index >= len(candidates): raise RepairExecutionError('candidate_index_invalid', '候选索引无效') candidate = candidates[candidate_index] matched_metadata = { 'title': candidate.get('title'), 'artist': candidate.get('artist'), 'album': candidate.get('album'), 'album_artist': candidate.get('album_artist'), 'track_number': candidate.get('track_number'), 'disc_number': candidate.get('disc_number'), 'year': candidate.get('year'), 'lyrics': candidate.get('lyrics'), 'recording_id': candidate.get('recording_id'), 'release_id': candidate.get('release_id'), 'release_group_id': candidate.get('release_group_id'), 'source_ids': candidate.get('source_ids') or {} } matched_metadata = self.metadata_normalizer.normalize_item( { **item, 'matched_metadata_json': matched_metadata } ) return self.task_store.update_task_item( item['id'], match_status='matched_fallback', match_reason='manual_candidate_selected', match_message='已手动确认匹配候选', match_source=candidate.get('provider'), match_confidence=candidate.get('score'), match_is_authoritative=1 if candidate.get('is_authoritative') else 0, matched_metadata_json=matched_metadata ) class PreprocessRetryService: def __init__(self, task_store, preprocessor: Preprocessor): self.task_store = task_store self.preprocessor = preprocessor def retry_preprocess(self, item: dict) -> dict: try: audio_props = self.preprocessor.probe_audio(item['current_file_path']) tags = self.preprocessor.read_tags(item['current_file_path']) fingerprint = self.preprocessor.calculate_fingerprint(item['current_file_path']) return self.task_store.update_task_item( item['id'], preprocess_status='completed' if tags else 'warning', preprocess_reason=None if tags else 'metadata_failed', preprocess_message='预处理已重新执行' if tags else '预处理完成,但元数据仍缺失', audio_props_json=audio_props, original_tags_json=tags, acoustic_fingerprint=fingerprint.get('fingerprint'), fingerprint_duration_seconds=fingerprint.get('duration_seconds') ) except PreprocessItemError as error: return self.task_store.update_task_item( item['id'], preprocess_status='failed', preprocess_reason=error.reason, preprocess_message=error.message ) class DedupeDecisionService: def __init__(self, task_store, organize_service: OrganizeService): self.task_store = task_store self.organize_service = organize_service def keep_existing(self, item: dict, *, task_id: str, trash_root: str) -> dict: if item.get('trash_file_path'): return self.task_store.update_task_item( item['id'], dedupe_status='duplicate_trashed', dedupe_message='已保留库内文件' ) trashed_path = self.organize_service.move_to_review_trash( trash_root=trash_root, task_id=task_id, item_id=item['id'], source_path=item['current_file_path'], reason='duplicates' ) return self.task_store.update_task_item( item['id'], is_active=0, current_file_path=trashed_path, trash_file_path=trashed_path, dedupe_status='duplicate_trashed', dedupe_reason='manual_keep_existing', dedupe_message='已保留库内文件,当前文件移入 review trash' ) def replace_existing( self, item: dict, *, task_id: str, output_root: str, trash_root: str ) -> tuple[dict, dict]: existing_path = item.get('duplicate_of_path') if not existing_path: raise RepairExecutionError('duplicate_target_missing', '缺少库内重复文件路径') existing = Path(existing_path) if not existing.exists(): raise RepairExecutionError('duplicate_target_missing', f'库内文件不存在: {existing}') replaced_path = self.organize_service.move_to_review_trash( trash_root=trash_root, task_id=task_id, item_id=item['id'], source_path=str(existing), reason='duplicates' ) current_path = Path(item['current_file_path']) if not current_path.exists(): raise RepairExecutionError('source_missing', f'源文件不存在: {current_path}') existing.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(current_path), str(existing)) final_item = self.task_store.update_task_item( item['id'], current_file_path=str(existing.resolve(strict=False)), filename=existing.name, dedupe_status='duplicate_replaced', dedupe_reason='replaced_library_duplicate', dedupe_message='已替换库内旧文件', library_relative_path=existing.relative_to( Path(output_root).expanduser().resolve(strict=False) ).as_posix() if str(existing).startswith(str(Path(output_root).expanduser().resolve(strict=False))) else item.get('library_relative_path'), library_file_path=str(existing.resolve(strict=False)), dedupe_decision_json={ 'comparison_scope': 'library', 'identity_basis': 'manual_replace', 'quality_breakdown': { 'kept': _build_quality_breakdown(item), 'replaced': {'total': None} }, 'kept_side': 'batch', 'trashed_path': replaced_path, 'replaced_existing_path': str(existing.resolve(strict=False)), 'compared_candidates': [ _serialize_compared_candidate('kept', item), {'side': 'replaced', 'path': str(existing.resolve(strict=False))} ] } ) return final_item, {'replaced_path': replaced_path, 'final_path': str(existing.resolve(strict=False))} def keep_both_with_rename( self, item: dict, *, output_root: str ) -> tuple[dict, dict]: return self.organize_service.organize_item(item, output_root=output_root) class RepairService: def __init__( self, task_store, exception_service: ExceptionService, matcher: Matcher, preprocessor: Preprocessor, task_stream, runner=None ): self.task_store = task_store self.exception_service = exception_service self.metadata_normalizer = MetadataNormalizationService(task_store) self.metadata_service = MetadataPatchService(task_store, self.metadata_normalizer) self.organize_service = OrganizeService(task_store, self.metadata_normalizer) self.match_service = MatchRetryService(task_store, matcher, self.metadata_normalizer) self.preprocess_service = PreprocessRetryService(task_store, preprocessor) self.dedupe_service = DedupeDecisionService(task_store, self.organize_service) self.task_stream = task_stream self.runner = runner def preview(self, payload: dict, config_snapshot: dict) -> dict: items = self._load_exception_items(payload['exception_ids']) action = payload['action'] params = payload.get('params') or {} self._validate_batch(items, action) preview_items = [] planned_operations = [] warnings: list[str] = [] risk_level = 'low' for item in items: item_operations, item_warnings, item_risk = self._preview_item(item, action, params, config_snapshot) preview_items.append( { 'exception_id': item['exception_id'], 'filename': item['filename'], 'exception_type': item['exception_type'], 'planned_operations': item_operations, 'warnings': item_warnings } ) planned_operations.extend(item_operations) warnings.extend(item_warnings) risk_level = self._merge_risk(risk_level, item_risk) return { 'action': action, 'items': preview_items, 'requires_confirmation': True, 'planned_operations': planned_operations, 'conflict_summary': { 'item_count': len(preview_items), 'mixed_types': len({item['exception_type'] for item in preview_items}) > 1 }, 'risk_level': risk_level, 'warnings': warnings } def execute(self, payload: dict, config_snapshot: dict) -> dict: items = self._load_exception_items(payload['exception_ids'], require_open=True) preview = self.preview(payload, config_snapshot) task = self.task_store.create_task_if_idle( config_snapshot, trigger_source='manual_ui', task_type='repair', source_task_id=items[0]['task_id'] if items else None, repair_plan_json={ 'action': payload['action'], 'params': payload.get('params') or {}, 'items': [item['exception_id'] for item in items], 'preview': preview } ) for item in items: before_snapshot = self.exception_service.get_item(item['exception_id']) previous_resolution = item.get('exception_resolution_json') or {} self.task_store.update_task_item( item['exception_id'], exception_resolution_status='planned', last_repair_task_id=task['task_id'], exception_resolution_json={ **previous_resolution, 'action': payload['action'], 'requested_at': current_timestamp(), 'resolved_at': None, 'repair_task_id': task['task_id'], 'operator': 'manual_ui', 'before_snapshot': before_snapshot, 'after_snapshot': None, 'notes': None, 'planned_operations': [ operation for operation in preview['planned_operations'] if operation.get('source_path') == before_snapshot.get('current_file_path') or operation.get('target_path') == before_snapshot.get('current_file_path') ], 'execution_result': None } ) return task def _validate_batch(self, items: list[dict], action: str): if not items: raise ValueError('至少选择一个异常项') types = {item['exception_type'] for item in items} if len(types) > 1: raise ValueError('批量动作不支持混合异常类型') for item in items: if action not in (item.get('available_actions') or []): raise ValueError(f'异常项 {item["exception_id"]} 不支持动作 {action}') def _load_exception_items(self, exception_ids: list[int], require_open: bool = False) -> list[dict]: ids = list(dict.fromkeys(exception_ids or [])) items = [self.exception_service.get_item(exception_id) for exception_id in ids] if require_open: for item in items: if item.get('exception_resolution_status') != 'open': raise ValueError(f'异常项 {item["exception_id"]} 当前不可执行') return items def _preview_item(self, item: dict, action: str, params: dict, config_snapshot: dict): current_path = item.get('current_file_path') if action == 'ignore_exception': return ( [self._op('status_update', current_path, None, '标记为已忽略,不执行物理删除')], ['真实执行仅做安全忽略或转入 review trash,不会物理删除源文件。'], 'low' ) if action == 'delete_file': return ( [self._op('trash', current_path, None, '永久删除当前文件')], ['该动作会真实删除文件,执行后无法恢复。'], 'high' ) if action == 'edit_metadata': return ([self._op('metadata_write', current_path, current_path, '写入元数据标签')], [], 'low') if action == 'retry_match': providers = params.get('providers') or [] description = '重新执行单文件匹配' if providers: description = f'重新执行单文件匹配 ({"/".join(providers)})' return ([self._op('status_update', current_path, None, description)], [], 'low') if action == 'select_match_candidate': return ([self._op('status_update', current_path, None, '确认现有匹配候选')], [], 'low') if action == 'retry_preprocess': return ([self._op('status_update', current_path, None, '重跑预处理与指纹提取')], [], 'low') if action == 'move_to_review_trash': return ([self._op('trash', current_path, None, '移动到 review trash')], [], 'medium') if action == 'keep_existing': return ([self._op('trash', current_path, item.get('trash_file_path'), '保留库内文件并移走当前文件')], [], 'medium') if action == 'replace_existing': return ( [ self._op('replace', item.get('duplicate_of_path'), None, '将库内旧文件移入 review trash'), self._op('move', current_path, item.get('duplicate_of_path'), '当前文件覆盖进入库内目标') ], [], 'high' ) if action in {'retry_organize', 'save_and_organize', 'keep_both_with_rename'}: override = params.get('target_relative_path') if action == 'retry_organize' else None if action == 'save_and_organize' and params.get('metadata_patch'): item = { **item, 'matched_metadata_json': self.metadata_normalizer.normalize_item(item, params['metadata_patch']) } if action == 'save_and_organize' and not can_ingest_metadata(self.metadata_normalizer.normalize_item(item)): raise ValueError('加入音乐库前必须补齐 title、artist、album_artist') plan = self.organize_service.plan(item, config_snapshot['output'], override) return ( [ self._op('move', current_path, str(Path(config_snapshot['output']) / plan['planned_relative_path']), '移动到目标库路径'), self._op('status_update', current_path, None, f'更新入库路径 {plan["planned_relative_path"]}') ], [], 'medium' ) raise ValueError(f'Unsupported action: {action}') def _merge_risk(self, current: str, next_risk: str) -> str: order = {'low': 0, 'medium': 1, 'high': 2} return next_risk if order[next_risk] > order[current] else current def _op(self, op_type: str, source_path: str | None, target_path: str | None, description: str) -> dict: return { 'type': op_type, 'source_path': source_path, 'target_path': target_path, 'description': description } class RepairRunner: def __init__(self, task_store, task_stream, repair_service: RepairService): self.task_store = task_store self.task_stream = task_stream self.repair_service = repair_service def start_task(self, repair_task_id: str, config_snapshot: dict): task = self.task_store.get_task(repair_task_id) plan = task.get('repair_plan_json') or {} stats = create_empty_repair_stats() stage_states = create_pending_repair_stage_states() try: stage_states['prepare'] = STAGE_STATUS_RUNNING self.task_store.update_task( repair_task_id, status=TASK_STATUS_RUNNING, current_stage='prepare', stage_states=stage_states, stats=stats ) self._log(repair_task_id, 'prepare', 'info', 'stage.started', '开始准备 repair 执行') stats['prepare']['previewed_items'] = len(plan.get('items') or []) stage_states['prepare'] = STAGE_STATUS_COMPLETED stage_states['execute'] = STAGE_STATUS_RUNNING self.task_store.update_task( repair_task_id, status=TASK_STATUS_RUNNING, current_stage='execute', stage_states=stage_states, stats=stats ) self._broadcast(repair_task_id, 'stage.completed', 'prepare', {'stats': stats}) for exception_id in plan.get('items') or []: try: self._apply_action_to_item(repair_task_id, exception_id, plan['action'], plan.get('params') or {}, config_snapshot, stats) stats['execute']['succeeded_items'] += 1 except Exception as error: stats['execute']['failed_items'] += 1 item = self.task_store.get_exception_source_item(exception_id) if item: resolution = dict(item.get('exception_resolution_json') or {}) resolution['resolved_at'] = current_timestamp() resolution['execution_result'] = {'status': 'failed', 'message': str(error)} self.task_store.update_task_item( exception_id, exception_resolution_status='open', exception_resolution_json=resolution ) self._log(repair_task_id, 'execute', 'error', 'repair.item_failed', f'异常项执行失败: {exception_id}', {'exception_id': exception_id, 'error': str(error)}) stage_states['execute'] = STAGE_STATUS_COMPLETED stage_states['complete'] = STAGE_STATUS_COMPLETED completed_at = current_timestamp() self.task_store.update_task( repair_task_id, status=TASK_STATUS_COMPLETED, current_stage='complete', stage_states=stage_states, stats=stats, completed_at=completed_at ) self._broadcast(repair_task_id, 'task.completed', 'complete', {'stats': stats}) self._log(repair_task_id, 'complete', 'success', 'task.completed', 'repair 任务已完成', {'stats': stats}) except Exception as error: stage_states['prepare'] = STAGE_STATUS_FAILED if stage_states['prepare'] == STAGE_STATUS_RUNNING else stage_states['prepare'] stage_states['execute'] = STAGE_STATUS_FAILED if stage_states['execute'] == STAGE_STATUS_RUNNING else stage_states['execute'] stage_states['complete'] = STAGE_STATUS_FAILED self.task_store.update_task( repair_task_id, status=TASK_STATUS_FAILED, current_stage='execute', stage_states=stage_states, stats=stats, error_message=str(error), completed_at=current_timestamp() ) self._broadcast(repair_task_id, 'task.failed', 'execute', {'error_message': str(error), 'stats': stats}) self._log(repair_task_id, 'execute', 'error', 'task.failed', f'repair 任务失败: {error}', {'error': str(error)}) def _apply_action_to_item(self, repair_task_id: str, exception_id: int, action: str, params: dict, config_snapshot: dict, stats: dict): item = self.task_store.get_exception_source_item(exception_id) if item is None: raise RepairExecutionError('item_missing', f'异常项不存在: {exception_id}') before_snapshot = self.repair_service.exception_service.get_item(exception_id) final_item = item execution_result = {'action': action, 'status': 'completed'} if action == 'ignore_exception': stats['execute']['ignored_items'] += 1 resolution_status = 'ignored' workflow_state = 'ignored' elif action == 'edit_metadata': final_item, changed = self.repair_service.metadata_service.apply(item, params.get('metadata_patch') or {}) if changed: stats['execute']['updated_metadata_items'] += 1 resolution_status = 'open' workflow_state = _metadata_workflow_state(final_item, params.get('metadata_patch') or {}) elif action == 'retry_match': final_item = self.repair_service.match_service.retry_match( item, config_snapshot, providers=params.get('providers') or None ) resolution_status = 'open' workflow_state = _metadata_workflow_state(final_item) elif action == 'select_match_candidate': final_item = self.repair_service.match_service.select_candidate(item, int(params.get('candidate_index', -1))) resolution_status = 'open' workflow_state = _candidate_workflow_state(final_item) elif action == 'retry_preprocess': final_item = self.repair_service.preprocess_service.retry_preprocess(item) resolution_status = 'resolved' if final_item.get('preprocess_status') != 'failed' else 'open' workflow_state = 'ingested' if resolution_status == 'resolved' else 'open' elif action == 'move_to_review_trash': trashed_path = self.repair_service.organize_service.move_to_review_trash( trash_root=config_snapshot['trash'], task_id=repair_task_id, item_id=item['id'], source_path=item['current_file_path'], reason='manual_review' ) final_item = self.task_store.update_task_item( item['id'], is_active=0, current_file_path=trashed_path, trash_file_path=trashed_path, organize_status='trashed', organize_reason='manual_review', organize_message='已移入 review trash' ) stats['execute']['moved_items'] += 1 resolution_status = 'resolved' workflow_state = 'ingested' elif action == 'keep_existing': final_item = self.repair_service.dedupe_service.keep_existing( item, task_id=repair_task_id, trash_root=config_snapshot['trash'] ) stats['execute']['moved_items'] += 1 resolution_status = 'resolved' workflow_state = 'ingested' elif action == 'replace_existing': final_item, execution_result = self.repair_service.dedupe_service.replace_existing( item, task_id=repair_task_id, output_root=config_snapshot['output'], trash_root=config_snapshot['trash'] ) stats['execute']['moved_items'] += 1 resolution_status = 'resolved' workflow_state = 'ingested' elif action == 'keep_both_with_rename': final_item, execution_result = self.repair_service.dedupe_service.keep_both_with_rename( item, output_root=config_snapshot['output'] ) stats['execute']['moved_items'] += 1 resolution_status = 'resolved' workflow_state = 'ingested' elif action == 'retry_organize': final_item, execution_result = self.repair_service.organize_service.organize_item( item, output_root=config_snapshot['output'], override_relative_path=params.get('target_relative_path') ) stats['execute']['moved_items'] += 1 resolution_status = 'resolved' workflow_state = 'ingested' elif action == 'save_and_organize': patched_item, changed = self.repair_service.metadata_service.apply(item, params.get('metadata_patch') or {}) if changed: stats['execute']['updated_metadata_items'] += 1 if not can_ingest_metadata(self.repair_service.metadata_normalizer.normalize_item(patched_item)): raise RepairExecutionError('metadata_incomplete', '加入音乐库前必须补齐 title、artist、album_artist') final_item, execution_result = self.repair_service.organize_service.organize_item( patched_item, output_root=config_snapshot['output'] ) stats['execute']['moved_items'] += 1 resolution_status = 'resolved' workflow_state = 'ingested' elif action == 'delete_file': file_path = Path(item['current_file_path']) if not file_path.exists(): raise RepairExecutionError('source_missing', f'源文件不存在: {file_path}') file_path.unlink() final_item = self.task_store.update_task_item( item['id'], is_active=0, organize_status='deleted', organize_reason='manual_delete', organize_message='文件已被永久删除' ) resolution_status = 'resolved' workflow_state = 'deleted' else: raise ValueError(f'Unsupported action: {action}') after_snapshot = self.repair_service.exception_service.get_item(exception_id) if resolution_status == 'open' else { **before_snapshot, 'current_file_path': final_item.get('current_file_path'), 'trash_file_path': final_item.get('trash_file_path'), 'library_relative_path': final_item.get('library_relative_path'), 'library_file_path': final_item.get('library_file_path'), 'matched_metadata_json': final_item.get('matched_metadata_json'), 'original_tags_json': final_item.get('original_tags_json') } resolution = dict(final_item.get('exception_resolution_json') or {}) resolution.update( { 'resolved_at': current_timestamp(), 'workflow_state': workflow_state, 'metadata_draft': _build_metadata_draft(final_item, params.get('metadata_patch') or {}, workflow_state), 'after_snapshot': after_snapshot, 'execution_result': execution_result } ) if action == 'keep_both_with_rename': resolution['secondary_version_retained'] = True self.task_store.update_task_item( final_item['id'], exception_resolution_status=resolution_status, exception_resolution_json=resolution, last_repair_task_id=repair_task_id ) self._log(repair_task_id, 'execute', 'success', 'repair.item_completed', f'异常项执行完成: {exception_id}', {'exception_id': exception_id, 'action': action, 'resolution_status': resolution_status}) self._broadcast(repair_task_id, 'repair.progress', 'execute', {'stats': stats, 'exception_id': exception_id}) def _log(self, task_id: str, stage: str, level: str, event_type: str, message: str, payload: dict | None = None): persisted_log = self.task_store.append_log(task_id, stage, level, event_type, message, payload) self._broadcast(task_id, 'log.appended', stage, {'log': persisted_log}) def _broadcast(self, task_id: str, event_type: str, stage: str, data: dict): self.task_stream.broadcast_event(task_id, event_type, stage, data) def _merge_metadata(item: dict, metadata_patch: dict | None = None) -> dict: return merge_metadata_layers( item.get('original_tags_json'), item.get('matched_metadata_json'), metadata_patch ) def _can_ingest(metadata: dict) -> bool: return can_ingest_metadata(metadata) def _metadata_workflow_state(item: dict, metadata_patch: dict | None = None) -> str: metadata = item.get('matched_metadata_json') or {} if metadata_patch: metadata = {**metadata, **{key: value for key, value in metadata_patch.items() if value is not None}} return 'ready_to_ingest' if _can_ingest(metadata) else 'open' def _candidate_workflow_state(item: dict) -> str: return 'ready_to_ingest' if _can_ingest(item.get('matched_metadata_json') or {}) else 'candidate_selected' def _build_metadata_draft(item: dict, metadata_patch: dict | None, workflow_state: str) -> dict | None: if workflow_state not in {'candidate_selected', 'ready_to_ingest'} and not metadata_patch: return None metadata = dict(item.get('matched_metadata_json') or {}) metadata.update({key: value for key, value in (metadata_patch or {}).items() if value is not None}) return metadata