chore: add project configs, backend repair services, docs, and code quality tooling
- 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>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.exception_service import ExceptionService
|
||||
from backend.app.library_postprocess import (
|
||||
_build_prefixed_name,
|
||||
_build_quality_breakdown,
|
||||
_build_unique_destination,
|
||||
_serialize_compared_candidate,
|
||||
)
|
||||
from backend.app.metadata_normalization import MetadataNormalizationService, can_ingest_metadata
|
||||
from backend.app.task_constants import current_timestamp
|
||||
|
||||
|
||||
class RepairExecutionError(Exception):
|
||||
def __init__(self, reason: str, message: str):
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
self.message = message
|
||||
|
||||
|
||||
class RepairExecutionService:
|
||||
def __init__(
|
||||
self,
|
||||
task_store,
|
||||
exception_service: ExceptionService,
|
||||
metadata_normalizer: MetadataNormalizationService,
|
||||
organize_service,
|
||||
match_service,
|
||||
preprocess_service,
|
||||
dedupe_service,
|
||||
):
|
||||
self.task_store = task_store
|
||||
self.exception_service = exception_service
|
||||
self.metadata_normalizer = metadata_normalizer
|
||||
self.organize_service = organize_service
|
||||
self.match_service = match_service
|
||||
self.preprocess_service = preprocess_service
|
||||
self.dedupe_service = dedupe_service
|
||||
|
||||
def apply_action_to_item(
|
||||
self, repair_task_id: str, exception_id: int, action: str, params: dict, config_snapshot: dict, stats: dict
|
||||
) -> 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.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._apply_metadata_patch(item, params.get('metadata_patch') or {})
|
||||
if changed:
|
||||
stats['execute']['updated_metadata_items'] += 1
|
||||
resolution_status = 'open'
|
||||
workflow_state = self._metadata_workflow_state(final_item, params.get('metadata_patch') or {})
|
||||
elif action == 'retry_match':
|
||||
final_item = self.match_service.retry_match(item, config_snapshot, providers=params.get('providers') or None)
|
||||
resolution_status = 'open'
|
||||
workflow_state = self._metadata_workflow_state(final_item)
|
||||
elif action == 'select_match_candidate':
|
||||
final_item = self.match_service.select_candidate(item, int(params.get('candidate_index', -1)))
|
||||
resolution_status = 'open'
|
||||
workflow_state = self._candidate_workflow_state(final_item)
|
||||
elif action == 'retry_preprocess':
|
||||
final_item = self.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.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.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.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.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.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._apply_metadata_patch(item, params.get('metadata_patch') or {})
|
||||
if changed:
|
||||
stats['execute']['updated_metadata_items'] += 1
|
||||
if not can_ingest_metadata(self.metadata_normalizer.normalize_item(patched_item)):
|
||||
raise RepairExecutionError('metadata_incomplete', '加入音乐库前必须补齐 title、artist、album_artist')
|
||||
final_item, execution_result = self.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._build_after_snapshot(before_snapshot, final_item, resolution_status)
|
||||
self._update_resolution(final_item, resolution_status, workflow_state, after_snapshot, execution_result, repair_task_id, params)
|
||||
return final_item
|
||||
|
||||
def _apply_metadata_patch(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 _write_tags(self, file_path: Path, tags: dict):
|
||||
import 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()
|
||||
|
||||
def _writable_keys(self):
|
||||
return {'title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics'}
|
||||
|
||||
def _build_after_snapshot(self, before_snapshot: dict, final_item: dict, resolution_status: str) -> dict:
|
||||
if resolution_status == 'open':
|
||||
return self.exception_service.get_item(before_snapshot.get('exception_id'))
|
||||
return {
|
||||
**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'),
|
||||
}
|
||||
|
||||
def _update_resolution(
|
||||
self,
|
||||
final_item: dict,
|
||||
resolution_status: str,
|
||||
workflow_state: str,
|
||||
after_snapshot: dict,
|
||||
execution_result: dict,
|
||||
repair_task_id: str,
|
||||
params: dict,
|
||||
):
|
||||
resolution = dict(final_item.get('exception_resolution_json') or {})
|
||||
resolution.update(
|
||||
{
|
||||
'resolved_at': current_timestamp(),
|
||||
'workflow_state': workflow_state,
|
||||
'metadata_draft': self._build_metadata_draft(final_item, params.get('metadata_patch') or {}, workflow_state),
|
||||
'after_snapshot': after_snapshot,
|
||||
'execution_result': execution_result,
|
||||
}
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
def _metadata_workflow_state(self, 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(metadata) else 'open'
|
||||
|
||||
def _candidate_workflow_state(self, item: dict) -> str:
|
||||
return 'ready_to_ingest' if can_ingest_metadata(item.get('matched_metadata_json') or {}) else 'candidate_selected'
|
||||
|
||||
def _build_metadata_draft(self, 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
|
||||
@@ -0,0 +1,110 @@
|
||||
from backend.app.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,
|
||||
)
|
||||
from backend.app.services.repair_execution import RepairExecutionError, RepairExecutionService
|
||||
from backend.app.services.repair_preview import RepairPreviewService
|
||||
|
||||
|
||||
class RepairOrchestrator:
|
||||
def __init__(self, task_store, task_stream, preview_service: RepairPreviewService, execution_service: RepairExecutionService):
|
||||
self.task_store = task_store
|
||||
self.task_stream = task_stream
|
||||
self.preview_service = preview_service
|
||||
self.execution_service = execution_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.execution_service.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 _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)
|
||||
@@ -0,0 +1,243 @@
|
||||
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 '匹配结果')
|
||||
@@ -0,0 +1,41 @@
|
||||
<![CDATA=[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
exclude = [
|
||||
".git",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
".venv",
|
||||
"venv",
|
||||
"migrations",
|
||||
]
|
||||
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"C", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
"UP", # pyupgrade
|
||||
"RUF", # ruff-specific rules
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"C901", # too complex (handled separately)
|
||||
]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "single"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "lf"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py311']
|
||||
]]>
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
os.environ['MUSIC_WORKSHOP_DB_PATH'] = str(
|
||||
Path(tempfile.gettempdir()) / f'music_workshop_repair_preview_{next(tempfile._get_candidate_names())}.db'
|
||||
)
|
||||
|
||||
from backend.app.exception_service import ExceptionService
|
||||
from backend.app.repair_runner import RepairService
|
||||
from backend.app.task_store import TaskStore
|
||||
|
||||
|
||||
class RepairPreviewTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.db_path = Path(os.environ['MUSIC_WORKSHOP_DB_PATH'])
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
self.output_root = Path(tempfile.gettempdir()) / f'music-workshop-output-{next(tempfile._get_candidate_names())}'
|
||||
self.task_store = TaskStore(self.db_path)
|
||||
self.exception_service = ExceptionService(self.task_store)
|
||||
self.repair_service = RepairService(
|
||||
self.task_store,
|
||||
self.exception_service,
|
||||
matcher=None,
|
||||
preprocessor=None,
|
||||
task_stream=None
|
||||
)
|
||||
self.task = self.task_store.create_task_if_idle(
|
||||
{
|
||||
'input': '/tmp/input',
|
||||
'output': str(self.output_root),
|
||||
'trash': '/tmp/trash'
|
||||
}
|
||||
)
|
||||
|
||||
def test_save_and_organize_preview_returns_final_library_preview(self):
|
||||
item = self._insert_metadata_exception(
|
||||
original_tags_json={
|
||||
'title': 'Raw Title',
|
||||
'artist': 'Raw Artist',
|
||||
'album': 'Raw Album',
|
||||
'year': 1999
|
||||
},
|
||||
matched_metadata_json={
|
||||
'title': 'Matched Title',
|
||||
'artist': 'Matched Artist',
|
||||
'album': 'Matched Album',
|
||||
'album_artist': 'Matched Artist',
|
||||
'track_number': 7
|
||||
},
|
||||
match_source='musicbrainz'
|
||||
)
|
||||
|
||||
preview = self.repair_service.preview(
|
||||
{
|
||||
'exception_ids': [item['id']],
|
||||
'action': 'save_and_organize',
|
||||
'params': {'metadata_patch': {'title': 'Manual Title'}}
|
||||
},
|
||||
{'output': str(self.output_root), 'trash': '/tmp/trash'}
|
||||
)
|
||||
|
||||
item_preview = preview['items'][0]['final_library_preview']
|
||||
self.assertEqual(item_preview['metadata']['title'], 'Manual Title')
|
||||
self.assertEqual(item_preview['metadata']['artist'], 'Matched Artist')
|
||||
self.assertEqual(item_preview['metadata']['album_artist'], 'Matched Artist')
|
||||
self.assertEqual(item_preview['metadata']['year'], 1999)
|
||||
self.assertEqual(item_preview['metadata_sources']['title'], '手动编辑')
|
||||
self.assertEqual(item_preview['metadata_sources']['artist'], 'MusicBrainz')
|
||||
self.assertEqual(item_preview['metadata_sources']['year'], '原始标签')
|
||||
self.assertEqual(item_preview['target_relative_path'], 'M/Matched Artist/Matched Album/07 - Manual Title.flac')
|
||||
self.assertEqual(
|
||||
item_preview['target_file_path'],
|
||||
str(self.output_root / 'M/Matched Artist/Matched Album/07 - Manual Title.flac')
|
||||
)
|
||||
|
||||
def test_save_and_organize_preview_rejects_missing_required_metadata(self):
|
||||
item = self._insert_metadata_exception(
|
||||
original_tags_json={'title': 'Raw Title'},
|
||||
matched_metadata_json={'title': 'Matched Title'},
|
||||
match_source='netease'
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, 'title、artist、album_artist'):
|
||||
self.repair_service.preview(
|
||||
{
|
||||
'exception_ids': [item['id']],
|
||||
'action': 'save_and_organize',
|
||||
'params': {'metadata_patch': {}}
|
||||
},
|
||||
{'output': str(self.output_root), 'trash': '/tmp/trash'}
|
||||
)
|
||||
|
||||
def test_save_and_organize_preview_manual_patch_overrides_candidate_and_raw(self):
|
||||
item = self._insert_metadata_exception(
|
||||
original_tags_json={
|
||||
'title': 'Raw Title',
|
||||
'artist': 'Raw Artist',
|
||||
'album_artist': 'Raw Album Artist'
|
||||
},
|
||||
matched_metadata_json={
|
||||
'title': 'Candidate Title',
|
||||
'artist': 'Candidate Artist',
|
||||
'album_artist': 'Candidate Album Artist',
|
||||
'album': 'Candidate Album'
|
||||
},
|
||||
match_source='netease'
|
||||
)
|
||||
|
||||
preview = self.repair_service.preview(
|
||||
{
|
||||
'exception_ids': [item['id']],
|
||||
'action': 'save_and_organize',
|
||||
'params': {
|
||||
'metadata_patch': {
|
||||
'artist': 'Manual Artist',
|
||||
'album_artist': 'Manual Album Artist'
|
||||
}
|
||||
}
|
||||
},
|
||||
{'output': str(self.output_root), 'trash': '/tmp/trash'}
|
||||
)
|
||||
|
||||
item_preview = preview['items'][0]['final_library_preview']
|
||||
self.assertEqual(item_preview['metadata']['title'], 'Candidate Title')
|
||||
self.assertEqual(item_preview['metadata']['artist'], 'Manual Artist')
|
||||
self.assertEqual(item_preview['metadata']['album_artist'], 'Manual Album Artist')
|
||||
self.assertEqual(item_preview['metadata_sources']['title'], '网易云')
|
||||
self.assertEqual(item_preview['metadata_sources']['artist'], '手动编辑')
|
||||
self.assertEqual(item_preview['metadata_sources']['album_artist'], '手动编辑')
|
||||
|
||||
def _insert_metadata_exception(self, **overrides):
|
||||
filename = overrides.pop('filename', f'item-{next(tempfile._get_candidate_names())}.flac')
|
||||
extension = Path(filename).suffix or '.flac'
|
||||
return self.task_store.insert_task_item(
|
||||
self.task['task_id'],
|
||||
original_path=f'/tmp/input/{filename}',
|
||||
current_file_path=f'/tmp/input/{filename}',
|
||||
relative_path=f'Artist/Album/{filename}',
|
||||
filename=filename,
|
||||
extension=extension,
|
||||
size_bytes=123456,
|
||||
modified_at='2024-01-01T00:00:00Z',
|
||||
local_cover=None,
|
||||
local_lyric=None,
|
||||
scan_status='queued',
|
||||
scan_reason=None,
|
||||
scan_message=None,
|
||||
match_status='low_score',
|
||||
match_reason='score_gap_too_small',
|
||||
match_message='匹配候选分数过低',
|
||||
**overrides
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user