Repository: streamlink/streamlink

Issue:
stream.dash: fix dynamic timeline-less streams
- Fix segment number iterator and segment availability-time iterator for dynamic timeline-less DASH streams by calculating the right segment number offset and basing the segment availability-time on the segment number, and not just on the current time alone
- Set a default value of 0 seconds for presentationTimeOffset
- Ensure that segment durations are known
- Never calculate negative segment number offsets
- Reduce unnecessary delay by starting with the upcoming segment in the generated timeline to be as close to the live-edge as possible (with minBufferTime and presentation offset+delay in mind)
- Add debug log output
- Update MPD manifest test fixture with better time data

Next:

- Consider removing the default value of suggestedPresentationDelay, as it seems to be a relict of Streamlink's initial DASH implementation
- Also take a look at properly synchronizing the generated timeline between multiple substreams, so that the correct segment numbers always get calculated in all threads
- Avoid interweaving debug log and make it flush per-thread

----

Closes #4607

Not 100% sure whether this will fix #4607, but the data in that thread is not enough to confirm that, so let's close it. Since this PR adds more debug log messages with actual timestamps, new issue threads can be opened if there's still a problem with DASH streams of that particular kind.

At least this PR fixes all issues with DASH streams from the steam plugin, and the correct segment numbers at the correct times should be queued and requested by the worker and writer threads respectively.


Discussion:
Example debug log from a random steamcommunity.com stream. No random failed segment requests anymore due to invalid segment numbers / availability times. Tested actual streams dozens of times.

Default value of `suggestedPresentationDelay` temporarily set to 0, so only the manifest's `minBufferTime` adds delay. The log shows that in the wait times, where the third segment requires a wait time of 0.7s while the first two are accessible immediately (segment duration = 2, minBufferTime = 3 -> 2 buffered segments). The remaining ones after that are queued properly.

```
...
[cli][info] Available streams: 360p (worst), 480p, 720p (best)
[cli][info] Opening stream: 720p (dash)
[cli][info] Starting player: mpv
[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_manifest][debug] Generating segment numbers for dynamic playlist: ('1', 'video', '1')
[stream.dash_manifest][debug] Stream start: 2023-03-03 17:11:53+00:00
[stream.dash_manifest][debug] Current time: 2023-03-05 07:27:40.102945+00:00
[stream.dash_manifest][debug] Availability: 2023-03-05 07:27:37+00:00
[stream.dash_manifest][debug] presentationTimeOffset: 0:00:00; suggestedPresentationDelay: 0:00:00; minBufferTime: 0:00:03
[stream.dash_manifest][debug] segmentDuration: 2.0; segmentStart: 1; segmentOffset: 68873 (137744.102945s)
[stream.dash_manifest][debug] Generating segment numbers for dynamic playlist: ('1', 'audio', '1')
[stream.dash_manifest][debug] Stream start: 2023-03-03 17:11:53+00:00
[stream.dash_manifest][debug] Current time: 2023-03-05 07:27:40.104530+00:00
[stream.dash_manifest][debug] Availability: 2023-03-05 07:27:37+00:00
[stream.dash_manifest][debug] presentationTimeOffset: 0:00:00; suggestedPresentationDelay: 0:00:00; minBufferTime: 0:00:03
[stream.dash_manifest][debug] segmentDuration: 2.0; segmentStart: 1; segmentOffset: 68873 (137744.10453s)
[stream.dash][debug] Download of video/mp4 segment: init complete
[stream.ffmpegmux][debug] ffmpeg version n5.1.2 Copyright (c) 2000-2022 the FFmpeg developers
...
[utils.named_pipe][info] Creating pipe streamlinkpipe-545850-1-930
[utils.named_pipe][info] Creating pipe streamlinkpipe-545850-2-8275
[stream.ffmpegmux][debug] ffmpeg command: /usr/bin/ffmpeg -nostats -y -i /tmp/streamlinkpipe-545850-1-930 -i /tmp/streamlinkpipe-545850-2-8275 -c:v copy -c:a copy -copyts -f matroska pipe:1
[stream.ffmpegmux][debug] Starting copy to pipe: /tmp/streamlinkpipe-545850-1-930
[stream.ffmpegmux][debug] Starting copy to pipe: /tmp/streamlinkpipe-545850-2-8275
[cli][debug] Pre-buffering 8192 bytes
[stream.dash][debug] Download of audio/mp4 segment: init complete
[stream.dash][debug] Download of video/mp4 segment: 68874 complete
[stream.dash][debug] Download of audio/mp4 segment: 68874 complete
[stream.dash][debug] Waiting for video/mp4 segment: 68876 (0.7s)
[stream.dash][debug] Download of video/mp4 segment: 68875 complete
[cli.output][debug] Opening subprocess: mpv --force-media-title=https://steamcommunity.com/broadcast/watch/76561199177801954 -
[stream.dash][debug] Download of audio/mp4 segment: 68875 complete
[stream.dash][debug] Waiting for audio/mp4 segment: 68876 (0.7s)
[cli][debug] Writing stream to output
[stream.dash][debug] Waiting for audio/mp4 segment: 68877 (2.0s)
[stream.dash][debug] Download of audio/mp4 segment: 68876 complete
[stream.dash][debug] Waiting for video/mp4 segment: 68877 (1.9s)
[stream.dash][debug] Download of video/mp4 segment: 68876 complete
[stream.dash][debug] Waiting for audio/mp4 segment: 68878 (1.9s)
[stream.dash][debug] Download of audio/mp4 segment: 68877 complete
[cli][info] Player closed
```
Got confused for a sec. There's a different issue with the Steam plugin where I've tested the DASH streams for this issue here, but that's unrelated to DASH itself.

Resolving patch:
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
@@ -736,14 +736,12 @@ def segment_numbers(self) -> Iterator[Tuple[int, datetime.datetime]]:
             buffer_time = self.root.minBufferTime
 
             # Segment number
-            # To reduce unnecessary delay, start with the next/upcoming segment: +1
             seconds_offset = (since_start - suggested_delay - buffer_time).total_seconds()
-            number_offset = max(0, int(seconds_offset / self.duration_seconds) + 1)
+            number_offset = max(0, int(seconds_offset / self.duration_seconds))
             number_iter = count(self.startNumber + number_offset)
 
             # Segment availability time
-            # The availability time marks the segment's beginning: -1
-            available_offset = datetime.timedelta(seconds=max(0, number_offset - 1) * self.duration_seconds)
+            available_offset = datetime.timedelta(seconds=number_offset * self.duration_seconds)
             available_start = self.period.availabilityStartTime + available_offset
             available_iter = count_dt(
                 available_start,


Test patch:
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
@@ -147,15 +147,15 @@ def test_segments_dynamic_number(self):
                 datetime.datetime(2000, 1, 1, 0, 1, 30, tzinfo=UTC),
             ),
             (
-                "http://test/hd-5_000000794.mp4",
+                "http://test/hd-5_000000793.mp4",
                 datetime.datetime(2000, 1, 1, 0, 59, 15, tzinfo=UTC),
             ),
             (
-                "http://test/hd-5_000000795.mp4",
+                "http://test/hd-5_000000794.mp4",
                 datetime.datetime(2000, 1, 1, 0, 59, 20, tzinfo=UTC),
             ),
             (
-                "http://test/hd-5_000000796.mp4",
+                "http://test/hd-5_000000795.mp4",
                 datetime.datetime(2000, 1, 1, 0, 59, 25, tzinfo=UTC),
             ),
         ]