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:
Child processes inherit the environment of the parent process unless it gets explicitly overridden, which Streamlink does not:
https://github.com/streamlink/streamlink/blob/79b8497add48a7068ece0a0b2bf538db2a7d61aa/src/streamlink_cli/output/player.py#L264-L270

Just set the env var(s) on the streamlink/python process.
@bastimeyer Yes I know as I said at the bottom of my initial issue, but the idea was to be able to use streamlink own config file. I guess I can work around by wrapping my launchers.
Ah, sorry, apparently I didn't finish reading the whole post :/

A new CLI arg could be added, similar to how `--http-header` works (multiple CLI args with `key=value` values that get appended), but I fear that we'd very likely see requests for adding such args for every other kind of child process Streamlink spawns, not just the player.

IMO, adding specific env vars just for the player is kind of an edge case, so writing a player wrapper script or streamlink wrapper script is probably the better idea.

I can see having a specific CLI arg being useful from the perspective of a config file though.
Yes the main point here is to be able to use a config file to pass environment variable to the player. It is an edge case, and I do not have many use cases for now. The only one I can think of is to configure libraries used by players, I mention pipewire which is an actual use case. But there are other things that can only be configured with env var:

- selecting wayland or xorg, with things like GDK_BACKEND or QT_QPA_PLATFORM
- QT or GTK theming
- locale
- SDL based players as SDL use many env vars

now the main point is: does it make sense to change those for streamlink only. In my case yes, as I want mpv to use a different audio sink when launched from streamlink but not when used alone.

I also opened the issue for reference and in case that someone else has the same problem. I realize this might be out of scope and it is perfectly fine if you close it.

I guess the best workaround would be to write a script, like `mpv_st` which sets env var before opening the player. It does require a bit of care to ensure args and stdin is handled correctly.

Resolving patch:
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
--- 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
--- 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
--- 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 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 @@ def __init__(
         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 @@ def __init__(
 
         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 @@ def _open(self):
             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
--- 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 @@ def test_title(self):
 
 # 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 @@ def mock_popen(self, playeroutput: PlayerOutput):
              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 @@ def test_open_popen_parameters(
         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 @@ def test_open_popen_parameters(
 
         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
--- 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 @@
 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 @@ def test_withvalue(self, parsed: Namespace):
     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
--- a/tests/cli/test_cmdline.py
+++ b/tests/cli/test_cmdline.py
@@ -46,9 +46,9 @@ def fn(*_):
         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
--- a/tests/cli/test_main.py
+++ b/tests/cli/test_main.py
@@ -289,10 +289,12 @@ def test_create_output_no_file_output_options(self, args: Mock):
         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 @@ def test_create_output_record(self, mock_check_file_output: Mock, args: Mock):
         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 @@ def test_create_output_record_stdout(self, args: Mock):
         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
--- 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