Archived
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留 大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述, 会误导后来者按不复存在的架构去改代码。 拆分(对外导入路径不变,行为零变更): - music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency) - auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...) - music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors) - services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/ streaming/tasks);services/runtime.py 保留为兼容门面 兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点 (auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性 动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。 修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字 等价」校验逐一定位,静态检查只会报出其中 4 处): - core/config.py ).strip() or '*' - core/state.py threading.Lock() x2 - qr_login.py datetime.now() / .strip() - music_downloader/downloader.py .lower() - services/podcast.py set() / time.time() x2 清理与删除: - 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt - 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等 失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明) - _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含 - pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败 验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed; 真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
583 lines
27 KiB
Python
583 lines
27 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""播客单集与整台打包 worker。
|
||
|
||
(自 services/runtime.py 拆出,行为与原实现一致。)
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import threading
|
||
import time
|
||
import traceback
|
||
import zipfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import auth_system
|
||
import podcast_api
|
||
from core.state import (
|
||
DOWNLOAD_TASKS,
|
||
DOWNLOAD_TASKS_LOCK,
|
||
)
|
||
from music_api import (
|
||
APIException,
|
||
)
|
||
from music_downloader import (
|
||
DownloadCanceled,
|
||
DownloadException,
|
||
fetch_cover_bytes,
|
||
)
|
||
|
||
from ._shared import (
|
||
BATCH_ZIP_SEM,
|
||
_record_log,
|
||
)
|
||
from ._taskcore import (
|
||
_mark_task_terminal,
|
||
_settle_batch_billing,
|
||
)
|
||
from .api_service import (
|
||
api_service,
|
||
)
|
||
from .streaming import (
|
||
_stream_open,
|
||
)
|
||
|
||
|
||
def _podcast_safe_name(s: str, fallback: str = 'unknown') -> str:
|
||
"""文件名安全化(去除非法字符)"""
|
||
s = ''.join(c for c in str(s or '') if c not in r'<>:"/\|?*' and ord(c) >= 32).strip()
|
||
return s or fallback
|
||
|
||
|
||
def _podcast_stream_download(url: str, target: Path, total_size: int,
|
||
cancel_ev: threading.Event,
|
||
task_id: str = '', progress_label: str = '') -> int:
|
||
"""流式下载播客音频到 target(先写 .part 再改名),返回实际字节数
|
||
|
||
自动加入全局下载信号量排队;每 0.5s 更新一次任务进度消息。
|
||
"""
|
||
done = 0
|
||
last_update = 0.0
|
||
part = target.with_name(target.name + '.part')
|
||
# 等待全局下载名额期间可被取消(与音乐批量 BATCH_ZIP_SEM 同语义)
|
||
sem = api_service.downloader._download_semaphore
|
||
if not sem.acquire_cancellable(cancel_ev):
|
||
raise DownloadCanceled("任务已取消")
|
||
try:
|
||
with _stream_open(url, timeout=60) as r:
|
||
if r.status >= 400:
|
||
raise DownloadException(f"上游返回 {r.status}")
|
||
total = total_size or int(r.headers.get('Content-Length') or 0)
|
||
with open(part, 'wb') as f:
|
||
for chunk in r.iter_content(chunk_size=256 * 1024):
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
if chunk:
|
||
f.write(chunk)
|
||
done += len(chunk)
|
||
now = time.time()
|
||
if now - last_update > 0.5:
|
||
last_update = now
|
||
if total:
|
||
pct = min(99, int(done / total * 100))
|
||
msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}"
|
||
else:
|
||
msg = f"{progress_label} 已下载 {api_service._format_file_size(done)}"
|
||
if task_id:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t and t['status'] == 'running':
|
||
t['msg'] = msg
|
||
# 单集任务的字节级进度(批量任务保持按集数口径)
|
||
if t.get('type') == 'single' and total:
|
||
t['live_percent'] = min(99.0, float(pct))
|
||
finally:
|
||
sem.release()
|
||
if part.exists():
|
||
part.replace(target)
|
||
# 完整性校验:CDN 提前断流时 iter_content 会"正常"结束,文件静默截断
|
||
# (API 报告的期望大小时精确相等——95% 容差会交付截断的坏文件还扣次数;
|
||
# 期望未知时退化为非空校验,Content-Length 兜底值可能是压缩口径不做精确比对)
|
||
if total_size and done != total_size:
|
||
try:
|
||
os.remove(str(target))
|
||
except OSError:
|
||
pass
|
||
raise DownloadException(f"下载不完整({done}/{total_size} 字节),请重试")
|
||
if not total_size and done <= 0:
|
||
try:
|
||
os.remove(str(target))
|
||
except OSError:
|
||
pass
|
||
raise DownloadException("下载内容为空,请重试")
|
||
return done
|
||
|
||
|
||
def _podcast_resolve_audio(program: dict[str, Any], quality: str,
|
||
cookies: dict[str, str]) -> dict[str, Any]:
|
||
"""解析单集音频直链,失败抛 DownloadException"""
|
||
song_id = program.get('main_song_id')
|
||
if not song_id:
|
||
raise DownloadException("该单集缺少音频信息(mainSong)")
|
||
url_info = podcast_api.resolve_episode_url(song_id, cookies, quality)
|
||
if not url_info or not url_info.get('url'):
|
||
raise DownloadException("未获取到音频直链(单集可能已下架或受版权限制)")
|
||
return url_info
|
||
|
||
|
||
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_api.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)
|
||
|
||
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)
|
||
|
||
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],
|
||
}
|
||
else:
|
||
result = {
|
||
'file_path': file_path_str,
|
||
'filename': filename,
|
||
'mimetype': f"audio/{ext}",
|
||
'audio_files': [file_path_str],
|
||
}
|
||
|
||
# 最终发布前的取消检查(末段取消不得覆盖为 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})")
|
||
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}")
|
||
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}")
|
||
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)
|
||
|
||
|
||
def _podcast_cleanup_files(paths: list[str | None]) -> None:
|
||
"""清理播客任务产生的临时/残留文件"""
|
||
for p in paths:
|
||
if not p:
|
||
continue
|
||
try:
|
||
os.remove(p)
|
||
except OSError:
|
||
pass
|
||
# 连带清理可能残留的 .part 文件
|
||
try:
|
||
part = Path(str(p) + '.part')
|
||
if part.exists():
|
||
part.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _podcast_zip_worker(task_id: str) -> None:
|
||
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
|
||
import tempfile as _tempfile
|
||
|
||
zip_tmp_path: str | None = None
|
||
ok_count = 0
|
||
failed: list[str] = []
|
||
# zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等
|
||
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
|
||
zip_valid = False
|
||
try:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task = DOWNLOAD_TASKS[task_id]
|
||
task['status'] = 'running'
|
||
task['msg'] = '解析单集列表...'
|
||
params = task['params']
|
||
cancel_ev = task['cancel']
|
||
pause_ev = task['pause']
|
||
radio_id = params['radio_id']
|
||
preset_ids = params.get('ids')
|
||
limit = params['limit']
|
||
quality = params['quality']
|
||
cookies = params['cookies']
|
||
remote_ip = params.get('remote_ip') or '-'
|
||
|
||
# 组装单集清单:整台电台(正序全量)或指定单集ID列表
|
||
if radio_id:
|
||
episodes = podcast_api.list_radio_all_program_ids(radio_id, cookies, max_count=limit)
|
||
if not episodes:
|
||
raise DownloadException("该电台暂无可下载的单集")
|
||
else:
|
||
episodes = []
|
||
for pid in (preset_ids or []):
|
||
try:
|
||
prog = podcast_api.get_program_detail(pid, cookies)
|
||
episodes.append({
|
||
'id': prog['id'],
|
||
'name': prog['name'],
|
||
'main_song_id': prog['main_song_id'],
|
||
'serial_num': prog['serial_num'],
|
||
'cover_url': prog['cover_url'],
|
||
'detail': prog,
|
||
})
|
||
except APIException as ae:
|
||
failed.append(f"{pid}({ae})")
|
||
episodes = episodes[:limit]
|
||
if not episodes:
|
||
raise DownloadException("没有可下载的单集")
|
||
|
||
radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客')
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
DOWNLOAD_TASKS[task_id]['total'] = len(episodes)
|
||
|
||
# 按次计费补扣:整台电台提交时只预扣 1 次,此处按实际单集数补足差额。
|
||
# 补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付
|
||
try:
|
||
_buid = params.get('billing_uid')
|
||
_extra = len(episodes) - int(params.get('billed') or 1)
|
||
if _buid and _extra > 0:
|
||
if not auth_system.ledger_supplement(task_id, _extra):
|
||
raise DownloadException(
|
||
f"补扣下载次数失败(需补扣 {_extra} 次),任务已取消,预扣部分将退回")
|
||
if _buid:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
DOWNLOAD_TASKS[task_id]['billing_user_id'] = int(_buid)
|
||
DOWNLOAD_TASKS[task_id]['billing_cost'] = len(episodes)
|
||
except DownloadCanceled:
|
||
raise
|
||
except DownloadException:
|
||
raise
|
||
except Exception as e:
|
||
raise DownloadException(f"补扣下载次数异常: {e}") from e
|
||
|
||
def _wait_if_paused() -> None:
|
||
"""暂停时等待恢复或取消"""
|
||
if not (pause_ev.is_set() or task['status'] == 'paused'):
|
||
return
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task['status'] = 'paused'
|
||
task['done'] = ok_count + len(failed)
|
||
task['msg'] = '已暂停(重新打开页面自动恢复)'
|
||
while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'):
|
||
time.sleep(1)
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
pause_ev.clear()
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task['status'] = 'running'
|
||
task['msg'] = '继续下载...'
|
||
|
||
def _update_progress(msg: str = '') -> None:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS[task_id]
|
||
t['done'] = ok_count + len(failed)
|
||
t['failed'] = len(failed)
|
||
if msg:
|
||
t['msg'] = msg
|
||
|
||
# 对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常
|
||
# 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头);
|
||
# 未 valid 一律不发布、不按 ok_count 结算
|
||
# 同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP%
|
||
# 与音乐批量打包同口径:建在任务专属目录 tasks/<task_id>/,根目录缓存清扫不再触及交付物
|
||
_podcast_task_dir = api_service.downloads_path / 'tasks' / task_id
|
||
_podcast_task_dir.mkdir(parents=True, exist_ok=True)
|
||
with _tempfile.NamedTemporaryFile(prefix='podcast_batch_', suffix='.zip',
|
||
dir=str(_podcast_task_dir),
|
||
delete=False) as zip_tmp:
|
||
zip_tmp_path = zip_tmp.name
|
||
used_arcnames = set() # 包内条目名去重(同名单集/同号不同集不得互相覆盖)
|
||
with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||
for idx, ep in enumerate(episodes, 1):
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
_wait_if_paused()
|
||
# 并发对齐音乐批量:播客打包同样占用全局批量名额(BATCH_ZIP_SEM,全局 3),
|
||
# 逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受
|
||
# 全局下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发
|
||
if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev):
|
||
raise DownloadCanceled("任务已取消")
|
||
try:
|
||
label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}"
|
||
_update_progress(f"{label} 解析中...")
|
||
try:
|
||
prog = ep.get('detail') or podcast_api.get_program_detail(ep['id'], cookies)
|
||
url_info = _podcast_resolve_audio(prog, quality, cookies)
|
||
_update_progress(f"{label} 下载中...")
|
||
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
|
||
serial = int(ep.get('serial_num') or 0)
|
||
prefix = f"{serial:03d} - " if serial else ''
|
||
base = _podcast_safe_name(f"{prefix}{ep['name']}")
|
||
# 中间文件移入任务目录——落 downloads 根目录会被
|
||
# 缓存清扫按 mtime 打断慢速单集(Linux 上 unlink 打开中的文件)
|
||
tmp_audio = _podcast_task_dir / f"podcast_zip_tmp_{task_id}_{idx}.{ext}"
|
||
try:
|
||
_podcast_stream_download(url_info['url'], tmp_audio,
|
||
url_info.get('size', 0), cancel_ev)
|
||
# 重名条目自动追加序号
|
||
arcname = f"{base}.{ext}"
|
||
k = 2
|
||
while arcname in used_arcnames:
|
||
arcname = f"{base} ({k}).{ext}"
|
||
k += 1
|
||
used_arcnames.add(arcname)
|
||
zf.write(str(tmp_audio), arcname=arcname, compress_type=zipfile.ZIP_STORED)
|
||
finally:
|
||
# finally 连带清理 .part 残片(原实现只删正文,
|
||
# 取消/失败时残片要等 1 小时缓存清扫兜底)
|
||
_podcast_cleanup_files([str(tmp_audio)])
|
||
# 首集附带封面,便于识别
|
||
if idx == 1 and ep.get('cover_url'):
|
||
try:
|
||
cover = fetch_cover_bytes(ep['cover_url'])
|
||
if cover:
|
||
zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED)
|
||
except Exception:
|
||
pass
|
||
ok_count += 1
|
||
_update_progress(f"{label} 完成")
|
||
except DownloadCanceled:
|
||
raise
|
||
except Exception as e:
|
||
failed.append(f"{ep['name']}({e})")
|
||
_update_progress()
|
||
finally:
|
||
BATCH_ZIP_SEM.release()
|
||
|
||
# 走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布
|
||
zip_valid = True
|
||
|
||
if ok_count == 0:
|
||
raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}")
|
||
|
||
# ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛
|
||
if not zip_valid:
|
||
if zip_tmp_path:
|
||
try:
|
||
os.remove(zip_tmp_path)
|
||
except OSError:
|
||
pass
|
||
zip_tmp_path = None
|
||
refunded = _settle_batch_billing(task_id, 0, 'error')
|
||
_mark_task_terminal(task_id, 'error',
|
||
f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded
|
||
else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回")
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t is not None:
|
||
t['failed'] = t.get('failed', 0) + ok_count
|
||
t['refunded'] = refunded
|
||
api_service.logger.error(f"播客打包构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次")
|
||
return
|
||
|
||
# 最终发布前的取消检查(末段取消不得覆盖为 done)
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
refunded = _settle_batch_billing(task_id, ok_count)
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
# 发布前锁内复查取消/失效状态(同音乐批量)
|
||
if t is None or t.get('status') in ('canceled', 'error'):
|
||
raise DownloadCanceled("任务已取消")
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
t['status'] = 'done'
|
||
t['completed_at'] = time.time()
|
||
t['done'] = ok_count + len(failed)
|
||
t['failed'] = len(failed)
|
||
t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '')
|
||
t['refunded'] = refunded
|
||
t['result'] = {
|
||
'zip_path': zip_tmp_path,
|
||
'filename': f"播客_{radio_name}_{ok_count}集.zip",
|
||
'audio_files': [],
|
||
}
|
||
try:
|
||
first_name = episodes[0].get('name') if episodes else ''
|
||
_record_log('下载', remote_ip, f"[播客打包] {radio_name} · {first_name}")
|
||
except Exception:
|
||
pass
|
||
api_service.logger.info(f"播客打包完成: {radio_name} 成功{ok_count} 失败{len(failed)} ({task_id})")
|
||
except DownloadCanceled:
|
||
# 部分成功后取消——已入包部分照常交付,未完成部分按账本退费
|
||
# ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛
|
||
delivered = ok_count if zip_valid else 0
|
||
refunded = _settle_batch_billing(task_id, delivered, 'canceled')
|
||
if delivered > 0 and zip_tmp_path:
|
||
_mark_task_terminal(task_id, 'canceled',
|
||
f"已取消(已打包 {ok_count} 集可领取)" +
|
||
(f",退回 {refunded} 次" if refunded else ''))
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t is not None:
|
||
t['refunded'] = refunded
|
||
t['result'] = {
|
||
'zip_path': zip_tmp_path,
|
||
'filename': f"播客_{radio_name or '打包'}_{ok_count}集.zip",
|
||
'audio_files': [],
|
||
}
|
||
else:
|
||
_mark_task_terminal(task_id, 'canceled',
|
||
'任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else ''))
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t is not None:
|
||
t['refunded'] = refunded
|
||
if zip_tmp_path:
|
||
try:
|
||
os.remove(zip_tmp_path)
|
||
except OSError:
|
||
pass
|
||
api_service.logger.info(f"播客打包已取消: {task_id}(成功{ok_count} 退回{refunded}次)")
|
||
except DownloadException as e:
|
||
delivered = ok_count if zip_valid else 0
|
||
refunded = _settle_batch_billing(task_id, delivered)
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
_t = DOWNLOAD_TASKS.get(task_id)
|
||
# 终态先到先得——已终态(如用户取消/超时兜底)
|
||
# 不再被覆盖为 error(堵 canceled→error 非法迁移边)
|
||
if _t and _t.get('status') not in ('done', 'error', 'canceled'):
|
||
_t['status'] = 'error'
|
||
_t['completed_at'] = time.time() # 保留期从完成时刻起算
|
||
_t['msg'] = str(e) + (f"(已退回 {refunded} 次下载次数)" if refunded else '')
|
||
_t['refunded'] = refunded
|
||
elif _t:
|
||
_t['refunded'] = refunded
|
||
if zip_tmp_path:
|
||
try:
|
||
os.remove(zip_tmp_path)
|
||
except OSError:
|
||
pass
|
||
api_service.logger.error(f"播客打包失败: {task_id} {e}(退回{refunded}次)")
|
||
except Exception as e:
|
||
# 非预期异常原文 logger 先行,用户可见文案泛化
|
||
# (DownloadException 的文案是我们自己拼的用户可读文本,保留 str(e))
|
||
api_service.logger.error(f"播客打包异常: {task_id} {e}\n{traceback.format_exc()}")
|
||
delivered = ok_count if zip_valid else 0
|
||
refunded = _settle_batch_billing(task_id, delivered)
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
_t = DOWNLOAD_TASKS.get(task_id)
|
||
# 同上——已终态不再覆盖为 error
|
||
if _t and _t.get('status') not in ('done', 'error', 'canceled'):
|
||
_t['status'] = 'error'
|
||
_t['completed_at'] = time.time() # 保留期从完成时刻起算
|
||
_t['msg'] = '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')
|
||
_t['refunded'] = refunded
|
||
elif _t:
|
||
_t['refunded'] = refunded
|
||
if zip_tmp_path:
|
||
try:
|
||
os.remove(zip_tmp_path)
|
||
except OSError:
|
||
pass
|
||
api_service.logger.error(f"播客打包异常: {task_id} {e}(退回{refunded}次)\n{traceback.format_exc()}")
|