Repository: streamlink/streamlink

Issue:
plugins.nicolive: Title and author name garbled.
### Checklist

- [x] This is a [plugin issue](https://streamlink.github.io/plugins.html) 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 plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [x] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Collaboration

- [x] [I will provide feedback should a pull request be opened with a fix for the plugin](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback)

### Streamlink version

streamlink 7.3.0

### Description

Japanese character has garbled.
live URL : https://live.nicovideo.jp/watch/lv347876151

**true author -> あかねこ。
**now ->ããã­ãã

**true live stream  title-> 小幡さんとゆるキャン△
**now -> å°å¹¡ããã¨ããã­ã£ã³â

Please check it.

### Debug log

```text
streamlink 7.3.0
[cli][info] Found matching plugin nicolive for URL https://live.nicovideo.jp/watch/lv347876151
[plugins.nicolive][info] Logging in via provided user session cookie
[cli][info] Available streams: 288p_alt (worst), 288p, 450p_alt, 450p, 720p (best)
[cli][info] Opening stream: 450p (hls-multi)
[cli][info] Writing output to
E:\niconico\REC\ããã­ãã\2025052918083070_[st]1748499129[ed]_lv347876151_å°å¹¡ããã¨ããã­ã£ã³â³_450p.ts
[utils.named_pipe][info] Creating pipe streamlinkpipe-5372-1-4219
[utils.named_pipe][info] Creating pipe streamlinkpipe-5372-2-8103
[stream.hls][info] Filtering out segments and pausing stream output
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][info] Resuming stream output
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[cli][info] Stream ended
[cli][info] Closing currently open stream...
```


Discussion:
Nicolive doesn't properly tell the HTTP client that its data is UTF-8 encoded, because there's no encoding info in the `Content-Type` HTTP response header, which is simply `text/html`. It does set the `<meta charset="utf-8"/>` HTML tag (HTML tags and attributes are case insensitive), but this doesn't affect Streamlink, because the response encoding is determined by `requests` before evaluating the HTML (with the embedded JSON data where the stream metadata comes from).

- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Type
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta#charset

```
$ curl -sIL --http1.1 https://live.nicovideo.jp/watch/lv347867912 | grep Content-Type
Content-Type: text/html

$ curl -sNL --http1.1 https://live.nicovideo.jp/watch/lv347867912 | head -c50
<!DOCTYPE html><html><head><meta charSet="utf-8"/>
```

`requests` always sets the response encoding to `ISO-8859-1` if the `Content-Type` is some kind of `text` without an included `charset` parameter. This is done according to RFC2616 (the HTTP/1.1 standard):
https://github.com/psf/requests/blame/v2.32.3/src/requests/utils.py#L556-L557

Web browsers on the other hand have lots of fallback mechanisms for detecting the website's text encoding if the website authors don't follow the standards strictly.

So in order to fix this, we'll have to force UTF-8 encoding in the plugin.
I understand.
Thank you very much.



Patch:
diff --git a/src/streamlink/plugins/nicolive.py b/src/streamlink/plugins/nicolive.py
index 397f55998d3..b5c38892cf3 100644
--- a/src/streamlink/plugins/nicolive.py
+++ b/src/streamlink/plugins/nicolive.py
@@ -269,6 +269,7 @@ def _get_hls_stream_url(self):
     def get_data(self):
         return self.session.http.get(
             self.url,
+            encoding="utf-8",
             schema=validate.Schema(
                 validate.parse_html(),
                 validate.xml_find(".//script[@id='embedded-data'][@data-props]"),
diff --git a/src/streamlink/session/http.py b/src/streamlink/session/http.py
index 90d4f168ac6..68483c176f9 100644
--- a/src/streamlink/session/http.py
+++ b/src/streamlink/session/http.py
@@ -155,6 +155,7 @@ def prepare_new_request(self, **req_keywords) -> PreparedRequest:
 
     def request(self, method, url, *args, **kwargs):
         acceptable_status = kwargs.pop("acceptable_status", [])
+        encoding = kwargs.pop("encoding", None)
         exception = kwargs.pop("exception", PluginError)
         headers = kwargs.pop("headers", {})
         params = kwargs.pop("params", {})
@@ -199,6 +200,9 @@ def request(self, method, url, *args, **kwargs):
                 delay = min(retry_max_backoff, retry_backoff * (2 ** (retries - 1)))
                 time.sleep(delay)
 
+        if encoding is not None:
+            res.encoding = encoding
+
         if schema:
             res = schema.validate(res.text, name="response text", exception=PluginError)
 
diff --git a/src/streamlink/session/http.pyi b/src/streamlink/session/http.pyi
index 96d215e860d..0987dcfbd83 100644
--- a/src/streamlink/session/http.pyi
+++ b/src/streamlink/session/http.pyi
@@ -128,6 +128,7 @@ class HTTPSession(Session):
         json: Any | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,
@@ -156,6 +157,7 @@ class HTTPSession(Session):
         json: Any | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,
@@ -212,6 +214,7 @@ class HTTPSession(Session):
         json: Any | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,
@@ -240,6 +243,7 @@ class HTTPSession(Session):
         cert: _Cert | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,
@@ -268,6 +272,7 @@ class HTTPSession(Session):
         json: Any | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,
@@ -296,6 +301,7 @@ class HTTPSession(Session):
         json: Any | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,
@@ -324,6 +330,7 @@ class HTTPSession(Session):
         json: Any | None = ...,
         # Streamlink stuff
         acceptable_status: _AcceptableStatus | None = ...,
+        encoding: str | None = ...,
         exception: _Exception | None = ...,
         raise_for_status: bool | None = ...,
         session: Streamlink | None = ...,


Test patch:
diff --git a/tests/session/test_http.py b/tests/session/test_http.py
index 1fc9a610bba..9a21cc9bbb9 100644
--- a/tests/session/test_http.py
+++ b/tests/session/test_http.py
@@ -6,6 +6,7 @@
 
 import pytest
 import requests
+import requests_mock as rm
 from requests.adapters import HTTPAdapter
 from urllib3.response import HTTPResponse
 
@@ -149,6 +150,76 @@ def test_json(self, monkeypatch: pytest.MonkeyPatch, encoding: str, override: st
 
         assert HTTPSession.json(res) == {"test": "Α and Ω"}  # noqa: RUF001
 
+    @pytest.mark.parametrize(
+        ("content_type", "encoding", "content", "expected"),
+        [
+            pytest.param(
+                "text/html",
+                None,
+                b"B\xe4r",
+                "ISO-8859-1",
+                id="default-iso-8859-1-charset-for-text",
+            ),
+            pytest.param(
+                "application/json",
+                None,
+                b"B\xc3\xa4r",
+                "utf-8",
+                id="default-utf-8-charset-for-json",
+            ),
+            pytest.param(
+                'text/html; charset="ISO-8859-1"',
+                None,
+                b"B\xe4r",
+                "ISO-8859-1",
+                id="declared-iso-8859-1-charset",
+            ),
+            pytest.param(
+                'text/html; charset="utf-8"',
+                None,
+                b"B\xc3\xa4r",
+                "utf-8",
+                id="declared-utf-8-charset",
+            ),
+            pytest.param(
+                "text/html",
+                "utf-8",
+                b"B\xc3\xa4r",
+                "utf-8",
+                id="override-missing-charset",
+            ),
+            pytest.param(
+                'text/html; charset="ISO-8859-1"',
+                "utf-8",
+                b"B\xc3\xa4r",
+                "utf-8",
+                id="override-incorrect-charset",
+            ),
+            pytest.param(
+                'text/html; charset="utf-8"',
+                "utf-8",
+                b"B\xc3\xa4r",
+                "utf-8",
+                id="override-same-charset",
+            ),
+        ],
+    )
+    @pytest.mark.parametrize("method", ["get", "post", "head", "put", "patch", "delete"])
+    def test_encoding_override(
+        self,
+        requests_mock: rm.Mocker,
+        method: str,
+        content_type: str,
+        encoding: str,
+        content: bytes,
+        expected: str,
+    ):
+        requests_mock.register_uri(rm.ANY, "http://mocked", headers={"Content-Type": content_type}, content=content)
+        httpsession = HTTPSession()
+        res = getattr(httpsession, method)("http://mocked", encoding=encoding)
+        assert res.encoding == expected
+        assert res.text == "Bär"
+
 
 class TestHTTPAdapters:
     @staticmethod