diff --git a/tests/core/test_dash_collapse_integration.py b/tests/core/test_dash_collapse_integration.py
index 3976164c..22ac5764 100644
--- a/tests/core/test_dash_collapse_integration.py
+++ b/tests/core/test_dash_collapse_integration.py
@@ -9,6 +9,8 @@ localhost server, asserting that:
- a SegmentList whose ranges collapse fetches the parent resource whole in one direct
(Range-less) request and writes a byte-correct file at ``save_path``, leaving no
segment dir or ``.!dev`` artifacts, and
+- a SegmentBase track writes the parent byte for byte, when the server sends its total
+ length and when it sends an unknown (``*``) one, and
- a track with mixed segment URLs still takes the per-segment download-and-merge path, and
- that same segmented path raises ``DownloadCancelled`` when a sibling track's failure has
already set ``DOWNLOAD_CANCELLED``, instead of treating the segments the cancel left
@@ -57,10 +59,12 @@ class _Handler(BaseHTTPRequestHandler):
self.end_headers()
return
if rng:
- start, end = (int(x) for x in rng.removeprefix("bytes=").split("-"))
+ first, _, last = rng.removeprefix("bytes=").partition("-")
+ start, end = int(first), int(last) if last else len(body) - 1
chunk = body[start : end + 1]
self.send_response(206)
- self.send_header("Content-Range", f"bytes {start}-{end}/{len(body)}")
+ total = "*" if server.unknown_total else len(body)
+ self.send_header("Content-Range", f"bytes {start}-{end}/{total}")
body = chunk
else:
self.send_response(200)
@@ -78,6 +82,7 @@ def server():
srv.lock = threading.Lock()
srv.requests = []
srv.routes = {}
+ srv.unknown_total = False
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
@@ -126,9 +131,8 @@ def sidx_box(sizes: list[int]) -> bytes:
return struct.pack(">I", 8 + len(body)) + b"sidx" + body
-def ranged_mpd(srv, ranges: list[tuple[int, int]], init: str = f'range="0-{INIT_LEN - 1}"') -> str:
- # SegmentList of byte ranges on one media.mp4, with the given Initialization attributes
- segment_urls = "\n".join(f' ' for s, e in ranges)
+def media_mpd(srv, segments: str) -> str:
+ # one Representation on media.mp4, described by the given segment element
return f"""
{base(srv)}
@@ -136,16 +140,35 @@ def ranged_mpd(srv, ranges: list[tuple[int, int]], init: str = f'range="0-{INIT_
media.mp4
-
-
-{segment_urls}
-
+{segments}
"""
+def ranged_mpd(srv, ranges: list[tuple[int, int]], init: str = f'range="0-{INIT_LEN - 1}"') -> str:
+ # SegmentList of byte ranges on one media.mp4, with the given Initialization attributes
+ segment_urls = "\n".join(f' ' for s, e in ranges)
+ return media_mpd(
+ srv,
+ f"""
+
+{segment_urls}
+ """,
+ )
+
+
+def segment_base_mpd(srv) -> str:
+ # the media range comes from the init response, not the manifest
+ return media_mpd(
+ srv,
+ f"""
+
+ """,
+ )
+
+
def make_track(mpd: str, srv):
return DASH.from_text(mpd, url=f"{base(srv)}manifest.mpd").to_tracks(language="en").videos[0]
@@ -204,6 +227,27 @@ def test_collapse_keeps_the_sidx_between_init_and_first_segment(server, tmp_path
assert [path for path, rng in server.requests if rng is None] == ["/media.mp4"]
+def test_segment_base_collapse_ends_at_the_last_byte(server, tmp_path):
+ server.routes["/media.mp4"] = PARENT
+ ctx = make_ctx(tmp_path, "segment_base.mp4")
+
+ DASH.download_track(make_track(segment_base_mpd(server), server), ctx)
+
+ # the media range ends at total - 1, so the truncate after the collapse adds no byte
+ assert ctx.save_path.read_bytes() == PARENT
+
+
+def test_segment_base_unknown_total_fetches_the_rest_open_ended(server, tmp_path):
+ server.routes["/media.mp4"] = PARENT
+ server.unknown_total = True
+ ctx = make_ctx(tmp_path, "unknown_total.mp4")
+
+ DASH.download_track(make_track(segment_base_mpd(server), server), ctx)
+
+ assert ctx.save_path.read_bytes() == PARENT
+ assert ("/media.mp4", f"bytes={INIT_LEN}-") in server.requests
+
+
def test_leading_gap_that_is_not_a_sidx_takes_segmented_path(server, tmp_path):
# a filtered period cut from the same file leaves a gap of media, not an index
gap = struct.pack(">I", 1000) + b"moof" + bytes(992)
diff --git a/tests/core/test_dash_multi_period_init.py b/tests/core/test_dash_multi_period_init.py
index da63b364..8d06a1cf 100644
--- a/tests/core/test_dash_multi_period_init.py
+++ b/tests/core/test_dash_multi_period_init.py
@@ -185,6 +185,6 @@ def test_segment_base_range_survives_a_skipped_probe(server, probes):
probe_kid=False,
)
- assert segments == [(f"{base(server)}p1.mp4", f"{INIT_LEN}-{len(MEDIA)}")]
+ assert segments == [(f"{base(server)}p1.mp4", f"{INIT_LEN}-{len(MEDIA) - 1}")]
assert track_kid is None
assert not probes
diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py
index f530acc0..a9501fe3 100644
--- a/unshackle/core/manifests/dash.py
+++ b/unshackle/core/manifests/dash.py
@@ -27,6 +27,7 @@ from requests import Session
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.constants import DOWNLOAD_ALL_DRM, DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
+from unshackle.core.downloaders.requests import parse_content_range
from unshackle.core.drm import DRM_T, ClearKeyCENC, PlayReady, Widevine
from unshackle.core.drm.segment_decrypt import SegmentDecrypter, can_use
from unshackle.core.drm.verify import decrypt_track
@@ -1104,9 +1105,10 @@ class DASH:
init_data = res.content
if probe_kid:
track_kid = track.get_key_id(init_data)
- total_size = res.headers.get("Content-Range", "").split("/")[-1]
- if total_size:
- media_range = f"{len(init_data)}-{total_size}"
+ content_range = parse_content_range(res.headers.get("Content-Range"))
+ if content_range:
+ _, init_end, total = content_range
+ media_range = f"{init_end + 1}-{'' if total is None else total - 1}"
subsegments: list[tuple[int, int, int]] = []
index_range = segment_base.get("indexRange")