diff --git a/music_downloader/downloader.py b/music_downloader/downloader.py index 3b2aa9a..55bc200 100644 --- a/music_downloader/downloader.py +++ b/music_downloader/downloader.py @@ -572,83 +572,35 @@ class MusicDownloader: ) -> None: """内置流式通道:core.sync_http 逐块落盘,支持 Range 断点续传。 + 四步:构造 Range 头 → 发请求 → 定追加/重写并校验分片起点 → 流式落盘 + (含体积上限与取消)。HTTP 错误在此分类成「确定性失败」或交外层重试。 + Raises: DownloadCanceled: 取消事件置位 DownloadException: 体积超限、CDN 4xx 等确定性失败 _RetryFromScratch: 续传起点失效(残片已清,外层从头重下) """ - headers = {} - mode = "wb" - if resume_from > 0: - headers["Range"] = f"bytes={resume_from}-" - mode = "ab" - - if progress_cb is not None: - try: - progress_cb(resume_from, music_info.file_size or 0) - except Exception: - pass # 进度回调绝不影响下载主流程 + expected_size = music_info.file_size or 0 + self._safe_progress(progress_cb, resume_from, expected_size) resp: requests.SyncResponse | None = None try: resp = get_http_session().get( music_info.download_url, stream=True, timeout=(10, 60), - headers=headers, + headers=self._range_headers(resume_from), ) - # 服务器忽略 Range 返回 200 → 从头重写而非追加 - if resume_from > 0 and resp.status_code != 206: - mode = "wb" - # 206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件 - if resume_from > 0 and resp.status_code == 206: - cr = resp.headers.get('Content-Range') or '' - m = re.match(r'bytes\s+(\d+)-', cr.strip()) - if m and int(m.group(1)) != resume_from: - self._unlink_quiet(part_path) - raise _RetryFromScratch( - f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载") - # 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件, - # 与起点错位同口径处理:清残片从头下 - if not m: - self._unlink_quiet(part_path) - raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载") + mode = self._resolve_write_mode(resp, resume_from, part_path) resp.raise_for_status() - # 下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘 - max_bytes = _MAX_DOWNLOAD_BYTES - written = resume_from if mode == 'ab' else 0 - over_limit = False - with open(part_path, mode) as f: - for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE): - if cancel_event is not None and cancel_event.is_set(): - raise DownloadCanceled("下载已取消") - if chunk: - f.write(chunk) - written += len(chunk) - if written > max_bytes: - # 超限残片没有任何续传价值。 - # Windows 无法删除打开中的文件——置标志 break, - # with 关闭后再清理(原实现在打开中 unlink 被静默吞掉) - over_limit = True - break - if progress_cb is not None: - try: - progress_cb(written, music_info.file_size or 0) - except Exception: - pass # 进度回调绝不影响下载主流程 - if over_limit: - self._unlink_quiet(part_path) # 文件已关闭,清理可达 - raise DownloadException( - f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止") + self._stream_body(resp, part_path, mode, resume_from, + expected_size, cancel_event, progress_cb) except requests.HTTPError as e: - # Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载 - status = getattr(getattr(e, "response", None), "status_code", None) + status = self._http_status(e) if status == 416: + # Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载 self._unlink_quiet(part_path) raise _RetryFromScratch("Range 起点失效,已从头重新下载") from e - if status is not None and 400 <= status < 500 and status != 408: - # CDN 4xx(403 防盗链/429 风控等)是确定性 - # 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回 - # (408 请求超时保留可重试) + if self._is_deterministic_client_error(status): self._unlink_quiet(part_path) raise DownloadException( f"下载失败(HTTP {status}),下载链接可能已失效") from e @@ -657,6 +609,97 @@ class MusicDownloader: if resp is not None: resp.close() + @staticmethod + def _range_headers(resume_from: int) -> dict[str, str]: + """续传时请求「起点之后的全部字节」,否则不带头。""" + if resume_from > 0: + return {"Range": f"bytes={resume_from}-"} + return {} + + def _resolve_write_mode(self, resp, resume_from: int, part_path: Path) -> str: + """落盘方式:续传且服务器确实给了 206 才追加,否则从头重写。 + + 服务器忽略 Range 返回 200 时若继续追加,会把整段拼在残片后面;206 还须 + 校验分片起点(见 _verify_content_range)。 + """ + if resume_from <= 0 or resp.status_code != 206: + return "wb" + self._verify_content_range(resp, resume_from, part_path) + return "ab" + + def _verify_content_range(self, resp, resume_from: int, part_path: Path) -> None: + """校验 206 的分片起点;无法确认起点就清残片从头下。 + + 起点错位的分片拼起来是坏文件——代价远大于多下一次,故与「头被中间层 + 剥离、解析不出起点」同口径处理。 + """ + content_range = (resp.headers.get("Content-Range") or "").strip() + m = re.match(r"bytes\s+(\d+)-", content_range) + if m is not None and int(m.group(1)) == resume_from: + return + self._unlink_quiet(part_path) + if m is None: + raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载") + raise _RetryFromScratch( + f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载") + + def _stream_body( + self, + resp, + part_path: Path, + mode: str, + resume_from: int, + expected_size: int, + cancel_event: threading.Event | None, + progress_cb, + ) -> None: + """逐块写入 part_path:取消检查 + 体积硬上限(500MB)+ 进度上报。 + + 超限不能就地删除——Windows 无法删除仍被打开的文件,先置标志退出 with + 再清理(原实现在打开中 unlink 被静默吞掉,超限残片留在磁盘上)。 + """ + max_bytes = _MAX_DOWNLOAD_BYTES + written = resume_from if mode == "ab" else 0 + over_limit = False + with open(part_path, mode) as f: + for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE): + if cancel_event is not None and cancel_event.is_set(): + raise DownloadCanceled("下载已取消") + if not chunk: + continue + f.write(chunk) + written += len(chunk) + if written > max_bytes: + over_limit = True + break + self._safe_progress(progress_cb, written, expected_size) + if over_limit: + self._unlink_quiet(part_path) # 文件已关闭,清理可达 + raise DownloadException( + f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止") + + @staticmethod + def _safe_progress(progress_cb, written: int, total: int) -> None: + """上报一次进度;回调抛异常绝不影响下载主流程。""" + if progress_cb is None: + return + try: + progress_cb(written, total) + except Exception: + pass + + @staticmethod + def _http_status(exc: BaseException) -> int | None: + """从 HTTPError 上取响应状态码(无响应对象时返回 None)。""" + return getattr(getattr(exc, "response", None), "status_code", None) + + @staticmethod + def _is_deterministic_client_error(status: int | None) -> bool: + """确定性 4xx(403 防盗链 / 429 风控等):对同一失效直链重试只会加剧风控、 + 拖慢失败返回。408 请求超时是唯一保留可重试的 4xx。 + """ + return status is not None and 400 <= status < 500 and status != 408 + def _fetch_via_aria2( self, backend: Aria2Backend, diff --git a/tests/test_downloader.py b/tests/test_downloader.py index 842f2b9..c3d23d3 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -394,6 +394,26 @@ def test_408_is_retried_then_reported_as_failure(env): assert len(session.calls) == 2, '408 请求超时应保留可重试' +def test_5xx_is_retried_and_keeps_part(env): + """5xx 是服务端瞬时故障(非确定性失败):应可重试,且保留 .part 供续传。 + + 与 4xx 的分界在这里钉住:error 分类只看 400<=status<500,5xx 落回裸重抛, + 由外层 RequestException 分支退避重试。此前这条路径无测试覆盖。 + """ + dl, _state, install = env + info = _info(size=100) + fp, part = _paths(dl, info) + part.write_bytes(b'p' * 10) + session = install(lambda a, u, kw: _FakeResponse([], status=500)) + + r = dl.download_music_file(101, music_info=info, retries=1) + + assert not r.success and '下载请求失败' in r.error_message + assert len(session.calls) == 2, '5xx 非确定性失败,应重试' + assert not fp.exists(), '未交付不得留下最终文件' + assert part.stat().st_size == 10, '.part 应保留供下轮续传' + + def test_incomplete_size_keeps_part_and_reports(env): dl, _state, install = env info = _info(size=100)