Repository: streamlink/streamlink

Issue:
param for source address or IPv4/IPv6 
<!-- 
Thanks for filing a feature request!
USE THE TEMPLATE. Otherwise your feature request may be rejected.

First, see the contribution guidelines:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Open a plugin request if you're requesting a new plugin instead of a new feature.

Also check the list of open and closed feature requests:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22feature+request%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Feature Request

<!-- Replace the space character between the square brackets with an x in order to check the boxes -->
- [x] This is a feature request and I have read the contribution guidelines.
- [x] I am using the latest development version from the master branch.


### Description

<!-- Explain the feature as clearly as you can. What is it, how would you expect it to work, and what value does it bring to Streamlink? -->

Is it possible to add parameters to select which ip to use on recording?

Like `curl`'s `-4 -6 --interface`, `youtube-dl`'s `-4 -6 --source-address`

If it can be implemented, we can prevent 429 errors at many cases.

### Expected / Actual behavior

<!-- What do you expect to happen with this new feature, and what is happening currently that does not satisfy this need? -->

I get 429 error on some cases because of IPv4 NAT, although a dedicated IPv6 address is available. 

### Additional comments, etc.



[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)


Discussion:
bastimeyer: Rebased and fixed merge conflict which was introduced by #3486 

Patch:
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index 09856260..1cce091c 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -2,8 +2,11 @@ import logging
 import pkgutil
 from collections import OrderedDict
 from functools import lru_cache
+from socket import AF_INET, AF_INET6
 
 import requests
+import requests.packages.urllib3.util.connection as urllib3_connection
+from requests.packages.urllib3.util.connection import allowed_gai_family
 
 from streamlink import __version__, plugins
 from streamlink.compat import is_win32
@@ -30,6 +33,9 @@ class Streamlink:
     def __init__(self, options=None):
         self.http = api.HTTPSession()
         self.options = Options({
+            "interface": None,
+            "ipv4": False,
+            "ipv6": False,
             "hds-live-edge": 10.0,
             "hds-segment-attempts": 3,
             "hds-segment-threads": 1,
@@ -83,7 +89,13 @@ class Streamlink:
         **Available options**:
 
         ======================== =========================================
-        hds-live-edge            ( float) Specify the time live HDS
+        interface                (str) Set the network interface,
+                                 default: ``None``
+        ipv4                     (bool) Resolve address names to IPv4 only.
+                                 This option overrides ipv6, default: ``False``
+        ipv6                     (bool) Resolve address names to IPv6 only.
+                                 This option overrides ipv4, default: ``False``
+        hds-live-edge            (float) Specify the time live HDS
                                  streams will start from the edge of
                                  stream, default: ``10.0``
 
@@ -242,7 +254,29 @@ class Streamlink:
 
         """
 
-        if key == "http-proxy":
+        if key == "interface":
+            for scheme, adapter in self.http.adapters.items():
+                if scheme not in ("http://", "https://"):
+                    continue
+                if not value:
+                    adapter.poolmanager.connection_pool_kw.pop("source_address")
+                else:
+                    adapter.poolmanager.connection_pool_kw.update(
+                        # https://docs.python.org/3/library/socket.html#socket.create_connection
+                        source_address=(value, 0)
+                    )
+            self.options.set(key, None if not value else value)
+
+        elif key == "ipv4" or key == "ipv6":
+            self.options.set(key, value)
+            if value:
+                self.options.set("ipv6" if key == "ipv4" else "ipv4", False)
+                urllib3_connection.allowed_gai_family = \
+                    (lambda: AF_INET) if key == "ipv4" else (lambda: AF_INET6)
+            else:
+                urllib3_connection.allowed_gai_family = allowed_gai_family
+
+        elif key == "http-proxy":
             self.http.proxies["http"] = update_scheme("http://", value)
             if "https" not in self.http.proxies:
                 self.http.proxies["https"] = update_scheme("http://", value)
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 96f3c99b..eb6bc809 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -303,6 +303,28 @@ def build_parser():
         Default is system locale.
         """
     )
+    general.add_argument(
+        "--interface",
+        type=str,
+        metavar="INTERFACE",
+        help="""
+        Set the network interface.
+        """
+    )
+    general.add_argument(
+        "-4", "--ipv4",
+        action="store_true",
+        help="""
+        Resolve address names to IPv4 only. This option overrides :option:`-6`.
+        """
+    )
+    general.add_argument(
+        "-6", "--ipv6",
+        action="store_true",
+        help="""
+        Resolve address names to IPv6 only. This option overrides :option:`-4`.
+        """
+    )
 
     player = parser.add_argument_group("Player options")
     player.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 955fa9c0..0dde8761 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -751,6 +751,15 @@ def setup_streamlink():
 
 def setup_options():
     """Sets Streamlink options."""
+    if args.interface:
+        streamlink.set_option("interface", args.interface)
+
+    if args.ipv4:
+        streamlink.set_option("ipv4", args.ipv4)
+
+    if args.ipv6:
+        streamlink.set_option("ipv6", args.ipv6)
+
     if args.hls_live_edge:
         streamlink.set_option("hls-live-edge", args.hls_live_edge)
 


Test patch:
diff --git a/tests/test_session.py b/tests/test_session.py
index 5ce51008..57c84d0a 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,6 +1,9 @@
 import os
 import unittest
-from unittest.mock import call, patch
+from socket import AF_INET, AF_INET6
+from unittest.mock import Mock, call, patch
+
+from requests.packages.urllib3.util.connection import allowed_gai_family
 
 from streamlink import NoPluginError, Streamlink
 from streamlink.plugin.plugin import HIGH_PRIORITY, LOW_PRIORITY
@@ -192,6 +195,66 @@ class TestSession(unittest.TestCase):
         self.assertEqual(session.localization.language.alpha2, "en")
         self.assertEqual(session.localization.language_code, "en_US")
 
+    @patch("streamlink.session.api")
+    def test_interface(self, mock_api):
+        adapter_http = Mock(poolmanager=Mock(connection_pool_kw={}))
+        adapter_https = Mock(poolmanager=Mock(connection_pool_kw={}))
+        adapter_foo = Mock(poolmanager=Mock(connection_pool_kw={}))
+        mock_api.HTTPSession.return_value = Mock(adapters={
+            "http://": adapter_http,
+            "https://": adapter_https,
+            "foo://": adapter_foo
+        })
+        session = self.subject(load_plugins=False)
+        self.assertEqual(session.get_option("interface"), None)
+
+        session.set_option("interface", "my-interface")
+        self.assertEqual(adapter_http.poolmanager.connection_pool_kw, {"source_address": ("my-interface", 0)})
+        self.assertEqual(adapter_https.poolmanager.connection_pool_kw, {"source_address": ("my-interface", 0)})
+        self.assertEqual(adapter_foo.poolmanager.connection_pool_kw, {})
+        self.assertEqual(session.get_option("interface"), "my-interface")
+
+        session.set_option("interface", None)
+        self.assertEqual(adapter_http.poolmanager.connection_pool_kw, {})
+        self.assertEqual(adapter_https.poolmanager.connection_pool_kw, {})
+        self.assertEqual(adapter_foo.poolmanager.connection_pool_kw, {})
+        self.assertEqual(session.get_option("interface"), None)
+
+    @patch("streamlink.session.urllib3_connection", allowed_gai_family=allowed_gai_family)
+    def test_ipv4_ipv6(self, mock_urllib3_connection):
+        session = self.subject(load_plugins=False)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
+        session.set_option("ipv4", True)
+        self.assertEqual(session.get_option("ipv4"), True)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertNotEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family(), AF_INET)
+
+        session.set_option("ipv4", False)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
+        session.set_option("ipv6", True)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), True)
+        self.assertNotEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family(), AF_INET6)
+
+        session.set_option("ipv6", False)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
+        session.set_option("ipv4", True)
+        session.set_option("ipv6", False)
+        self.assertEqual(session.get_option("ipv4"), True)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
     def test_https_proxy_default(self):
         session = self.subject(load_plugins=False)
         session.set_option("http-proxy", "http://testproxy.com")