Repository: streamlink/streamlink

Issue:
stream name is inconsistent for dash dynamic playlists
## Bug / Feature Report

- [x] This is a bug report and I have read the contribution guidelines.

### Description

A dash dynamic playlist has **different bandwidths** all the time,
which makes the stream name inconsistent.

If you want to switch to the dash stream you can't guess the stream name.

`streamlink URL 2500k,2400k` would be easier than `2500k,2499k,2498k ...`

It is not a big issue for streams with only one stream,
but if you want the lower stream there is a big chance that you won't guess the right stream name.

### Expected / Actual behavior

**V**ideo and **A**udio bandwidth from `self.bandwidth`
https://github.com/streamlink/streamlink/blob/71a327ae4c053ddda0f842c3f0e38ddac3e31c4f/src/streamlink/stream/dash_manifest.py#L559

1. Actual behavior

```
V 2514.252
A 61.436

[cli][info] Available streams: 2514k
```


1. **Expected**

```
V 2500.0
A 60.0

[cli][info] Available streams: 2500k
```

---

2. Actual behavior

```
V 2433.165
A 61.439

[cli][info] Available streams: 2433k
```

2. **Expected**

```
V 2400.0
A 60.0

[cli][info] Available streams: 2400k
```

### Reproduction steps / Explicit stream URLs to test

1. DASHStream from https://ltv.lsm.lv/lv/tieshraide/visiemltv.lv/live.1480/

### Additional comments, screenshots, etc.

**The bandwidth should be rounded,**

here is an **example** of how it could be rounded,
but it is a bit long and maybe it could be shortened.

```py
        x = self.bandwidth
        if x >= 1000:
            x = round(x, -2)
        elif 1000 > x <= 100:
            x = round(x, -1)
        else:
            x = round(x)
```

it could be added to

https://github.com/streamlink/streamlink/blob/71a327ae4c053ddda0f842c3f0e38ddac3e31c4f/src/streamlink/stream/dash_manifest.py#L559

or

https://github.com/streamlink/streamlink/blob/71a327ae4c053ddda0f842c3f0e38ddac3e31c4f/src/streamlink/stream/dash.py#L204-L206


Discussion:
I'll added a rounded bandwidth attribute or something.

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
@@ -227,7 +227,7 @@ def parse_manifest(cls, session, url, **args):
             stream_name = []
 
             if vid:
-                stream_name.append("{:0.0f}{}".format(vid.height or vid.bandwidth, "p" if vid.height else "k"))
+                stream_name.append("{:0.0f}{}".format(vid.height or vid.bandwidth_rounded, "p" if vid.height else "k"))
             if audio and len(audio) > 1:
                 stream_name.append("a{:0.0f}k".format(aud.bandwidth))
             ret['+'.join(stream_name)] = stream
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
@@ -8,6 +8,8 @@
 
 from collections import defaultdict, namedtuple
 from itertools import repeat, count
+
+import math
 from isodate import parse_datetime, parse_duration, Duration
 from contextlib import contextmanager
 from streamlink.compat import urlparse, urljoin, urlunparse, izip, urlsplit, urlunsplit
@@ -580,6 +582,10 @@ def __init__(self, node, root=None, parent=None, *args, **kwargs):
         self.segmentList = self.children(SegmentList)
         self.segmentTemplate = self.only_child(SegmentTemplate)
 
+    @property
+    def bandwidth_rounded(self):
+        return round(self.bandwidth, 1 - int(math.log10(self.bandwidth)))
+
     def segments(self, **kwargs):
         """
         Segments are yielded when they are available


Test patch:
diff --git a/tests/resources/dash/test_9.mpd b/tests/resources/dash/test_9.mpd
new file mode 100644
--- /dev/null
+++ b/tests/resources/dash/test_9.mpd
@@ -0,0 +1,47 @@
+<?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:xlink="http://www.w3.org/1999/xlink"
+     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="dynamic"
+     minimumUpdatePeriod="PT3.502S"
+     publishTime="2018-08-06T10:14:52Z"
+     availabilityStartTime="2018-07-19T09:10:45Z"
+     timeShiftBufferDepth="PT50.0S"
+     suggestedPresentationDelay="PT20.0S"
+     minBufferTime="PT6.0S">
+<ProgramInformation>
+    <Title>ltv9</Title>
+</ProgramInformation>
+<Location>https://595ac1e01d44e.streamlock.net/ltv9/ltv9/manifest_TOKEN.mpd</Location>
+<Period id="0" start="PT0.0S">
+    <AdaptationSet id="0" group="1" mimeType="video/mp4" width="848" height="480" par="16:9" frameRate="25" segmentAlignment="true" startWithSAP="1" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
+        <SegmentTemplate timescale="90000" media="chunk_ctvideo_cfm4s_rid$RepresentationID$_cs$Time$_TOKEN_mpd.m4s" initialization="chunk_ctvideo_cfm4s_rid$RepresentationID$_cinit_TOKEN_mpd.m4s">
+            <SegmentTimeline>
+                <S t="140309188560" d="900000"/>
+                <S d="900000"/>
+                <S d="900000"/>
+                <S d="900000"/>
+                <S d="900000"/>
+            </SegmentTimeline>
+        </SegmentTemplate>
+        <Representation id="p0va0br2467130" codecs="avc1.42c01e" sar="1:1" bandwidth="2467130" />
+    </AdaptationSet>
+    <AdaptationSet id="1" group="2" mimeType="audio/mp4" lang="eng" segmentAlignment="true" startWithSAP="1" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
+        <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
+        <SegmentTemplate timescale="48000" media="chunk_ctaudio_cfm4s_rid$RepresentationID$_cs$Time$_TOKEN_mpd.m4s" initialization="chunk_ctaudio_cfm4s_rid$RepresentationID$_cinit_TOKEN_mpd.m4s">
+            <SegmentTimeline>
+                <S t="74831568240" d="479232"/>
+                <S d="480240"/>
+                <S d="480288"/>
+                <S d="480240"/>
+                <S d="479232"/>
+            </SegmentTimeline>
+        </SegmentTemplate>
+        <Representation id="p0aa0br61417" codecs="mp4a.40.2" audioSamplingRate="48000" bandwidth="61417">
+        </Representation>
+    </AdaptationSet>
+</Period>
+<UTCTiming schemeIdUri="urn:mpeg:dash:utc:direct:2014" value="2018-08-06T10:14:52Z"/>
+</MPD>
diff --git a/tests/streams/test_dash.py b/tests/streams/test_dash.py
--- a/tests/streams/test_dash.py
+++ b/tests/streams/test_dash.py
@@ -3,7 +3,9 @@
 from streamlink import PluginError
 from streamlink.stream import *
 from streamlink.stream.dash import DASHStreamWorker
+from streamlink.stream.dash_manifest import MPD
 from tests.mock import MagicMock, patch, ANY, Mock, call
+from tests.resources import xml
 
 
 class TestDASHStream(unittest.TestCase):
@@ -233,6 +235,16 @@ def test_stream_open_video_audio(self, muxer, reader):
         self.assertSequenceEqual(muxer.mock_calls, [call(self.session, open_reader, open_reader, copyts=True),
                                                     call().open()])
 
+    @patch('streamlink.stream.dash.MPD')
+    def test_segments_number_time(self, mpdClass):
+        with xml("dash/test_9.mpd") as mpd_xml:
+            mpdClass.return_value = MPD(mpd_xml, base_url="http://test.bar", url="http://test.bar/foo.mpd")
+
+            streams = DASHStream.parse_manifest(self.session, self.test_url)
+            mpdClass.assert_called_with(ANY, base_url="http://test.bar", url="http://test.bar/foo.mpd")
+
+            self.assertSequenceEqual(list(streams.keys()), ['2500k'])
+
 
 class TestDASHStreamWorker(unittest.TestCase):
     @patch("streamlink.stream.dash_manifest.time.sleep")
@@ -322,5 +334,7 @@ def test_duplicate_rep_id(self, sleep):
         self.assertEqual(representation_aud, DASHStreamWorker.get_representation(mpd, 1, "audio/aac"))
 
 
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/streams/test_dash_parser.py b/tests/streams/test_dash_parser.py
--- a/tests/streams/test_dash_parser.py
+++ b/tests/streams/test_dash_parser.py
@@ -7,8 +7,9 @@
 
 from freezegun import freeze_time
 from freezegun.api import FakeDatetime
+from tests.mock import MagicMock, Mock
 
-from streamlink.stream.dash_manifest import MPD, MPDParsers, MPDParsingError, utc
+from streamlink.stream.dash_manifest import MPD, MPDParsers, MPDParsingError, utc, Representation
 from tests.resources import xml
 
 
@@ -220,3 +221,20 @@ def test_tsegment_t_is_none_1895(self):
                                       'http://test.se/video-time=8000-2799000-0.m4s?z32=CENSORED_SESSION',
                                       ])
 
+    def test_bitrate_rounded(self):
+        def mock_rep(bandwidth):
+            node = Mock(
+                tag="Representation",
+                attrib={
+                    "id": "test",
+                    "bandwidth": bandwidth,
+                    "mimeType": "video/mp4"
+                }
+            )
+            node.findall.return_value = []
+            return Representation(node)
+
+        self.assertEqual(mock_rep(1.2*1000.0).bandwidth_rounded, 1.2)
+        self.assertEqual(mock_rep(45.6*1000.0).bandwidth_rounded, 46.0)
+        self.assertEqual(mock_rep(134.0*1000.0).bandwidth_rounded, 130.0)
+        self.assertEqual(mock_rep(1324.0*1000.0).bandwidth_rounded, 1300.0)