from pathlib import Path from typing import Any from backend.app.exception_service import ExceptionService from backend.app.metadata_normalization import MetadataNormalizationService, can_ingest_metadata from backend.app.task_store import TaskStore METADATA_PREVIEW_FIELDS = ( 'title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics' ) class RepairPreviewService: def __init__( self, task_store: TaskStore, exception_service: ExceptionService, metadata_normalizer: MetadataNormalizationService, organize_service, ): self.task_store = task_store self.exception_service = exception_service self.metadata_normalizer = metadata_normalizer self.organize_service = organize_service 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 = [] risk_level = 'low' for item in items: item_preview = self._preview_item(item, action, params, config_snapshot) item_operations = item_preview['planned_operations'] item_warnings = item_preview['warnings'] item_risk = item_preview['risk_level'] preview_item = { 'exception_id': item['exception_id'], 'filename': item['filename'], 'exception_type': item['exception_type'], 'planned_operations': item_operations, 'warnings': item_warnings, } if item_preview.get('final_library_preview') is not None: preview_item['final_library_preview'] = item_preview['final_library_preview'] preview_items.append(preview_item) 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 _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) -> dict: item = self._with_task_item_id(item) source_item = item current_path = item.get('current_file_path') if action == 'ignore_exception': return self._item_preview( [self._op('status_update', current_path, None, '标记为已忽略,不执行物理删除')], ['真实执行仅做安全忽略或转入 review trash,不会物理删除源文件。'], 'low', ) if action == 'delete_file': return self._item_preview( [self._op('trash', current_path, None, '永久删除当前文件')], ['该动作会真实删除文件,执行后无法恢复。'], 'high', ) if action == 'edit_metadata': return self._item_preview([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._item_preview([self._op('status_update', current_path, None, description)], [], 'low') if action == 'select_match_candidate': return self._item_preview([self._op('status_update', current_path, None, '确认现有匹配候选')], [], 'low') if action == 'retry_preprocess': return self._item_preview([self._op('status_update', current_path, None, '重跑预处理与指纹提取')], [], 'low') if action == 'move_to_review_trash': return self._item_preview([self._op('trash', current_path, None, '移动到 review trash')], [], 'medium') if action == 'keep_existing': return self._item_preview( [self._op('trash', current_path, item.get('trash_file_path'), '保留库内文件并移走当前文件')], [], 'medium', ) if action == 'replace_existing': return self._item_preview( [ 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 metadata_patch = params.get('metadata_patch') if action == 'save_and_organize' else None if action == 'save_and_organize' and params.get('metadata_patch'): item = { **item, 'matched_metadata_json': self.metadata_normalizer.normalize_item(item, 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) final_library_preview = None if action == 'save_and_organize': final_library_preview = self._build_final_library_preview( source_item, metadata_patch, plan, self.metadata_normalizer.normalize_item(item) ) return self._item_preview( [ 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', final_library_preview=final_library_preview, ) raise ValueError(f'Unsupported action: {action}') def _item_preview( self, planned_operations: list[dict], warnings: list[str], risk_level: str, *, final_library_preview: dict | None = None, ) -> dict: return { 'planned_operations': planned_operations, 'warnings': warnings, 'risk_level': risk_level, 'final_library_preview': final_library_preview, } def _build_final_library_preview( self, item: dict, metadata_patch: dict | None, plan: dict, final_metadata: dict ) -> dict: return { 'metadata': final_metadata, 'metadata_sources': self._build_metadata_sources(item, metadata_patch, final_metadata), 'target_relative_path': plan['planned_relative_path'], 'target_file_path': str(Path(plan['output_root']) / plan['planned_relative_path']), } 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, } def _with_task_item_id(self, item: dict) -> dict: if 'id' in item: return item if 'exception_id' not in item: return item return {**item, 'id': item['exception_id']} def _build_metadata_sources(self, item: dict, metadata_patch: dict | None, final_metadata: dict) -> dict: raw_metadata = item.get('original_tags_json') or {} matched_metadata = item.get('matched_metadata_json') or {} patch = metadata_patch or {} sources = {} for field in METADATA_PREVIEW_FIELDS: if field not in final_metadata: continue if field in patch and patch[field] is not None: sources[field] = '手动编辑' elif self._has_metadata_value(matched_metadata, field): sources[field] = self._match_source_label(item) elif self._has_metadata_value(raw_metadata, field): sources[field] = '原始标签' else: sources[field] = '归一化推导' for field in ('normalization_strategy', 'album_artist_reason', 'compilation', 'artist_tokens', 'display_artist'): if field in final_metadata: sources[field] = '归一化推导' return sources def _has_metadata_value(self, metadata: dict, field: str) -> bool: return field in metadata and metadata.get(field) not in (None, '') def _match_source_label(self, item: dict) -> str: source = str(item.get('match_source') or '').strip() labels = { 'musicbrainz': 'MusicBrainz', 'acoustid': 'AcoustID', 'netease': '网易云', 'qq': 'QQ 音乐', 'spotify': 'Spotify', } return labels.get(source.lower(), source or '匹配结果')