Repository: streamlink/streamlink

Issue:
stream.dash: generated timelines of substreams are not properly synchronized
### Checklist

- [X] This is a bug report and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

The generated DASH timelines of audio and video streams are currently not properly synchronized.

This comes from the fact that the `DASHStreamReader` instances of the audio and video substreams do have their own worker threads where the timelines get generated by the parser. If the timeline depends on the current time (e.g. segment templates without a segment timeline), then this can lead to synchronization issues when the threads don't parse the manifest at the exact same time (which they never do). The window for desynchronization depends on each segment duration and the time it takes for the parser to parse the manifest and generate the timeline. It's a rather tiny window for failure, but the issue should still get fixed at some point.

The `DASHStreamReader` instance needs to receive a common timestamp in the `DASHStream.open()` call where the initial timeline needs to get built from.

### Debug log

```text
[stream.dash][debug] Opening DASH reader for: ('1', 'video', '1') - video/mp4
[stream.dash][debug] Opening DASH reader for: ('1', 'audio', '1') - audio/mp4
[stream.dash][debug] video/mp4 segment initialization: downloading (2023-03-20T04:12:05.000000Z / 2023-03-21T15:07:44.330582Z)
[stream.dash_manifest][debug] Generating segment numbers for dynamic playlist: ('1', 'video', '1')
[stream.dash_manifest][debug] Stream start: 2023-03-20 04:12:05+00:00
[stream.dash_manifest][debug] Current time: 2023-03-21 15:07:44.331717+00:00
[stream.dash_manifest][debug] Availability: 2023-03-21 15:07:37+00:00
[stream.dash_manifest][debug] presentationTimeOffset: 0:00:00; suggestedPresentationDelay: 0:00:03; minBufferTime: 0:00:03
[stream.dash_manifest][debug] segmentDuration: 2.0; segmentStart: 1; segmentOffset: 62866 (125733.331717s)
[stream.dash][debug] audio/mp4 segment initialization: downloading (2023-03-20T04:12:05.000000Z / 2023-03-21T15:07:44.332672Z)
[stream.dash_manifest][debug] Generating segment numbers for dynamic playlist: ('1', 'audio', '1')
[stream.dash_manifest][debug] Stream start: 2023-03-20 04:12:05+00:00
[stream.dash_manifest][debug] Current time: 2023-03-21 15:07:44.333485+00:00
[stream.dash_manifest][debug] Availability: 2023-03-21 15:07:37+00:00
[stream.dash_manifest][debug] presentationTimeOffset: 0:00:00; suggestedPresentationDelay: 0:00:03; minBufferTime: 0:00:03
[stream.dash_manifest][debug] segmentDuration: 2.0; segmentStart: 1; segmentOffset: 62866 (125733.333485s)
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -3,6 +3,7 @@
 import logging
 from collections import defaultdict
 from contextlib import contextmanager, suppress
+from datetime import datetime
 from time import time
 from typing import Any, Dict, List, Optional, Tuple
 from urllib.parse import urlparse, urlunparse
@@ -15,6 +16,7 @@
 from streamlink.stream.stream import Stream
 from streamlink.utils.l10n import Language
 from streamlink.utils.parse import parse_xml
+from streamlink.utils.times import now
 
 
 log = logging.getLogger(__name__)
@@ -108,7 +110,12 @@ def iter_segments(self):
                 if not representation:
                     continue
 
-                for segment in representation.segments(init=init):
+                iter_segments = representation.segments(
+                    init=init,
+                    # sync initial timeline generation between audio and video threads
+                    timestamp=self.reader.timestamp if init else None,
+                )
+                for segment in iter_segments:
                     if self.closed:
                         break
                     yield segment
@@ -163,10 +170,18 @@ class DASHStreamReader(SegmentedStreamReader):
     writer: "DASHStreamWriter"
     stream: "DASHStream"
 
-    def __init__(self, stream: "DASHStream", representation: Representation, *args, **kwargs):
+    def __init__(
+        self,
+        stream: "DASHStream",
+        representation: Representation,
+        timestamp: datetime,
+        *args,
+        **kwargs,
+    ):
         super().__init__(stream, *args, **kwargs)
         self.ident = representation.ident
         self.mime_type = representation.mimeType
+        self.timestamp = timestamp
 
 
 class DASHStream(Stream):
@@ -357,13 +372,15 @@ def open(self):
         video, audio = None, None
         rep_video, rep_audio = self.video_representation, self.audio_representation
 
+        timestamp = now()
+
         if rep_video:
-            video = DASHStreamReader(self, rep_video)
+            video = DASHStreamReader(self, rep_video, timestamp)
             log.debug(f"Opening DASH reader for: {rep_video.ident!r} - {rep_video.mimeType}")
             video.open()
 
         if rep_audio:
-            audio = DASHStreamReader(self, rep_audio)
+            audio = DASHStreamReader(self, rep_audio, timestamp)
             log.debug(f"Opening DASH reader for: {rep_audio.ident!r} - {rep_audio.mimeType}")
             audio.open()
 
diff --git a/src/streamlink/stream/dash_manifest.py b/src/streamlink/stream/dash_manifest.py
--- a/src/streamlink/stream/dash_manifest.py
+++ b/src/streamlink/stream/dash_manifest.py
@@ -635,13 +635,14 @@ def lang(self):
     def bandwidth_rounded(self) -> float:
         return round(self.bandwidth, 1 - int(math.log10(self.bandwidth)))
 
-    def segments(self, **kwargs) -> Iterator[Segment]:
+    def segments(self, timestamp: Optional[datetime] = None, **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 timestamp: Optional initial timestamp for syncing timelines of multiple substreams
         :param kwargs: extra args to pass to the segment template
         :return: yields Segments
         """
@@ -654,6 +655,7 @@ def segments(self, **kwargs) -> Iterator[Segment]:
             yield from segmentTemplate.segments(
                 self.ident,
                 self.base_url,
+                timestamp=timestamp,
                 RepresentationID=self.id,
                 Bandwidth=int(self.bandwidth * 1000),
                 **kwargs,
@@ -790,7 +792,13 @@ def __init__(self, *args, **kwargs) -> None:
             parser=MPDParsers.segment_template,
         )
 
-    def segments(self, ident: TTimelineIdent, base_url: str, **kwargs) -> Iterator[Segment]:
+    def segments(
+        self,
+        ident: TTimelineIdent,
+        base_url: str,
+        timestamp: Optional[datetime] = None,
+        **kwargs,
+    ) -> Iterator[Segment]:
         if kwargs.pop("init", True):  # pragma: no branch
             init_url = self.format_initialization(base_url, **kwargs)
             if init_url:  # pragma: no branch
@@ -803,7 +811,7 @@ def segments(self, ident: TTimelineIdent, base_url: str, **kwargs) -> Iterator[S
                     content=False,
                     byterange=None,
                 )
-        for media_url, number, available_at in self.format_media(ident, base_url, **kwargs):
+        for media_url, number, available_at in self.format_media(ident, base_url, timestamp=timestamp, **kwargs):
             yield Segment(
                 url=media_url,
                 number=number,
@@ -818,7 +826,7 @@ def segments(self, ident: TTimelineIdent, base_url: str, **kwargs) -> Iterator[S
     def make_url(base_url: str, url: str) -> str:
         return BaseURL.join(base_url, url)
 
-    def segment_numbers(self) -> Iterator[Tuple[int, datetime]]:
+    def segment_numbers(self, timestamp: Optional[datetime] = None) -> Iterator[Tuple[int, datetime]]:
         """
         yield the segment number and when it will be available.
 
@@ -845,7 +853,7 @@ def segment_numbers(self) -> Iterator[Tuple[int, datetime]]:
             else:
                 number_iter = count(self.startNumber)
         else:
-            current_time = now()
+            current_time = timestamp or now()
             since_start = current_time - self.period.availabilityStartTime - self.presentationTimeOffset
 
             suggested_delay = self.root.suggestedPresentationDelay
@@ -918,13 +926,19 @@ def format_initialization(self, base_url: str, **kwargs) -> Optional[str]:
         if self.fmt_initialization is not None:  # pragma: no branch
             return self.make_url(base_url, self.fmt_initialization(**kwargs))
 
-    def format_media(self, ident: TTimelineIdent, base_url: str, **kwargs) -> Iterator[Tuple[str, int, datetime]]:
+    def format_media(
+        self,
+        ident: TTimelineIdent,
+        base_url: str,
+        timestamp: Optional[datetime] = None,
+        **kwargs,
+    ) -> Iterator[Tuple[str, int, datetime]]:
         if self.fmt_media is None:  # pragma: no cover
             return
 
         if not self.segmentTimeline:
             log.debug(f"Generating segment numbers for {self.root.type} playlist: {ident!r}")
-            for number, available_at in self.segment_numbers():
+            for number, available_at in self.segment_numbers(timestamp=timestamp):
                 url = self.make_url(base_url, self.fmt_media(Number=number, **kwargs))
                 yield url, number, available_at
         else:


Test patch:
diff --git a/tests/stream/test_dash.py b/tests/stream/test_dash.py
--- a/tests/stream/test_dash.py
+++ b/tests/stream/test_dash.py
@@ -1,6 +1,8 @@
+from datetime import datetime, timezone
 from typing import List
 from unittest.mock import ANY, Mock, call
 
+import freezegun
 import pytest
 import requests_mock as rm
 from lxml.etree import ParseError
@@ -13,6 +15,12 @@
 from tests.resources import text, xml
 
 
+@pytest.fixture()
+def timestamp():
+    with freezegun.freeze_time("2000-01-01T00:00:00Z"):
+        yield datetime.now(timezone.utc)
+
+
 class TestDASHStreamParseManifest:
     @pytest.fixture(autouse=True)
     def _response(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker):
@@ -265,28 +273,29 @@ def muxer(self, monkeypatch: pytest.MonkeyPatch):
         monkeypatch.setattr("streamlink.stream.dash.FFMPEGMuxer", muxer)
         return muxer
 
-    def test_stream_open_video_only(self, session: Streamlink, muxer: Mock, reader: Mock):
+    def test_stream_open_video_only(self, session: Streamlink, timestamp: datetime, muxer: Mock, reader: Mock):
         rep_video = Mock(ident=(None, None, "1"), mimeType="video/mp4")
         stream = DASHStream(session, Mock(), rep_video)
         stream.open()
 
-        assert reader.call_args_list == [call(stream, rep_video)]
-        reader_video = reader(stream, rep_video)
+        assert reader.call_args_list == [call(stream, rep_video, timestamp)]
+        reader_video = reader(stream, rep_video, timestamp)
         assert reader_video.open.called_once
         assert muxer.call_args_list == []
 
-    def test_stream_open_video_audio(self, session: Streamlink, muxer: Mock, reader: Mock):
+    def test_stream_open_video_audio(self, session: Streamlink, timestamp: datetime, muxer: Mock, reader: Mock):
         rep_video = Mock(ident=(None, None, "1"), mimeType="video/mp4")
         rep_audio = Mock(ident=(None, None, "2"), mimeType="audio/mp3", lang="en")
         stream = DASHStream(session, Mock(), rep_video, rep_audio)
         stream.open()
 
-        assert reader.call_args_list == [call(stream, rep_video), call(stream, rep_audio)]
-        reader_video = reader(stream, rep_video)
-        reader_audio = reader(stream, rep_audio)
+        assert reader.call_args_list == [call(stream, rep_video, timestamp), call(stream, rep_audio, timestamp)]
+        reader_video = reader(stream, rep_video, timestamp)
+        reader_audio = reader(stream, rep_audio, timestamp)
         assert reader_video.open.called_once
         assert reader_audio.open.called_once
         assert muxer.call_args_list == [call(session, reader_video, reader_audio, copyts=True)]
+        assert reader_video.timestamp is reader_audio.timestamp
 
 
 class TestDASHStreamWorker:
@@ -339,15 +348,25 @@ def representation(self, mpd) -> Mock:
         return mpd.periods[0].adaptationSets[0].representations[0]
 
     @pytest.fixture()
-    def worker(self, mpd):
-        stream = Mock(mpd=mpd, period=0, args={})
-        reader = Mock(stream=stream, ident=(None, None, "1"))
+    def worker(self, timestamp: datetime, mpd: Mock):
+        stream = Mock(
+            mpd=mpd,
+            period=0,
+            args={},
+        )
+        reader = Mock(
+            stream=stream,
+            ident=(None, None, "1"),
+            timestamp=timestamp,
+        )
         worker = DASHStreamWorker(reader)
+
         return worker
 
     def test_dynamic_reload(
         self,
         monkeypatch: pytest.MonkeyPatch,
+        timestamp: datetime,
         worker: DASHStreamWorker,
         representation: Mock,
         segments: List[Mock],
@@ -361,18 +380,19 @@ def test_dynamic_reload(
 
         representation.segments.return_value = segments[:1]
         assert next(segment_iter) is segments[0]
-        assert representation.segments.call_args_list == [call(init=True)]
+        assert representation.segments.call_args_list == [call(init=True, timestamp=timestamp)]
         assert not worker._wait.is_set()
 
         representation.segments.reset_mock()
         representation.segments.return_value = segments[1:]
         assert [next(segment_iter), next(segment_iter)] == segments[1:]
-        assert representation.segments.call_args_list == [call(), call(init=False)]
+        assert representation.segments.call_args_list == [call(), call(init=False, timestamp=None)]
         assert not worker._wait.is_set()
 
     def test_static(
         self,
         worker: DASHStreamWorker,
+        timestamp: datetime,
         representation: Mock,
         segments: List[Mock],
         mpd: Mock,
@@ -382,7 +402,7 @@ def test_static(
 
         representation.segments.return_value = segments
         assert list(worker.iter_segments()) == segments
-        assert representation.segments.call_args_list == [call(init=True)]
+        assert representation.segments.call_args_list == [call(init=True, timestamp=timestamp)]
         assert worker._wait.is_set()
 
     # Verify the fix for https://github.com/streamlink/streamlink/issues/2873
@@ -392,13 +412,14 @@ def test_static(
     ])
     def test_static_refresh_wait(
         self,
-        duration: float,
+        timestamp: datetime,
         mock_wait: Mock,
         mock_time: Mock,
         worker: DASHStreamWorker,
         representation: Mock,
         segments: List[Mock],
         mpd: Mock,
+        duration: float,
     ):
         mpd.dynamic = False
         mpd.type = "static"
@@ -406,6 +427,6 @@ def test_static_refresh_wait(
 
         representation.segments.return_value = segments
         assert list(worker.iter_segments()) == segments
-        assert representation.segments.call_args_list == [call(init=True)]
+        assert representation.segments.call_args_list == [call(init=True, timestamp=timestamp)]
         assert mock_wait.call_args_list == [call(5)]
         assert worker._wait.is_set()
diff --git a/tests/stream/test_dash_parser.py b/tests/stream/test_dash_parser.py
--- a/tests/stream/test_dash_parser.py
+++ b/tests/stream/test_dash_parser.py
@@ -1,6 +1,6 @@
 import datetime
 import itertools
-import unittest
+from contextlib import nullcontext
 from operator import attrgetter
 from unittest.mock import Mock
 
@@ -46,7 +46,7 @@ def test_availability(self):
             assert segment.availability == "2000-01-02T03:04:05.123456Z / 2000-01-01T00:00:00.000000Z"
 
 
-class TestMPDParsers(unittest.TestCase):
+class TestMPDParsers:
     def test_bool_str(self):
         assert MPDParsers.bool_str("true")
         assert MPDParsers.bool_str("TRUE")
@@ -89,9 +89,7 @@ def test_range(self):
             MPDParsers.range("100")
 
 
-class TestMPDParser(unittest.TestCase):
-    maxDiff = None
-
+class TestMPDParser:
     def test_no_segment_list_or_template(self):
         with xml("dash/test_no_segment_list_or_template.mpd") as mpd_xml:
             mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
@@ -175,14 +173,26 @@ def test_segments_dynamic_time(self):
                 "http://test.se/video-time=1525450872000-2800000-0.m4s?z32=",
             ]
 
-    def test_segments_dynamic_number(self):
-        # access manifest one hour after its availabilityStartTime
+    # access manifest one hour after its availabilityStartTime
+    @pytest.mark.parametrize(("frozen_time", "timestamp"), [
+        pytest.param(
+            freeze_time("2000-01-01T01:00:00Z"),
+            None,
+            id="Without explicit timestamp",
+        ),
+        pytest.param(
+            nullcontext(),
+            datetime.datetime(2000, 1, 1, 1, 0, 0, 0, tzinfo=UTC),
+            id="With explicit timestamp",
+        ),
+    ])
+    def test_segments_dynamic_number(self, frozen_time, timestamp):
         with xml("dash/test_segments_dynamic_number.mpd") as mpd_xml, \
-             freeze_time("2000-01-01T01:00:00Z"):
+             frozen_time:
             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)
+                for segment in itertools.islice(mpd.periods[0].adaptationSets[0].representations[0].segments(timestamp), 4)
             ]
 
         assert stream_urls == [