Repository: streamlink/streamlink

Issue:
Fallback selection for --stream-sorting-excludes
### Checklist

- [ ] This is a bug report.
- [x] This is a feature request.
- [ ] This is a plugin (improvement) request.
- [ ] I have read the contribution guidelines.

### Description

Related issue:
https://github.com/streamlink/streamlink-twitch-gui/issues/481

In certain situations, the usage of `--stream-sorting-excludes` can lead to an empty stream selection. This is an issue for applications which try to map the available streams to a custom list of qualities.

The Streamlink Twitch GUI needs to do this stream quality mapping because of Twitch's inconsistent quality naming. This is an example of trying to find the "low" quality:

```
# available: 160p, 360p, 480p, 720p, 1080p
streamlink --stream-sorting-excludes ">360p" twitch.tv/CHANNEL low,best
# will open 360p

# available: low, medium, high, source
streamlink --stream-sorting-excludes ">360p" twitch.tv/CHANNEL low,best
# will open low

# available: 720p
streamlink --stream-sorting-excludes ">360p" twitch.tv/CHANNEL low,best
# returns an empty selection (channel doesn't have re-encoded qualities)
```

Having a fallback parameter (eg. `--stream-sorting-excludes-fallback`) with a list of streams that will be opened if no matching stream could be found would fix this situation.

Another solution could be having a different notation in the actual quality selection, but this is probably a bit too complex.

```
# 1. try to find the quality labeled "medium"
# 2. look for qualities named by video resolution matching the defined range
# 3. fall back to lower qualities
# 4. use any available quality
streamlink twitch.tv/CHANNEL "medium||>360p&&<=480p||<=360p||best"
streamlink twitch.tv/CHANNEL "medium,>360p<=480p,<=360p,best"
```

Comments:

Patch:
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 1f5513d7..ef38ac47 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -384,6 +384,7 @@ class Plugin(object):
 
         stream_names = filter(stream_weight_only, streams.keys())
         sorted_streams = sorted(stream_names, key=stream_weight_only)
+        unfiltered_sorted_streams = sorted_streams
 
         if isinstance(sorting_excludes, list):
             for expr in sorting_excludes:
@@ -402,6 +403,11 @@ class Plugin(object):
             worst = sorted_streams[0]
             final_sorted_streams["worst"] = streams[worst]
             final_sorted_streams["best"] = streams[best]
+        elif len(unfiltered_sorted_streams) > 0:
+            best = unfiltered_sorted_streams[-1]
+            worst = unfiltered_sorted_streams[0]
+            final_sorted_streams["worst-unfiltered"] = streams[worst]
+            final_sorted_streams["best-unfiltered"] = streams[best]
 
         return final_sorted_streams
 
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 0c77adb3..6af91976 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -107,7 +107,7 @@ def build_parser():
         help="""
         Stream to play.
 
-        Use "best" or "worst" for selecting the highest or lowest available
+        Use ``best`` or ``worst`` for selecting the highest or lowest available
         quality.
 
         Fallback streams can be specified by using a comma-separated list:
@@ -523,7 +523,7 @@ def build_parser():
         help="""
         Stream to play.
 
-        Use "best" or "worst" for selecting the highest or lowest available
+        Use ``best`` or ``worst`` for selecting the highest or lowest available
         quality.
 
         Fallback streams can be specified by using a comma-separated list:
@@ -590,13 +590,17 @@ def build_parser():
         metavar="STREAMS",
         type=comma_list,
         help="""
-        Fine tune best/worst synonyms by excluding unwanted streams.
+        Fine tune the ``best`` and ``worst`` stream name synonyms by excluding unwanted streams.
+
+        If all of the available streams get excluded, ``best`` and ``worst`` will become
+        inaccessible and new special stream synonyms ``best-unfiltered`` and ``worst-unfiltered``
+        can be used as a fallback selection method.
 
         Uses a filter expression in the format:
 
           [operator]<value>
 
-        Valid operators are >, >=, < and <=. If no operator is specified then
+        Valid operators are ``>``, ``>=``, ``<`` and ``<=``. If no operator is specified then
         equality is tested.
 
         For example this will exclude streams ranked higher than "480p":
diff --git a/src/streamlink_cli/constants.py b/src/streamlink_cli/constants.py
index 05ec5dba..45eb7894 100644
--- a/src/streamlink_cli/constants.py
+++ b/src/streamlink_cli/constants.py
@@ -28,7 +28,7 @@ else:
     ]
     PLUGINS_DIR = os.path.expanduser(XDG_CONFIG_HOME + "/streamlink/plugins")
 
-STREAM_SYNONYMS = ["best", "worst"]
+STREAM_SYNONYMS = ["best", "worst", "best-unfiltered", "worst-unfiltered"]
 STREAM_PASSTHROUGH = ["hls", "http", "rtmp"]
 
 __all__ = [


Test patch:
diff --git a/tests/plugins/testplugin.py b/tests/plugins/testplugin.py
index 06b693c4..66822ef4 100644
--- a/tests/plugins/testplugin.py
+++ b/tests/plugins/testplugin.py
@@ -37,6 +37,14 @@ class TestPlugin(Plugin):
     def _get_streams(self):
         if "empty" in self.url:
             return
+
+        if "UnsortableStreamNames" in self.url:
+            def gen():
+                for i in range(3):
+                    yield "vod", HTTPStream(self.session, "http://test.se/stream")
+
+            return gen()
+
         if "NoStreamsError" in self.url:
             raise NoStreamsError(self.url)
 
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index dccd30a7..2b58ea5f 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -3,8 +3,9 @@ import os.path
 import tempfile
 
 import streamlink_cli.main
-from streamlink_cli.main import resolve_stream_name, check_file_output
+from streamlink_cli.main import resolve_stream_name, format_valid_streams, check_file_output
 from streamlink_cli.output import FileOutput
+from streamlink.plugin.plugin import Plugin
 import unittest
 from tests.mock import Mock, patch
 
@@ -59,19 +60,71 @@ class TestCLIMain(unittest.TestCase):
             tmpfile.close()
 
     def test_resolve_stream_name(self):
-        high = Mock()
-        medium = Mock()
-        low = Mock()
+        a = Mock()
+        b = Mock()
+        c = Mock()
+        d = Mock()
+        e = Mock()
         streams = {
-            "low": low,
-            "medium": medium,
-            "high": high,
-            "worst": low,
-            "best": high
+            "160p": a,
+            "360p": b,
+            "480p": c,
+            "720p": d,
+            "1080p": e,
+            "worst": b,
+            "best": d,
+            "worst-unfiltered": a,
+            "best-unfiltered": e
         }
-        self.assertEqual("high", resolve_stream_name(streams, "best"))
-        self.assertEqual("low", resolve_stream_name(streams, "worst"))
-        self.assertEqual("medium", resolve_stream_name(streams, "medium"))
-        self.assertEqual("high", resolve_stream_name(streams, "high"))
-        self.assertEqual("low", resolve_stream_name(streams, "low"))
 
+        self.assertEqual(resolve_stream_name(streams, "unknown"), "unknown")
+        self.assertEqual(resolve_stream_name(streams, "160p"), "160p")
+        self.assertEqual(resolve_stream_name(streams, "360p"), "360p")
+        self.assertEqual(resolve_stream_name(streams, "480p"), "480p")
+        self.assertEqual(resolve_stream_name(streams, "720p"), "720p")
+        self.assertEqual(resolve_stream_name(streams, "1080p"), "1080p")
+        self.assertEqual(resolve_stream_name(streams, "worst"), "360p")
+        self.assertEqual(resolve_stream_name(streams, "best"), "720p")
+        self.assertEqual(resolve_stream_name(streams, "worst-unfiltered"), "160p")
+        self.assertEqual(resolve_stream_name(streams, "best-unfiltered"), "1080p")
+
+    def test_format_valid_streams(self):
+        class FakePlugin:
+            @classmethod
+            def stream_weight(cls, stream):
+                return Plugin.stream_weight(stream)
+        a = Mock()
+        b = Mock()
+        c = Mock()
+
+        streams = {
+            "audio": a,
+            "720p": b,
+            "1080p": c,
+            "worst": b,
+            "best": c
+        }
+        self.assertEqual(
+            format_valid_streams(FakePlugin, streams),
+            ", ".join([
+                "audio",
+                "720p (worst)",
+                "1080p (best)"
+            ])
+        )
+
+        streams = {
+            "audio": a,
+            "720p": b,
+            "1080p": c,
+            "worst-unfiltered": b,
+            "best-unfiltered": c
+        }
+        self.assertEqual(
+            format_valid_streams(FakePlugin, streams),
+            ", ".join([
+                "audio",
+                "720p (worst-unfiltered)",
+                "1080p (best-unfiltered)"
+            ])
+        )
diff --git a/tests/test_session.py b/tests/test_session.py
index 573aae37..e923ab53 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -99,12 +99,23 @@ class TestSession(unittest.TestCase):
         self.assertTrue(isinstance(streams["480p"], RTMPStream))
         self.assertTrue(isinstance(streams["480p_http"], HTTPStream))
 
-    def test_plugin_stream_sorted_excludes(self):
+    def test_plugin_stream_sorting_excludes(self):
         channel = self.session.resolve_url("http://test.se/channel")
-        streams = channel.streams(sorting_excludes=["1080p", "3000k"])
 
+        streams = channel.streams(sorting_excludes=[])
         self.assertTrue("best" in streams)
         self.assertTrue("worst" in streams)
+        self.assertFalse("best-unfiltered" in streams)
+        self.assertFalse("worst-unfiltered" in streams)
+        self.assertTrue(streams["worst"] is streams["350k"])
+        self.assertTrue(streams["best"] is streams["1080p"])
+
+        streams = channel.streams(sorting_excludes=["1080p", "3000k"])
+        self.assertTrue("best" in streams)
+        self.assertTrue("worst" in streams)
+        self.assertFalse("best-unfiltered" in streams)
+        self.assertFalse("worst-unfiltered" in streams)
+        self.assertTrue(streams["worst"] is streams["350k"])
         self.assertTrue(streams["best"] is streams["1500k"])
 
         streams = channel.streams(sorting_excludes=[">=1080p", ">1500k"])
@@ -113,6 +124,24 @@ class TestSession(unittest.TestCase):
         streams = channel.streams(sorting_excludes=lambda q: not q.endswith("p"))
         self.assertTrue(streams["best"] is streams["3000k"])
 
+        streams = channel.streams(sorting_excludes=lambda q: False)
+        self.assertFalse("best" in streams)
+        self.assertFalse("worst" in streams)
+        self.assertTrue("best-unfiltered" in streams)
+        self.assertTrue("worst-unfiltered" in streams)
+        self.assertTrue(streams["worst-unfiltered"] is streams["350k"])
+        self.assertTrue(streams["best-unfiltered"] is streams["1080p"])
+
+        channel = self.session.resolve_url("http://test.se/UnsortableStreamNames")
+        streams = channel.streams()
+        self.assertFalse("best" in streams)
+        self.assertFalse("worst" in streams)
+        self.assertFalse("best-unfiltered" in streams)
+        self.assertFalse("worst-unfiltered" in streams)
+        self.assertTrue("vod" in streams)
+        self.assertTrue("vod_alt" in streams)
+        self.assertTrue("vod_alt2" in streams)
+
     def test_plugin_support(self):
         channel = self.session.resolve_url("http://test.se/channel")
         streams = channel.streams()