Files
unshackle/tests/core/test_resume_hls_integration.py
imSp4rky 619c86cd10 feat(downloader): resume failed downloads from completed segments across runs
Fingerprint-gated reuse of atomically-published segment files (DASH/ISM/HLS) behind a continue_downloads config option and one-off dl --continue-downloads flag.
2026-08-16 19:38:06 -06:00

140 lines
4.1 KiB
Python

"""Integration coverage for cross-run segment resume on the HLS path.
Same two-run shape as the DASH test, plus the HLS-specific rules: the sidecar (the
"download phase completed cleanly" proof) survives a mid-download crash and is gone once
the merge pass runs, and AES-128 content is excluded from resume entirely because its
segments are decrypted in place during the merge pass.
"""
from __future__ import annotations
import importlib
import threading
from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from unshackle.core.config import config
from unshackle.core.constants import DOWNLOAD_CANCELLED
from unshackle.core.manifests import HLS
from unshackle.core.tracks import resume
from unshackle.core.tracks.track import DownloadContext
PART0 = bytes([0xA0]) * 300
PART1 = bytes([0xB1]) * 200
AES_KEY = bytes(range(16))
class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
server = self.server
with server.lock:
server.requests.append(self.path)
body = server.routes.get(self.path)
if body is None:
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def server():
srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
srv.lock = threading.Lock()
srv.requests = []
srv.routes = {}
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
@pytest.fixture(autouse=True)
def resume_enabled(monkeypatch):
monkeypatch.setattr(config, "continue_downloads", True)
monkeypatch.setattr(importlib.import_module("unshackle.core.downloaders.requests"), "MAX_ATTEMPTS", 1)
DOWNLOAD_CANCELLED.clear()
yield
DOWNLOAD_CANCELLED.clear()
def base(srv) -> str:
return f"http://127.0.0.1:{srv.server_address[1]}/"
MASTER = """#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1920x1080,CODECS="avc1.640028"
media.m3u8
"""
def media_playlist(key_line: str = "") -> bytes:
return f"""#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:4
{key_line}#EXTINF:4.0,
part0.bin
#EXTINF:4.0,
part1.bin
#EXT-X-ENDLIST
""".encode()
def make_track(srv):
return HLS.from_text(MASTER, url=f"{base(srv)}master.m3u8").to_tracks(language="en").videos[0]
def make_ctx(tmp_path):
return DownloadContext(
save_path=tmp_path / "out.mp4",
save_dir=tmp_path / "v0_segments",
progress=partial(lambda **kw: None),
max_workers=1,
)
def test_hls_second_run_reuses_completed_segments(server, tmp_path):
server.routes["/media.m3u8"] = media_playlist()
server.routes["/part0.bin"] = PART0
# part1.bin missing -> run 1 completes part0 then dies during the download phase
ctx = make_ctx(tmp_path)
with pytest.raises(Exception):
HLS.download_track(make_track(server), ctx)
assert (ctx.save_dir / "segments" / "0.bin").read_bytes() == PART0
assert resume.sidecar_path(ctx.save_dir).exists()
part0_fetches_run1 = server.requests.count("/part0.bin")
DOWNLOAD_CANCELLED.clear()
server.routes["/part1.bin"] = PART1
HLS.download_track(make_track(server), ctx)
assert ctx.save_path.read_bytes() == PART0 + PART1
assert server.requests.count("/part0.bin") == part0_fetches_run1
assert not resume.sidecar_path(ctx.save_dir).exists()
def test_hls_aes128_content_never_writes_sidecar(server, tmp_path):
key_line = f'#EXT-X-KEY:METHOD=AES-128,URI="{base(server)}key.bin"\n'
server.routes["/media.m3u8"] = media_playlist(key_line)
server.routes["/key.bin"] = AES_KEY
server.routes["/part0.bin"] = PART0
# part1.bin missing -> crash mid-download, the state resume would normally preserve
ctx = make_ctx(tmp_path)
with pytest.raises(Exception):
HLS.download_track(make_track(server), ctx)
assert not resume.sidecar_path(ctx.save_dir).exists()