From e00f6cca8693fb3ea82064d4e0f7b4a6ac1307f2 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 1 Sep 2026 14:05:06 -0600 Subject: [PATCH] fix(dash): number SegmentTimeline segments from startNumber Timeline manifests with startNumber between 2 and the segment count capped the range at the count, truncating live replays. --- tests/core/test_dash_segment_numbering.py | 63 +++++++++++++++++++++++ unshackle/core/manifests/dash.py | 3 -- 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/core/test_dash_segment_numbering.py diff --git a/tests/core/test_dash_segment_numbering.py b/tests/core/test_dash_segment_numbering.py new file mode 100644 index 00000000..a465b0e6 --- /dev/null +++ b/tests/core/test_dash_segment_numbering.py @@ -0,0 +1,63 @@ +"""Segment numbering must start at ``startNumber`` and cover every segment, in both +``SegmentTemplate`` forms: an explicit ``SegmentTimeline``, and a fixed segment duration.""" + +from unittest.mock import MagicMock + +import pytest + +from unshackle.core.manifests.dash import DASH +from unshackle.core.utils.xml import load_xml + +TIMELINE_MPD = """ + + + + + + + + + + +""" + +# github.com/unshackle-dl/unshackle/issues/37: 96.72s at 6s per segment is 17 segments, 0 to 16, never a segment 17. +DURATION_MPD = """ + + + + + + + + +""" + + +def _numbers(mpd: str) -> list[int]: + tree = load_xml(mpd.encode()) + period = tree.find("Period") + aset = period.find("AdaptationSet") + rep = aset.find("Representation") + _, segments, _, _, _ = DASH.get_period_segments( + period, aset, rep, tree, MagicMock(), "https://example.com/main.mpd", MagicMock() + ) + return [int(url.rsplit("/", 1)[1].split(".")[0]) for url, _ in segments] + + +@pytest.mark.parametrize( + ("start", "count"), + [ + (0, 5), # zero-based numbering + (1, 5), # plain VOD + (2642, 4667), # DVR catch-up: startNumber below segment count + (5000, 10), # DVR catch-up: startNumber above segment count + ], +) +def test_timeline_numbers_follow_start_number(start: int, count: int) -> None: + assert _numbers(TIMELINE_MPD.format(start=start, repeat=count - 1)) == list(range(start, start + count)) + + +@pytest.mark.parametrize(("start", "seconds", "count"), [(0, "1M36.720", 17), (1, "1M36.720", 17), (2642, "30", 5)]) +def test_duration_numbers_follow_start_number(start: int, seconds: str, count: int) -> None: + assert _numbers(DURATION_MPD.format(start=start, seconds=seconds)) == list(range(start, start + count)) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index f59a708c..5ca43bb9 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -830,9 +830,6 @@ class DASH: current_time += int(s.get("d")) if not end_number: - end_number = len(segment_durations) - # Handle high startNumber in DVR/catch-up manifests where startNumber > segment count - if start_number > end_number: end_number = start_number + len(segment_durations) - 1 for t, n in zip(segment_durations, range(start_number, end_number + 1)):