842 lines
34 KiB
Python
842 lines
34 KiB
Python
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
|