# -*- coding: utf-8 -*- """单曲下载任务 worker:下载 → 打包 → 交付 → 结算。 账号探测(VIP/音质)与二维码登录客户端均已迁出(前者只剩 web.routers.account 的异步实现,后者只剩 web.routers.admin 自己的客户端),本模块只负责单曲任务。 ``_single_download_worker`` 原是 177 行 / 圈复杂度 30 的单函数,三个 except 分支 各写一遍「结算 + 删任务目录」,正文里又散着四处取消检查。此处按「一步一个具名 函数」拆开:主函数只留线性流程,失败收尾收敛到 ``_fail_single_task`` 一处。 """ import os import shutil import threading import time import traceback import zipfile from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any import auth_system from core.state import ( DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK, ) from core.utils import format_lyric as _format_lyric from music_downloader import ( DownloadCanceled, DownloadException, MusicInfo, fetch_cover_bytes, ) from ._shared import ( _build_download_base, _record_log, ) from ._taskcore import ( _mark_task_terminal, ) from .api_service import ( api_service, ) def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str: """把共享缓存文件复制/硬链接为任务专属交付文件(任务成果与共享缓存分离, 任何任务的清理都不会再删掉别的任务的成果)""" task_dir = api_service.downloads_path / 'tasks' / task_id task_dir.mkdir(parents=True, exist_ok=True) dst = task_dir / filename if dst.exists(): return str(dst) try: os.link(str(src), str(dst)) # 同卷硬链接:零拷贝 except OSError: shutil.copy2(str(src), str(dst)) return str(dst) # _cleanup_task_dir 死代码删除——worker 直接 rmtree、监控线程直接 # rmtree,本函数全文件零调用(审计确认)。 @dataclass(frozen=True) class SingleTaskParams: """单曲任务参数快照(worker 只读)。 原来是 10 个局部变量从 ``task['params']`` 逐个取出、再原样穿过整段流程; 收成一个 frozen 对象后,各步骤按需取字段,调用点不必重列一遍长参数表。 ``cancel_ev`` 不是 params 里的值,而是任务对象上的取消事件,一并放这里 以免每一步都多传一个参数。 ``music_id`` 刻意保持 **原样透传**(不转换):它来自前端、经 ``_extract_id`` 归一化成数字字符串,而下游 ``get_music_info`` / ``download_music_file`` 的 签名声明 ``int``——两侧一直不一致,运行时靠字符串参与上游查询拼装而正常工作 (调用链上无算术)。类型标注为 ``Any`` 如实反映这个输入边界的实际形状; 顺手 ``int()`` 会改变异常面(非数字时从「上游查不到」变成 ``ValueError``), 属于行为变更,不在重构范围内。 """ music_id: Any quality: str filename_format: str write_meta: bool meta_fields: Any lyric_format: str need_zip: bool cookies: dict[str, str] remote_ip: str cancel_ev: threading.Event def _begin_single_task(task_id: str) -> SingleTaskParams: """把任务置为 running 并取出参数快照。""" with DOWNLOAD_TASKS_LOCK: task = DOWNLOAD_TASKS[task_id] task['status'] = 'running' task['msg'] = '下载中...' params = task['params'] return SingleTaskParams( music_id=params['music_id'], quality=params['quality'], filename_format=params['filename_format'], write_meta=params['write_meta'], meta_fields=params['meta_fields'], lyric_format=params['lyric_format'], need_zip=params['need_zip'], cookies=params['cookies'], remote_ip=params.get('remote_ip') or '-', cancel_ev=task['cancel'], ) def _fetch_single_metadata(p: SingleTaskParams) -> tuple[MusicInfo, dict[str, Any], str, str]: """一次预取歌曲完整信息(URL/详情/歌词三接口并行),并算出交付基础名。 替代原先 name_v1 + url_v1 + 下载器内部 get_music_info 的重复串行调用。 返回 ``(MusicInfo, 下载器口径的 music_info, base, filename)``。 """ mi = api_service.downloader.get_music_info(p.music_id, p.quality, p.cookies) music_info = { 'id': p.music_id, 'name': mi.name, 'artist_string': mi.artists, 'album': mi.album, 'pic_url': mi.pic_url, 'file_type': mi.file_type, 'file_size': mi.file_size, 'duration': mi.duration * 1000, 'download_url': mi.download_url, } base = _build_download_base(p.filename_format, music_info) return mi, music_info, base, f"{base}.{music_info['file_type']}" def _single_progress_cb(task_id: str) -> Callable[[int, int], None]: """构造字节级进度回调(限频 0.4s):写进任务 live_percent。 /api/batch_progress 对运行中的单曲任务优先返回它——原实现 done 按首计数, 单曲进度条全程 0%。回调内任何故障都被吞掉,绝不影响下载主流程。 """ last_ts = {'t': 0.0} def _cb(written: int, total_bytes: int) -> None: try: now = time.time() if now - last_ts['t'] < 0.4 and written < (total_bytes or 0): return last_ts['t'] = now with DOWNLOAD_TASKS_LOCK: t = DOWNLOAD_TASKS.get(task_id) if not t or t.get('status') != 'running': return if total_bytes: pct = min(99.0, written / total_bytes * 100) t['live_percent'] = pct t['msg'] = (f"下载中 {int(pct)}% · " f"{api_service._format_file_size(written)}/{api_service._format_file_size(total_bytes)}") else: t['msg'] = f"下载中 · 已接收 {api_service._format_file_size(written)}" except Exception: pass # 进度上报故障绝不影响下载 return _cb def _abort_if_canceled(cancel_ev: threading.Event) -> None: """取消检查点:下载完成后到发布前,每一步之间都要过这道闸。 原先这三处检查(下载后 / 打包后 / 发布前)逐字重复,且末段那次还兼 「不得把已取消覆盖成 done」的职责——后者在 ``_publish_single_task`` 里另做。 """ if cancel_ev.is_set(): raise DownloadCanceled("任务已取消") def _single_file_result(file_path_str: str, filename: str, music_info: dict[str, Any]) -> dict[str, Any]: """不打包(zip=false)的交付描述。""" return { 'file_path': file_path_str, 'filename': filename, 'mimetype': f"audio/{music_info['file_type']}", 'audio_files': [file_path_str], } def _pack_single_zip(file_path_str: str, base: str, filename: str, mi: MusicInfo, music_info: dict[str, Any], p: SingleTaskParams) -> dict[str, Any]: """把音频 + 歌词 + 封面 + info.txt 打成单曲 ZIP 交付包。 歌词与封面都是**尽力而为**:歌词在预取时已一并拿到(无需再请求一次), 封面走共享缓存;两者拿不到就不写对应条目,不影响音频交付。 """ import tempfile as _tempfile fd, zip_tmp_path = _tempfile.mkstemp(prefix='single_zip_', suffix='.zip', dir=str(Path(file_path_str).parent)) os.close(fd) with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: zf.write(file_path_str, arcname=filename) # 歌词已在预取时一并拿到,无需再请求一次 if mi.lyric: try: zf.writestr(f"{base}.{p.lyric_format}", _format_lyric(mi.lyric, p.lyric_format)) except Exception: pass # 封面走共享缓存(与元数据写入共用同一次网络请求) cover = fetch_cover_bytes(mi.pic_url) if cover: zf.writestr(f"{base}.jpg", cover) info_txt = ( f"歌曲: {music_info['name']}\n" f"歌手: {music_info['artist_string']}\n" f"专辑: {music_info['album']}\n" f"音质: {api_service._get_quality_display_name(p.quality)}\n" ) zf.writestr("info.txt", info_txt) return { 'file_path': file_path_str, 'zip_path': zip_tmp_path, 'filename': f"{base}.zip", 'mimetype': 'application/zip', 'audio_files': [file_path_str], } def _publish_single_task(task_id: str, result: dict[str, Any], remote_ip: str, name: str, filename: str) -> None: """最终发布:置 done(已终态不覆盖)→ 结算 → 记操作日志。 交付已落定;结算失败**不再**把任务改标 error——钱由重启对账兜底, 宁可商家小损不坑用户。末段取消检查与此处的状态检查合流:任务已被外部 置成 canceled/error 时,worker 的成功路径不得把它覆盖回 done。 """ with DOWNLOAD_TASKS_LOCK: t = DOWNLOAD_TASKS.get(task_id) if t is None or t.get('status') in ('canceled', 'error'): raise DownloadCanceled("任务已取消") t['status'] = 'done' t['completed_at'] = time.time() t['done'] = 1 t['msg'] = '下载完成' t['result'] = result try: auth_system.ledger_settle(task_id, 1, 'delivered') except Exception as _e: api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}") try: _record_log('下载', remote_ip, name) except Exception: pass api_service.logger.info(f"单曲下载完成: {filename} ({task_id})") def _fail_single_task(task_id: str, status: str, msg: str, ledger_status: str, task_dir_path: str | None) -> None: """失败/取消的统一收尾:置终态 → 退款 → 清掉任务目录。 这三个 except 分支原先各写一遍(共 9 行 × 3 处);收敛后新增一种失败 只需要挑一个 ``ledger_status``,不会再漏掉清理或退款。 """ _mark_task_terminal(task_id, status, msg) try: auth_system.ledger_settle(task_id, 0, ledger_status) except Exception: pass if task_dir_path: shutil.rmtree(task_dir_path, ignore_errors=True) def _single_download_worker(task_id: str) -> None: """后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理""" task_dir_path: str | None = None try: p = _begin_single_task(task_id) mi, music_info, base, filename = _fetch_single_metadata(p) download_result = api_service.downloader.download_music_file( p.music_id, p.quality, write_meta=p.write_meta, meta_fields=p.meta_fields, cookies=p.cookies, cancel_event=p.cancel_ev, music_info=mi, progress_cb=_single_progress_cb(task_id), ) if not download_result.success: raise DownloadException(download_result.error_message or "下载失败") _abort_if_canceled(p.cancel_ev) assert download_result.file_path is not None # 交付文件用任务专属副本,共享缓存不进清理清单 file_path_str = _task_delivery_copy(task_id, Path(download_result.file_path), filename) task_dir_path = str(Path(file_path_str).parent) if p.need_zip: result = _pack_single_zip(file_path_str, base, filename, mi, music_info, p) else: result = _single_file_result(file_path_str, filename, music_info) # 打包阶段同样以取消检查收口 _abort_if_canceled(p.cancel_ev) _publish_single_task(task_id, result, p.remote_ip, music_info['name'], filename) except DownloadCanceled: api_service.logger.info(f"单曲下载已取消: {task_id}") _fail_single_task(task_id, 'canceled', '任务已取消', 'canceled', task_dir_path) except DownloadException as e: api_service.logger.error(f"单曲下载失败: {task_id} {e}") _fail_single_task(task_id, 'error', str(e), 'error', task_dir_path) except Exception as e: # 非预期异常的 repr 可能含上游主机/连接细节——用户可见文案 # 泛化为固定文案,原文 logger 先行留档(教训:先记录再泛化) api_service.logger.error(f"单曲下载异常: {task_id} {e}\n{traceback.format_exc()}") _fail_single_task(task_id, 'error', '下载失败,请稍后重试', 'error', task_dir_path)