Archived
refactor: 播客单集 worker 拆解(140 行 / 圈 35 → 40 行 / 圈 6 / 嵌套 1)
为什么:_podcast_download_worker 是播客单集下载的唯一后台执行路径,
此前零直接测试覆盖;三条 except 分支各写一遍「结算+删任务目录」,
正文散着三处取消检查,改坏任意一处不会有测试报警。
拆出(与单曲 worker 9cbd341 同风格):
- _PodcastTaskSpec(frozen dataclass,program_id 标注 Any——params 历史
存数字字符串,worker 只透传,int() 是行为变更)
- _PodcastTarget(磁盘名/交付名/任务目录的命名组装)
- _begin_podcast_task / _prepare_podcast_target / _write_podcast_zip /
_podcast_file_result / _podcast_zip_result / _publish_podcast_task /
_fail_podcast_task(三处 ×9 行收尾收敛)
- 取消检查复用 downloads._abort_if_canceled(无循环依赖);tempfile
局部 import 上移模块顶
先写测试后动代码:新增 tests/test_podcast_worker.py 18 例,在**未改动**
实现上一次跑绿 = 忠实编码旧行为,重构后同一套测试即等价性验证。
唯一语义顺序变化:except 分支的日志从「收尾后」挪到「收尾前」(与
单曲 worker 对齐,日志内容不变,无行为影响)。
门禁:ruff 全过 · pyright 0/0 · pytest 312 passed(294→312)。
This commit is contained in:
1 parent
d08ae85cd4
commit
1c6b17f718
2 files changed
+737
-118
No files matched your search
+187
-118
@@ -7,6 +7,7 @@
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -45,6 +46,9 @@ from ._zipcore import (
|
||||
from .api_service import (
|
||||
api_service,
|
||||
)
|
||||
from .downloads import (
|
||||
_abort_if_canceled,
|
||||
)
|
||||
from .streaming import (
|
||||
_stream_open,
|
||||
)
|
||||
@@ -166,146 +170,211 @@ def _podcast_resolve_audio(program: dict[str, Any], quality: str,
|
||||
return url_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PodcastTaskSpec:
|
||||
"""worker 启动时从任务表取出的不可变执行参数。
|
||||
|
||||
``program_id`` 标注 Any 与单曲 worker(SingleTaskParams.music_id)同因:
|
||||
任务 params 里历史存的是数字字符串,下游上游解析各自处理,worker 只透传
|
||||
不做 int() 转换(那是行为变更)。
|
||||
"""
|
||||
|
||||
program_id: Any
|
||||
quality: str
|
||||
need_zip: bool
|
||||
cookies: dict[str, Any]
|
||||
remote_ip: str
|
||||
cancel_ev: threading.Event
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PodcastTarget:
|
||||
"""单集落盘目标与交付命名。
|
||||
|
||||
磁盘名附节目 ID + 任务目录隔离(两个任务同下载一集互不覆盖、互不删除);
|
||||
交付名保持干净(不带 ID)。
|
||||
"""
|
||||
|
||||
task_dir: Path
|
||||
target: Path
|
||||
filename: str # 交付名(含扩展名)
|
||||
base: str # 交付名去扩展名(ZIP 条目名与封面名共用)
|
||||
name: str # 单集原始名(info.txt 与操作记录用)
|
||||
ext: str
|
||||
|
||||
|
||||
def _begin_podcast_task(task_id: str) -> _PodcastTaskSpec:
|
||||
"""置 running 并取出执行参数(任务不存在时 KeyError → 通用 except 收敛 error)。"""
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
task = DOWNLOAD_TASKS[task_id]
|
||||
task['status'] = 'running'
|
||||
task['msg'] = '解析单集信息...'
|
||||
params = task['params']
|
||||
return _PodcastTaskSpec(
|
||||
program_id=params['program_id'],
|
||||
quality=params['quality'],
|
||||
need_zip=params['need_zip'],
|
||||
cookies=params['cookies'],
|
||||
remote_ip=params.get('remote_ip') or '-',
|
||||
cancel_ev=task['cancel'],
|
||||
)
|
||||
|
||||
|
||||
def _prepare_podcast_target(task_id: str, program_id: Any, program: dict[str, Any],
|
||||
url_info: dict[str, Any]) -> _PodcastTarget:
|
||||
"""组装文件名与任务专属目录(顺带 mkdir)。"""
|
||||
serial = int(program.get('serial_num') or 0)
|
||||
name = program.get('name') or program.get('main_song_name') or '播客单集'
|
||||
prefix = f"{serial:03d} - " if serial else ''
|
||||
base = _podcast_safe_name(f"{prefix}{name}")
|
||||
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
|
||||
task_dir = api_service.downloads_path / 'tasks' / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
return _PodcastTarget(
|
||||
task_dir=task_dir,
|
||||
target=task_dir / f"{base} [{program_id}].{ext}",
|
||||
filename=f"{base}.{ext}",
|
||||
base=base,
|
||||
name=name,
|
||||
ext=ext,
|
||||
)
|
||||
|
||||
|
||||
def _write_podcast_zip(zip_path: str, file_path_str: str, arcname: str, base: str,
|
||||
name: str, program: dict[str, Any], url_info: dict[str, Any],
|
||||
fallback_quality: str) -> None:
|
||||
"""单集入包:音频 + 封面(可选,失败不炸任务)+ info.txt。
|
||||
|
||||
info.txt 不再写直链(真实 URL 不应随交付文件外泄)。
|
||||
"""
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(file_path_str, arcname=arcname)
|
||||
cover_url = program.get('cover_url') or ''
|
||||
if cover_url:
|
||||
try:
|
||||
cover = fetch_cover_bytes(cover_url)
|
||||
if cover:
|
||||
zf.writestr(f"{base}.jpg", cover)
|
||||
except Exception:
|
||||
pass
|
||||
info_txt = (
|
||||
f"单集: {name}\n"
|
||||
f"播客: {program.get('radio_name') or ''}\n"
|
||||
f"主播: {program.get('dj_name') or ''}\n"
|
||||
f"时长: {program.get('duration_text') or ''}\n"
|
||||
f"音质: {api_service._get_quality_display_name(url_info.get('level', fallback_quality))}\n"
|
||||
)
|
||||
zf.writestr("info.txt", info_txt)
|
||||
|
||||
|
||||
def _podcast_file_result(file_path_str: str, filename: str, ext: str) -> dict[str, Any]:
|
||||
"""不打包时的交付形状。"""
|
||||
return {
|
||||
'file_path': file_path_str,
|
||||
'filename': filename,
|
||||
'mimetype': f"audio/{ext}",
|
||||
'audio_files': [file_path_str],
|
||||
}
|
||||
|
||||
|
||||
def _podcast_zip_result(prep: _PodcastTarget, file_path_str: str,
|
||||
program: dict[str, Any], url_info: dict[str, Any],
|
||||
spec: _PodcastTaskSpec) -> dict[str, Any]:
|
||||
"""打包交付形状:ZIP 中间文件建在任务目录下(失败收尾 rmtree 一并覆盖)。"""
|
||||
fd, zip_tmp_path = tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
|
||||
dir=str(prep.task_dir))
|
||||
os.close(fd)
|
||||
_write_podcast_zip(zip_tmp_path, file_path_str, arcname=prep.filename,
|
||||
base=prep.base, name=prep.name, program=program,
|
||||
url_info=url_info, fallback_quality=spec.quality)
|
||||
return {
|
||||
'file_path': file_path_str,
|
||||
'zip_path': zip_tmp_path,
|
||||
'filename': f"{prep.base}.zip",
|
||||
'mimetype': 'application/zip',
|
||||
'audio_files': [file_path_str],
|
||||
}
|
||||
|
||||
|
||||
def _publish_podcast_task(task_id: str, result: dict[str, Any], remote_ip: str,
|
||||
name: str, filename: str) -> None:
|
||||
"""最终发布:置 done(已终态不覆盖)→ 结算 → 记操作日志。
|
||||
|
||||
交付已落定;结算失败**不再**把任务改标 error——钱由重启对账兜底,
|
||||
宁可商家小损不坑用户(与单曲 _publish_single_task 同口径)。
|
||||
"""
|
||||
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, f"[播客] {name}")
|
||||
except Exception:
|
||||
pass
|
||||
api_service.logger.info(f"播客单集下载完成: {filename} ({task_id})")
|
||||
|
||||
|
||||
def _fail_podcast_task(task_id: str, status: str, msg: str, ledger_status: str,
|
||||
task_dir: Path | None) -> None:
|
||||
"""失败/取消的统一收尾:置终态 → 退款 → 清掉任务目录(含 ZIP 中间件)。"""
|
||||
_mark_task_terminal(task_id, status, msg)
|
||||
try:
|
||||
auth_system.ledger_settle(task_id, 0, ledger_status)
|
||||
except Exception:
|
||||
pass
|
||||
if task_dir is not None:
|
||||
shutil.rmtree(str(task_dir), ignore_errors=True)
|
||||
|
||||
|
||||
def _podcast_download_worker(task_id: str) -> None:
|
||||
"""后台播客单集下载:解析单集 → 音质降级取直链 → 流式下载(可选 ZIP 打包)
|
||||
|
||||
任务 type 为 'single',完成后经 /download/result 交付,与单曲下载共用交付通道。
|
||||
落盘文件进入任务专属目录且带节目 ID,跨任务永不重名;清理只动本任务目录。
|
||||
"""
|
||||
import tempfile as _tempfile
|
||||
|
||||
file_path_str: str | None = None
|
||||
zip_tmp_path: str | None = None
|
||||
task_dir: Path | None = None
|
||||
try:
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
task = DOWNLOAD_TASKS[task_id]
|
||||
task['status'] = 'running'
|
||||
task['msg'] = '解析单集信息...'
|
||||
params = task['params']
|
||||
cancel_ev = task['cancel']
|
||||
program_id = params['program_id']
|
||||
quality = params['quality']
|
||||
need_zip = params['need_zip']
|
||||
cookies = params['cookies']
|
||||
remote_ip = params.get('remote_ip') or '-'
|
||||
|
||||
program = podcast.get_program_detail(program_id, cookies)
|
||||
url_info = _podcast_resolve_audio(program, quality, cookies)
|
||||
|
||||
serial = int(program.get('serial_num') or 0)
|
||||
name = program.get('name') or program.get('main_song_name') or '播客单集'
|
||||
prefix = f"{serial:03d} - " if serial else ''
|
||||
base = _podcast_safe_name(f"{prefix}{name}")
|
||||
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
|
||||
# 磁盘名附节目 ID + 任务目录隔离(两个任务同下载一集互不覆盖、互不删除)
|
||||
task_dir = api_service.downloads_path / 'tasks' / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
disk_name = f"{base} [{program_id}].{ext}"
|
||||
target = task_dir / disk_name
|
||||
filename = f"{base}.{ext}" # 交付名保持干净(不带 ID)
|
||||
spec = _begin_podcast_task(task_id)
|
||||
program = podcast.get_program_detail(spec.program_id, spec.cookies)
|
||||
url_info = _podcast_resolve_audio(program, spec.quality, spec.cookies)
|
||||
prep = _prepare_podcast_target(task_id, spec.program_id, program, url_info)
|
||||
task_dir = prep.task_dir
|
||||
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
t = DOWNLOAD_TASKS[task_id]
|
||||
t['msg'] = '下载中...'
|
||||
_podcast_stream_download(url_info['url'], target, url_info.get('size', 0),
|
||||
cancel_ev, task_id, '下载中')
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
file_path_str = str(target)
|
||||
_podcast_stream_download(url_info['url'], prep.target, url_info.get('size', 0),
|
||||
spec.cancel_ev, task_id, '下载中')
|
||||
_abort_if_canceled(spec.cancel_ev)
|
||||
file_path_str = str(prep.target)
|
||||
|
||||
result: dict[str, Any]
|
||||
if need_zip:
|
||||
fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
|
||||
dir=str(task_dir))
|
||||
os.close(fd)
|
||||
with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(file_path_str, arcname=filename)
|
||||
cover_url = program.get('cover_url') or ''
|
||||
if cover_url:
|
||||
try:
|
||||
cover = fetch_cover_bytes(cover_url)
|
||||
if cover:
|
||||
zf.writestr(f"{base}.jpg", cover)
|
||||
except Exception:
|
||||
pass
|
||||
# info.txt 不再写直链(真实 URL 不应随交付文件外泄)
|
||||
info_txt = (
|
||||
f"单集: {name}\n"
|
||||
f"播客: {program.get('radio_name') or ''}\n"
|
||||
f"主播: {program.get('dj_name') or ''}\n"
|
||||
f"时长: {program.get('duration_text') or ''}\n"
|
||||
f"音质: {api_service._get_quality_display_name(url_info.get('level', quality))}\n"
|
||||
)
|
||||
zf.writestr("info.txt", info_txt)
|
||||
# 打包阶段后同样以取消检查收口
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
result = {
|
||||
'file_path': file_path_str,
|
||||
'zip_path': zip_tmp_path,
|
||||
'filename': f"{base}.zip",
|
||||
'mimetype': 'application/zip',
|
||||
'audio_files': [file_path_str],
|
||||
}
|
||||
if spec.need_zip:
|
||||
result = _podcast_zip_result(prep, file_path_str, program, url_info, spec)
|
||||
else:
|
||||
result = {
|
||||
'file_path': file_path_str,
|
||||
'filename': filename,
|
||||
'mimetype': f"audio/{ext}",
|
||||
'audio_files': [file_path_str],
|
||||
}
|
||||
result = _podcast_file_result(file_path_str, prep.filename, prep.ext)
|
||||
# 打包阶段同样以取消检查收口(末段取消不得覆盖为 done)
|
||||
_abort_if_canceled(spec.cancel_ev)
|
||||
|
||||
# 最终发布前的取消检查(末段取消不得覆盖为 done)
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
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
|
||||
# 交付已落定;结算失败不再把任务改标 error(同单曲)
|
||||
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, f"[播客] {name}")
|
||||
except Exception:
|
||||
pass
|
||||
api_service.logger.info(f"播客单集下载完成: {filename} ({task_id})")
|
||||
_publish_podcast_task(task_id, result, spec.remote_ip, prep.name, prep.filename)
|
||||
except DownloadCanceled:
|
||||
_mark_task_terminal(task_id, 'canceled', '任务已取消')
|
||||
try:
|
||||
auth_system.ledger_settle(task_id, 0, 'canceled')
|
||||
except Exception:
|
||||
pass
|
||||
if task_dir is not None:
|
||||
shutil.rmtree(str(task_dir), ignore_errors=True)
|
||||
api_service.logger.info(f"播客单集下载已取消: {task_id}")
|
||||
_fail_podcast_task(task_id, 'canceled', '任务已取消', 'canceled', task_dir)
|
||||
except DownloadException as e:
|
||||
_mark_task_terminal(task_id, 'error', str(e))
|
||||
try:
|
||||
auth_system.ledger_settle(task_id, 0, 'error')
|
||||
except Exception:
|
||||
pass
|
||||
if task_dir is not None:
|
||||
shutil.rmtree(str(task_dir), ignore_errors=True)
|
||||
api_service.logger.error(f"播客单集下载失败: {task_id} {e}")
|
||||
_fail_podcast_task(task_id, 'error', str(e), 'error', task_dir)
|
||||
except Exception as e:
|
||||
# 非预期异常原文 logger 先行,用户可见文案泛化(教训)
|
||||
api_service.logger.error(f"播客单集下载异常: {task_id} {e}\n{traceback.format_exc()}")
|
||||
_mark_task_terminal(task_id, 'error', '下载失败,请稍后重试')
|
||||
try:
|
||||
auth_system.ledger_settle(task_id, 0, 'error')
|
||||
except Exception:
|
||||
pass
|
||||
if task_dir is not None:
|
||||
shutil.rmtree(str(task_dir), ignore_errors=True)
|
||||
_fail_podcast_task(task_id, 'error', '下载失败,请稍后重试', 'error', task_dir)
|
||||
|
||||
|
||||
def _podcast_cleanup_files(paths: list[str | None]) -> None:
|
||||
|
||||
Reference in new issue
Block a user