# -*- coding: utf-8 -*- """播客单集与整台打包 worker。 """ import os import shutil import threading import time import traceback import zipfile from pathlib import Path from typing import Any import auth_system from core.state import ( DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK, ) from music_api import ( APIException, podcast, ) 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, _task_work_dir, _wait_if_paused, ) 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 pct = 0 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.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.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 / radio_name 必须在函数头初始化——空电台/无单集/补扣失败等 # 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程 zip_valid = False radio_name = '' 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.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.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 _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//,根目录缓存清扫不再触及交付物 _podcast_task_dir = _task_work_dir(task_id) 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(task, pause_ev, cancel_ev, ok_count + len(failed)) # 并发对齐音乐批量:播客打包同样占用全局批量名额(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.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()}")