stream.dash: fix segment availability times

The segment availability "anchor time" depends on the sum of the
manifest's `availabilityStartTime` and the period's `start` attribute,
for both static and dynamic manifests.

Fix segment availability times:
- Set the correct `available_at` value for static manifests with segment
  templates and segment timelines instead of using the current time
- Set the `available_at` value for `SegmentList` segments instead of
  defaulting to `EPOCH_START`
- Set the `available_at` value for all initialization segments

Fix segment numbers:
- The number offset now also takes the period start into consideration

Also:
- Allow passing keywords to child node constructors
- Keep the `Period` reference on `SegmentList`, `SegmentTemplate`
  and `Representation`
- Check segment availability times in certain tests
- Rename DASH manifest fixture files of updated tests
This commit is contained in:
bastimeyer 2023-03-02 19:15:58 +01:00 committed by Forrest
parent 1b9ce00454
commit 651739f236
6 changed files with 179 additions and 110 deletions

View File

@ -212,13 +212,14 @@ class MPDNode:
cls: Type[TMPDNode],
minimum: int = 0,
maximum: Optional[int] = None,
**kwargs,
) -> List[TMPDNode]:
children = self.node.findall(cls.__tag__)
if len(children) < minimum or (maximum and len(children) > maximum):
raise MPDParsingError(f"Expected to find {self.__tag__}/{cls.__tag__} required [{minimum}..{maximum or 'unbound'})")
return [
cls(child, root=self.root, parent=self, i=i, base_url=self.base_url)
cls(child, root=self.root, parent=self, i=i, base_url=self.base_url, **kwargs)
for i, child in enumerate(children)
]
@ -226,8 +227,9 @@ class MPDNode:
self,
cls: Type[TMPDNode],
minimum: int = 0,
**kwargs,
) -> Optional[TMPDNode]:
children = self.children(cls, minimum=minimum, maximum=1)
children = self.children(cls, minimum=minimum, maximum=1, **kwargs)
return children[0] if len(children) else None
def walk_back(
@ -304,7 +306,7 @@ class MPD(MPDNode):
self.availabilityStartTime = self.attr(
"availabilityStartTime",
parser=MPDParsers.datetime,
default=datetime.datetime.fromtimestamp(0, UTC), # earliest date
default=EPOCH_START,
required=self.type == "dynamic",
)
self.publishTime = self.attr(
@ -398,17 +400,18 @@ class Period(MPDNode):
default=Duration(),
)
if self.start is None and self.i == 0 and self.root.type == "static":
self.start = 0
# anchor time for segment availability
offset = self.start if self.root.type == "dynamic" else Duration()
self.availabilityStartTime = self.root.availabilityStartTime + offset
# TODO: Early Access Periods
self.baseURLs = self.children(BaseURL)
self.segmentBase = self.only_child(SegmentBase)
self.segmentBase = self.only_child(SegmentBase, period=self)
self.segmentList = self.only_child(SegmentList, period=self)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self)
self.adaptationSets = self.children(AdaptationSet, minimum=1)
self.segmentList = self.only_child(SegmentList)
self.segmentTemplate = self.only_child(SegmentTemplate)
self.sssetIdentifier = self.only_child(AssetIdentifier)
self.assetIdentifier = self.only_child(AssetIdentifier)
self.eventStream = self.children(EventStream)
self.subset = self.children(Subset)
@ -458,9 +461,13 @@ class SegmentURL(MPDNode):
class SegmentList(MPDNode):
__tag__ = "SegmentList"
def __init__(self, node, root=None, parent=None, *args, **kwargs):
period: "Period"
def __init__(self, node, root=None, parent=None, period=None, *args, **kwargs):
super().__init__(node, root, parent, *args, **kwargs)
self.period = period
self.presentation_time_offset = self.attr("presentationTimeOffset")
self.timescale = self.attr(
"timescale",
@ -486,18 +493,20 @@ class SegmentList(MPDNode):
@property
def segments(self) -> Iterator[Segment]:
if self.initialization:
if self.initialization: # pragma: no branch
yield Segment(
url=self.make_url(self.initialization.source_url),
duration=0,
init=True,
content=False,
available_at=self.period.availabilityStartTime,
byterange=self.initialization.range,
)
for n, segment_url in enumerate(self.segment_urls, self.start_number):
yield Segment(
url=self.make_url(segment_url.media),
duration=self.duration_seconds,
available_at=self.period.availabilityStartTime,
byterange=segment_url.media_range,
)
@ -566,8 +575,8 @@ class AdaptationSet(MPDNode):
)
self.baseURLs = self.children(BaseURL)
self.segmentTemplate = self.only_child(SegmentTemplate)
self.representations = self.children(Representation, minimum=1)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self.parent)
self.representations = self.children(Representation, minimum=1, period=self.parent)
self.contentProtection = self.children(ContentProtection)
@ -575,10 +584,13 @@ class SegmentTemplate(MPDNode):
__tag__ = "SegmentTemplate"
parent: Union["Period", "AdaptationSet", "Representation"]
period: "Period"
def __init__(self, node, root=None, parent=None, *args, **kwargs):
def __init__(self, node, root=None, parent=None, period=None, *args, **kwargs):
super().__init__(node, root, parent, *args, **kwargs)
self.period = period
self.defaultSegmentTemplate = self.walk_back_get_attr("segmentTemplate")
self.initialization = self.attr(
@ -614,20 +626,19 @@ class SegmentTemplate(MPDNode):
else:
self.duration_seconds = None
self.period = list(self.walk_back(Period))[0]
# children
self.segmentTimeline = self.only_child(SegmentTimeline)
def segments(self, ident: TTimelineIdent, base_url: str, **kwargs) -> Iterator[Segment]:
if kwargs.pop("init", True):
if kwargs.pop("init", True): # pragma: no branch
init_url = self.format_initialization(base_url, **kwargs)
if init_url:
if init_url: # pragma: no branch
yield Segment(
url=init_url,
duration=0,
init=True,
content=False,
available_at=self.period.availabilityStartTime,
)
for media_url, available_at in self.format_media(ident, base_url, **kwargs):
yield Segment(
@ -663,7 +674,7 @@ class SegmentTemplate(MPDNode):
available_iter: Iterator[datetime.datetime]
if self.root.type == "static":
available_iter = repeat(EPOCH_START)
available_iter = repeat(self.period.availabilityStartTime)
duration = self.period.duration.seconds or self.root.mediaPresentationDuration.seconds
if duration:
number_iter = range(self.startNumber, int(duration / self.duration_seconds) + 1)
@ -672,11 +683,10 @@ class SegmentTemplate(MPDNode):
else:
now = datetime.datetime.now(UTC)
if self.presentationTimeOffset:
since_start = (now - self.presentationTimeOffset) - self.root.availabilityStartTime
available_start_date = self.root.availabilityStartTime + self.presentationTimeOffset + since_start
available_start = available_start_date
since_start = (now - self.presentationTimeOffset) - self.period.availabilityStartTime
available_start = self.period.availabilityStartTime + self.presentationTimeOffset + since_start
else:
since_start = now - self.root.availabilityStartTime
since_start = now - self.period.availabilityStartTime
available_start = now
# if there is no delay, use a delay of 3 seconds
@ -709,9 +719,9 @@ class SegmentTemplate(MPDNode):
log.debug(f"Generating segment timeline for {self.root.type} playlist: {ident!r}")
if self.root.type == "static":
available_at = self.period.availabilityStartTime
for segment, n in zip(self.segmentTimeline.segments, count(self.startNumber)):
url = self.make_url(base_url, self.media(Time=segment.t, Number=n, **kwargs))
available_at = datetime.datetime.now(tz=UTC) # TODO: replace with EPOCH_START ?!
yield url, available_at
return
@ -749,10 +759,13 @@ class Representation(MPDNode):
__tag__ = "Representation"
parent: "AdaptationSet"
period: "Period"
def __init__(self, node, root=None, parent=None, *args, **kwargs):
def __init__(self, node, root=None, parent=None, period=None, *args, **kwargs):
super().__init__(node, root, parent, *args, **kwargs)
self.period = period
self.id = self.attr(
"id",
required=True,
@ -805,9 +818,9 @@ class Representation(MPDNode):
self.baseURLs = self.children(BaseURL)
self.subRepresentation = self.children(SubRepresentation)
self.segmentBase = self.only_child(SegmentBase)
self.segmentList = self.children(SegmentList)
self.segmentTemplate = self.only_child(SegmentTemplate)
self.segmentBase = self.only_child(SegmentBase, period=self.period)
self.segmentList = self.children(SegmentList, period=self.period)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self.period)
self.contentProtection = self.children(ContentProtection)
@property
@ -846,6 +859,7 @@ class Representation(MPDNode):
duration=0,
init=True,
content=True,
available_at=self.period.availabilityStartTime,
)

View File

@ -6,11 +6,12 @@
xsi:schemaLocation="urn:mpeg:DASH:schema:MPD:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
profiles="urn:mpeg:dash:profile:isoff-live:2011"
type="static"
availabilityStartTime="2020-01-01T00:00:00Z"
mediaPresentationDuration="PT0H0M6.00S"
minBufferTime="PT6.0S"
>
<BaseURL>https://hostname/</BaseURL>
<Period id="0" start="PT0.0S">
<Period id="0" start="PT12M34S">
<BaseURL>period/</BaseURL>
<AdaptationSet
id="0"

View File

@ -5,13 +5,15 @@
xsi:schemaLocation="urn:mpeg:DASH:schema:MPD:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
profiles="urn:mpeg:dash:profile:isoff-main:2011"
type="static"
availabilityStartTime="2020-01-01T00:00:00Z"
publishTime="2018-06-25T18:01:58Z"
mediaPresentationDuration="PT53M18.059S"
minBufferTime="PT1.5S">
minBufferTime="PT1.5S"
>
<ProgramInformation>
<Title>test/dash.smil</Title>
</ProgramInformation>
<Period id="0" start="PT0.0S">
<Period id="0" start="PT12M34S">
<AdaptationSet id="0" group="1" mimeType="video/mp4" maxWidth="1920" maxHeight="1080" par="16:9" frameRate="25" segmentAlignment="true" startWithSAP="1" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
<Representation id="p0va0br4332748" codecs="avc1.640028" width="1920" height="1080" sar="1:1" bandwidth="4332748">
<SegmentList presentationTimeOffset="0" timescale="90000" duration="900000" startNumber="1">

View File

@ -1,7 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:cenc="urn:mpeg:cenc:2013" xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd" type="dynamic" publishTime="2018-05-10T09:27:09" minimumUpdatePeriod="PT10S" availabilityStartTime="2018-05-04T13:20:07Z" minBufferTime="PT2S" suggestedPresentationDelay="PT40S" timeShiftBufferDepth="PT24H0M0S" profiles="urn:mpeg:dash:profile:isoff-live:2011">
<Period start="PT0S" id="1">
<AdaptationSet mimeType="video/mp4" frameRate="25/1" segmentAlignment="true" subsegmentAlignment="true" startWithSAP="1" subsegmentStartsWithSAP="1" bitstreamSwitching="false">
<MPD
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cenc="urn:mpeg:cenc:2013"
xmlns="urn:mpeg:dash:schema:mpd:2011"
xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
type="dynamic"
availabilityStartTime="2018-05-04T13:20:07Z"
publishTime="2018-05-10T09:27:09"
minimumUpdatePeriod="PT10S"
minBufferTime="PT2S"
suggestedPresentationDelay="PT40S"
timeShiftBufferDepth="PT24H0M0S"
profiles="urn:mpeg:dash:profile:isoff-live:2011"
>
<Period id="1" start="PT12M34S">
<AdaptationSet
mimeType="video/mp4"
frameRate="25/1"
segmentAlignment="true"
subsegmentAlignment="true"
startWithSAP="1"
subsegmentStartsWithSAP="1"
bitstreamSwitching="false"
>
<SegmentTemplate timescale="90000" duration="450000" startNumber="1"/>
<Representation id="1" width="1280" height="720" bandwidth="2400000" codecs="avc1.64001f">
<SegmentTemplate duration="450000" startNumber="1" media="hd-5_$Number%09d$.mp4" initialization="hd-5-init.mp4"/>
@ -22,14 +43,33 @@
<SegmentTemplate duration="450000" startNumber="1" media="hd-0_$Number%09d$.mp4" initialization="hd-0-init.mp4"/>
</Representation>
</AdaptationSet>
<AdaptationSet mimeType="audio/mp4" lang="rus" segmentAlignment="0">
<SegmentTemplate timescale="48000" media="hd-audio_$Number%09d$.mp4" initialization="hd-audio-init.mp4" duration="240000" startNumber="1"/>
<AdaptationSet
mimeType="audio/mp4"
lang="rus"
segmentAlignment="0"
>
<SegmentTemplate
timescale="48000"
media="hd-audio_$Number%09d$.mp4"
initialization="hd-audio-init.mp4"
duration="240000"
startNumber="1"
/>
<Representation id="7" bandwidth="96000" audioSamplingRate="48000" codecs="mp4a.40.2"/>
</AdaptationSet>
<AdaptationSet mimeType="application/mp4" lang="rus">
<AdaptationSet
mimeType="application/mp4"
lang="rus"
>
<Role schemeIdUri="urn:mpeg:dash:role" value="subtitle"/>
<SegmentTemplate timescale="90000" media="hd-caption_$Number%09d$.mp4" initialization="hd-caption-init.mp4" duration="450000" startNumber="1"/>
<SegmentTemplate
timescale="90000"
media="hd-caption_$Number%09d$.mp4"
initialization="hd-caption-init.mp4"
duration="450000"
startNumber="1"
/>
<Representation id="8" bandwidth="256" codecs="stpp"/>
</AdaptationSet>
</Period>
</MPD>
</MPD>

View File

@ -5,11 +5,13 @@
xmlns="urn:mpeg:dash:schema:mpd:2011"
xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
type="static"
availabilityStartTime="2020-01-01T00:00:00Z"
mediaPresentationDuration="PT1M23.847347S"
maxSegmentDuration="PT3S"
minBufferTime="PT10S"
profiles="urn:mpeg:dash:profile:isoff-live:2011">
<Period>
profiles="urn:mpeg:dash:profile:isoff-live:2011"
>
<Period start="PT12M34S">
<BaseURL>dash/</BaseURL>
<AdaptationSet
group="1"
@ -131,4 +133,4 @@
</Representation>
</AdaptationSet>
</Period>
</MPD>
</MPD>

View File

@ -6,7 +6,6 @@ from unittest.mock import Mock
import pytest
from freezegun import freeze_time
from freezegun.api import FakeDatetime # type: ignore[attr-defined]
from streamlink.stream.dash_manifest import MPD, MPDParsers, MPDParsingError, Representation
from tests.resources import xml
@ -110,64 +109,68 @@ class TestMPDParser(unittest.TestCase):
]
def test_segments_dynamic_number(self):
with freeze_time(FakeDatetime(2018, 5, 22, 13, 37, 0, tzinfo=UTC)):
with xml("dash/test_4.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
init_segment = next(segments)
assert init_segment.url == "http://test.se/hd-5-init.mp4"
video_segments = []
for _ in range(3):
seg = next(segments)
video_segments.append((seg.url,
seg.available_at))
assert video_segments == [
(
"http://test.se/hd-5_000311235.mp4",
datetime.datetime(2018, 5, 22, 13, 37, 0, tzinfo=UTC),
),
(
"http://test.se/hd-5_000311236.mp4",
datetime.datetime(2018, 5, 22, 13, 37, 5, tzinfo=UTC),
),
(
"http://test.se/hd-5_000311237.mp4",
datetime.datetime(2018, 5, 22, 13, 37, 10, tzinfo=UTC),
),
]
def test_segments_static_no_publish_time(self):
with xml("dash/test_5.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
segments = mpd.periods[0].adaptationSets[1].representations[0].segments()
init_segment = next(segments)
assert init_segment.url == "http://test.se/dash/150633-video_eng=194000.dash"
video_segments = [x.url for x in itertools.islice(segments, 3)]
assert video_segments == [
"http://test.se/dash/150633-video_eng=194000-0.dash",
"http://test.se/dash/150633-video_eng=194000-2000.dash",
"http://test.se/dash/150633-video_eng=194000-4000.dash",
with xml("dash/test_segments_dynamic_number.mpd") as mpd_xml, \
freeze_time("2018-05-22T13:37:00Z"):
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
stream_urls = [
(segment.url, segment.available_at)
for segment in itertools.islice(mpd.periods[0].adaptationSets[0].representations[0].segments(), 4)
]
def test_segments_list(self):
with xml("dash/test_7.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
assert stream_urls == [
# The initialization segment gets its availability time from
# the sum of the manifest's availabilityStartTime value and the period's start value, similar to static manifests
(
"http://test/hd-5-init.mp4",
datetime.datetime(2018, 5, 4, 13, 32, 41, tzinfo=UTC),
),
# The segment number also takes the availabilityStartTime and period start sum into consideration,
# but the availability time depends on the current time and the segment durations
(
"http://test/hd-5_000311084.mp4",
datetime.datetime(2018, 5, 22, 13, 37, 0, tzinfo=UTC),
),
(
"http://test/hd-5_000311085.mp4",
datetime.datetime(2018, 5, 22, 13, 37, 5, tzinfo=UTC),
),
(
"http://test/hd-5_000311086.mp4",
datetime.datetime(2018, 5, 22, 13, 37, 10, tzinfo=UTC),
),
]
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
init_segment = next(segments)
assert init_segment.url == "http://test.se/chunk_ctvideo_ridp0va0br4332748_cinit_mpd.m4s"
def test_static_no_publish_time(self):
with xml("dash/test_static_no_publish_time.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
video_segments = [x.url for x in itertools.islice(segments, 3)]
assert video_segments == [
"http://test.se/chunk_ctvideo_ridp0va0br4332748_cn1_mpd.m4s",
"http://test.se/chunk_ctvideo_ridp0va0br4332748_cn2_mpd.m4s",
"http://test.se/chunk_ctvideo_ridp0va0br4332748_cn3_mpd.m4s",
]
segments = mpd.periods[0].adaptationSets[1].representations[0].segments()
segment_urls = [(segment.url, segment.available_at) for segment in itertools.islice(segments, 4)]
# ignores period start time in static manifests
expected_availability = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
assert segment_urls == [
("http://test/dash/150633-video_eng=194000.dash", expected_availability),
("http://test/dash/150633-video_eng=194000-0.dash", expected_availability),
("http://test/dash/150633-video_eng=194000-2000.dash", expected_availability),
("http://test/dash/150633-video_eng=194000-4000.dash", expected_availability),
]
def test_segment_list(self):
with xml("dash/test_segment_list.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
segment_urls = [(segment.url, segment.available_at) for segment in itertools.islice(segments, 4)]
# ignores period start time in static manifests
expected_availability = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
assert segment_urls == [
("http://test/chunk_ctvideo_ridp0va0br4332748_cinit_mpd.m4s", expected_availability),
("http://test/chunk_ctvideo_ridp0va0br4332748_cn1_mpd.m4s", expected_availability),
("http://test/chunk_ctvideo_ridp0va0br4332748_cn2_mpd.m4s", expected_availability),
("http://test/chunk_ctvideo_ridp0va0br4332748_cn3_mpd.m4s", expected_availability),
]
def test_segments_dynamic_timeline_continue(self):
with xml("dash/test_6_p1.mpd") as mpd_xml_p1:
@ -265,13 +268,16 @@ class TestMPDParser(unittest.TestCase):
def test_segments_byterange(self):
with xml("dash/test_segments_byterange.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
assert [
segment_urls = [
[
(seg.url, seg.init, seg.byterange)
for seg in adaptationset.representations[0].segments()
]
for adaptationset in mpd.periods[0].adaptationSets
] == [
]
assert segment_urls == [
[
("http://test/video-frag.mp4", True, (36, 711)),
("http://test/video-frag.mp4", False, (747, 875371)),
@ -291,30 +297,34 @@ class TestMPDParser(unittest.TestCase):
def test_nested_baseurls(self):
with xml("dash/test_nested_baseurls.mpd") as mpd_xml:
mpd = MPD(mpd_xml, base_url="https://foo/", url="https://test/manifest.mpd")
segment_urls = [
[seg.url for seg in itertools.islice(representation.segments(), 2)]
[(segment.url, segment.available_at) for segment in itertools.islice(representation.segments(), 2)]
for adaptationset in mpd.periods[0].adaptationSets for representation in adaptationset.representations
]
# ignores period start time in static manifests
expected_availability = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
assert segment_urls == [
[
"https://hostname/period/init_video_5000kbps.m4s",
"https://hostname/period/media_video_5000kbps-1.m4s",
("https://hostname/period/init_video_5000kbps.m4s", expected_availability),
("https://hostname/period/media_video_5000kbps-1.m4s", expected_availability),
],
[
"https://hostname/period/representation/init_video_9000kbps.m4s",
"https://hostname/period/representation/media_video_9000kbps-1.m4s",
("https://hostname/period/representation/init_video_9000kbps.m4s", expected_availability),
("https://hostname/period/representation/media_video_9000kbps-1.m4s", expected_availability),
],
[
"https://hostname/period/adaptationset/init_audio_128kbps.m4s",
"https://hostname/period/adaptationset/media_audio_128kbps-1.m4s",
("https://hostname/period/adaptationset/init_audio_128kbps.m4s", expected_availability),
("https://hostname/period/adaptationset/media_audio_128kbps-1.m4s", expected_availability),
],
[
"https://hostname/period/adaptationset/representation/init_audio_256kbps.m4s",
"https://hostname/period/adaptationset/representation/media_audio_256kbps-1.m4s",
("https://hostname/period/adaptationset/representation/init_audio_256kbps.m4s", expected_availability),
("https://hostname/period/adaptationset/representation/media_audio_256kbps-1.m4s", expected_availability),
],
[
"https://other/init_audio_320kbps.m4s",
"https://other/media_audio_320kbps-1.m4s",
("https://other/init_audio_320kbps.m4s", expected_availability),
("https://other/media_audio_320kbps-1.m4s", expected_availability),
],
]