This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/services/batch.py
T
snowy-yang 5c8f941d19 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 沙箱)。
2026-09-26 19:25:06 +08:00

180 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""歌单/专辑批量打包 worker。
打包骨架(状态机 / 暂停 / 名额 / 终态收敛 / 结算)在 :mod:`services._zipcore`,
本模块只保留「音乐歌单」这一侧的策略:窗口化预取与并行下载、每首三条目
(音频 + 歌词 + 封面)入包、同名去重、包名与文案。
"""
import zipfile
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 (
fetch_cover_bytes,
)
from ._shared import (
_build_download_base,
)
from ._taskcore import (
_settle_batch_billing,
)
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(音频免压缩),支持取消与断线暂停
- 断线暂停:设备心跳超时后,当前窗口下载完即释放 BATCH_ZIP_SEM 名额给下一位,
保存进度进入暂停等待;重新打开网站(心跳恢复)后自动回到队列继续下载
- 音频入包即删,缓解磁盘占用
"""
run_zip_job(_SonglistZipJob(), task_id, settle=_settle_batch_billing)