import re import unittest import warnings from pathlib import Path from socket import AF_INET, AF_INET6 from unittest.mock import Mock, call, patch import pytest import requests_mock import urllib3 import tests.plugin from streamlink.exceptions import NoPluginError, StreamlinkDeprecationWarning from streamlink.plugin import HIGH_PRIORITY, LOW_PRIORITY, NO_PRIORITY, NORMAL_PRIORITY, Plugin, pluginmatcher from streamlink.session import Streamlink from streamlink.stream.hls import HLSStream from streamlink.stream.http import HTTPStream PATH_TESTPLUGINS = Path(tests.plugin.__path__[0]) PATH_TESTPLUGINS_OVERRIDE = PATH_TESTPLUGINS / "override" _original_allowed_gai_family = urllib3.util.connection.allowed_gai_family # type: ignore[attr-defined] class EmptyPlugin(Plugin): def _get_streams(self): pass # pragma: no cover # TODO: rewrite using pytest class TestSession(unittest.TestCase): mocker: requests_mock.Mocker def setUp(self): self.mocker = requests_mock.Mocker() self.mocker.register_uri(requests_mock.ANY, requests_mock.ANY, text="") self.mocker.start() def tearDown(self): self.mocker.stop() Streamlink.resolve_url.cache_clear() def subject(self, load_plugins=True): session = Streamlink() if load_plugins: session.load_plugins(str(PATH_TESTPLUGINS)) session.load_plugins(str(PATH_TESTPLUGINS_OVERRIDE)) return session # ---- def test_load_plugins(self): session = self.subject() plugins = session.get_plugins() assert "testplugin" in plugins assert "testplugin_missing" not in plugins assert "testplugin_invalid" not in plugins def test_load_plugins_builtin(self): session = self.subject() plugins = session.get_plugins() assert "twitch" in plugins assert plugins["twitch"].__module__ == "streamlink.plugins.twitch" @patch("streamlink.session.log") def test_load_plugins_override(self, mock_log): session = self.subject() plugins = session.get_plugins() assert "testplugin" in plugins assert "testplugin_override" not in plugins assert plugins["testplugin"].__name__ == "TestPluginOverride" assert plugins["testplugin"].__module__ == "streamlink.plugins.testplugin" assert mock_log.debug.call_args_list == [ call(f"Plugin testplugin is being overridden by {PATH_TESTPLUGINS_OVERRIDE / 'testplugin.py'}"), ] @patch("streamlink.session.load_module") @patch("streamlink.session.log") def test_load_plugins_importerror(self, mock_log, mock_load_module): mock_load_module.side_effect = ImportError() session = self.subject() assert not session.get_plugins() assert len(mock_log.exception.call_args_list) > 0 @patch("streamlink.session.load_module") @patch("streamlink.session.log") def test_load_plugins_syntaxerror(self, mock_log, mock_load_module): mock_load_module.side_effect = SyntaxError() with pytest.raises(SyntaxError): self.subject() def test_resolve_url(self): session = self.subject() plugins = session.get_plugins() with warnings.catch_warnings(record=True) as record_warnings: pluginname, pluginclass, resolved_url = session.resolve_url("http://test.se/channel") assert issubclass(pluginclass, Plugin) assert pluginclass is plugins["testplugin"] assert resolved_url == "http://test.se/channel" assert hasattr(session.resolve_url, "cache_info"), "resolve_url has a lookup cache" assert record_warnings == [] def test_resolve_url__noplugin(self): session = self.subject() self.mocker.get("http://invalid2", status_code=301, headers={"Location": "http://invalid3"}) with pytest.raises(NoPluginError): session.resolve_url("http://invalid1") with pytest.raises(NoPluginError): session.resolve_url("http://invalid2") def test_resolve_url__redirected(self): session = self.subject() plugins = session.get_plugins() self.mocker.head("http://redirect1", status_code=501) self.mocker.get("http://redirect1", status_code=301, headers={"Location": "http://redirect2"}) self.mocker.head("http://redirect2", status_code=301, headers={"Location": "http://test.se/channel"}) pluginname, pluginclass, resolved_url = session.resolve_url("http://redirect1") assert issubclass(pluginclass, Plugin) assert pluginclass is plugins["testplugin"] assert resolved_url == "http://test.se/channel" def test_resolve_url_no_redirect(self): session = self.subject() plugins = session.get_plugins() pluginname, pluginclass, resolved_url = session.resolve_url_no_redirect("http://test.se/channel") assert issubclass(pluginclass, Plugin) assert pluginclass is plugins["testplugin"] assert resolved_url == "http://test.se/channel" def test_resolve_url_no_redirect__noplugin(self): session = self.subject() with pytest.raises(NoPluginError): session.resolve_url_no_redirect("http://invalid") def test_resolve_url_scheme(self): @pluginmatcher(re.compile("http://insecure")) class PluginHttp(EmptyPlugin): pass @pluginmatcher(re.compile("https://secure")) class PluginHttps(EmptyPlugin): pass session = self.subject(load_plugins=False) session.plugins = { "insecure": PluginHttp, "secure": PluginHttps, } with pytest.raises(NoPluginError): session.resolve_url("insecure") assert session.resolve_url("http://insecure")[1] is PluginHttp with pytest.raises(NoPluginError): session.resolve_url("https://insecure") assert session.resolve_url("secure")[1] is PluginHttps with pytest.raises(NoPluginError): session.resolve_url("http://secure") assert session.resolve_url("https://secure")[1] is PluginHttps def test_resolve_url_priority(self): @pluginmatcher(priority=HIGH_PRIORITY, pattern=re.compile( "https://(high|normal|low|no)$", )) class HighPriority(EmptyPlugin): pass @pluginmatcher(priority=NORMAL_PRIORITY, pattern=re.compile( "https://(normal|low|no)$", )) class NormalPriority(EmptyPlugin): pass @pluginmatcher(priority=LOW_PRIORITY, pattern=re.compile( "https://(low|no)$", )) class LowPriority(EmptyPlugin): pass @pluginmatcher(priority=NO_PRIORITY, pattern=re.compile( "https://(no)$", )) class NoPriority(EmptyPlugin): pass session = self.subject(load_plugins=False) session.plugins = { "high": HighPriority, "normal": NormalPriority, "low": LowPriority, "no": NoPriority, } no = session.resolve_url_no_redirect("no")[1] low = session.resolve_url_no_redirect("low")[1] normal = session.resolve_url_no_redirect("normal")[1] high = session.resolve_url_no_redirect("high")[1] assert no is HighPriority assert low is HighPriority assert normal is HighPriority assert high is HighPriority session.resolve_url.cache_clear() session.plugins = { "no": NoPriority, } with pytest.raises(NoPluginError): session.resolve_url_no_redirect("no") def test_resolve_deprecated(self): @pluginmatcher(priority=LOW_PRIORITY, pattern=re.compile( "https://low", )) class LowPriority(EmptyPlugin): pass class DeprecatedNormalPriority(EmptyPlugin): # noinspection PyUnusedLocal @classmethod def can_handle_url(cls, url): return True class DeprecatedHighPriority(DeprecatedNormalPriority): # noinspection PyUnusedLocal @classmethod def priority(cls, url): return HIGH_PRIORITY session = self.subject(load_plugins=False) session.plugins = { "empty": EmptyPlugin, "low": LowPriority, "dep-normal-one": DeprecatedNormalPriority, "dep-normal-two": DeprecatedNormalPriority, "dep-high": DeprecatedHighPriority, } with pytest.warns() as recwarn: plugin = session.resolve_url_no_redirect("low")[1] assert plugin is DeprecatedHighPriority assert [(record.category, str(record.message)) for record in recwarn.list] == [ (StreamlinkDeprecationWarning, "Resolved plugin dep-normal-one with deprecated can_handle_url API"), (StreamlinkDeprecationWarning, "Resolved plugin dep-high with deprecated can_handle_url API"), ] def test_options(self): session = self.subject() session.set_option("test_option", "option") assert session.get_option("test_option") == "option" assert session.get_option("non_existing") is None assert session.get_plugin_option("testplugin", "a_option") == "default" session.set_plugin_option("testplugin", "another_option", "test") assert session.get_plugin_option("testplugin", "another_option") == "test" assert session.get_plugin_option("non_existing", "non_existing") is None assert session.get_plugin_option("testplugin", "non_existing") is None def test_streams(self): session = self.subject() streams = session.streams("http://test.se/channel") assert "best" in streams assert "worst" in streams assert streams["best"] is streams["1080p"] assert streams["worst"] is streams["350k"] assert isinstance(streams["http"], HTTPStream) assert isinstance(streams["hls"], HLSStream) def test_streams_stream_types(self): session = self.subject() streams = session.streams("http://test.se/channel", stream_types=["http", "hls"]) assert isinstance(streams["480p"], HTTPStream) assert isinstance(streams["480p_hls"], HLSStream) streams = session.streams("http://test.se/channel", stream_types=["hls", "http"]) assert isinstance(streams["480p"], HLSStream) assert isinstance(streams["480p_http"], HTTPStream) def test_streams_stream_sorting_excludes(self): session = self.subject() streams = session.streams("http://test.se/channel", sorting_excludes=[]) assert "best" in streams assert "worst" in streams assert "best-unfiltered" not in streams assert "worst-unfiltered" not in streams assert streams["worst"] is streams["350k"] assert streams["best"] is streams["1080p"] streams = session.streams("http://test.se/channel", sorting_excludes=["1080p", "3000k"]) assert "best" in streams assert "worst" in streams assert "best-unfiltered" not in streams assert "worst-unfiltered" not in streams assert streams["worst"] is streams["350k"] assert streams["best"] is streams["1500k"] streams = session.streams("http://test.se/channel", sorting_excludes=[">=1080p", ">1500k"]) assert streams["best"] is streams["1500k"] streams = session.streams("http://test.se/channel", sorting_excludes=lambda q: not q.endswith("p")) assert streams["best"] is streams["3000k"] streams = session.streams("http://test.se/channel", sorting_excludes=lambda q: False) assert "best" not in streams assert "worst" not in streams assert "best-unfiltered" in streams assert "worst-unfiltered" in streams assert streams["worst-unfiltered"] is streams["350k"] assert streams["best-unfiltered"] is streams["1080p"] streams = session.streams("http://test.se/UnsortableStreamNames") assert "best" not in streams assert "worst" not in streams assert "best-unfiltered" not in streams assert "worst-unfiltered" not in streams assert "vod" in streams assert "vod_alt" in streams assert "vod_alt2" in streams def test_set_and_get_locale(self): session = Streamlink() session.set_option("locale", "en_US") assert session.localization.country.alpha2 == "US" assert session.localization.language.alpha2 == "en" assert session.localization.language_code == "en_US" @patch("streamlink.session.HTTPSession") def test_interface(self, mock_httpsession): 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_httpsession.return_value = Mock(adapters={ "http://": adapter_http, "https://": adapter_https, "foo://": adapter_foo, }) session = self.subject(load_plugins=False) assert session.get_option("interface") is None session.set_option("interface", "my-interface") assert adapter_http.poolmanager.connection_pool_kw == {"source_address": ("my-interface", 0)} assert adapter_https.poolmanager.connection_pool_kw == {"source_address": ("my-interface", 0)} assert adapter_foo.poolmanager.connection_pool_kw == {} assert session.get_option("interface") == "my-interface" session.set_option("interface", None) assert adapter_http.poolmanager.connection_pool_kw == {} assert adapter_https.poolmanager.connection_pool_kw == {} assert adapter_foo.poolmanager.connection_pool_kw == {} assert session.get_option("interface") is None @patch("streamlink.session.urllib3_util_connection", allowed_gai_family=_original_allowed_gai_family) def test_ipv4_ipv6(self, mock_urllib3_util_connection): session = self.subject(load_plugins=False) assert session.get_option("ipv4") is False assert session.get_option("ipv6") is False assert mock_urllib3_util_connection.allowed_gai_family is _original_allowed_gai_family session.set_option("ipv4", True) assert session.get_option("ipv4") is True assert session.get_option("ipv6") is False assert mock_urllib3_util_connection.allowed_gai_family is not _original_allowed_gai_family assert mock_urllib3_util_connection.allowed_gai_family() is AF_INET session.set_option("ipv4", False) assert session.get_option("ipv4") is False assert session.get_option("ipv6") is False assert mock_urllib3_util_connection.allowed_gai_family is _original_allowed_gai_family session.set_option("ipv6", True) assert session.get_option("ipv4") is False assert session.get_option("ipv6") is True assert mock_urllib3_util_connection.allowed_gai_family is not _original_allowed_gai_family assert mock_urllib3_util_connection.allowed_gai_family() is AF_INET6 session.set_option("ipv6", False) assert session.get_option("ipv4") is False assert session.get_option("ipv6") is False assert mock_urllib3_util_connection.allowed_gai_family is _original_allowed_gai_family session.set_option("ipv4", True) session.set_option("ipv6", False) assert session.get_option("ipv4") is True assert session.get_option("ipv6") is False assert mock_urllib3_util_connection.allowed_gai_family is _original_allowed_gai_family @patch("streamlink.session.urllib3_util_ssl", DEFAULT_CIPHERS="foo:!bar:baz") def test_http_disable_dh(self, mock_urllib3_util_ssl): session = self.subject(load_plugins=False) assert mock_urllib3_util_ssl.DEFAULT_CIPHERS == "foo:!bar:baz" session.set_option("http-disable-dh", True) assert mock_urllib3_util_ssl.DEFAULT_CIPHERS == "foo:!bar:baz:!DH" session.set_option("http-disable-dh", True) assert mock_urllib3_util_ssl.DEFAULT_CIPHERS == "foo:!bar:baz:!DH" session.set_option("http-disable-dh", False) assert mock_urllib3_util_ssl.DEFAULT_CIPHERS == "foo:!bar:baz" class TestSessionOptionHttpProxy: @pytest.fixture() def _no_deprecation(self, recwarn: pytest.WarningsRecorder): yield assert recwarn.list == [] @pytest.fixture() def _logs_deprecation(self, recwarn: pytest.WarningsRecorder): yield assert [(record.category, str(record.message), record.filename) for record in recwarn.list] == [ ( StreamlinkDeprecationWarning, "The `https-proxy` option has been deprecated in favor of a single `http-proxy` option", __file__, ), ] @pytest.mark.usefixtures("_no_deprecation") def test_https_proxy_default(self, session: Streamlink): session.set_option("http-proxy", "http://testproxy.com") assert session.http.proxies["http"] == "http://testproxy.com" assert session.http.proxies["https"] == "http://testproxy.com" @pytest.mark.usefixtures("_logs_deprecation") def test_https_proxy_set_first(self, session: Streamlink): session.set_option("https-proxy", "https://testhttpsproxy.com") session.set_option("http-proxy", "http://testproxy.com") assert session.http.proxies["http"] == "http://testproxy.com" assert session.http.proxies["https"] == "http://testproxy.com" @pytest.mark.usefixtures("_logs_deprecation") def test_https_proxy_default_override(self, session: Streamlink): session.set_option("http-proxy", "http://testproxy.com") session.set_option("https-proxy", "https://testhttpsproxy.com") assert session.http.proxies["http"] == "https://testhttpsproxy.com" assert session.http.proxies["https"] == "https://testhttpsproxy.com" @pytest.mark.usefixtures("_logs_deprecation") def test_https_proxy_set_only(self, session: Streamlink): session.set_option("https-proxy", "https://testhttpsproxy.com") assert session.http.proxies["http"] == "https://testhttpsproxy.com" assert session.http.proxies["https"] == "https://testhttpsproxy.com" @pytest.mark.usefixtures("_no_deprecation") def test_http_proxy_socks(self, session: Streamlink): session.set_option("http-proxy", "socks5://localhost:1234") assert session.http.proxies["http"] == "socks5://localhost:1234" assert session.http.proxies["https"] == "socks5://localhost:1234" @pytest.mark.usefixtures("_logs_deprecation") def test_https_proxy_socks(self, session: Streamlink): session.set_option("https-proxy", "socks5://localhost:1234") assert session.http.proxies["http"] == "socks5://localhost:1234" assert session.http.proxies["https"] == "socks5://localhost:1234" @pytest.mark.usefixtures("_no_deprecation") def test_get_http_proxy(self, session: Streamlink): session.http.proxies["http"] = "http://testproxy1.com" session.http.proxies["https"] = "http://testproxy2.com" assert session.get_option("http-proxy") == "http://testproxy1.com" @pytest.mark.usefixtures("_logs_deprecation") def test_get_https_proxy(self, session: Streamlink): session.http.proxies["http"] = "http://testproxy1.com" session.http.proxies["https"] = "http://testproxy2.com" assert session.get_option("https-proxy") == "http://testproxy2.com" @pytest.mark.usefixtures("_logs_deprecation") def test_https_proxy_get_directly(self, session: Streamlink): # The DeprecationWarning's origin must point to this call, even without the set_option() wrapper session.options.get("https-proxy") @pytest.mark.usefixtures("_logs_deprecation") def test_https_proxy_set_directly(self, session: Streamlink): # The DeprecationWarning's origin must point to this call, even without the set_option() wrapper session.options.set("https-proxy", "https://foo") class TestOptionsKeyEqualsValue: @pytest.fixture() def option(self, request, session: Streamlink): option, attr = request.param httpsessionattr = getattr(session.http, attr) assert session.get_option(option) is httpsessionattr assert "foo" not in httpsessionattr assert "bar" not in httpsessionattr yield option assert httpsessionattr.get("foo") == "foo=bar" assert httpsessionattr.get("bar") == "123" @pytest.mark.parametrize( "option", [ pytest.param(("http-cookies", "cookies"), id="http-cookies"), pytest.param(("http-headers", "headers"), id="http-headers"), pytest.param(("http-query-params", "params"), id="http-query-params"), ], indirect=["option"], ) def test_dict(self, session: Streamlink, option: str): session.set_option(option, {"foo": "foo=bar", "bar": "123"}) @pytest.mark.parametrize( ("option", "value"), [ pytest.param(("http-cookies", "cookies"), "foo=foo=bar;bar=123;baz", id="http-cookies"), pytest.param(("http-headers", "headers"), "foo=foo=bar;bar=123;baz", id="http-headers"), pytest.param(("http-query-params", "params"), "foo=foo=bar&bar=123&baz", id="http-query-params"), ], indirect=["option"], ) def test_string(self, session: Streamlink, option: str, value: str): session.set_option(option, value) @pytest.mark.parametrize( ("option", "attr", "default", "value"), [ ("http-ssl-cert", "cert", None, "foo"), ("http-ssl-verify", "verify", True, False), ("http-trust-env", "trust_env", True, False), ("http-timeout", "timeout", 20.0, 30.0), ], ) def test_options_http_other(session: Streamlink, option: str, attr: str, default, value): httpsessionattr = getattr(session.http, attr) assert httpsessionattr == default assert session.get_option(option) == httpsessionattr session.set_option(option, value) assert session.get_option(option) == value class TestOptionsDocumentation: @pytest.fixture() def docstring(self, session: Streamlink): docstring = session.set_option.__doc__ assert docstring is not None return docstring def test_default_option_is_documented(self, session: Streamlink, docstring: str): assert session.options.keys() for option in session.options: assert f"* - {option}" in docstring, f"Option '{option}' is documented" def test_documented_option_exists(self, session: Streamlink, docstring: str): options = session.options setters = options._MAP_SETTERS.keys() documented = re.compile(r"\* - (\S+)").findall(docstring)[1:] assert documented for option in documented: assert option in options or option in setters, f"Documented option '{option}' exists"