Repository: streamlink/streamlink

Issue:
plugins.twitcasting: Download ends abruptly with an error: string argument without an encoding
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [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)

### Streamlink version

Latest stable release

### Description

Sometimes a download would end abruptly with an error. I can't reliably reproduce it, so I don't have `--loglevel debug` logs, but I do have the error message.

### Debug log

```text
[plugins.twitcasting][error] string argument without an encoding
[plugin.api.websocket][error] cannot join current thread
```



Discussion:
I'm running it with `--loglevel debug` now, so when I encounter this bug again I would be able to provide the logs.
> I'm running it with --loglevel debug now, so when I encounter this bug again I would be able to provide the logs.

it will be the same as `string argument without an encoding`
not that useful

---

if you could change this

https://github.com/streamlink/streamlink/blob/9e4a6577fa1d7efa9b828b13de18b9508b9599de/src/streamlink/plugins/twitcasting.py#L105-L110=

to

```py
    def on_message(self, wsapp, data: str) -> None:
        try:
            self.buffer.write(data)
        except Exception as err:
            log.error(err)
            print("---")
            print(data)
            print("---")
            self.close()
```

it would be more useful
Ok, edited the file. Can I remove the `--loglevel debug` flag or is it required for this codepath to be hit?
> Can I remove the `--loglevel debug` flag

Debug logging is required for posting issues because - as explained in the issue template/form - it includes the version number and your informations about your OS, which is important for diagnosing issues. The remaining debug log should be irrelevant for this issue here.

The issue seems to be related to string message data returned by `websocket-client`'s `on_message` callback. Streamlink requires binary `bytes` data in its buffer, but the websocket message data is a UTF-8 `string` which gets converted back to `bytes`, which requires an encoding parameter that's missing.

From what it looks like, the plugin should implement the `on_data` callback instead of `on_message`, because `on_data` supports both binary data and text data, according to the op-code of the websocket response frame. It's also possible that there's an issue with incomplete responses, which are handled by `on_cont_message` and `on_data`. I will have a look at this later.
Here are the version numbers:

```
[cli][debug] OS:         Linux-5.10.0-14-amd64-x86_64-with-glibc2.31
[cli][debug] Python:     3.9.2
[cli][debug] Streamlink: 4.1.0
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(1.2.3)
```

Debian 11 x86_64, using Debian's python 3.9 with streamlink installed inside a virtualenv via pip.

Resolving patch:
diff --git a/src/streamlink/plugin/api/websocket.py b/src/streamlink/plugin/api/websocket.py
--- a/src/streamlink/plugin/api/websocket.py
+++ b/src/streamlink/plugin/api/websocket.py
@@ -14,6 +14,13 @@
 
 
 class WebsocketClient(Thread):
+    OPCODE_CONT: int = ABNF.OPCODE_CONT
+    OPCODE_TEXT: int = ABNF.OPCODE_TEXT
+    OPCODE_BINARY: int = ABNF.OPCODE_BINARY
+    OPCODE_CLOSE: int = ABNF.OPCODE_CLOSE
+    OPCODE_PING: int = ABNF.OPCODE_PING
+    OPCODE_PONG: int = ABNF.OPCODE_PONG
+
     _id: int = 0
 
     ws: WebSocketApp
@@ -157,17 +164,17 @@ def on_error(self, wsapp: WebSocketApp, error: Exception) -> None:
     def on_close(self, wsapp: WebSocketApp, status: int, message: str) -> None:
         log.debug(f"Closed: {wsapp.url}")  # pragma: no cover
 
-    def on_ping(self, wsapp: WebSocketApp, data: str) -> None:
+    def on_ping(self, wsapp: WebSocketApp, data: bytes) -> None:
         pass  # pragma: no cover
 
-    def on_pong(self, wsapp: WebSocketApp, data: str) -> None:
+    def on_pong(self, wsapp: WebSocketApp, data: bytes) -> None:
         pass  # pragma: no cover
 
     def on_message(self, wsapp: WebSocketApp, data: str) -> None:
         pass  # pragma: no cover
 
-    def on_cont_message(self, wsapp: WebSocketApp, data: str, cont: Any) -> None:
+    def on_cont_message(self, wsapp: WebSocketApp, data: bytes, cont: Any) -> None:
         pass  # pragma: no cover
 
-    def on_data(self, wsapp: WebSocketApp, data: str, data_type: int, cont: Any) -> None:
+    def on_data(self, wsapp: WebSocketApp, data: Union[bytes, str], data_type: int, cont: Any) -> None:
         pass  # pragma: no cover
diff --git a/src/streamlink/plugins/twitcasting.py b/src/streamlink/plugins/twitcasting.py
--- a/src/streamlink/plugins/twitcasting.py
+++ b/src/streamlink/plugins/twitcasting.py
@@ -102,7 +102,10 @@ def on_close(self, *args, **kwargs):
         super().on_close(*args, **kwargs)
         self.buffer.close()
 
-    def on_message(self, wsapp, data: str) -> None:
+    def on_data(self, wsapp, data, data_type, cont):
+        if data_type == self.OPCODE_TEXT:
+            data = bytes(data, "utf-8")
+
         try:
             self.buffer.write(data)
         except Exception as err:


Test patch:
diff --git a/tests/test_api_websocket.py b/tests/test_api_websocket.py
--- a/tests/test_api_websocket.py
+++ b/tests/test_api_websocket.py
@@ -2,6 +2,7 @@
 from threading import Event
 from unittest.mock import Mock, call, patch
 
+import pytest
 from websocket import ABNF, STATUS_NORMAL  # type: ignore[import]
 
 from streamlink.logger import DEBUG, TRACE
@@ -9,6 +10,18 @@
 from streamlink.session import Streamlink
 
 
+@pytest.mark.parametrize("name,value", [
+    ("OPCODE_CONT", ABNF.OPCODE_CONT),
+    ("OPCODE_TEXT", ABNF.OPCODE_TEXT),
+    ("OPCODE_BINARY", ABNF.OPCODE_BINARY),
+    ("OPCODE_CLOSE", ABNF.OPCODE_CLOSE),
+    ("OPCODE_PING", ABNF.OPCODE_PING),
+    ("OPCODE_PONG", ABNF.OPCODE_PONG),
+])
+def test_opcode_export(name, value):
+    assert getattr(WebsocketClient, name) == value
+
+
 class TestWebsocketClient(unittest.TestCase):
     def setUp(self):
         self.session = Streamlink()