1
mirror of https://github.com/streamlink/streamlink synced 2024-11-01 01:19:33 +01:00

stream.dash: refactor AdaptationSet+Representation

- Make `AdaptationSet` and `Representation` inherit from the common
  `_RepresentationBaseType` class
- Move and logically group class definitions
- Define common attributes in `_RepresentationBaseType` that get
  inherited from ancestor nodes of the same base type, and find
  common child nodes
- Fix, clean up, reorder and selectively add attributes
  - Remove `Representation.numChannels` (invalid and unused)
  - Turn `Representation.lang` into a property which reads its value
    from the parent `AdaptationSet.lang`
    (it's not an attribute on `Representation` according to the spec,
    but it gets read by `DASHStream.parse_manifest`, so keep the alias)
  - Rename `contentProtection` attribute to `contentProtections`
    and `subRepresentation` to `subRepresentations`, to stay consistent
    with other attributes of child node lists
- Fix tests with old assertions: non-inhertied attributes that led to
  different expectations (e.g. the resulting stream name)
This commit is contained in:
bastimeyer 2023-03-16 08:31:43 +01:00 committed by Forrest
parent fae1be42f3
commit c04048d52e
3 changed files with 226 additions and 230 deletions

View File

@ -273,10 +273,10 @@ class DASHStream(Stream):
# Search for suitable video and audio representations
for aset in mpd.periods[period].adaptationSets:
if aset.contentProtection:
if aset.contentProtections:
raise PluginError(f"{source} is protected by DRM")
for rep in aset.representations:
if rep.contentProtection:
if rep.contentProtections:
raise PluginError(f"{source} is protected by DRM")
if rep.mimeType.startswith("video"):
video.append(rep)

View File

@ -504,6 +504,182 @@ class EventStream(MPDNode):
__tag__ = "EventStream"
class _RepresentationBaseType(MPDNode):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# mimeType must be set on the AdaptationSet or Representation
self.mimeType: str = self.attr( # type: ignore[assignment]
"mimeType",
required=type(self) is Representation,
inherited=_RepresentationBaseType,
)
self.profiles = self.attr(
"profiles",
inherited=_RepresentationBaseType,
)
self.width = self.attr(
"width",
parser=int,
inherited=_RepresentationBaseType,
)
self.height = self.attr(
"height",
parser=int,
inherited=_RepresentationBaseType,
)
self.sar = self.attr(
"sar",
inherited=_RepresentationBaseType,
)
self.frameRate = self.attr(
"frameRate",
parser=MPDParsers.frame_rate,
inherited=_RepresentationBaseType,
)
self.audioSamplingRate = self.attr(
"audioSamplingRate",
parser=int,
inherited=_RepresentationBaseType,
)
self.codecs = self.attr(
"codecs",
inherited=_RepresentationBaseType,
)
self.scanType = self.attr(
"scanType",
inherited=_RepresentationBaseType,
)
self.contentProtections = self.children(ContentProtection)
class AdaptationSet(_RepresentationBaseType):
__tag__ = "AdaptationSet"
parent: Period
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.id = self.attr("id")
self.group = self.attr("group")
self.lang = self.attr("lang")
self.contentType = self.attr("contentType")
self.par = self.attr("par")
self.minBandwidth = self.attr("minBandwidth", parser=int)
self.maxBandwidth = self.attr("maxBandwidth", parser=int)
self.minWidth = self.attr("minWidth", parser=int)
self.maxWidth = self.attr("maxWidth", parser=int)
self.minHeight = self.attr("minHeight", parser=int)
self.maxHeight = self.attr("maxHeight", parser=int)
self.minFrameRate = self.attr("minFrameRate", parser=MPDParsers.frame_rate)
self.maxFrameRate = self.attr("maxFrameRate", parser=MPDParsers.frame_rate)
self.segmentAlignment = self.attr(
"segmentAlignment",
parser=MPDParsers.bool_str,
default=False,
)
self.subsegmentAlignment = self.attr(
"subsegmentAlignment",
parser=MPDParsers.bool_str,
default=False,
)
self.subsegmentStartsWithSAP = self.attr(
"subsegmentStartsWithSAP",
parser=int,
default=0,
)
self.bitstreamSwitching = self.attr(
"bitstreamSwitching",
parser=MPDParsers.bool_str,
)
self.baseURLs = self.children(BaseURL)
self.segmentBase = self.only_child(SegmentBase, period=self.parent)
self.segmentList = self.only_child(SegmentList, period=self.parent)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self.parent)
self.representations = self.children(Representation, minimum=1, period=self.parent)
class Representation(_RepresentationBaseType):
__tag__ = "Representation"
parent: AdaptationSet
def __init__(self, *args, period: Period, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.period = period
self.id: str = self.attr( # type: ignore[assignment]
"id",
required=True,
)
self.bandwidth: float = self.attr( # type: ignore[assignment]
"bandwidth",
parser=lambda b: float(b) / 1000.0,
required=True,
)
self.ident = self.parent.parent.id, self.parent.id, self.id
self.baseURLs = self.children(BaseURL)
self.subRepresentations = self.children(SubRepresentation)
self.segmentBase = self.only_child(SegmentBase, period=self.period)
self.segmentList = self.only_child(SegmentList, period=self.period)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self.period)
@property
def lang(self):
return self.parent.lang
@property
def bandwidth_rounded(self) -> float:
return round(self.bandwidth, 1 - int(math.log10(self.bandwidth)))
def segments(self, **kwargs) -> Iterator[Segment]:
"""
Segments are yielded when they are available
Segments appear on a timeline, for dynamic content they are only available at a certain time
and sometimes for a limited time. For static content they are all available at the same time.
:param kwargs: extra args to pass to the segment template
:return: yields Segments
"""
# segmentBase = self.segmentBase or self.walk_back_get_attr("segmentBase")
segmentList = self.segmentList or self.walk_back_get_attr("segmentList")
segmentTemplate = self.segmentTemplate or self.walk_back_get_attr("segmentTemplate")
if segmentTemplate:
yield from segmentTemplate.segments(
self.ident,
self.base_url,
RepresentationID=self.id,
Bandwidth=int(self.bandwidth * 1000),
**kwargs,
)
elif segmentList:
yield from segmentList.segments()
else:
yield Segment(
url=self.base_url,
number=None,
duration=None,
available_at=self.period.availabilityStartTime,
init=True,
content=True,
byterange=None,
)
class SubRepresentation(_RepresentationBaseType):
__tag__ = "SubRepresentation"
class Initialization(MPDNode):
__tag__ = "Initialization"
@ -599,74 +775,6 @@ class SegmentList(MPDNode):
return BaseURL.join(self.base_url, url) if url else self.base_url
class AdaptationSet(MPDNode):
__tag__ = "AdaptationSet"
parent: "Period"
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.id = self.attr("id")
self.group = self.attr("group")
self.mimeType = self.attr("mimeType")
self.lang = self.attr("lang")
self.contentType = self.attr("contentType")
self.par = self.attr("par")
self.minBandwidth = self.attr("minBandwidth")
self.maxBandwidth = self.attr("maxBandwidth")
self.minWidth = self.attr(
"minWidth",
parser=int,
)
self.maxWidth = self.attr(
"maxWidth",
parser=int,
)
self.minHeight = self.attr(
"minHeight",
parser=int,
)
self.maxHeight = self.attr(
"maxHeight",
parser=int,
)
self.minFrameRate = self.attr(
"minFrameRate",
parser=MPDParsers.frame_rate,
)
self.maxFrameRate = self.attr(
"maxFrameRate",
parser=MPDParsers.frame_rate,
)
self.segmentAlignment = self.attr(
"segmentAlignment",
parser=MPDParsers.bool_str,
default=False,
)
self.bitstreamSwitching = self.attr(
"bitstreamSwitching",
parser=MPDParsers.bool_str,
)
self.subsegmentAlignment = self.attr(
"subsegmentAlignment",
parser=MPDParsers.bool_str,
default=False,
)
self.subsegmentStartsWithSAP = self.attr(
"subsegmentStartsWithSAP",
parser=int,
default=0,
)
self.baseURLs = self.children(BaseURL)
self.segmentBase = self.only_child(SegmentBase, period=self.parent)
self.segmentList = self.only_child(SegmentList, period=self.parent)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self.parent)
self.representations = self.children(Representation, minimum=1, period=self.parent)
self.contentProtection = self.children(ContentProtection)
class SegmentTemplate(MPDNode):
__tag__ = "SegmentTemplate"
@ -860,118 +968,6 @@ class SegmentTemplate(MPDNode):
yield url, number, available_at
class Representation(MPDNode):
__tag__ = "Representation"
parent: "AdaptationSet"
def __init__(self, *args, period: "Period", **kwargs) -> None:
super().__init__(*args, **kwargs)
self.period = period
self.id: str = self.attr( # type: ignore[assignment]
"id",
required=True,
)
self.bandwidth: float = self.attr( # type: ignore[assignment]
"bandwidth",
parser=lambda b: float(b) / 1000.0,
required=True,
)
self.mimeType: str = self.attr( # type: ignore[assignment]
"mimeType",
required=True,
inherited=AdaptationSet,
)
self.codecs = self.attr("codecs")
self.startWithSAP = self.attr("startWithSAP")
# video
self.width = self.attr(
"width",
parser=int,
)
self.height = self.attr(
"height",
parser=int,
)
self.frameRate = self.attr(
"frameRate",
parser=MPDParsers.frame_rate,
)
# audio
self.audioSamplingRate = self.attr(
"audioSamplingRate",
parser=int,
)
self.numChannels = self.attr(
"numChannels",
parser=int,
)
# subtitle
self.lang = self.attr(
"lang",
inherited=AdaptationSet,
)
self.ident = self.parent.parent.id, self.parent.id, self.id
self.baseURLs = self.children(BaseURL)
self.subRepresentation = self.children(SubRepresentation)
self.segmentBase = self.only_child(SegmentBase, period=self.period)
self.segmentList = self.only_child(SegmentList, period=self.period)
self.segmentTemplate = self.only_child(SegmentTemplate, period=self.period)
self.contentProtection = self.children(ContentProtection)
@property
def bandwidth_rounded(self) -> float:
return round(self.bandwidth, 1 - int(math.log10(self.bandwidth)))
def segments(self, **kwargs) -> Iterator[Segment]:
"""
Segments are yielded when they are available
Segments appear on a timeline, for dynamic content they are only available at a certain time
and sometimes for a limited time. For static content they are all available at the same time.
:param kwargs: extra args to pass to the segment template
:return: yields Segments
"""
# segmentBase = self.segmentBase or self.walk_back_get_attr("segmentBase")
segmentList = self.segmentList or self.walk_back_get_attr("segmentList")
segmentTemplate = self.segmentTemplate or self.walk_back_get_attr("segmentTemplate")
if segmentTemplate:
yield from segmentTemplate.segments(
self.ident,
self.base_url,
RepresentationID=self.id,
Bandwidth=int(self.bandwidth * 1000),
**kwargs,
)
elif segmentList:
yield from segmentList.segments()
else:
yield Segment(
url=self.base_url,
number=None,
duration=None,
available_at=self.period.availabilityStartTime,
init=True,
content=True,
byterange=None,
)
class SubRepresentation(MPDNode):
__tag__ = "SubRepresentation"
class SegmentTimeline(MPDNode):
__tag__ = "SegmentTimeline"

View File

@ -49,10 +49,10 @@ class TestDASHStreamParseManifest:
def test_video_only(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -63,10 +63,10 @@ class TestDASHStreamParseManifest:
def test_audio_only(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="audio/mp4", bandwidth=128.0, lang="en"),
Mock(id="2", contentProtection=None, mimeType="audio/mp4", bandwidth=256.0, lang="en"),
Mock(id="1", contentProtections=None, mimeType="audio/mp4", bandwidth=128.0, lang="en"),
Mock(id="2", contentProtections=None, mimeType="audio/mp4", bandwidth=256.0, lang="en"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -77,11 +77,11 @@ class TestDASHStreamParseManifest:
def test_audio_single(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -92,12 +92,12 @@ class TestDASHStreamParseManifest:
def test_audio_multi(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="4", contentProtection=None, mimeType="audio/aac", bandwidth=256.0, lang="en"),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="4", contentProtections=None, mimeType="audio/aac", bandwidth=256.0, lang="en"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -108,12 +108,12 @@ class TestDASHStreamParseManifest:
def test_audio_multi_lang(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="4", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="es"),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="4", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="es"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -126,12 +126,12 @@ class TestDASHStreamParseManifest:
def test_audio_multi_lang_alpha3(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="eng"),
Mock(id="4", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="spa"),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="eng"),
Mock(id="4", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="spa"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -144,11 +144,11 @@ class TestDASHStreamParseManifest:
def test_audio_invalid_lang(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="en_no_voice"),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="en_no_voice"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -163,12 +163,12 @@ class TestDASHStreamParseManifest:
session.set_option("locale", "es_ES")
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="4", contentProtection=None, mimeType="audio/aac", bandwidth=128.0, lang="es"),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
Mock(id="3", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="en"),
Mock(id="4", contentProtections=None, mimeType="audio/aac", bandwidth=128.0, lang="es"),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -182,12 +182,12 @@ class TestDASHStreamParseManifest:
# Verify the fix for https://github.com/streamlink/streamlink/issues/3365
def test_duplicated_resolutions(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=1080, bandwidth=128.0),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080, bandwidth=64.0),
Mock(id="3", contentProtection=None, mimeType="video/mp4", height=1080, bandwidth=32.0),
Mock(id="4", contentProtection=None, mimeType="video/mp4", height=720),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=1080, bandwidth=128.0),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080, bandwidth=64.0),
Mock(id="3", contentProtections=None, mimeType="video/mp4", height=1080, bandwidth=32.0),
Mock(id="4", contentProtections=None, mimeType="video/mp4", height=720),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -199,11 +199,11 @@ class TestDASHStreamParseManifest:
# Verify the fix for https://github.com/streamlink/streamlink/issues/4217
def test_duplicated_resolutions_sorted_bandwidth(self, session: Streamlink, mpd: Mock):
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[
Mock(id="1", contentProtection=None, mimeType="video/mp4", height=1080, bandwidth=64.0),
Mock(id="2", contentProtection=None, mimeType="video/mp4", height=1080, bandwidth=128.0),
Mock(id="3", contentProtection=None, mimeType="video/mp4", height=1080, bandwidth=32.0),
Mock(id="1", contentProtections=None, mimeType="video/mp4", height=1080, bandwidth=64.0),
Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080, bandwidth=128.0),
Mock(id="3", contentProtections=None, mimeType="video/mp4", height=1080, bandwidth=32.0),
],
)
mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
@ -216,11 +216,11 @@ class TestDASHStreamParseManifest:
@pytest.mark.parametrize("adaptationset", [
pytest.param(
Mock(contentProtection="DRM", representations=[]),
Mock(contentProtections="DRM", representations=[]),
id="ContentProtection on AdaptationSet",
),
pytest.param(
Mock(contentProtection=None, representations=[Mock(id="1", contentProtection="DRM")]),
Mock(contentProtections=None, representations=[Mock(id="1", contentProtections="DRM")]),
id="ContentProtection on Representation",
),
])
@ -239,7 +239,7 @@ class TestDASHStreamParseManifest:
streams = DASHStream.parse_manifest(session, test_manifest)
assert mpd.call_args_list == [call(ANY)]
assert list(streams.keys()) == ["2500k"]
assert list(streams.keys()) == ["480p"]
# TODO: Move this test to test_dash_parser and properly test segment URLs.
# This test currently achieves nothing... (manifest fixture added in 7aada92)
@ -249,7 +249,7 @@ class TestDASHStreamParseManifest:
streams = DASHStream.parse_manifest(session, "http://test/manifest.mpd")
assert mpd.call_args_list == [call(ANY, url="http://test/manifest.mpd", base_url="http://test")]
assert list(streams.keys()) == ["2500k"]
assert list(streams.keys()) == ["480p"]
class TestDASHStreamOpen:
@ -318,7 +318,7 @@ class TestDASHStreamWorker:
height=720,
)
adaptationset = Mock(
contentProtection=None,
contentProtections=None,
representations=[representation],
)
period = Mock(