Repository: streamlink/streamlink

Issue:
plugins.tiktok: Could not find room ID
### 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

[cli][info] Your Streamlink version (7.1.1) is up to date!

### Description

From my analysis using the browser dev tools it appears to me that tiktok changed the data provided on the website of a live. The script with id "SIGI_STATE" which should contain the roomInfo / roomId still contains the same data structure, but the roomId property stays empty ("") on multiple lives that i have checked. Resulting in the "Could not find room ID" error message and therefore rendering the plugin currently unusable for me. 

It stopped working today morning (UTC+1) and checked it at different times throughout the day and it seems to not be a temporary issue.

Do you have any idea what to do about that? 

Thanks in advance.

### Debug log

```text
streamlink https://www.tiktok.com/@{user}/live best --player-external-http --player-external-http-port 12345 --loglevel=debug
[session][debug] Loading plugin: tiktok
[cli][debug] OS:         Linux-6.8.0-76060800daily20240311-generic-x86_64-with-glibc2.35
[cli][debug] Python:     3.10.12
[cli][debug] OpenSSL:    OpenSSL 3.0.2 15 Mar 2022
[cli][debug] Streamlink: 7.1.1
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.2.2
[cli][debug]  exceptiongroup: 1.2.1
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 5.2.1
[cli][debug]  pycountry: 23.12.11
[cli][debug]  pycryptodome: 3.20.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.25.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  urllib3: 2.2.1
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.tiktok.com/@{user}/live
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player-external-http=True
[cli][debug]  --player-external-http-port=12345
[cli][info] Found matching plugin tiktok for URL https://www.tiktok.com/{user}/live
[plugins.tiktok][error] Could not find room ID
error: No playable streams found on this URL: https://www.tiktok.com/{user}/live
```


Discussion:
Had a quick look at the plugin and website. Apparently the website is using a different API endpoint when retrieving the stream data. The HTTP request doesn't require a numeric `room_id`, but a `uniqueID` parameter instead, which is the channel name. It also includes a `_signature` and an `msToken` parameter, but those seem to be optional, in addition to lots of other optional request parameters. The one that doesn't seem to be optional is the `sourceType=54` param+value, as well as the `aid=1988` param+value, which is also used on the other API endpoints the plugin currently implements.

Here's an example that returns all stream qualities (JSON data within the JSON response):
```sh
$ curl 'https://www.tiktok.com/api-live/user/room?aid=1988&sourceType=54&uniqueId=CHANNELNAME' \
    -s \
    -H 'accept: */*' \
    -H 'accept-language: en-US,en;q=0.9' \
    -H 'referer: https://www.tiktok.com/@CHANNELNAME/live' \
    -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' \
    | jq -r .data.liveRoom.streamData.pull_data.stream_data \
    | jq
```
I've rewritten the plugin and got live streams working again. I'll open a PR for this tomorrow.



Patch:
diff --git a/src/streamlink/plugins/tiktok.py b/src/streamlink/plugins/tiktok.py
index 52fb554dad1..1c65d06aa2f 100644
--- a/src/streamlink/plugins/tiktok.py
+++ b/src/streamlink/plugins/tiktok.py
@@ -11,6 +11,7 @@
 
 import logging
 import re
+import sys
 
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
@@ -21,16 +22,17 @@
 
 
 @pluginmatcher(
-    re.compile(
-        r"https?://(?:www\.)?tiktok\.com/@(?P<channel>[^/?]+)",
-    ),
+    name="live",
+    pattern=re.compile(r"https?://(?:www\.)?tiktok\.com/@(?P<channel>[^/?]+)(?:$|/live)"),
+)
+@pluginmatcher(
+    name="video",
+    pattern=re.compile(r"https?://(?:www\.)?tiktok\.com/@(?P<channel>[^/?]+)/video/(?P<id>\d+)"),
 )
 class TikTok(Plugin):
     QUALITY_WEIGHTS: dict[str, int] = {}
 
-    _URL_WEB_LIVE = "https://www.tiktok.com/@{channel}/live"
-    _URL_API_LIVE_DETAIL = "https://www.tiktok.com/api/live/detail/?aid=1988&roomID={room_id}"
-    _URL_WEBCAST_ROOM_INFO = "https://webcast.tiktok.com/webcast/room/info/?aid=1988&room_id={room_id}"
+    _URL_API_LIVE = "https://www.tiktok.com/api-live/user/room"
 
     _STATUS_OFFLINE = 4
 
@@ -42,114 +44,187 @@ def stream_weight(cls, key):
 
         return super().stream_weight(key)
 
-    def _get_streams(self):
-        self.id = self.session.http.get(
-            self._URL_WEB_LIVE.format(channel=self.match["channel"]),
-            allow_redirects=False,
+    def _query_api(self, url, **kwargs):
+        schema = kwargs.pop("schema")
+
+        success, data = self.session.http.get(
+            url,
             schema=validate.Schema(
-                validate.parse_html(),
+                validate.parse_json(),
                 validate.any(
                     validate.all(
-                        validate.xml_xpath_string(
-                            ".//head/meta[@property='al:android:url'][contains(@content,'live?room_id=')]/@content",
-                        ),
-                        str,
-                        re.compile(r"room_id=(\d+)"),
-                        validate.get(1),
+                        {
+                            "statusCode": 0,
+                            "data": schema,
+                        },
+                        validate.transform(lambda data: (True, data["data"])),
                     ),
                     validate.all(
-                        validate.xml_xpath_string(
-                            ".//script[@type='application/json'][@id='SIGI_STATE'][1]/text()",
-                        ),
-                        str,
-                        validate.parse_json(),
                         {
-                            "LiveRoom": {
-                                "liveRoomUserInfo": {
-                                    "user": {
-                                        "roomId": str,
-                                    },
-                                },
-                            },
+                            "message": str,
                         },
-                        validate.get(("LiveRoom", "liveRoomUserInfo", "user", "roomId")),
+                        validate.transform(lambda data: (False, data["message"])),
                     ),
-                    validate.transform(lambda *_: None),
                 ),
             ),
+            **kwargs,
         )
-        if not self.id:
-            log.error("Could not find room ID")
-            return
 
-        log.debug(f"room_id={self.id}")
-
-        live_detail = self.session.http.get(
-            self._URL_API_LIVE_DETAIL.format(room_id=self.id),
+        if not success:
+            log.error(data or "Error while querying API")
+            return None
+
+        return data
+
+    def _get_streams_live(self):
+        self.author = self.match["channel"]
+
+        data = self._query_api(
+            self._URL_API_LIVE,
+            params={
+                "aid": 1988,
+                "sourceType": 54,
+                "uniqueId": self.author,
+            },
+            headers={
+                "Referer": self.url,
+            },
             schema=validate.Schema(
-                validate.parse_json(),
                 {
-                    "status_code": 0,
-                    "LiveRoomInfo": {
+                    "liveRoom": {
                         "status": int,
+                        validate.optional("streamId"): str,
                         "title": str,
-                        "ownerInfo": {"nickname": str},
+                        validate.optional("streamData"): validate.all(
+                            {
+                                "pull_data": {
+                                    "stream_data": validate.all(
+                                        str,
+                                        validate.parse_json(),
+                                        {
+                                            "data": dict,
+                                        },
+                                        validate.get("data"),
+                                        {
+                                            str: validate.all(
+                                                {
+                                                    "main": {
+                                                        "flv": validate.url(),
+                                                        "sdk_params": validate.all(
+                                                            validate.parse_json(),
+                                                            {
+                                                                "vbitrate": int,
+                                                            },
+                                                        ),
+                                                    },
+                                                },
+                                                validate.union_get(
+                                                    ("main", "flv"),
+                                                    ("main", "sdk_params", "vbitrate"),
+                                                ),
+                                            ),
+                                        },
+                                    ),
+                                },
+                            },
+                            validate.get(("pull_data", "stream_data")),
+                        ),
                     },
                 },
-                validate.get("LiveRoomInfo"),
+                validate.get("liveRoom"),
                 validate.union_get(
                     "status",
-                    ("ownerInfo", "nickname"),
+                    "streamId",
                     "title",
+                    "streamData",
                 ),
             ),
         )
-        status, self.author, self.title = live_detail
+        if not data:
+            return
+
+        status, self.id, self.title, stream_data = data
         if status == self._STATUS_OFFLINE:
             log.info("The channel is currently offline")
             return
 
-        streams = self.session.http.get(
-            self._URL_WEBCAST_ROOM_INFO.format(room_id=self.id),
+        if not stream_data:
+            log.error("The stream is inaccessible")
+            return
+
+        streams = {}
+        for name, (url, vbitrate) in stream_data.items():
+            self.QUALITY_WEIGHTS[name] = vbitrate
+            streams[name] = HTTPStream(self.session, url)
+
+        self.QUALITY_WEIGHTS["origin"] = sys.maxsize
+
+        return streams
+
+    def _get_streams_video(self):
+        self.id = self.match["id"]
+
+        data = self.session.http.get(
+            self.url,
             schema=validate.Schema(
-                validate.parse_json(),
-                {"data": {validate.optional("stream_url"): {"live_core_sdk_data": {"pull_data": {"stream_data": str}}}}},
-                validate.get(("data", "stream_url")),
+                validate.parse_html(),
+                validate.xml_xpath_string(
+                    ".//script[@type='application/json'][@id='__UNIVERSAL_DATA_FOR_REHYDRATION__'][1]/text()",
+                ),
                 validate.none_or_all(
-                    validate.get(("live_core_sdk_data", "pull_data", "stream_data")),
                     validate.parse_json(),
                     {
-                        "data": {
-                            str: validate.all(
-                                {
-                                    "main": {
-                                        "flv": validate.url(),
-                                        "sdk_params": validate.all(
-                                            validate.parse_json(),
-                                            {
-                                                "vbitrate": int,
+                        "__DEFAULT_SCOPE__": {
+                            "webapp.video-detail": validate.any(
+                                validate.all(
+                                    {
+                                        "statusCode": 0,
+                                        "itemInfo": {
+                                            "itemStruct": {
+                                                "author": {
+                                                    "uniqueId": str,
+                                                },
+                                                "video": {
+                                                    "downloadAddr": validate.url(),
+                                                },
                                             },
-                                        ),
+                                        },
                                     },
-                                },
-                                validate.union_get(
-                                    ("main", "flv"),
-                                    ("main", "sdk_params", "vbitrate"),
+                                    validate.get(("itemInfo", "itemStruct")),
+                                    validate.union_get(
+                                        ("author", "uniqueId"),
+                                        ("video", "downloadAddr"),
+                                    ),
+                                    validate.transform(lambda data: (True, data)),
+                                ),
+                                validate.all(
+                                    {
+                                        "statusMsg": str,
+                                    },
+                                    validate.transform(lambda data: (False, data["statusMsg"])),
                                 ),
                             ),
                         },
                     },
-                    validate.get("data"),
+                    validate.get(("__DEFAULT_SCOPE__", "webapp.video-detail")),
                 ),
             ),
         )
-        if not streams:
-            log.error("The stream is inaccessible")
+        if not data:
+            return
+        if not data[0]:
+            log.error(data[1] or "The video is inaccessible")
             return
 
-        for name, (url, vbitrate) in streams.items():
-            self.QUALITY_WEIGHTS[name] = vbitrate
-            yield name, HTTPStream(self.session, url)
+        self.author, url = data[1]
+
+        return {"video": HTTPStream(self.session, url)}
+
+    def _get_streams(self):
+        if self.matches["live"]:
+            return self._get_streams_live()
+        elif self.matches["video"]:
+            return self._get_streams_video()
 
 
 __plugin__ = TikTok


Test patch:
diff --git a/tests/plugins/test_tiktok.py b/tests/plugins/test_tiktok.py
index 56701fb9fef..5de8b08b64a 100644
--- a/tests/plugins/test_tiktok.py
+++ b/tests/plugins/test_tiktok.py
@@ -6,8 +6,9 @@ class TestPluginCanHandleUrlTikTok(PluginCanHandleUrl):
     __plugin__ = TikTok
 
     should_match_groups = [
-        ("https://www.tiktok.com/@CHANNEL", {"channel": "CHANNEL"}),
-        ("https://www.tiktok.com/@CHANNEL/live", {"channel": "CHANNEL"}),
+        (("live", "https://www.tiktok.com/@LIVE"), {"channel": "LIVE"}),
+        (("live", "https://www.tiktok.com/@LIVE/live"), {"channel": "LIVE"}),
+        (("video", "https://www.tiktok.com/@VIDEO/video/0123456789"), {"channel": "VIDEO", "id": "0123456789"}),
     ]
 
     should_not_match = [