优化资源文件下载,增加断点续传的能力。

This commit is contained in:
Hommy
2026-08-22 21:54:50 +08:00
parent 4010029b56
commit f626d73a2b
2 changed files with 493 additions and 45 deletions
+201 -45
View File
@@ -310,6 +310,113 @@ def _http_get(url: str, **kwargs) -> requests.Response:
return requests.get(url, headers=headers, **kwargs)
# 草稿文件列表里既有 json 元数据,也有音视频/图片。仅后者需要 HTTP Range 断点续传。
_MEDIA_RESOURCE_EXTENSIONS = frozenset(
{
".mp4",
".mov",
".avi",
".mkv",
".webm",
".m4v",
".flv",
".wmv",
".ts",
".mpeg",
".mpg",
".3gp",
".mp3",
".wav",
".aac",
".m4a",
".flac",
".ogg",
".wma",
".aiff",
".aif",
".opus",
".amr",
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
".heic",
".heif",
".ico",
".svg",
}
)
def _extract_extension(url_or_path: str) -> str:
"""从 URL 或本地路径取出小写扩展名(忽略 query)。"""
if not url_or_path:
return ""
path = urlparse(url_or_path).path if "://" in url_or_path else url_or_path
return os.path.splitext(path)[1].lower()
def _is_media_resource(url_or_path: str) -> bool:
"""
判断是否为需要断点续传的资源文件(视频/图片/音频)。
json、bin、草稿元数据等返回 False,保持「失败即整文件重下」的原有行为。
"""
return _extract_extension(url_or_path) in _MEDIA_RESOURCE_EXTENSIONS
def _local_file_size(path: str) -> int:
"""返回本地文件字节数;不存在或无法读取时视为 0。"""
try:
if os.path.isfile(path):
return os.path.getsize(path)
except OSError:
return 0
return 0
def _resume_request_headers(local_path: str) -> Tuple[Optional[Dict[str, str]], int]:
"""
按本地半成品大小构造 Range 请求头。
无半成品时返回 (None, 0),调用方应发普通 GET,请求形态与改造前一致。
"""
size = _local_file_size(local_path)
if size <= 0:
return None, 0
return {"Range": f"bytes={size}-"}, size
def _is_download_success_status(status_code: int, resume_from: int) -> bool:
"""200 始终成功;206 仅在确实携带断点(resume_from>0)时视为续传成功。"""
if status_code == 200:
return True
return status_code == 206 and resume_from > 0
def _write_http_body_to_file(
response: requests.Response, file_path: str, *, append: bool
) -> None:
"""
将 HTTP 响应体写入本地文件。
append=True:断点续传,在已有内容后追加(配合 206)。
append=False:覆盖写入(JSON 等非资源,或服务端忽略 Range 返回 200 时的整文件重下)。
"""
parent_dir = os.path.dirname(file_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
mode = "ab" if append else "wb"
with open(file_path, mode) as out:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
out.write(chunk)
def _resolve_download_target_path(
file_url: str, target_dir: str
) -> Tuple[str, Optional[str]]:
@@ -715,16 +822,33 @@ def _download_single_file(file_url: str, target_dir: str) -> None:
retry_count = 0
full_file_path, url_draft_id = _resolve_download_target_path(file_url, target_dir)
# 仅视频/图片/音频走 Range 续传;json 等仍每次整文件覆盖下载。
enable_resume = _is_media_resource(file_url) or _is_media_resource(full_file_path)
while retry_count <= max_retries:
try:
response = _http_get(
file_url,
timeout=(_REQUEST_CONNECT_TIMEOUT, _REQUEST_READ_TIMEOUT),
stream=True,
)
extra_headers = None
resume_from = 0
if enable_resume:
extra_headers, resume_from = _resume_request_headers(full_file_path)
if resume_from > 0:
logger.info(
"Resume media download from byte %s: %s",
resume_from,
file_url,
)
get_kwargs = {
"timeout": (_REQUEST_CONNECT_TIMEOUT, _REQUEST_READ_TIMEOUT),
"stream": True,
}
# 无半成品时不传 headers,保持与改造前完全相同的 GET 调用形态。
if extra_headers:
get_kwargs["headers"] = extra_headers
response = _http_get(file_url, **get_kwargs)
try:
if response.status_code != 200:
if not _is_download_success_status(response.status_code, resume_from):
if not _is_retryable_http_status(response.status_code):
status = response.status_code
logger.error(
@@ -763,14 +887,14 @@ def _download_single_file(file_url: str, target_dir: str) -> None:
_sleep_transient_http_backoff(retry_count, response)
continue
parent_dir = os.path.dirname(full_file_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(full_file_path, "wb") as out:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
out.write(chunk)
# 206:从断点追加;200:整文件覆盖(含服务端忽略 Range 的情况)。
append = response.status_code == 206 and resume_from > 0
if resume_from > 0 and response.status_code == 200:
logger.info(
"Server ignored Range, re-downloading from start: %s",
file_url,
)
_write_http_body_to_file(response, full_file_path, append=append)
finally:
response.close()
@@ -1040,15 +1164,31 @@ def _download_remote_material_raising(
fallback_ext: str,
) -> str:
"""下载 URL 素材;失败抛出 DraftDownloadAbort。"""
# 本函数只拉取音视频/图片(含无扩展名的 CDN URL),始终允许断点续传。
local_path: Optional[str] = None
for attempt in range(_MAX_RETRIES + 1):
response = None
try:
response = _http_get(
file_url,
timeout=(_REQUEST_CONNECT_TIMEOUT, _REQUEST_READ_TIMEOUT),
stream=True,
)
if response.status_code != 200:
extra_headers = None
resume_from = 0
if local_path:
extra_headers, resume_from = _resume_request_headers(local_path)
if resume_from > 0:
logger.info(
"Resume remote material from byte %s: %s",
resume_from,
file_url,
)
get_kwargs = {
"timeout": (_REQUEST_CONNECT_TIMEOUT, _REQUEST_READ_TIMEOUT),
"stream": True,
}
if extra_headers:
get_kwargs["headers"] = extra_headers
response = _http_get(file_url, **get_kwargs)
if not _is_download_success_status(response.status_code, resume_from):
if not _is_retryable_http_status(response.status_code):
status = response.status_code
logger.error(
@@ -1082,19 +1222,20 @@ def _download_remote_material_raising(
response.close()
continue
ext = _infer_ext_from_content_type(
response.headers.get("Content-Type"), fallback_ext
)
filename = _build_material_filename(base_name, ext)
local_path = os.path.join(target_dir, "assets", sub_dir, filename)
if local_path is None:
ext = _infer_ext_from_content_type(
response.headers.get("Content-Type"), fallback_ext
)
filename = _build_material_filename(base_name, ext)
local_path = os.path.join(target_dir, "assets", sub_dir, filename)
parent_dir = os.path.dirname(local_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(local_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
append = response.status_code == 206 and resume_from > 0
if resume_from > 0 and response.status_code == 200:
logger.info(
"Server ignored Range, re-downloading from start: %s",
file_url,
)
_write_http_body_to_file(response, local_path, append=append)
return local_path
except DraftDownloadAbort:
raise
@@ -1152,14 +1293,29 @@ def _download_remote_file(file_url: str, local_path: str) -> bool:
def _download_remote_file_raising(file_url: str, local_path: str) -> None:
"""下载单个 URL 素材;失败抛出 DraftDownloadAbort。"""
enable_resume = _is_media_resource(file_url) or _is_media_resource(local_path)
for attempt in range(_MAX_RETRIES + 1):
try:
response = _http_get(
file_url,
timeout=(_REQUEST_CONNECT_TIMEOUT, _REQUEST_READ_TIMEOUT),
stream=True,
)
if response.status_code != 200:
extra_headers = None
resume_from = 0
if enable_resume:
extra_headers, resume_from = _resume_request_headers(local_path)
if resume_from > 0:
logger.info(
"Resume remote file from byte %s: %s",
resume_from,
file_url,
)
get_kwargs = {
"timeout": (_REQUEST_CONNECT_TIMEOUT, _REQUEST_READ_TIMEOUT),
"stream": True,
}
if extra_headers:
get_kwargs["headers"] = extra_headers
response = _http_get(file_url, **get_kwargs)
if not _is_download_success_status(response.status_code, resume_from):
if not _is_retryable_http_status(response.status_code):
status = response.status_code
logger.error(
@@ -1193,13 +1349,13 @@ def _download_remote_file_raising(file_url: str, local_path: str) -> None:
response.close()
continue
parent_dir = os.path.dirname(local_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(local_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
append = response.status_code == 206 and resume_from > 0
if resume_from > 0 and response.status_code == 200:
logger.info(
"Server ignored Range, re-downloading from start: %s",
file_url,
)
_write_http_body_to_file(response, local_path, append=append)
return
except DraftDownloadAbort:
raise
+292
View File
@@ -0,0 +1,292 @@
"""草稿下载:资源文件断点续传;JSON 等非资源仍整文件重下。"""
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import requests
import src.utils.draft_downloader as dd
@pytest.fixture
def no_sleep():
with patch.object(dd, "time") as m_time:
m_time.sleep = MagicMock()
yield m_time
def _stream_response(
chunks,
status: int = 200,
headers=None,
raise_after=None,
) -> MagicMock:
r = MagicMock()
r.status_code = status
r.headers = headers or {}
r.close = MagicMock()
def iter_content(chunk_size=8192):
for chunk in chunks:
yield chunk
if raise_after is not None:
raise raise_after
r.iter_content = iter_content
return r
def _range_from_call(call) -> str:
headers = call.kwargs.get("headers") or {}
return headers.get("Range", "")
class TestIsMediaResource:
@pytest.mark.parametrize(
"value",
[
"clip.mp4",
"https://cdn.example.com/a.MP4?token=1",
r"C:\draft\assets\videos\x.mov",
"photo.PNG",
"https://x.test/img.jpg",
"audio.mp3",
"track.wav",
"pic.webp",
],
)
def test_media_extensions_are_detected(self, value: str) -> None:
assert dd._is_media_resource(value) is True
@pytest.mark.parametrize(
"value",
[
"draft_content.json",
"draft_meta_info.json",
"https://cdn.example.com/app/output/draft/20251204214904ccb1af38/a.bin",
"notes.txt",
"https://x.test/foo.image?sig=1",
"",
],
)
def test_non_media_extensions_are_excluded(self, value: str) -> None:
assert dd._is_media_resource(value) is False
class TestResumeHelpers:
def test_resume_headers_none_when_file_missing(self, tmp_path) -> None:
headers, resume_from = dd._resume_request_headers(str(tmp_path / "miss.mp4"))
assert headers is None
assert resume_from == 0
def test_resume_headers_use_existing_size(self, tmp_path) -> None:
path = tmp_path / "part.mp4"
path.write_bytes(b"hello")
headers, resume_from = dd._resume_request_headers(str(path))
assert resume_from == 5
assert headers == {"Range": "bytes=5-"}
def test_success_status_206_only_when_resuming(self) -> None:
assert dd._is_download_success_status(200, 0) is True
assert dd._is_download_success_status(200, 10) is True
assert dd._is_download_success_status(206, 10) is True
assert dd._is_download_success_status(206, 0) is False
assert dd._is_download_success_status(404, 10) is False
class TestDownloadSingleFileResume:
_BASE = "https://capcut.example.com"
_DRAFT = "20251204214904ccb1af38"
_TIMEOUT = (dd._REQUEST_CONNECT_TIMEOUT, dd._REQUEST_READ_TIMEOUT)
_HEADERS = dd._REQUEST_HEADERS
def _url(self, name: str) -> str:
return f"{self._BASE}/app/output/draft/{self._DRAFT}/{name}"
def test_first_media_request_has_no_range_header(self, no_sleep) -> None:
"""首次下载资源文件不带 Range,GET 形态与改造前一致。"""
file_url = self._url("Resources/clip.mp4")
with tempfile.TemporaryDirectory() as td:
resp = _stream_response([b"ab", b"cd"])
with patch.object(dd, "requests") as m_req:
m_req.get.return_value = resp
m_req.exceptions = requests.exceptions
assert dd.download_single_file(file_url, td) is True
m_req.get.assert_called_once_with(
file_url,
timeout=self._TIMEOUT,
stream=True,
headers=self._HEADERS,
)
out = os.path.join(td, "Resources", "clip.mp4")
with open(out, "rb") as f:
assert f.read() == b"abcd"
def test_media_retry_sends_range_and_appends(self, no_sleep) -> None:
"""中途断开后,重试从已写入字节续传,206 响应追加到原文件。"""
file_url = self._url("assets/clip.mp4")
first = _stream_response(
[b"hello"],
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response([b"world"], status=206)
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd.download_single_file(file_url, td) is True
assert m_req.get.call_count == 2
assert "Range" not in (m_req.get.call_args_list[0].kwargs.get("headers") or {})
assert _range_from_call(m_req.get.call_args_list[1]) == "bytes=5-"
out = os.path.join(td, "assets", "clip.mp4")
with open(out, "rb") as f:
assert f.read() == b"helloworld"
def test_media_retry_overwrites_when_server_ignores_range(self, no_sleep) -> None:
"""服务端忽略 Range 返回 200 时,整文件覆盖,避免拼出损坏文件。"""
file_url = self._url("assets/clip.mp4")
first = _stream_response(
[b"hello"],
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response([b"FULLFILE"], status=200)
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd.download_single_file(file_url, td) is True
out = os.path.join(td, "assets", "clip.mp4")
with open(out, "rb") as f:
assert f.read() == b"FULLFILE"
def test_json_retry_overwrites_without_range(self, no_sleep) -> None:
"""JSON 失败重试必须整文件重下,即使本地已有半成品也不发 Range。"""
file_url = self._url("draft_meta_info.json")
first = _stream_response(
[b'{"x":'],
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response([b'{"ok": true}'])
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd.download_single_file(file_url, td) is True
second_headers = m_req.get.call_args_list[1].kwargs.get("headers") or {}
assert "Range" not in second_headers
out = os.path.join(td, "draft_meta_info.json")
with open(out, "rb") as f:
assert f.read() == b'{"ok": true}'
def test_bin_retry_overwrites_without_range(self, no_sleep) -> None:
file_url = self._url("assets/x.bin")
first = _stream_response(
[b"123"],
raise_after=requests.exceptions.ReadTimeout("stalled"),
)
second = _stream_response([b"abc"])
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd.download_single_file(file_url, td) is True
assert "Range" not in (m_req.get.call_args_list[1].kwargs.get("headers") or {})
out = os.path.join(td, "assets", "x.bin")
with open(out, "rb") as f:
assert f.read() == b"abc"
def test_image_resume_same_as_video(self, no_sleep) -> None:
file_url = self._url("Resources/pic.png")
first = _stream_response(
[b"PNG"],
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response([b"DATA"], status=206)
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd.download_single_file(file_url, td) is True
assert _range_from_call(m_req.get.call_args_list[1]) == "bytes=3-"
out = os.path.join(td, "Resources", "pic.png")
with open(out, "rb") as f:
assert f.read() == b"PNGDATA"
class TestDownloadRemoteMaterialResume:
def test_extensionless_cdn_url_still_resumes(self, no_sleep) -> None:
"""无扩展名的图片 CDN URL 也走续传(本函数只下载素材)。"""
url = "https://p3-bot-workflow-sign.byteimg.com/tos-cn-i-mdko3gqilj/foo.png~tplv.image?x=1"
first = _stream_response(
[b"img-"],
headers={"Content-Type": "image/png"},
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response(
[b"rest"],
status=206,
headers={"Content-Type": "image/png"},
)
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
path = dd._download_remote_material(url, td, "images", "双行", ".mp4")
assert path is not None
assert path.endswith(".png")
with open(path, "rb") as f:
assert f.read() == b"img-rest"
assert _range_from_call(m_req.get.call_args_list[1]) == "bytes=4-"
def test_first_material_request_has_no_range(self, no_sleep) -> None:
url = "https://cdn.example.com/v?id=1"
resp = _stream_response([b"mp4"], headers={"Content-Type": "video/mp4"})
with tempfile.TemporaryDirectory() as td:
with patch.object(dd, "requests") as m_req:
m_req.get.return_value = resp
m_req.exceptions = requests.exceptions
path = dd._download_remote_material(url, td, "videos", "clip1", ".bin")
assert path is not None
first_headers = m_req.get.call_args.kwargs.get("headers") or {}
assert "Range" not in first_headers
m_req.get.assert_called_once()
class TestDownloadRemoteFileResume:
def test_mp4_retry_appends_on_206(self, no_sleep) -> None:
first = _stream_response(
[b"AAA"],
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response([b"BBB"], status=206)
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "a.mp4")
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd._download_remote_file("https://x.test/a.mp4", out) is True
assert _range_from_call(m_req.get.call_args_list[1]) == "bytes=3-"
with open(out, "rb") as f:
assert f.read() == b"AAABBB"
def test_non_media_remote_file_does_not_resume(self, no_sleep) -> None:
first = _stream_response(
[b"AAA"],
raise_after=requests.exceptions.ChunkedEncodingError("truncated"),
)
second = _stream_response([b"ZZZ"])
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "a.bin")
with patch.object(dd, "requests") as m_req:
m_req.get.side_effect = [first, second]
m_req.exceptions = requests.exceptions
assert dd._download_remote_file("https://x.test/a.bin", out) is True
assert "Range" not in (m_req.get.call_args_list[1].kwargs.get("headers") or {})
with open(out, "rb") as f:
assert f.read() == b"ZZZ"