Repository: streamlink/streamlink

Issue:
plugins.twitcasting: Doesn't download the stream, incorrectly prints "[cli][error] Login required"
### 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

Previously [we have made an assumption](https://github.com/streamlink/streamlink/issues/4281#issuecomment-1008870858) that if there is no `fmp4` key in the `streamserver.php` json response, then the stream required login. However, looks like it's not (entirely) true. There is a stream currently going on at https://twitcasting.tv/twitcasting_jp and `streamserver.php` returns the same looking json as in that previous issue:

```json
{
  "movie": {
    "id": 762209471,
    "live": true
  },
  "hls": {
    "host": "twitcasting.tv",
    "proto": "https",
    "source": true
  }
}
```

but the stream doesn't show the "You must be logged in to watch" message when viewed in Chrome's Incognito, the stream actually plays just fine.

Checking the browser network requests, it requests `https://twitcasting.tv/twitcasting_jp/metastream.m3u8?mode=source&__n=1679193002006`, receiving back something like:

```
#EXTM3U


#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,CODECS="mp4a.40.2,avc1.4D401E"
https://202-218-193-225.twitcasting.tv/mpegts/762209471-2f3e7000786d37eb/ts-source-mp4.m3u8
```

and then it keeps periodically requesting `https://202-218-193-225.twitcasting.tv/mpegts/762209471-2f3e7000786d37eb/ts-source-mp4.m3u8`, getting something like:

```
#EXTM3U
#EXT-X-ALLOW-CACHE:NO
#EXT-X-VERSION:6
#EXT-X-MEDIA-SEQUENCE:1688
#EXT-X-TARGETDURATION:2
#EXT-X-KEY:METHOD=AES-128,URI="/livelicense.php?u=%2Fmpegts%2F762209471-2f3e7000786d37eb%2Fts-source-0",IV=0xc2f5b1e309ea3434736214b2b0bfe7b1
#EXT-X-MAP:URI="/mpegts/762209471-2f3e7000786d37eb/ts-source-0.mp4"
#EXTINF:2.002,
/mpegts/762209471-2f3e7000786d37eb/ts-source-1688.mp4
#EXTINF:2.002,
/mpegts/762209471-2f3e7000786d37eb/ts-source-1689.mp4
#EXTINF:2.002,
/mpegts/762209471-2f3e7000786d37eb/ts-source-1690.mp4
#EXTINF:2.002,
/mpegts/762209471-2f3e7000786d37eb/ts-source-1691.mp4
#EXTINF:2.002,
/mpegts/762209471-2f3e7000786d37eb/ts-source-1692.mp4
#EXTINF:2.002,
/mpegts/762209471-2f3e7000786d37eb/ts-source-1693.mp4
```

getting `livelicense.php`, `ts-source-0.mp4` and downloading the corresponding `ts-source-*.mp4` files.

It seems to be a pre-recorded stream that Twitcasting is playing, so perhaps that's why it's such an outlier and doesn't use WebSocket to communicate.

### Debug log

```text
$ streamlink --loglevel=debug 'https://twitcasting.tv/twitcasting_jp'
[cli][debug] OS:         Linux-5.10.0-20-amd64-x86_64-with-glibc2.31
[cli][debug] Python:     3.9.2
[cli][debug] Streamlink: 5.3.1
[cli][debug] Dependencies:
[cli][debug]  certifi: 2020.12.5
[cli][debug]  isodate: 0.6.0
[cli][debug]  lxml: 4.7.1
[cli][debug]  pycountry: 20.7.3
[cli][debug]  pycryptodome: 3.10.1
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.28.0
[cli][debug]  urllib3: 1.26.3
[cli][debug]  websocket-client: 1.2.3
[cli][debug] Arguments:
[cli][debug]  url=https://twitcasting.tv/twitcasting_jp
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin twitcasting for URL https://twitcasting.tv/twitcasting_jp
[plugins.twitcasting][debug] Live stream info: {'movie': {'id': 762209471, 'live': True}}
error: Login required
```



Discussion:

Resolving patch:
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
@@ -7,11 +7,14 @@
 import hashlib
 import logging
 import re
+import sys
+from time import time
 
 from streamlink.buffers import RingBuffer
-from streamlink.plugin import Plugin, PluginError, pluginargument, pluginmatcher
+from streamlink.plugin import Plugin, pluginargument, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.plugin.api.websocket import WebsocketClient
+from streamlink.stream.hls import HLSStream
 from streamlink.stream.stream import Stream, StreamIO
 from streamlink.utils.url import update_qsd
 
@@ -29,66 +32,103 @@
     help="Password for private Twitcasting streams.",
 )
 class TwitCasting(Plugin):
-    _STREAM_INFO_URL = "https://twitcasting.tv/streamserver.php?target={channel}&mode=client"
-    _STREAM_REAL_URL = "{proto}://{host}/ws.app/stream/{movie_id}/fmp4/bd/1/1500?mode={mode}"
-
-    _STREAM_INFO_SCHEMA = validate.Schema({
-        validate.optional("movie"): {
-            "id": int,
-            "live": bool,
-        },
-        validate.optional("fmp4"): {
-            "host": str,
-            "proto": str,
-            "source": bool,
-            "mobilesource": bool,
-        },
-    })
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.channel = self.match.group("channel")
+    _URL_API_STREAMSERVER = "https://twitcasting.tv/streamserver.php"
+    _URL_STREAM_HLS = "https://{host}/{channel}/metastream.m3u8"
+    _URL_STREAM_WEBSOCKET = "wss://{host}/ws.app/stream/{id}/fmp4/bd/1/1500?mode={mode}"
+
+    _STREAM_HOST_DEFAULT = "twitcasting.tv"
+
+    _WEBSOCKET_MODES = {
+        "main": "source",
+        "mobilesource": "mobilesource",
+        "base": None,
+    }
+
+    # prefer websocket streams over HLS streams due to latency reasons
+    _WEIGHTS = {
+        "main": sys.maxsize,
+        "mobilesource": sys.maxsize - 1,
+        "base": sys.maxsize - 2,
+    }
+
+    @classmethod
+    def stream_weight(cls, stream):
+        return (cls._WEIGHTS[stream], "none") if stream in cls._WEIGHTS else super().stream_weight(stream)
+
+    def _api_query_streamserver(self):
+        return self.session.http.get(
+            self._URL_API_STREAMSERVER,
+            params={
+                "target": self.match["channel"],
+                "mode": "client",
+            },
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    validate.optional("movie"): {
+                        "id": int,
+                        "live": bool,
+                    },
+                    # ignore llfmp4 websocket streams, as those seem to cause video/audio desync
+                    validate.optional("fmp4"): {
+                        "proto": str,
+                        "host": str,
+                        "source": bool,
+                        "mobilesource": bool,
+                    },
+                    # ignore the "dvr" HLS URL, as it results in a 403 response
+                    validate.optional("hls"): {
+                        "host": str,
+                        "proto": str,
+                        "source": bool,
+                    },
+                },
+                validate.union_get("movie", "fmp4", "hls"),
+            ),
+        )
 
-    def _get_streams(self):
-        stream_info = self._get_stream_info()
-        log.debug(f"Live stream info: {stream_info}")
+    def _get_streams_hls(self, data):
+        host = data.get("host") or self._STREAM_HOST_DEFAULT
+        url = self._URL_STREAM_HLS.format(host=host, channel=self.match["channel"])
+        params = {"__n": int(time() * 1000)}
 
-        if not stream_info.get("movie") or not stream_info["movie"]["live"]:
-            raise PluginError("The live stream is offline")
+        streams = [params]
+        if data.get("source"):
+            streams.append({"mode": "source", **params})
 
-        if not stream_info.get("fmp4"):
-            raise PluginError("Login required")
+        for params in streams:
+            yield from HLSStream.parse_variant_playlist(self.session, url, params=params).items()
 
-        # Keys are already validated by schema above
-        proto = stream_info["fmp4"]["proto"]
-        host = stream_info["fmp4"]["host"]
-        movie_id = stream_info["movie"]["id"]
+    def _get_streams_websocket(self, data):
+        host = data.get("host") or self._STREAM_HOST_DEFAULT
+        password = self.options.get("password")
 
-        if stream_info["fmp4"]["source"]:
-            mode = "main"  # High quality
-        elif stream_info["fmp4"]["mobilesource"]:
-            mode = "mobilesource"  # Medium quality
-        else:
-            mode = "base"  # Low quality
+        for mode, prop in self._WEBSOCKET_MODES.items():
+            if prop is not None and not data.get(prop):
+                continue
 
-        if (proto == "") or (host == "") or (not movie_id):
-            raise PluginError(f"No stream available for user {self.channel}")
+            url = self._URL_STREAM_WEBSOCKET.format(host=host, id=self.id, mode=mode)
+            if password is not None:
+                password_hash = hashlib.md5(password.encode()).hexdigest()
+                url = update_qsd(url, {"word": password_hash})
 
-        real_stream_url = self._STREAM_REAL_URL.format(proto=proto, host=host, movie_id=movie_id, mode=mode)
+            yield mode, TwitCastingStream(self.session, url)
 
-        password = self.options.get("password")
-        if password is not None:
-            password_hash = hashlib.md5(password.encode()).hexdigest()
-            real_stream_url = update_qsd(real_stream_url, {"word": password_hash})
-
-        log.debug(f"Real stream url: {real_stream_url}")
+    def _get_streams(self):
+        movie, websocket, hls = self._api_query_streamserver()
+        if not movie or not movie.get("id") or not movie.get("live"):
+            log.error(f"No live stream available for user {self.match['channel']}")
+            return
+        if not websocket and not hls:
+            log.error("Unsupported stream type")
+            return
 
-        return {mode: TwitCastingStream(session=self.session, url=real_stream_url)}
+        self.id = movie.get("id")
 
-    def _get_stream_info(self):
-        url = self._STREAM_INFO_URL.format(channel=self.channel)
-        res = self.session.http.get(url)
-        return self.session.http.json(res, schema=self._STREAM_INFO_SCHEMA)
+        if websocket:
+            yield from self._get_streams_websocket(websocket)
+        if hls:
+            yield from self._get_streams_hls(hls)
 
 
 class TwitCastingWsClient(WebsocketClient):


Test patch:
diff --git a/tests/plugins/test_twitcasting.py b/tests/plugins/test_twitcasting.py
--- a/tests/plugins/test_twitcasting.py
+++ b/tests/plugins/test_twitcasting.py
@@ -6,6 +6,5 @@ class TestPluginCanHandleUrlTwitCasting(PluginCanHandleUrl):
     __plugin__ = TwitCasting
 
     should_match = [
-        "https://twitcasting.tv/c:kk1992kkkk",
-        "https://twitcasting.tv/icchy8591/movie/566593738",
+        "https://twitcasting.tv/twitcasting_jp",
     ]