Repository: streamlink/streamlink

Issue:
Allow setting environment variables when launching the player
### Checklist

- [X] This is a feature request and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed feature requests](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22feature+request%22)

### Description

There is currently the `--player-arg`  that allows to pass argument to the player.

It would be very handy to have another one, `--player-env` to enable setting environment variables when the player is launched.

I currently have only a single use case:

- I use pipewire, and I want to hint pipewire to connect a specific node, this is done via the `PIPEWIRE_NODE` environment variable.

I can set the environment variable before launching streamlink, but this requires editing all my launchers, instead of setting it in the config file.


Discussion:

Patch:
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 32a3a645..a31495ef 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -427,6 +427,18 @@ def build_parser():
         Default is "".
         """,
     )
+    player.add_argument(
+        "--player-env",
+        metavar="KEY=VALUE",
+        type=keyvalue,
+        action="append",
+        help="""
+        Add an additional environment variable to the spawned player process, in addition to the ones inherited from
+        the Streamlink/Python parent process. This allows setting player environment variables in config files.
+
+        Can be repeated to add multiple environment variables.
+        """,
+    )
     player.add_argument(
         "-v", "--verbose-player",
         action="store_true",
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index df578a9a..a60c0122 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -140,8 +140,9 @@ def create_output(formatter: Formatter) -> Union[FileOutput, PlayerOutput]:
         log.info(f"Starting player: {args.player}")
 
         return PlayerOutput(
-            args.player,
-            args.player_args,
+            path=args.player,
+            args=args.player_args,
+            env=args.player_env,
             quiet=not args.verbose_player,
             kill=not args.player_no_close,
             namedpipe=namedpipe,
@@ -187,8 +188,9 @@ def output_stream_http(
 
         server = create_http_server()
         player = output = PlayerOutput(
-            args.player,
-            args.player_args,
+            path=args.player,
+            args=args.player_args,
+            env=args.player_env,
             quiet=not args.verbose_player,
             filename=server.url,
             title=formatter.title(args.title, defaults=DEFAULT_STREAM_METADATA) if args.title else args.url,
@@ -276,8 +278,9 @@ def output_stream_passthrough(stream, formatter: Formatter):
         return False
 
     output = PlayerOutput(
-        args.player,
-        args.player_args,
+        path=args.player,
+        args=args.player_args,
+        env=args.player_env,
         quiet=not args.verbose_player,
         call=True,
         filename=url,
diff --git a/src/streamlink_cli/output/player.py b/src/streamlink_cli/output/player.py
index 6851968e..965f01be 100644
--- a/src/streamlink_cli/output/player.py
+++ b/src/streamlink_cli/output/player.py
@@ -1,4 +1,5 @@
 import logging
+import os
 import re
 import shlex
 import subprocess
@@ -8,7 +9,7 @@ from contextlib import suppress
 from pathlib import Path
 from shutil import which
 from time import sleep
-from typing import ClassVar, Dict, List, Optional, TextIO, Type, Union
+from typing import ClassVar, Dict, List, Mapping, Optional, Sequence, TextIO, Tuple, Type, Union
 
 from streamlink.compat import is_win32
 from streamlink.exceptions import StreamlinkWarning
@@ -158,6 +159,7 @@ class PlayerOutput(Output):
         self,
         path: Path,
         args: str = "",
+        env: Optional[Sequence[Tuple[str, str]]] = None,
         quiet: bool = True,
         kill: bool = True,
         call: bool = False,
@@ -171,6 +173,8 @@ class PlayerOutput(Output):
 
         self.path = path
         self.args = args
+        self.env: Mapping[str, str] = dict(env or {})
+
         self.kill = kill
         self.call = call
         self.quiet = quiet
@@ -248,22 +252,30 @@ class PlayerOutput(Output):
             self._open_subprocess(args)
 
     def _open_call(self, args: List[str]):
-        log.debug(f"Calling: {args!r}")
+        log.debug(f"Calling: {args!r}{f', env: {self.env!r}' if self.env else ''}")
+
+        environ = dict(os.environ)
+        environ.update(self.env)
 
         subprocess.call(
             args,
+            env=environ,
             stdout=self.stdout,
             stderr=self.stderr,
         )
 
     def _open_subprocess(self, args: List[str]):
-        log.debug(f"Opening subprocess: {args!r}")
+        log.debug(f"Opening subprocess: {args!r}{f', env: {self.env!r}' if self.env else ''}")
+
+        environ = dict(os.environ)
+        environ.update(self.env)
 
         # Force bufsize=0 on all Python versions to avoid writing the
         # unflushed buffer when closing a broken input pipe
         self.player = subprocess.Popen(
             args,
             bufsize=0,
+            env=environ,
             stdin=self.stdin,
             stdout=self.stdout,
             stderr=self.stderr,


Test patch:
diff --git a/tests/cli/output/test_player.py b/tests/cli/output/test_player.py
index 99dff4ef..dff26060 100644
--- a/tests/cli/output/test_player.py
+++ b/tests/cli/output/test_player.py
@@ -1,7 +1,7 @@
 import subprocess
 from contextlib import nullcontext
 from pathlib import Path
-from typing import ContextManager, Type
+from typing import ContextManager, Dict, Mapping, Sequence, Type
 from unittest.mock import Mock, call, patch
 
 import pytest
@@ -251,6 +251,12 @@ class TestPlayerArgs:
 
 # TODO: refactor PlayerOutput and write proper tests
 class TestPlayerOutput:
+    @pytest.fixture(autouse=True)
+    def _os_environ(self, os_environ: Dict[str, str]):
+        os_environ["FAKE"] = "ENVIRONMENT"
+        yield
+        assert sorted(os_environ.keys()) == ["FAKE"], "Doesn't pollute the os.environ dict with custom env vars"
+
     @pytest.fixture()
     def playeroutput(self, request: pytest.FixtureRequest):
         with patch("streamlink_cli.output.player.sleep"):
@@ -269,10 +275,22 @@ class TestPlayerOutput:
              patch("subprocess.Popen", return_value=Mock(poll=Mock(side_effect=Mock(return_value=None)))) as mock_popen:
             yield mock_popen
 
-    @pytest.mark.parametrize(("playeroutput", "mock_which"), [
-        (
-            dict(path=Path("player"), args="param1 param2", call=False),
+    @pytest.mark.parametrize(("playeroutput", "mock_which", "args", "env", "logmessage"), [
+        pytest.param(
+            {"path": Path("player"), "args": "param1 param2", "call": False},
+            "/resolved/player",
+            ["/resolved/player", "param1", "param2", "-"],
+            {"FAKE": "ENVIRONMENT"},
+            "Opening subprocess: ['/resolved/player', 'param1', 'param2', '-']",
+            id="Without custom env vars",
+        ),
+        pytest.param(
+            {"path": Path("player"), "args": "param1 param2", "env": [("VAR1", "abc"), ("VAR2", "def")], "call": False},
             "/resolved/player",
+            ["/resolved/player", "param1", "param2", "-"],
+            {"FAKE": "ENVIRONMENT", "VAR1": "abc", "VAR2": "def"},
+            "Opening subprocess: ['/resolved/player', 'param1', 'param2', '-'], env: {'VAR1': 'abc', 'VAR2': 'def'}",
+            id="With custom env vars",
         ),
     ], indirect=["playeroutput", "mock_which"])
     def test_open_popen_parameters(
@@ -281,6 +299,9 @@ class TestPlayerOutput:
         playeroutput: PlayerOutput,
         mock_which: Mock,
         mock_popen: Mock,
+        args: Sequence[str],
+        env: Mapping[str, str],
+        logmessage: str,
     ):
         caplog.set_level(1, "streamlink")
 
@@ -288,12 +309,13 @@ class TestPlayerOutput:
 
         playeroutput.open()
         assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
-            ("streamlink.cli.output", "debug", "Opening subprocess: ['/resolved/player', 'param1', 'param2', '-']"),
+            ("streamlink.cli.output", "debug", logmessage),
         ]
         assert mock_which.call_args_list == [call("player")]
         assert mock_popen.call_args_list == [call(
-            ["/resolved/player", "param1", "param2", "-"],
+            args,
             bufsize=0,
+            env=env,
             stdin=subprocess.PIPE,
             stdout=subprocess.DEVNULL,
             stderr=subprocess.DEVNULL,
diff --git a/tests/cli/test_argparser.py b/tests/cli/test_argparser.py
index c157cecc..2088107c 100644
--- a/tests/cli/test_argparser.py
+++ b/tests/cli/test_argparser.py
@@ -1,5 +1,4 @@
-import argparse
-from argparse import ArgumentParser, Namespace
+from argparse import Namespace
 from pathlib import Path
 from typing import Any, List
 from unittest.mock import Mock
@@ -7,7 +6,7 @@ from unittest.mock import Mock
 import pytest
 
 from streamlink.session import Streamlink
-from streamlink_cli.argparser import build_parser, setup_session_options
+from streamlink_cli.argparser import ArgumentParser, build_parser, setup_session_options
 from streamlink_cli.main import main as streamlink_cli_main
 
 
@@ -18,7 +17,7 @@ def parser():
 
 class TestConfigFileArguments:
     @pytest.fixture()
-    def parsed(self, request: pytest.FixtureRequest, parser: argparse.ArgumentParser, tmp_path: Path):
+    def parsed(self, request: pytest.FixtureRequest, parser: ArgumentParser, tmp_path: Path):
         content = "\n".join([
             "",
             " ",
@@ -74,6 +73,13 @@ class TestConfigFileArguments:
     def test_emptyvalue(self, parsed: Namespace):
         assert parsed.title == ""
 
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["http-header=foo=bar=baz", "http-header=FOO=BAR=BAZ"], id="With operator"),
+        pytest.param(["http-header foo=bar=baz", "http-header FOO=BAR=BAZ"], id="Without operator"),
+    ], indirect=True)
+    def test_keyequalsvalue(self, parsed: Namespace):
+        assert parsed.http_header == [("foo", "bar=baz"), ("FOO", "BAR=BAZ")]
+
 
 @pytest.mark.filterwarnings("ignore")
 @pytest.mark.parametrize(("argv", "option", "expected"), [
diff --git a/tests/cli/test_cmdline.py b/tests/cli/test_cmdline.py
index bd3e054d..b1bf7e38 100644
--- a/tests/cli/test_cmdline.py
+++ b/tests/cli/test_cmdline.py
@@ -46,9 +46,9 @@ class CommandLineTestCase(unittest.TestCase):
         assert exit_code == actual_exit_code
         assert mock_setup_streamlink.call_count == 1
         if not passthrough:
-            assert mock_popen.call_args_list == [call(commandline, bufsize=ANY, stdin=ANY, stdout=ANY, stderr=ANY)]
+            assert mock_popen.call_args_list == [call(commandline, env=ANY, bufsize=ANY, stdin=ANY, stdout=ANY, stderr=ANY)]
         else:
-            assert mock_call.call_args_list == [call(commandline, stdout=ANY, stderr=ANY)]
+            assert mock_call.call_args_list == [call(commandline, env=ANY, stdout=ANY, stderr=ANY)]
 
 
 class TestCommandLine(CommandLineTestCase):
diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py
index 2123229f..cbb1b9ac 100644
--- a/tests/cli/test_main.py
+++ b/tests/cli/test_main.py
@@ -289,10 +289,12 @@ class TestCLIMainCreateOutput(unittest.TestCase):
         args.url = "URL"
         args.player = Path("mpv")
         args.player_args = ""
+        args.player_env = None
 
         output = create_output(formatter)
         assert type(output) is PlayerOutput
         assert output.playerargs.title == "URL"
+        assert output.env == {}
 
         args.title = "{author} - {title}"
         output = create_output(formatter)
@@ -379,12 +381,14 @@ class TestCLIMainCreateOutput(unittest.TestCase):
         args.url = "URL"
         args.player = Path("mpv")
         args.player_args = ""
+        args.player_env = [("VAR1", "abc"), ("VAR2", "def")]
         args.player_fifo = None
         args.player_http = None
 
         output = create_output(formatter)
         assert type(output) is PlayerOutput
         assert output.playerargs.title == "URL"
+        assert output.env == {"VAR1": "abc", "VAR2": "def"}
         assert type(output.record) is FileOutput
         assert output.record.filename == Path("foo")
         assert output.record.fd is None
@@ -415,12 +419,14 @@ class TestCLIMainCreateOutput(unittest.TestCase):
         args.url = "URL"
         args.player = Path("mpv")
         args.player_args = ""
+        args.player_env = [("VAR1", "abc"), ("VAR2", "def")]
         args.player_fifo = None
         args.player_http = None
 
         output = create_output(formatter)
         assert type(output) is PlayerOutput
         assert output.playerargs.title == "foo - bar"
+        assert output.env == {"VAR1": "abc", "VAR2": "def"}
         assert type(output.record) is FileOutput
         assert output.record.filename is None
         assert output.record.fd is stdout
diff --git a/tests/conftest.py b/tests/conftest.py
index 3b16736a..6e600ffd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -83,3 +83,17 @@ def requests_mock(requests_mock: rm.Mocker) -> rm.Mocker:
     """
     requests_mock.register_uri(rm.ANY, rm.ANY, exc=rm.exceptions.InvalidRequest)
     return requests_mock
+
+
+@pytest.fixture()
+def os_environ(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]:
+    class FakeEnviron(dict):
+        def __setitem__(self, key, value):
+            if key == "PYTEST_CURRENT_TEST":
+                return
+            return super().__setitem__(key, value)
+
+    fakeenviron = FakeEnviron(getattr(request, "param", {}))
+    monkeypatch.setattr("os.environ", fakeenviron)
+
+    return fakeenviron