""" 提供文件整理任务查询、执行、重试和控制接口。 """ import re from pathlib import Path from typing import Annotated, Any, List, Literal, Optional from fastapi import Depends, HTTPException, Query, status from app.adapters.web.security.access import verify_apitoken, verify_token from app.api.dependencies.auth import get_current_active_manage_user from app.api.dependencies.history import get_transfer_execution_repository, get_transfer_history_lookup_service from app.api.endpoints.transferhistory import ( restore_manual_transfer_history_batch, restore_manual_transfer_history_metadata, ) from app.api.response import ( COLLECTION_MAX_PAGE_SIZE, CompatibleCountParam, CompatiblePageParam, ResponseAPIRouter, ) from app.application.configuration import get_api_runtime_config_snapshot from app.application.directory import DirectoryHelper from app.application.history import TransferHistoryLookupService from app.application.transfer.execution import ( TransferExecutionCommand, TransferExecutionConflictError, TransferExecutionRepository, TransferExecutionState, TransferManualReviewDecision, TransferManualReviewQuery, TransferManualReviewTaskView, TransferStepResult, ) from app.chain.media import MediaChain from app.chain.transfer.facade import TransferChain from app.runtime.log import logger from app.runtime.stop import runtime_stop_state from app.schemas.common import NameData as _SchemaNameData from app.schemas.file import FileItem from app.schemas.file import FileItem as _SchemaFileItem from app.schemas.response import Response as _SchemaResponse from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.transfer import EpisodeFormat as _SchemaEpisodeFormat from app.schemas.transfer import EpisodeFormatRecommendData as _SchemaEpisodeFormatRecommendData from app.schemas.transfer import EpisodeFormatRecommendItem, ManualTransferItem from app.schemas.transfer import ManualTransferHistoryInfo as _SchemaManualTransferHistoryInfo from app.schemas.transfer import ManualTransferResultData as _SchemaManualTransferResultData from app.schemas.transfer import ManualTransferSubmissionData as _SchemaManualTransferSubmissionData from app.schemas.transfer import ManualTransferSubmissionItem as _SchemaManualTransferSubmissionItem from app.schemas.transfer import ManualTransferTargetPath as _SchemaManualTransferTargetPath from app.schemas.transfer import TransferJob as _SchemaTransferJob from app.schemas.transfer import TransferManualReviewData as _SchemaTransferManualReviewData from app.schemas.transfer import TransferManualReviewDiscardData as _SchemaTransferManualReviewDiscardData from app.schemas.transfer import TransferManualReviewDiscardRequest as _SchemaTransferManualReviewDiscardRequest from app.schemas.transfer import TransferManualReviewPageData as _SchemaTransferManualReviewPageData from app.schemas.transfer import TransferManualReviewRequest as _SchemaTransferManualReviewRequest from app.schemas.transfer import TransferManualReviewTaskData as _SchemaTransferManualReviewTaskData from app.schemas.transfer import TransferQueuePageData as _SchemaTransferQueuePageData from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType router = ResponseAPIRouter() def _public_transfer_message(message: Optional[object]) -> Optional[str]: """ 把整理链返回的错误转换为前端可直接展示的文案。 """ if message is None or not str(message).strip(): return None from app.runtime.errors import public_error_message return public_error_message(message, context="transfer") def _public_transfer_result(data: dict[str, Any]) -> dict[str, Any]: """ 裁剪整理结果中的错误字段,保留预览数据的原有结构。 """ from app.application.transfer.feedback import classify_transfer_failure result = dict(data) if result.get("message"): result["message"] = _public_transfer_message(result["message"]) items = result.get("items") if isinstance(items, list): public_items = [] for item in items: if not isinstance(item, dict): public_items.append(item) continue public_item = { **item, "message": _public_transfer_message(item.get("message")), } if public_item.get("success") is False: feedback = classify_transfer_failure( public_item.get("message"), overwrite_skipped=bool(public_item.get("overwrite_skipped")), ) public_item["failure_stage"] = public_item.get("failure_stage") or feedback.stage.value public_item["recovery_action"] = public_item.get("recovery_action") or feedback.action public_items.append(public_item) result["items"] = public_items return result def _build_failure_preview_item( file_item: FileItem, message: Optional[str] ) -> dict[str, Any]: """ 构造手动整理预览失败项。 """ from app.application.transfer.feedback import classify_transfer_failure feedback = classify_transfer_failure(message) return { "source": file_item.path if file_item else None, "source_storage": file_item.storage if file_item else None, "source_item": file_item.model_dump(exclude={"children", "url", "thumbnail"}) if file_item else None, "target": None, "target_dir": None, "success": False, "message": _public_transfer_message(message), "failure_stage": feedback.stage.value, "recovery_action": feedback.action, "overwrite_skipped": False, "type": None, "title": None, "season": None, "episode": None, "episode_end": None, "part": None, "org_string": None, "apply_words": [], "resource_team": None, "customization": None, } def _restore_history_fileitem(payload: object) -> Optional[FileItem]: """ 从整理历史的 JSON 快照恢复文件项,拒绝缺失或非对象载荷。 """ if not isinstance(payload, dict): return None return FileItem.model_validate(payload) def _format_manual_transfer_failure( *, message: Optional[object], source_path: Optional[str], target_path: Optional[str] = None, ) -> str: """ 把手动整理失败转换为包含路径、阶段和下一步动作的用户提示。 """ from app.application.transfer.feedback import format_transfer_failure_message public_message = _public_transfer_message(message) or "整理失败" return format_transfer_failure_message( public_message, source_path=source_path, target_path=target_path, ) def _merge_transfer_messages(messages: List[str]) -> str: """ 合并手动整理批量预览提示信息,并统一转换错误文案。 """ valid_messages = [ public_message for msg in messages if msg for public_message in [_public_transfer_message(msg)] if public_message ] if not valid_messages: return "" return "、".join(valid_messages[:2]) + ( f",等{len(valid_messages)}条消息" if len(valid_messages) > 2 else "" ) def _manual_submission_items( fileitem: FileItem, success: bool, result: object, target_path: Optional[Path], ) -> list[_SchemaManualTransferSubmissionItem]: """ 映射真实执行回执,旧返回值最多确认接收,不能推断入库完成。 """ if isinstance(result, dict): items = _public_transfer_result(result).get("items") if isinstance(items, list): return [_SchemaManualTransferSubmissionItem.model_validate(item) for item in items] message = _public_transfer_message(result) data = _public_transfer_result({"items": [{ "source": fileitem.path, "success": success, "state": "accepted" if success else "failed", "message": message, "target_dir": target_path.as_posix() if target_path else None, }]}) return [_SchemaManualTransferSubmissionItem.model_validate(item) for item in data["items"]] def _manual_submission_response( success: bool, items: list[_SchemaManualTransferSubmissionItem], ) -> Any: """ 保留部分成功文件,并继续为旧客户端提供包含失败路径的总提示。 """ errors = [ _format_manual_transfer_failure( message=item.message, source_path=item.source, target_path=item.target or item.target_dir, ) for item in items if item.state in {"failed", "retry_wait", "manual_review"} and item.message ] message = _merge_transfer_messages(errors or [item.message for item in items if item.message and not success]) return _SchemaResponse( success=success, message=message, data=_SchemaManualTransferSubmissionData(items=items, message=message), ) def _is_music_file_batch(fileitems: List[FileItem]) -> bool: """ 判断显式选中项是否为可按专辑识别的多音轨批次。 """ if len(fileitems) < 2 or any(fileitem.type != "file" for fileitem in fileitems): return False audio_extensions = set(get_api_runtime_config_snapshot().audio_extensions) return all( Path(fileitem.path or fileitem.name or "").suffix.lower() in audio_extensions for fileitem in fileitems ) def _selected_music_fileitems( fileitems: List[FileItem], *, explicitly_selected: bool, media_type: Optional[MediaType], ) -> Optional[List[FileItem]]: """ 仅在用户明确选择音乐类型时,将显式多选音轨作为专辑批次处理。 """ if not explicitly_selected or media_type != MediaType.MUSIC: return None if not _is_music_file_batch(fileitems): return None paths = [Path(fileitem.path) for fileitem in fileitems if fileitem.path] parent_counts: dict[Path, int] = {} for path in paths: parent_counts[path.parent] = parent_counts.get(path.parent, 0) + 1 album_roots = [ parent for parent in parent_counts if all(path.parent == parent or parent in path.parents for path in paths) ] if not album_roots: return fileitems album_root = max(album_roots, key=lambda path: len(path.parts)) filtered = [] for fileitem, path in zip(fileitems, paths): if path.parent == album_root: filtered.append(fileitem) continue relative_parent = path.parent.relative_to(album_root) first_directory = relative_parent.parts[0] if relative_parent.parts else "" if re.fullmatch(r"(?:cd|disc|disk)\s*\d{1,2}", first_directory.strip(), re.IGNORECASE): filtered.append(fileitem) if filtered and len(filtered) < len(fileitems): logger.info( "音乐专辑批次忽略 %s 个非碟片子目录中的重复或附加音频", len(fileitems) - len(filtered), ) return filtered return fileitems def _manual_review_actor(current_user: object) -> str: """ 按名称、用户名和用户 ID 的稳定顺序提取人工复核操作者。 """ for attribute in ("name", "username", "id"): value = getattr(current_user, attribute, None) if value is not None and str(value).strip(): return str(value).strip() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="当前管理用户缺少可审计身份", ) def _manual_review_task_data( task: TransferManualReviewTaskView, ) -> _SchemaTransferManualReviewTaskData: """ 把 Application 人工复核投影映射为严格公开响应。 """ return _SchemaTransferManualReviewTaskData.model_validate({ "task_id": task.task_id, "source": { "storage": task.source.storage, "path": task.source.path, }, "state": task.state.value, "step": { "operation_id": task.step.operation_id, "kind": task.step.kind, "intent": task.step.intent, "evidence": task.step.evidence, "error": _public_transfer_message(task.step.error), }, "review_revision": task.review_revision, }) @router.get( "/tasks/manual-reviews", summary="分页查询 durable 整理人工复核任务", response_model=_SchemaResponse[_SchemaTransferManualReviewPageData], ) def list_transfer_manual_reviews( state_filter: Literal["manual_review", "retry_wait"] = Query( default="manual_review", alias="state", ), page: int = Query(default=1, ge=1), page_size: int = Query(default=30, ge=1, le=100), current_user: object = Depends(get_current_active_manage_user), repository: TransferExecutionRepository = Depends( get_transfer_execution_repository ), ) -> Any: """ 分页返回待复核或已判定等待 durable 恢复的任务。 """ del current_user result = TransferManualReviewQuery(repository).list( state=TransferExecutionState(state_filter), page=page, page_size=page_size, ) return _SchemaResponse( success=True, data=_SchemaTransferManualReviewPageData( items=[_manual_review_task_data(item) for item in result.items], total=result.total, page=result.page, page_size=result.page_size, ), ) @router.get( "/tasks/{task_id}/manual-review", summary="查询 durable 整理人工复核详情", response_model=_SchemaResponse[_SchemaTransferManualReviewTaskData], ) def get_transfer_manual_review( task_id: str, current_user: object = Depends(get_current_active_manage_user), repository: TransferExecutionRepository = Depends( get_transfer_execution_repository ), ) -> Any: """ 按任务标识返回严格裁剪的人工复核详情。 """ del current_user task = TransferManualReviewQuery(repository).get(task_id=task_id) if task is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="人工复核任务不存在", ) return _SchemaResponse(success=True, data=_manual_review_task_data(task)) @router.post( "/tasks/{task_id}/manual-review", summary="人工判定整理步骤的外部执行结果", response_model=_SchemaResponse[_SchemaTransferManualReviewData], ) def resolve_transfer_manual_review( task_id: str, review: _SchemaTransferManualReviewRequest, current_user: object = Depends(get_current_active_manage_user), repository: TransferExecutionRepository = Depends( get_transfer_execution_repository ), ) -> Any: """ 提交无租约人工判定,并返回不含 attempt 与 lease 的公开状态。 """ result = ( TransferStepResult(payload=dict(review.result_payload)) if review.result_payload is not None else None ) try: resolved = TransferExecutionCommand(repository).resolve_manual_review( task_id=task_id, operation_id=review.operation_id, decision=TransferManualReviewDecision(review.decision), actor=_manual_review_actor(current_user), reason=review.reason, result=result, ) except TransferExecutionConflictError as error: logger.warning(f"整理人工复核请求冲突:{error}", exc_info=True) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="整理任务状态已变化,请刷新后重试", ) from error return _SchemaResponse( success=True, data=_SchemaTransferManualReviewData( task_id=resolved.task_id, operation_id=resolved.operation_id, decision=( "applied" if resolved.decision is TransferManualReviewDecision.APPLIED else "not_applied" ), state=resolved.state.value, review_revision=resolved.review_revision, ), ) @router.post( "/tasks/{task_id}/manual-review/discard", summary="放弃人工复核整理任务但保留文件", response_model=_SchemaResponse[_SchemaTransferManualReviewDiscardData], ) def discard_transfer_manual_review( task_id: str, review: _SchemaTransferManualReviewDiscardRequest, current_user: object = Depends(get_current_active_manage_user), repository: TransferExecutionRepository = Depends(get_transfer_execution_repository), ) -> Any: """解除无法确认的整理任务;不删除、不移动且不回滚任何文件。""" try: discarded = TransferExecutionCommand(repository).discard_manual_review( task_id=task_id, operation_id=review.operation_id, actor=_manual_review_actor(current_user), reason=review.reason, ) except TransferExecutionConflictError as error: logger.warning(f"整理人工复核放弃请求冲突:{error}", exc_info=True) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="整理任务状态已变化,请刷新后重试", ) from error return _SchemaResponse( success=True, data=_SchemaTransferManualReviewDiscardData( task_id=discarded.task_id, operation_id=discarded.operation_id, discarded=discarded.discarded, message=discarded.message, ), ) @router.get( "/name", summary="查询整理后的名称", response_model=_SchemaResponse[_SchemaNameData], ) def query_name( path: str, filetype: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询整理后的名称 :param path: 文件路径 :param filetype: 文件类型 :param _: Token校验 """ context = MediaChain().recognize_by_path( path, obtain_images=False, ) if not context or not context.media_info: return _SchemaResponse(success=False, message="未识别到媒体信息") new_path = TransferChain().recommend_name( meta=context.meta_info, mediainfo=context.media_info ) if not new_path: return _SchemaResponse(success=False, message="未识别到新名称") if filetype == "dir": media_type = context.media_info.type if media_type is None: return _SchemaResponse(success=False, message="未识别到媒体类型") media_path = DirectoryHelper.get_media_root_path( rename_format=get_api_runtime_config_snapshot().rename_format( media_type ), rename_path=Path(new_path), media_type=media_type, ) if media_path: new_name = media_path.name else: # 模板未提供媒体根目录时,沿用已有父目录层级推导名称。 parents = Path(new_path).parents if len(parents) > 2: new_name = parents[1].name else: new_name = parents[0].name else: new_name = Path(new_path).name return _SchemaResponse(success=True, data={"name": new_name}) @router.get("/queue", summary="查询整理队列", response_model=List[_SchemaTransferJob]) async def query_queue(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询整理队列 :param _: Token校验 """ return TransferChain().get_queue_tasks() @router.get( "/queue/page", summary="查询受限整理队列快照", response_model=_SchemaTransferQueuePageData, ) async def query_queue_page( _: _SchemaTokenPayload = Depends(verify_token), page: int = Query(1, ge=1), count: int = Query(100, ge=1, le=COLLECTION_MAX_PAGE_SIZE), ) -> _SchemaTransferQueuePageData: """ 返回前端所需的有限整理队列窗口,避免序列化完整云盘目录树。 """ items, total = TransferChain().get_queue_tasks_page(page, count) return _SchemaTransferQueuePageData( items=items, total=total, page=page, count=count, ) @router.delete( "/queue", summary="从整理队列中删除任务", response_model=_SchemaResponse[None] ) async def remove_queue( fileitem: _SchemaFileItem, _: Any = Depends(get_current_active_manage_user) ) -> Any: """ 查询整理队列 :param fileitem: 文件项 :param _: Token校验 """ TransferChain().remove_from_queue(fileitem) # 取消整理 if not fileitem.path: return _SchemaResponse(success=False, message="文件路径为空") runtime_stop_state.stop_transfer(fileitem.path) return _SchemaResponse(success=True) def _resolve_manual_transfer_source_fileitems( transer_item: ManualTransferItem, history_query: TransferHistoryLookupService, ) -> tuple[List[FileItem], Optional[str]]: """ 从手动整理请求中解析源文件项。 """ if transer_item.logids: fileitems: List[FileItem] = [] for logid in transer_item.logids: history = history_query.get(logid) if not history: return [], f"整理记录不存在,ID:{logid}" if history.status and ("move" in (history.mode or "")): fileitem = _restore_history_fileitem(history.dest_fileitem) else: fileitem = _restore_history_fileitem(history.src_fileitem) if fileitem is None: return [], f"整理记录缺少文件项,ID:{logid}" fileitems.append(fileitem) return fileitems, None if transer_item.logid: history = history_query.get(transer_item.logid) if not history: return [], f"整理记录不存在,ID:{transer_item.logid}" if history.status and ("move" in (history.mode or "")): fileitem = _restore_history_fileitem(history.dest_fileitem) else: fileitem = _restore_history_fileitem(history.src_fileitem) if fileitem is None: return [], f"整理记录缺少文件项,ID:{transer_item.logid}" return [fileitem], None if transer_item.fileitems: return [fileitem for fileitem in transer_item.fileitems if fileitem], None if transer_item.fileitem: return [transer_item.fileitem], None return [], None def _deduplicate_fileitems(fileitems: List[FileItem]) -> List[FileItem]: """ 按存储和路径去重文件项。 """ dedup_fileitems: List[FileItem] = [] seen_paths = set() for current_fileitem in fileitems: storage = current_fileitem.storage or "local" path = current_fileitem.path if not path: continue key = (storage, path) if key in seen_paths: continue seen_paths.add(key) dedup_fileitems.append(current_fileitem) return dedup_fileitems def _build_manual_transfer_target_path( directory: Optional[_SchemaTransferDirectoryConf] = None, ) -> _SchemaManualTransferTargetPath: """ 根据目录配置生成手动整理目的路径响应。 """ if not directory or not directory.library_path: return _SchemaManualTransferTargetPath() return _SchemaManualTransferTargetPath( target_storage=directory.library_storage or "local", target_path=directory.library_path, transfer_type=directory.transfer_type, scrape=directory.scraping or False, library_type_folder=directory.library_type_folder or False, library_category_folder=directory.library_category_folder or False, ) def _get_manual_transfer_target_key( directory: _SchemaTransferDirectoryConf, ) -> tuple[Optional[str], Optional[str]]: """ 生成目的目录唯一键。 """ return ( directory.library_storage or "local", Path(directory.library_path).as_posix() if directory.library_path else None, ) @router.post( "/manual/target-path", summary="匹配手动转移目的路径", response_model=_SchemaResponse[_SchemaManualTransferTargetPath], ) def match_manual_transfer_target_path( transer_item: ManualTransferItem, history_query: TransferHistoryLookupService = Depends( get_transfer_history_lookup_service ), _: object = Depends(get_current_active_manage_user), ) -> Any: """ 根据源文件匹配手动整理目的路径。 :param transer_item: 手工整理项 :param history_query: 整理历史投影服务 :param _: Token校验 """ src_fileitems, error_message = _resolve_manual_transfer_source_fileitems( transer_item=transer_item, history_query=history_query, ) if error_message: return _SchemaResponse(success=False, message=error_message) matched_directories: List[_SchemaTransferDirectoryConf] = [] target_storage = transer_item.target_storage or None for src_fileitem in _deduplicate_fileitems(src_fileitems): if not src_fileitem.path: return _SchemaResponse(success=False, message="源文件路径为空") directory = DirectoryHelper().get_dir( media=None, storage=src_fileitem.storage or "local", src_path=Path(src_fileitem.path), target_storage=target_storage, ) if not directory or not directory.library_path: return _SchemaResponse( success=True, data=_SchemaManualTransferTargetPath().model_dump(), ) matched_directories.append(directory) if not matched_directories: return _SchemaResponse( success=True, data=_SchemaManualTransferTargetPath().model_dump(), ) first_directory = matched_directories[0] first_key = _get_manual_transfer_target_key(first_directory) if any( _get_manual_transfer_target_key(directory) != first_key for directory in matched_directories[1:] ): return _SchemaResponse( success=True, data=_SchemaManualTransferTargetPath().model_dump(), ) return _SchemaResponse( success=True, data=_build_manual_transfer_target_path(first_directory).model_dump(), ) @router.post( "/manual/history", summary="查询手动转移成功历史", response_model=_SchemaResponse[_SchemaManualTransferHistoryInfo], ) def query_manual_transfer_history( transer_item: ManualTransferItem, history_query: TransferHistoryLookupService = Depends( get_transfer_history_lookup_service ), _: object = Depends(get_current_active_manage_user), ) -> Any: """ 查询文件或目录命中的成功整理记录。 :param transer_item: 手工整理项 :param history_query: 整理历史投影服务 :param _: Token校验 """ src_fileitems, error_message = _resolve_manual_transfer_source_fileitems( transer_item=transer_item, history_query=history_query, ) if error_message: return _SchemaResponse(success=False, message=error_message) histories = TransferChain().get_manual_transfer_histories( _deduplicate_fileitems(src_fileitems) ) history_info = _SchemaManualTransferHistoryInfo( reorganize=bool(histories), history_count=len(histories), ) return _SchemaResponse(success=True, data=history_info.model_dump()) @router.post( "/manual", summary="手动转移", response_model=_SchemaResponse[_SchemaManualTransferSubmissionData | _SchemaManualTransferResultData], ) def manual_transfer( transer_item: ManualTransferItem, background: Optional[bool] = False, history_query: TransferHistoryLookupService = Depends( get_transfer_history_lookup_service ), _: object = Depends(get_current_active_manage_user), ) -> Any: """ 解析手动整理 HTTP 请求并委托兼容用例处理器。 :param transer_item: 手工整理项 :param background: 后台运行 :param history_query: 整理历史投影服务 :param _: Token校验 """ return _route_manual_transfer( transer_item=transer_item, background=background, history_query=history_query, ) def _route_manual_transfer( transer_item: ManualTransferItem, background: Optional[bool], history_query: TransferHistoryLookupService, ) -> Any: """ 执行历史恢复、批量预览与 TransferChain 兼容编排。 """ if not transer_item.logids: return _execute_manual_transfer( transer_item=transer_item, background=background, history_query=history_query, ) ( src_fileitems, force, downloader, download_hash, history_error, ) = restore_manual_transfer_history_batch( transer_item=transer_item, history_query=history_query, ) if history_error or not src_fileitems: return _SchemaResponse( success=False, message=history_error or "缺少参数", ) transer_item.fileitems = src_fileitems transer_item.logids = None return _execute_manual_transfer( transer_item=transer_item, background=background, history_query=history_query, force=force, downloader=downloader, download_hash=download_hash, ) def _execute_manual_transfer( transer_item: ManualTransferItem, background: Optional[bool], history_query: TransferHistoryLookupService, force: bool = False, downloader: Optional[str] = None, download_hash: Optional[str] = None, ) -> Any: """ 执行已还原源文件项的手动整理兼容编排。 """ src_fileitems: List[FileItem] = [] cleanup_dest_fileitem: Optional[FileItem] = None target_path = Path(transer_item.target_path) if transer_item.target_path else None if transer_item.logid: # 查询历史记录 history = history_query.get(transer_item.logid) if not history: return _SchemaResponse( success=False, message=f"整理记录不存在,ID:{transer_item.logid}" ) # 失败历史必须经过整理链的重试/重整判定,不能绕过旧任务直接重新准入。 force = bool(history.status) # 下载器与 Hash 是同一组下载上下文,重新识别时由当前文件路径重新匹配。 downloader = history.downloader if transer_item.from_history else None download_hash = history.download_hash if transer_item.from_history else None if history.status and ("move" in (history.mode or "")): # 重新整理成功的转移,则使用成功的 dest 做 in_path fileitem = _restore_history_fileitem(history.dest_fileitem) else: # 源路径 fileitem = _restore_history_fileitem(history.src_fileitem) if ( history.dest_fileitem and not transer_item.preview and not transer_item.reorganize ): cleanup_dest_fileitem = _restore_history_fileitem(history.dest_fileitem) src_fileitems = [fileitem] if fileitem else [] # 从历史数据获取信息 if transer_item.from_history: restore_manual_transfer_history_metadata(transer_item, history) elif transer_item.fileitems: src_fileitems = [fileitem for fileitem in transer_item.fileitems if fileitem] elif transer_item.fileitem: src_fileitems = [transer_item.fileitem] else: return _SchemaResponse(success=False, message="缺少参数") src_fileitems = _deduplicate_fileitems(src_fileitems) if not src_fileitems: return _SchemaResponse(success=False, message="缺少参数") # 类型(“自动/auto/none”按未指定处理) mtype = None type_name = str(transer_item.type_name).strip() if transer_item.type_name else "" if type_name and type_name.lower() not in {"自动", "auto", "none"}: # 先按 Agent 使用的 movie/tv/music 取值解析,再回退到原生中文枚举值。 try: mtype = MediaType.from_agent(type_name) or MediaType(type_name) except ValueError: return _SchemaResponse( success=False, message=f"不支持的媒体类型:{type_name}" ) def _resolve_music_type(file_item: FileItem) -> Optional[str]: """ 为未显式指定实体的旧客户端按源项类型补全音乐命名空间。 """ if mtype != MediaType.MUSIC or transer_item.music_type: return transer_item.music_type return ( MUSIC_ENTITY_ALBUM if file_item.type == "dir" else MUSIC_ENTITY_RECORDING ) # 自定义格式 epformat = None if ( transer_item.episode_offset or transer_item.episode_part or transer_item.episode_detail or transer_item.episode_format ): epformat = _SchemaEpisodeFormat( format=transer_item.episode_format, detail=transer_item.episode_detail, part=transer_item.episode_part, offset=transer_item.episode_offset, ) explicit_selected_files = bool(transer_item.fileitems or transer_item.logids) selected_music_fileitems = _selected_music_fileitems( src_fileitems, explicitly_selected=explicit_selected_files, media_type=mtype ) mtype = MediaType.MUSIC if selected_music_fileitems is not None else mtype explicit_selected_files = explicit_selected_files and selected_music_fileitems is None release_kwargs: dict[str, Any] = ( {"musicbrainz_release_id": transer_item.musicbrainz_release_id} if transer_item.musicbrainz_release_id is not None else {} ) # 前端显式传入文件列表时,按选中的文件逐个处理,避免将目录整体展开。 if explicit_selected_files: preview_items: List[dict[str, Any]] = [] submission_items: list[_SchemaManualTransferSubmissionItem] = [] error_messages: List[str] = [] all_success = True for src_fileitem in src_fileitems: state, errormsg = TransferChain().manual_transfer( fileitem=src_fileitem, target_storage=transer_item.target_storage, target_path=target_path, media_source=transer_item.media_source, media_id=transer_item.media_id, music_type=_resolve_music_type(src_fileitem), music_release_regions=transer_item.music_release_regions, music_release_scripts=transer_item.music_release_scripts, music_cue_enable=transer_item.music_cue_enable, **release_kwargs, mtype=mtype, season=transer_item.season, episode_group=transer_item.episode_group, transfer_type=transer_item.transfer_type, epformat=epformat, min_filesize=transer_item.min_filesize, scrape=transer_item.scrape, library_type_folder=transer_item.library_type_folder, library_category_folder=transer_item.library_category_folder, force=force, background=background, downloader=downloader, download_hash=download_hash, preview=transer_item.preview, reorganize=transer_item.reorganize, skip_success=transer_item.skip_success, sync_extra_files=False, cleanup_dest_fileitem=cleanup_dest_fileitem, report_results=not transer_item.preview, ) if transer_item.preview: if isinstance(errormsg, dict): preview_items.extend( _public_transfer_result(errormsg).get("items") or [] ) if errormsg.get("message"): message = _public_transfer_message(errormsg.get("message")) error_messages.append(message or "") if not state: all_success = False else: if errormsg: public_message = _public_transfer_message(errormsg) if public_message: error_messages.append(public_message) preview_items.append( _build_failure_preview_item( src_fileitem, _public_transfer_message(errormsg), ) ) all_success = False else: all_success = all_success and state submission_items.extend(_manual_submission_items(src_fileitem, state, errormsg, target_path)) if transer_item.preview: merged_preview_items: List[dict[str, Any]] = [] seen_sources = set() for preview_item in preview_items: source = (preview_item.get("source_storage"), preview_item.get("source")) if source in seen_sources: continue seen_sources.add(source) merged_preview_items.append( _public_transfer_result(preview_item) if isinstance(preview_item, dict) else preview_item ) merged_message = _merge_transfer_messages(error_messages) preview_data = { "summary": { "total": len(merged_preview_items), "success": len( [item for item in merged_preview_items if item.get("success")] ), "failed": len( [item for item in merged_preview_items if not item.get("success")] ), }, "items": merged_preview_items, "message": merged_message, } return _SchemaResponse( success=True, message=merged_message or "", data=preview_data, ) return _manual_submission_response(all_success, submission_items) src_fileitem = src_fileitems[0] # 开始转移 state, errormsg = TransferChain().manual_transfer( fileitem=src_fileitem, target_storage=transer_item.target_storage, target_path=target_path, media_source=transer_item.media_source, media_id=transer_item.media_id, music_type=( MUSIC_ENTITY_ALBUM if selected_music_fileitems is not None else _resolve_music_type(src_fileitem) ), music_release_regions=transer_item.music_release_regions, music_release_scripts=transer_item.music_release_scripts, music_cue_enable=transer_item.music_cue_enable, **release_kwargs, mtype=mtype, season=transer_item.season, episode_group=transer_item.episode_group, transfer_type=transer_item.transfer_type, epformat=epformat, min_filesize=transer_item.min_filesize, scrape=transer_item.scrape, library_type_folder=transer_item.library_type_folder, library_category_folder=transer_item.library_category_folder, force=force, background=background, downloader=downloader, download_hash=download_hash, preview=transer_item.preview, reorganize=transer_item.reorganize, skip_success=transer_item.skip_success, sync_extra_files=selected_music_fileitems is None, cleanup_dest_fileitem=cleanup_dest_fileitem, selected_fileitems=selected_music_fileitems, report_results=not transer_item.preview, ) if not transer_item.preview: return _manual_submission_response(state, _manual_submission_items(src_fileitem, state, errormsg, target_path)) # 预览失败仍返回完整预览列表,实际提交的总状态已在上方独立处理。 if isinstance(errormsg, dict): public_result = _public_transfer_result(errormsg) return _SchemaResponse(success=True, message=public_result.get("message") or "", data=public_result) if state: return _SchemaResponse(success=True, data=errormsg or {}) return _SchemaResponse(success=False, message=_format_manual_transfer_failure( message=errormsg, source_path=src_fileitem.path, target_path=target_path.as_posix() if target_path else None, )) @router.post( "/episode-format/recommend", summary="推荐集数定位模板", response_model=_SchemaResponse[_SchemaEpisodeFormatRecommendData], ) def recommend_episode_format( recommend_item: EpisodeFormatRecommendItem, _: object = Depends(get_current_active_manage_user), ) -> Any: """ 根据目录样本推荐集数定位模板 :param recommend_item: 推荐请求 :param _: Token校验 """ target_path = recommend_item.fileitem.path if recommend_item.fileitem else None logger.info(f"开始推荐集数定位模板:{target_path}") state, errmsg, data = TransferChain().recommend_episode_format( fileitem=recommend_item.fileitem, fileitems=recommend_item.fileitems, ) if not state: logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}") return _SchemaResponse( success=False, message=_public_transfer_message(errmsg) or "", ) logger.info( f"推荐集数定位模板成功:{target_path} - 规则 {data.get('rule_name') if data else None}" ) return _SchemaResponse(success=True, data=data) @router.get("/now", summary="立即执行下载器文件整理", response_model=_SchemaResponse[None]) def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 立即执行下载器文件整理 API_TOKEN认证(?token=xxx) """ TransferChain().process() return _SchemaResponse(success=True)