Repository: streamlink/streamlink
Pull request #1591 (2018-04-04 21:36:31)

stream.hls: change --hls-audio-select to take a list and wildcard
This changes the --hls-audio-select flag to take a comma-separated
list of audio source names or language codes. This will cause the
ffmpeg muxer to include all selected audio tracks in the output
stream. It also changes so the option takes an asterisk character to
select all available audio tracks.

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1591?src=pr&el=h1) Report
> Merging [#1591](https://codecov.io/gh/streamlink/streamlink/pull/1591?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/248d1babda9f3070a53a4cf849ce2046947e9fda?src=pr&el=desc) will **increase** coverage by `0.09%`.
> The diff coverage is `85%`.


```diff
@@            Coverage Diff             @@
##           master    #1591      +/-   ##
==========================================
+ Coverage   33.18%   33.28%   +0.09%     
==========================================
  Files         225      225              
  Lines       12611    12619       +8     
==========================================
+ Hits         4185     4200      +15     
+ Misses       8426     8419       -7
```

beardypig: Looks good, I cannot test it right now but I cannot see any obvious problems. Thanks @mattiasa 🗡 
Lemme test it out later today, or tomorrow morning - do you have any publicly access sources that we can test with? 
mattiasa: You can use the MLB.tv free game of the day. You still have to register for an account, but it seems the stream is available without a paid subscription. If you want to do that you can go to http://mlb.com and register for an account in the upper right-hand corner. The stream is encrypted so the easiest way to get the stream is to use https://github.com/tonycpsu/mlbstreamer

Install that in the same virtual env as streamlink, then run:

```
mlbplay  --init-config
```

and enter your mlb.com credentials. You can use /bin/true for player. Then run:

```
mlbplay -s . -d 2018-04-04 -v hou
```

to get last night's free game. That will print streamlink command including the Authorization header needed for the stream. With `--hls-audio-select *` that downloads all the audio streams.

mattiasa: I forgot to mention that because of a bug in ffmpeg you have to run the latest master branch of ffmpeg with https://patchwork.ffmpeg.org/patch/8333/ applied to actually get the additional audio streams from mlb.com. It should work on streams which don't have the weird duplicated ID3 tags in them.
mattiasa: It does look like this test stream from https://github.com/mangui/flashls/issues/538 can be used to test this too: http://sample.vodobox.com/planete_interdite/planete_interdite_alternate.m3u8

beardypig: @mattiasa thanks for the public source without login :) It does appear to be working as expected :)
gravyboat: Good stuff, thanks for the PR @mattiasa, and thanks for the review @beardypig!

Patch:
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index 1532ffa4..019d3b5c 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -289,7 +289,13 @@ class MuxedHLSStream(MuxedStream):
     __shortname__ = "hls-multi"
 
     def __init__(self, session, video, audio, force_restart=False, ffmpeg_options=None, **args):
-        substreams = map(lambda url: HLSStream(session, url, force_restart=force_restart, **args), [video, audio])
+        tracks = [video]
+        if audio:
+            if isinstance(audio, list):
+                tracks.extend(audio)
+            else:
+                tracks.append(audio)
+        substreams = map(lambda url: HLSStream(session, url, force_restart=force_restart, **args), tracks)
         ffmpeg_options = ffmpeg_options or {}
 
         super(MuxedHLSStream, self).__init__(session, *substreams, format="mpegts", **ffmpeg_options)
@@ -357,7 +363,7 @@ class HLSStream(HTTPStream):
         # Backwards compatibility with "namekey" and "nameprefix" params.
         name_key = request_params.pop("namekey", name_key)
         name_prefix = request_params.pop("nameprefix", name_prefix)
-        audio_select = session_.options.get("hls-audio-select")
+        audio_select = session_.options.get("hls-audio-select") or []
 
         res = session_.http.get(url, exception=IOError, **request_params)
 
@@ -370,34 +376,38 @@ class HLSStream(HTTPStream):
         for playlist in filter(lambda p: not p.is_iframe, parser.playlists):
             names = dict(name=None, pixels=None, bitrate=None)
             audio_streams = []
-            fallback_audio = None
-            default_audio = None
-            preferred_audio = None
-
+            fallback_audio = []
+            default_audio = []
+            preferred_audio = []
             for media in playlist.media:
                 if media.type == "VIDEO" and media.name:
                     names["name"] = media.name
                 elif media.type == "AUDIO":
                     audio_streams.append(media)
-
             for media in audio_streams:
+                # Media without a uri is not relevant as external audio
+                if not media.uri:
+                    continue
+
                 if not fallback_audio and media.default:
-                    fallback_audio = media
+                    fallback_audio = [media]
 
                 # if the media is "audoselect" and it better matches the users preferences, use that
                 # instead of default
                 if not default_audio and (media.autoselect and locale.equivalent(language=media.language)):
-                    default_audio = media
+                    default_audio = [media]
 
                 # select the first audio stream that matches the users explict language selection
-                if ((media.language == audio_select or media.name == audio_select) or
+                if (('*' in audio_select or media.language in audio_select or
+                                   media.name in audio_select) or
                     ((not preferred_audio or media.default) and
                         locale.explicit and
                         locale.equivalent(language=media.language))):
-                    preferred_audio = media
+                    preferred_audio.append(media)
 
             # final fallback on the first audio stream listed
-            fallback_audio = fallback_audio or (len(audio_streams) and audio_streams[0])
+            fallback_audio = fallback_audio or (len(audio_streams) and
+                                                audio_streams[0].uri and [audio_streams[0]])
 
             if playlist.stream_info.resolution:
                 width, height = playlist.stream_info.resolution
@@ -438,14 +448,18 @@ class HLSStream(HTTPStream):
                     continue
 
             external_audio = preferred_audio or default_audio or fallback_audio
-            if external_audio and external_audio.uri and FFMPEGMuxer.is_usable(session_):
-                logger.debug("Using external audio track for stream {0} (language={1}, name={2})".format(
-                    name_prefix + stream_name,
-                    external_audio.language, external_audio.name or "N/A"))
+
+            if external_audio and FFMPEGMuxer.is_usable(session_):
+                external_audio_msg = ", ".join([
+                    "(language={0}, name={1})".format(x.language, (x.name or "N/A"))
+                    for x in external_audio
+                ])
+                logger.debug("Using external audio tracks for stream {0} {1}", name_prefix + stream_name,
+                             external_audio_msg)
 
                 stream = MuxedHLSStream(session_,
                                         video=playlist.uri,
-                                        audio=external_audio and external_audio.uri,
+                                        audio=[x.uri for x in external_audio if x.uri],
                                         force_restart=force_restart,
                                         start_offset=start_offset,
                                         duration=duration,
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index dc7c34c9..5ef90c00 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -742,12 +742,22 @@ transport.add_argument(
 )
 transport.add_argument(
     "--hls-audio-select",
-    type=str,
+    type=comma_list,
     metavar="CODE",
     help="""
-    Selects a specific audio source, by language code, when multiple audio sources are available.
+    Selects a specific audio source or sources, by language code or name,
+    when multiple audio sources are available. Can be * to download all audio
+    sources.
+
+    Examples:
+
+      --hls-audio-select "English,German"
+      --hls-audio-select "en,de"
+      --hls-audio-select "*"
 
-    Note: This is only useful in special circumstances where the regular locale option fails.
+    Note: This is only useful in special circumstances where the
+    regular locale option fails, such as when multiple sources of the
+    same language exists.
     """)
 transport.add_argument(
     "--hls-timeout",


Test patch:
diff --git a/tests/test_hls.py b/tests/test_hls.py
index 5e3f70aa..d17d1a52 100644
--- a/tests/test_hls.py
+++ b/tests/test_hls.py
@@ -11,8 +11,9 @@ from binascii import hexlify
 from streamlink.stream import hls
 from streamlink.session import Streamlink
 from functools import partial
+from mock import patch, Mock
 import requests_mock
-
+import pytest
 
 def pkcs7_encode(data, keySize):
     val = keySize - (len(data) % keySize)
@@ -142,5 +143,116 @@ audio_only.m3u8
         self.assertEqual(streamlinkResult, expectedResult)
 
 
+@patch('streamlink.stream.hls.FFMPEGMuxer.is_usable', Mock(return_value=True))
+class TestHlsExtAudio(unittest.TestCase):
+    playlist = """
+#EXTM3U
+#EXT-X-INDEPENDENT-SEGMENTS
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English",LANGUAGE="en",AUTOSELECT=NO,URI="en.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Spanish",LANGUAGE="es",AUTOSELECT=NO,URI="es.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="chunked",NAME="video",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3982010,RESOLUTION=1920x1080,CODECS="avc1.4D4029,mp4a.40.2",VIDEO="chunked", AUDIO="aac"
+playlist.m3u8
+    """
+
+    def run_streamlink(self, playlist, audio_select=None):
+        streamlink = Streamlink()
+
+        if audio_select:
+            streamlink.set_option("hls-audio-select", audio_select)
+        streamlink.logger.set_level("debug")
+
+        master_stream = hls.HLSStream.parse_variant_playlist(streamlink, playlist)
+
+        return master_stream
+
+    def test_hls_ext_audio_not_selected(self):
+        master_url = "http://mocked/path/master.m3u8"
+
+        with requests_mock.Mocker() as mock:
+            mock.get(master_url, text=self.playlist)
+            master_stream = self.run_streamlink(master_url)['video']
+
+        with pytest.raises(AttributeError):
+            master_stream.substreams
+
+        assert master_stream.url == 'http://mocked/path/playlist.m3u8'
+
+    def test_hls_ext_audio_en(self):
+        """
+        m3u8 with ext audio but no options should not download additional streams
+        :return:
+        """
+
+        master_url = "http://mocked/path/master.m3u8"
+        expected = ['http://mocked/path/playlist.m3u8', 'http://mocked/path/en.m3u8']
+
+        with requests_mock.Mocker() as mock:
+            mock.get(master_url, text=self.playlist)
+            master_stream = self.run_streamlink(master_url, 'en')
+
+        substreams = master_stream['video'].substreams
+        result = [x.url for x in substreams]
+
+        # Check result
+        self.assertEqual(result, expected)
+
+    def test_hls_ext_audio_es(self):
+        """
+        m3u8 with ext audio but no options should not download additional streams
+        :return:
+        """
+
+        master_url = "http://mocked/path/master.m3u8"
+        expected = ['http://mocked/path/playlist.m3u8', 'http://mocked/path/es.m3u8']
+
+        with requests_mock.Mocker() as mock:
+            mock.get(master_url, text=self.playlist)
+            master_stream = self.run_streamlink(master_url, 'es')
+
+        substreams = master_stream['video'].substreams
+
+        result = [x.url for x in substreams]
+
+        # Check result
+        self.assertEqual(result, expected)
+
+    def test_hls_ext_audio_all(self):
+        """
+        m3u8 with ext audio but no options should not download additional streams
+        :return:
+        """
+
+        master_url = "http://mocked/path/master.m3u8"
+        expected = ['http://mocked/path/playlist.m3u8', 'http://mocked/path/en.m3u8', 'http://mocked/path/es.m3u8']
+
+        with requests_mock.Mocker() as mock:
+            mock.get(master_url, text=self.playlist)
+            master_stream = self.run_streamlink(master_url, 'en,es')
+
+        substreams = master_stream['video'].substreams
+
+        result = [x.url for x in substreams]
+
+        # Check result
+        self.assertEqual(result, expected)
+
+    def test_hls_ext_audio_wildcard(self):
+        master_url = "http://mocked/path/master.m3u8"
+        expected = ['http://mocked/path/playlist.m3u8', 'http://mocked/path/en.m3u8', 'http://mocked/path/es.m3u8']
+
+        with requests_mock.Mocker() as mock:
+            mock.get(master_url, text=self.playlist)
+            master_stream = self.run_streamlink(master_url, '*')
+
+        substreams = master_stream['video'].substreams
+
+        result = [x.url for x in substreams]
+
+        # Check result
+        self.assertEqual(result, expected)
+
+
 if __name__ == "__main__":
     unittest.main()