be3c086975
- Add pre-commit hooks (ruff, black, prettier) and ESLint/Prettier configs - Add backend repair services (execution, orchestration, preview) with tests - Add project documentation (CLAUDE.md, README.md, design specs and plans) - Add MissingTagsInlinePanel component for exception handling - Add pyproject.toml with ruff/black configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
11 KiB
Python
244 lines
11 KiB
Python
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 '匹配结果')
|