Archived
refactor: 两个 ZIP 打包 worker 合并为一个参数化引擎(604 行重复骨架 → 一份)
为什么:_batch_zip_worker(327 行 / 圈 78 / 嵌套 9)与 _podcast_zip_worker
(277 行 / 圈 84 / 嵌套 9)是两份平行实现——任务状态机、断线暂停、全局批量
名额、「逐条失败不计整批」、取消与异常的四路终态收敛、账本结算全部各写一遍,
连注释都逐字重复。上一轮修的 zip_valid 未绑定缺陷正是同型出现在这两处,
重复代码在持续制造同类 bug。
改法:新增 services/_zipcore.py 只保留共用骨架,差异收敛到 ZipJob 钩子:
prepare / download_one / write_entry / zip_filename 必实现,prefetch /
fail_label / done_msg / log_label / all_failed_msg / partial_msg / supplement /
on_delivered / cleanup 按需覆写。batch.py 与 podcast.py 只剩薄包装 + flavor 类。
线程模型(改动中最容易踩坏的一条,已写进模块 docstring):
ZipFile 不是线程安全的,且包内名去重是「读改写 used_arcnames」。因此把下载与
落包拆成两个钩子——只有 download_one 进线程池,zf.write 与去重一律在调用线程
串行。旧的音乐批量之所以安全,只是因为它的 zf.write 恰好写在主线程的
as_completed 循环里;照搬成「整个下载单元进池」会引入静默的 ZIP 损坏。
复杂度(同口径 AST 实测):
两个 worker 合计 604 行 / 圈 162 / 嵌套 9,9
→ run_zip_job 167 行 / 圈 37 / 嵌套 4
+ _fill_zip 64 行 / 圈 13 / 嵌套 5
全仓最大圈复杂度 84 → 37;>80 行函数 3 → 2(余下两个之一是本次未改动的
_podcast_download_worker)。两文件改为三文件后总行数 930 → 1071,多出的
主要是引擎 docstring——注释比代码贵,但它把「为什么这么拆」留给了下一个人。
逐条对齐的分叉(合并必须做的决定,此处全部列明):
1. 账本终态:播客全失败走自己的 except DownloadException → 落 'settled',音乐
同语义事件落 'error'。统一为 'error'。全仓仅读取 'open'(对账/清理/计数),
其余取值只作排障留痕 → 这是本次**唯一可观察的语义变更**,由新增用例固化。
2. 通用异常路径的「部分交付」:音乐侧会发布已打包的 ZIP 并挂 result,播客侧
直接丢弃(却已按 delivered 结算 = 用户扣了费拿不到货)。统一采用音乐侧口径。
该路径实际不可达(ZIP 写完后到发布前只有取消检查会抛错,已被取消分支接管)。
3. 全失败判定:音乐用 `ok == 0 and failed`、播客用早抛 DownloadException。
统一为显式 `ok == 0`——好处是不再发布空 ZIP、不再把空批次标 done。
4. 早抛业务失败(无可下载条目 / 补扣失败):播客走 DownloadException 分支,
音乐无此分支(落通用异常,用户看到的是「打包失败,请稍后重试」)。统一保留
该分支,音乐侧同类失败从此也给出具体原因。
5. 错误终态写法:播客在手写任务字典(重复一遍「已终态不覆盖」,且漏掉 /F-E 的
「worker 线程已死则按账本补结算」兜底),统一走 _mark_task_terminal。
6. 暂停检查点:统一采用音乐侧更严的一处——拿到 BATCH_ZIP_SEM 后再查一次
pause/cancel。播客此前会多下完一集才停。
7. total 字段:音乐由提交侧设置、播客由 worker 设置,统一为 worker 设
len(items)(对音乐是同一个值)。
8. 日志文案统一(tag / 名词提成类属性):'批量打包异常' 与 '批量ZIP任务异常'
两种前缀收敛为一种;完成日志统一带上 task_id 与退回次数。
删除的不可达分支(可证明):
两个 worker 在 zip_valid = True 之后各留了一段 `if not zip_valid:` 失败收敛
(音乐 16 行 / 播客 18 行,含「ZIP 打包失败(可能磁盘空间不足)…」文案)。
赋值紧跟其后、中间无任何语句可改写 zip_valid,故恒不可达——是原注释所称
「复审 修正」留下的护栏。zip_valid 在异常路径仍有意义(决定 delivered 是否为 0)。
等价性证据(三道,互相独立):
1. AST 字面量多重集比对 + 逐条位置定位:数值 16 种一个不少。少掉的 8 处 `0`
全部来自上面删的不可达分支与重复字面量合并(`delivered = … else 0` 5 处→3 处)。
2. 反向注入验证:把 git HEAD 的旧 worker 注入成 services.batch / services.podcast,
用同一套 tests/test_zip_workers.py 去跑 → 第 1 批 9 例行为契约**全过**,证明
这套测试忠实编码了旧行为、因而对新实现构成等价性验证;新增 4 例中只有账本
终态那 1 例失败('settled' == 'error'),恰好把上面第 1 条对齐实证出来。该用例
里「全部单集下载失败: …(已退回 2 次下载次数)」的文案断言在旧实现上也通过
→ 用户可见文案逐字保留。
3. 新增 4 例固化测试:同名歌声的音频/歌词/封面三条目共享序号后缀(F-C)、
两边的全失败文案与账本终态、单集失败后任务目录不留中间文件与 .part 残片。
顺带修掉一个自己引入的缺陷:引擎化后播客侧的单条失败文案本会退化成整个条目
字典,字面量审计里 '(' ')' 各少 1 处把它逼了出来 → 补 ZipJob.fail_label 覆盖。
验证:ruff 全过 · pyright 0/0 · pytest 213 passed(209→213)· 生产 data/ 未被
触碰(全部用例走 tmp_path 沙箱)。
This commit is contained in:
1 parent
65a81af94a
commit
5c8f941d19
4 files changed
+848
-579
No files matched your search
+151
-336
@@ -1,40 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""歌单/专辑批量打包 worker。
|
||||
|
||||
打包骨架(状态机 / 暂停 / 名额 / 终态收敛 / 结算)在 :mod:`services._zipcore`,
|
||||
本模块只保留「音乐歌单」这一侧的策略:窗口化预取与并行下载、每首三条目
|
||||
(音频 + 歌词 + 封面)入包、同名去重、包名与文案。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from core.state import (
|
||||
BATCH_DURATIONS,
|
||||
BATCH_DURATIONS_LOCK,
|
||||
DOWNLOAD_TASKS,
|
||||
DOWNLOAD_TASKS_LOCK,
|
||||
from concurrent.futures import (
|
||||
Future,
|
||||
ThreadPoolExecutor,
|
||||
)
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.utils import format_lyric as _format_lyric
|
||||
from music_downloader import (
|
||||
DownloadCanceled,
|
||||
fetch_cover_bytes,
|
||||
)
|
||||
|
||||
from ._shared import (
|
||||
BATCH_ZIP_SEM,
|
||||
_build_download_base,
|
||||
)
|
||||
from ._taskcore import (
|
||||
_mark_task_terminal,
|
||||
_settle_batch_billing,
|
||||
_task_work_dir,
|
||||
_wait_if_paused,
|
||||
)
|
||||
from ._zipcore import (
|
||||
ItemFailed,
|
||||
ZipContext,
|
||||
ZipJob,
|
||||
run_zip_job,
|
||||
)
|
||||
from .api_service import (
|
||||
api_service,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SongPayload:
|
||||
"""一首歌下载完成后的入包素材(下载线程产出,落包线程消费)。"""
|
||||
|
||||
path: str
|
||||
name: str
|
||||
artists: str
|
||||
file_type: str
|
||||
lyric: str
|
||||
cover: bytes
|
||||
|
||||
|
||||
class _SonglistZipJob(ZipJob):
|
||||
"""音乐歌单打包:窗口化并行下载,每首写 音频 + 歌词 + 封面 三条目。"""
|
||||
|
||||
tag = '批量ZIP'
|
||||
noun = '首'
|
||||
item_noun = '歌曲'
|
||||
tmp_prefix = 'batch_zip_'
|
||||
start_msg = '下载打包中...'
|
||||
record_duration = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._info_pool: ThreadPoolExecutor | None = None
|
||||
self._info_futures: dict[Any, Future[Any]] = {}
|
||||
|
||||
def prepare(self, params: dict[str, Any], task_id: str, failed: list[str]) -> list[Any]:
|
||||
self.concurrency = int(params['concurrency'])
|
||||
return list(params['ids'])
|
||||
|
||||
def prefetch(self, window: list[Any], ctx: ZipContext) -> None:
|
||||
"""按窗口预取歌曲信息。
|
||||
|
||||
原实现一次性提交全部(最多 500 首 ×3 接口)预取任务,大歌单瞬间以 10 并发
|
||||
连发上游约 1500 次请求打风控;改为每个下载窗口只预取本窗口的歌(与下载节奏
|
||||
对齐),``download_one`` 语义不变(等预取结果再下载)。
|
||||
"""
|
||||
if self._info_pool is None:
|
||||
self._info_pool = ThreadPoolExecutor(max_workers=min(4, max(1, self.concurrency)))
|
||||
quality = ctx.params['quality']
|
||||
cookies = ctx.params['cookies']
|
||||
for mid in window:
|
||||
if mid not in self._info_futures:
|
||||
self._info_futures[mid] = self._info_pool.submit(
|
||||
api_service.downloader.get_music_info, mid, quality, cookies
|
||||
)
|
||||
|
||||
def download_one(self, item: Any, idx: int, ctx: ZipContext) -> _SongPayload:
|
||||
"""并行下载单元:等待预取信息 → 下载 → 复用预取歌词与缓存封面(零重复请求)。"""
|
||||
mid = item
|
||||
info = self._info_futures[mid].result()
|
||||
params = ctx.params
|
||||
result = api_service.downloader.download_music_file(
|
||||
mid, params['quality'],
|
||||
write_meta=params['write_meta'],
|
||||
meta_fields=params['meta_fields'],
|
||||
cookies=params['cookies'],
|
||||
cancel_event=ctx.cancel_ev,
|
||||
music_info=info,
|
||||
)
|
||||
lyric = ''
|
||||
cover = b''
|
||||
if result.success:
|
||||
# 歌词已随 get_music_info 预取返回,无需再调一次歌词接口
|
||||
lyric = getattr(info, 'lyric', '') or ''
|
||||
# 封面走共享缓存(同一专辑只拉一次网络);取消后不再发起新请求
|
||||
if not ctx.cancel_ev.is_set() and info.pic_url:
|
||||
cover = fetch_cover_bytes(info.pic_url) or b''
|
||||
if not result.success:
|
||||
raise ItemFailed(str(mid))
|
||||
assert result.file_path is not None
|
||||
if not Path(result.file_path).exists():
|
||||
# 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务
|
||||
raise ItemFailed(str(mid))
|
||||
mi = result.music_info
|
||||
if mi is None:
|
||||
# 与上面 exists() 同口径:成功但缺元信息(上游详情缺失)按单曲失败计,
|
||||
# 不让 mi.name 抛 AttributeError 把整批转 error
|
||||
raise ItemFailed(str(mid))
|
||||
return _SongPayload(path=result.file_path, name=mi.name, artists=mi.artists,
|
||||
file_type=mi.file_type, lyric=lyric, cover=cover)
|
||||
|
||||
def write_entry(self, item: Any, idx: int, payload: _SongPayload, ctx: ZipContext) -> None:
|
||||
mid = item
|
||||
base = _build_download_base(ctx.params['filename_format'],
|
||||
{'name': payload.name, 'artist_string': payload.artists})
|
||||
lyric_format = ctx.params['lyric_format']
|
||||
# 同名去重:歌单里同一首歌出现两次/同名不同版本时,追加序号避免 ZIP 内重名
|
||||
# 互相覆盖(用户被扣 2 次只拿到 1 首)。F-C:音频/歌词/封面三个 arcname 都要
|
||||
# 查重——同名同歌手但音源扩展不同(flac vs mp3)时歌词/封面也会互相覆盖
|
||||
final_name = f"{base}.{payload.file_type}"
|
||||
lrc_name = f"{base}.{lyric_format}"
|
||||
jpg_name = f"{base}.jpg"
|
||||
k = 2
|
||||
while (final_name in ctx.used_arcnames
|
||||
or lrc_name in ctx.used_arcnames
|
||||
or jpg_name in ctx.used_arcnames):
|
||||
final_name = f"{base} ({k}).{payload.file_type}"
|
||||
lrc_name = f"{base} ({k}).{lyric_format}"
|
||||
jpg_name = f"{base} ({k}).jpg"
|
||||
k += 1
|
||||
ctx.used_arcnames.update((final_name, lrc_name, jpg_name))
|
||||
# F-D:exists 与 zf.write 之间的缓存竞态(另一任务缓存 miss 删文件)
|
||||
# 按单曲失败计,不再让整批转 error
|
||||
try:
|
||||
ctx.zf.write(payload.path, arcname=final_name, compress_type=zipfile.ZIP_STORED)
|
||||
except OSError as e:
|
||||
raise ItemFailed(f"{mid}(缓存文件读取失败: {e})") from e
|
||||
# 缓存文件是跨任务共享资源,任务不得删除;磁盘回收交给监控线程的下载缓存
|
||||
# 清扫(按文件年龄)。歌词与封面已在并行下载线程中拉取,这里直接写包(无网络等待)
|
||||
if payload.lyric:
|
||||
try:
|
||||
ctx.zf.writestr(lrc_name, _format_lyric(payload.lyric, lyric_format),
|
||||
compress_type=zipfile.ZIP_DEFLATED)
|
||||
except Exception:
|
||||
pass
|
||||
if payload.cover:
|
||||
try:
|
||||
ctx.zf.writestr(jpg_name, payload.cover, compress_type=zipfile.ZIP_STORED)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def zip_filename(self, ok_count: int) -> str:
|
||||
return f"批量下载_{ok_count}首.zip"
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._info_pool is not None:
|
||||
try:
|
||||
self._info_pool.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
self._info_pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def _batch_zip_worker(task_id: str) -> None:
|
||||
"""后台批量打包:窗口化下载 → 写 ZIP(音频免压缩),支持取消与断线暂停
|
||||
|
||||
@@ -42,323 +176,4 @@ def _batch_zip_worker(task_id: str) -> None:
|
||||
保存进度进入暂停等待;重新打开网站(心跳恢复)后自动回到队列继续下载
|
||||
- 音频入包即删,缓解磁盘占用
|
||||
"""
|
||||
import tempfile as _tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
zip_tmp_path: str | None = None
|
||||
# zip_valid 必须在函数头初始化:ZIP 构建前的业务错误(参数缺失/无歌曲等)
|
||||
# 会 early raise,异常处理器若引用未绑定名会 NameError 二次崩溃 worker 线程
|
||||
zip_valid = False
|
||||
ok_count = 0
|
||||
failed: list[str] = []
|
||||
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
|
||||
info_pool = None
|
||||
info_futures = {}
|
||||
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']
|
||||
ids = list(params['ids'])
|
||||
quality = params['quality']
|
||||
filename_format = params['filename_format']
|
||||
write_meta = params['write_meta']
|
||||
meta_fields = params['meta_fields']
|
||||
lyric_format = params['lyric_format']
|
||||
concurrency = params['concurrency']
|
||||
cookies = params['cookies']
|
||||
batch_start = time.time()
|
||||
|
||||
# 预取按窗口进行——原实现一次性提交全部(最多 500 首 ×3 接口)预取任务,
|
||||
# 大歌单瞬间以 10 并发连发上游约 1500 次请求打风控;改为每个下载窗口只预取本窗口
|
||||
# 的歌(与下载节奏对齐),_download_one 语义不变(等预取结果再下载)
|
||||
from concurrent.futures import ThreadPoolExecutor as _InfoPool
|
||||
info_pool = _InfoPool(max_workers=min(4, max(1, concurrency)))
|
||||
|
||||
def _prefetch(window_ids):
|
||||
for mid in window_ids:
|
||||
if mid not in info_futures:
|
||||
info_futures[mid] = info_pool.submit(
|
||||
api_service.downloader.get_music_info, mid, quality, cookies
|
||||
)
|
||||
|
||||
def _download_one(mid):
|
||||
"""并行下载单元:等待预取信息 → 下载 → 复用预取歌词与缓存封面(零重复请求)"""
|
||||
info = info_futures[mid].result()
|
||||
result = api_service.downloader.download_music_file(
|
||||
mid, quality,
|
||||
write_meta=write_meta,
|
||||
meta_fields=meta_fields,
|
||||
cookies=cookies,
|
||||
cancel_event=cancel_ev,
|
||||
music_info=info,
|
||||
)
|
||||
extra = {'lyric': '', 'cover': b''}
|
||||
if result.success:
|
||||
# 歌词已随 get_music_info 预取返回,无需再调一次歌词接口
|
||||
extra['lyric'] = getattr(info, 'lyric', '') or ''
|
||||
# 封面走共享缓存(同一专辑只拉一次网络);取消后不再发起新请求
|
||||
if not cancel_ev.is_set() and info.pic_url:
|
||||
cover = fetch_cover_bytes(info.pic_url)
|
||||
if cover:
|
||||
extra['cover'] = cover
|
||||
return result, extra
|
||||
|
||||
def _update_progress() -> None:
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
DOWNLOAD_TASKS[task_id]['done'] = ok_count + len(failed)
|
||||
DOWNLOAD_TASKS[task_id]['failed'] = len(failed)
|
||||
|
||||
# ZIP 构建放 downloads 卷(原实现落系统 %TEMP%——500 首无损可达 GB 级,
|
||||
# 小系统盘一次大单即塞爆;单曲/播客早已指定 dir,唯批量漏了)
|
||||
# 且建在任务专属目录(见 _task_work_dir):根目录会被缓存清扫按 mtime 回收
|
||||
_task_dir_for_zip = _task_work_dir(task_id)
|
||||
# 中央目录完整写出后(下方)才置 True,否则一律按失败全额退费
|
||||
with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip',
|
||||
dir=str(_task_dir_for_zip),
|
||||
delete=False) as zip_tmp:
|
||||
zip_tmp_path = zip_tmp.name
|
||||
# 音频本身已压缩,用 ZIP_STORED(免压缩)大幅提速;歌词等文本仍压缩
|
||||
with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# 不用 with 管理下载池——取消时 with 退出会 wait=True 等在途
|
||||
# future(其内部阻塞在预取 HTTP 上最长 30s),改为不等待 + 取消排队任务
|
||||
pool = ThreadPoolExecutor(max_workers=concurrency)
|
||||
try:
|
||||
while ids:
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
# 断线暂停:释放名额等待恢复/取消,恢复后重新进入排队
|
||||
_wait_if_paused(task, pause_ev, cancel_ev, ok_count + len(failed))
|
||||
# 每个窗口持有 BATCH_ZIP_SEM(同一时刻全局最多 3 个批量任务在下载)
|
||||
# 等待名额期间可被取消,不再无限阻塞线程与退款
|
||||
if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev):
|
||||
raise DownloadCanceled("任务已取消")
|
||||
try:
|
||||
if cancel_ev.is_set() or pause_ev.is_set() or task['status'] == 'paused':
|
||||
continue # 立即释放名额,回到循环顶部处理暂停
|
||||
window = ids[:concurrency]
|
||||
ids = ids[concurrency:]
|
||||
_prefetch(window) # 只预取本窗口
|
||||
future_map = {
|
||||
pool.submit(_download_one, mid): mid
|
||||
for mid in window
|
||||
}
|
||||
for future in as_completed(future_map):
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
mid = future_map[future]
|
||||
try:
|
||||
result, extra = future.result()
|
||||
except DownloadCanceled:
|
||||
raise
|
||||
except Exception as e:
|
||||
failed.append(f"{mid}({e})")
|
||||
_update_progress()
|
||||
continue
|
||||
if not result.success:
|
||||
failed.append(str(mid))
|
||||
_update_progress()
|
||||
continue
|
||||
assert result.file_path is not None
|
||||
fp = Path(result.file_path)
|
||||
if not fp.exists():
|
||||
# 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务
|
||||
failed.append(str(mid))
|
||||
_update_progress()
|
||||
continue
|
||||
mi = result.music_info
|
||||
if mi is None:
|
||||
# 与上面 fp.exists() 同口径:成功但缺元信息(上游详情缺失)
|
||||
# 按单曲失败计,不让 mi.name 抛 AttributeError 把整批转 error
|
||||
failed.append(str(mid))
|
||||
_update_progress()
|
||||
continue
|
||||
base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists})
|
||||
# 同名去重:歌单里同一首歌出现两次/同名不同版本时,
|
||||
# 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首)
|
||||
# F-C:音频/歌词/封面三个 arcname 都要查重——同名同歌手但
|
||||
# 音源扩展不同(flac vs mp3)时歌词/封面条目也会互相覆盖
|
||||
final_name = f"{base}.{mi.file_type}"
|
||||
lrc_name = f"{base}.{lyric_format}"
|
||||
jpg_name = f"{base}.jpg"
|
||||
k = 2
|
||||
while (final_name in used_arcnames
|
||||
or lrc_name in used_arcnames
|
||||
or jpg_name in used_arcnames):
|
||||
final_name = f"{base} ({k}).{mi.file_type}"
|
||||
lrc_name = f"{base} ({k}).{lyric_format}"
|
||||
jpg_name = f"{base} ({k}).jpg"
|
||||
k += 1
|
||||
used_arcnames.update((final_name, lrc_name, jpg_name))
|
||||
# F-D:exists 与 zf.write 之间的缓存竞态(另一任务缓存 miss
|
||||
# 删文件)按单曲失败计,不再让整批转 error
|
||||
try:
|
||||
zf.write(str(fp), arcname=final_name, compress_type=zipfile.ZIP_STORED)
|
||||
except OSError as e:
|
||||
failed.append(f"{mid}(缓存文件读取失败: {e})")
|
||||
_update_progress()
|
||||
continue
|
||||
# 缓存文件是跨任务共享资源,任务不得删除;
|
||||
# 磁盘回收交给监控线程的下载缓存清扫(按文件年龄)
|
||||
# 歌词与封面已在并行下载线程中拉取,直接写入 ZIP(无网络等待)
|
||||
lrc = extra.get('lyric', '')
|
||||
if lrc:
|
||||
try:
|
||||
zf.writestr(lrc_name, _format_lyric(lrc, lyric_format), compress_type=zipfile.ZIP_DEFLATED)
|
||||
except Exception:
|
||||
pass
|
||||
if extra.get('cover'):
|
||||
try:
|
||||
zf.writestr(jpg_name, extra['cover'], compress_type=zipfile.ZIP_STORED)
|
||||
except Exception:
|
||||
pass
|
||||
ok_count += 1
|
||||
_update_progress()
|
||||
finally:
|
||||
BATCH_ZIP_SEM.release()
|
||||
finally:
|
||||
# 取消/异常时不等待在途下载线程(其可能阻塞在预取 HTTP 上),
|
||||
# 排队任务一并取消;在途线程自行收尾(.part 残片由续传/清扫兜底)
|
||||
try:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
pool.shutdown(wait=False)
|
||||
# 复审 修正:zip_valid = True 必须在内层 with zipfile 退出之后
|
||||
# (中央目录在 close 时才写出)——原缩进 16 在 with 内部,守卫不可达
|
||||
zip_valid = True
|
||||
|
||||
if ok_count > 0:
|
||||
with BATCH_DURATIONS_LOCK:
|
||||
BATCH_DURATIONS.append(time.time() - batch_start)
|
||||
|
||||
# F-B: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"批量ZIP构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次")
|
||||
return
|
||||
|
||||
# 全部失败不发布空 ZIP、不标 done —— 按失败收敛并全额退费
|
||||
if ok_count == 0 and failed:
|
||||
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"全部失败({len(failed)} 首)" + (f",已退回 {refunded} 次" if refunded else ''))
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
DOWNLOAD_TASKS[task_id]['failed'] = len(failed)
|
||||
DOWNLOAD_TASKS[task_id]['refunded'] = refunded
|
||||
api_service.logger.info(f"批量ZIP全部失败: {task_id} 失败{len(failed)} 退回{refunded}次")
|
||||
return
|
||||
|
||||
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"批量下载_{ok_count}首.zip",
|
||||
'audio_files': [], # 缓存文件不归任务所有,不进入清理清单
|
||||
}
|
||||
api_service.logger.info(f"批量ZIP完成: 成功{ok_count} 失败{len(failed)} 退回{refunded}次")
|
||||
except DownloadCanceled:
|
||||
# 部分成功后取消——已入包部分照常交付(成果守恒),未完成部分按账本退费
|
||||
# F-B: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"批量下载_{ok_count}首.zip",
|
||||
'audio_files': [],
|
||||
}
|
||||
else:
|
||||
if zip_tmp_path:
|
||||
try:
|
||||
os.remove(zip_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
_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
|
||||
api_service.logger.info(f"批量ZIP已取消: {task_id}(成功{ok_count} 退回{refunded}次)")
|
||||
except Exception as e:
|
||||
# 通用异常与取消同一套交付/结算规则:有成果则交付,按账本退未交付部分
|
||||
# F-B:ZIP 未正常完成(写坏)时按全额退费,不发布坏包
|
||||
# 非预期异常原文 logger 先行,用户可见文案泛化
|
||||
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, 'error')
|
||||
if delivered > 0 and zip_tmp_path:
|
||||
_mark_task_terminal(task_id, 'error',
|
||||
"部分歌曲已打包完成,可领取" +
|
||||
(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"批量下载_{ok_count}首.zip",
|
||||
'audio_files': [],
|
||||
}
|
||||
else:
|
||||
_mark_task_terminal(task_id, 'error',
|
||||
'打包失败,请稍后重试' + (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.error(f"批量ZIP任务异常: {task_id} {e}(成功{ok_count} 退回{refunded}次)\n{traceback.format_exc()}")
|
||||
finally:
|
||||
# 回收信息预取线程池,避免线程泄漏(未启动的预取任务一并取消)
|
||||
if info_pool is not None:
|
||||
try:
|
||||
info_pool.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
info_pool.shutdown(wait=False)
|
||||
run_zip_job(_SonglistZipJob(), task_id, settle=_settle_batch_billing)
|
||||
Reference in new issue
Block a user