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:
bastimeyer: @drehtisch
Please check this PR and see if you find any channels that may not be working correctly (I can't check everything)
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback
drehtisch: @bastimeyer Seems to be working fine. Worked for 2 out of 3 lives i tried. Wasn't different before the change, not understanding what is different between the lives that work and the ones that don't but that isn't the point. 

Some other feedback, about the pull-request-feedback documentation. I had to run `pip install versioningit` to get your pr version running. 

Thank you very much for your fix. 
bastimeyer: > Seems to be working fine. Worked for 2 out of 3 lives i tried. Wasn't different before the change, not understanding what is different between the lives that work and the ones that don't but that isn't the point.

What is that supposed to mean? I am looking for feedback with **actual** data (which you btw also avoided in #6380), so the plugin can be fully fixed. The current implementation on `master` is broken, so I don't know what you mean with "before the change" either.
drehtisch: > > Seems to be working fine. Worked for 2 out of 3 lives i tried. Wasn't different before the change, not understanding what is different between the lives that work and the ones that don't but that isn't the point.
> 
> What is that supposed to mean? I am looking for feedback with **actual** data (which you btw also avoided in #6380), so the plugin can be fully fixed. The current implementation on `master` is broken, so I don't know what you mean with "before the change" either.

When the version in master still worked before your fix, some lives worked and others didn't. As none of the lives i used this with weren't online, were this usually worked fine until yesterday when it broke, i tested it with random lives from the live feed. Where 2 worked and 1 didn't. 

If you want i can repeat the test, possible also with more lives and then send you the names with which it worked and with which it didn't. I don't know if they will still be online when you try to reproduce my test. 

drehtisch: streamlink https://www.tiktok.com/@hofzeitprojekt/live best --player-external-http --player-external-http-port 12345 --loglevel=debug
[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+6.g9026a544
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.12.14
[cli][debug]  exceptiongroup: 1.2.2
[cli][debug]  isodate: 0.7.2
[cli][debug]  lxml: 5.3.0
[cli][debug]  pycountry: 24.6.1
[cli][debug]  pycryptodome: 3.21.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.32.3
[cli][debug]  trio: 0.28.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  urllib3: 2.3.0
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.tiktok.com/@hofzeitprojekt/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/@hofzeitprojekt/live
[plugins.tiktok][error] Error while querying API
error: No playable streams found on this URL: https://www.tiktok.com/@hofzeitprojekt/live

This does currently not work. 
drehtisch: While this does work, and it is not apparent to me why this works and the other one doesn't.
streamlink https://www.tiktok.com/@svensupertramp/live best --player-external-http --player-external-http-port 12345 --loglevel=debug
[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+6.g9026a544
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.12.14
[cli][debug]  exceptiongroup: 1.2.2
[cli][debug]  isodate: 0.7.2
[cli][debug]  lxml: 5.3.0
[cli][debug]  pycountry: 24.6.1
[cli][debug]  pycryptodome: 3.21.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.32.3
[cli][debug]  trio: 0.28.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  urllib3: 2.3.0
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.tiktok.com/@svensupertramp/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/@svensupertramp/live
[cli][info] Available streams: ao, ld (worst), sd, hd, uhd, origin (best)
[cli][info] Starting server, access with one of:
[cli][info]  http://127.0.0.1:12345/
(Additional ip addresses redacted - can be provided if needed for some reason)

drehtisch: > streamlink https://www.tiktok.com/@hofzeitprojekt/live best --player-external-http --player-external-http-port 12345 --loglevel=debug [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+6.g9026a544 [cli][debug] Dependencies: [cli][debug] certifi: 2024.12.14 [cli][debug] exceptiongroup: 1.2.2 [cli][debug] isodate: 0.7.2 [cli][debug] lxml: 5.3.0 [cli][debug] pycountry: 24.6.1 [cli][debug] pycryptodome: 3.21.0 [cli][debug] PySocks: 1.7.1 [cli][debug] requests: 2.32.3 [cli][debug] trio: 0.28.0 [cli][debug] trio-websocket: 0.11.1 [cli][debug] urllib3: 2.3.0 [cli][debug] websocket-client: 1.8.0 [cli][debug] Arguments: [cli][debug] url=https://www.tiktok.com/@hofzeitprojekt/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/@hofzeitprojekt/live [plugins.tiktok][error] Error while querying API error: No playable streams found on this URL: https://www.tiktok.com/@hofzeitprojekt/live
> 
> This does currently not work.

![image](https://github.com/user-attachments/assets/f029ca1c-db06-4f67-bb10-c4931ba26b48)

Something i just found out, there are lives that are not accessible without being logged in, and this might be the reason why it doesn't work. 

bastimeyer: Thanks for the follow-up data.

Streams that require a login for some reason still have a success response type, but don't include the `streamId` and `streamData` attributes:

```sh
curl 'https://www.tiktok.com/api-live/user/room?aid=1988&sourceType=54&uniqueId=hofzeitprojekt' \
  -s \
  -H 'accept: */*' \
  -H 'accept-language: en-US,en;q=0.9' \
  -H 'referer: https://www.tiktok.com/@hofzeitproject/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
```
```json
{
  "data": {
    "user": {
      "avatarLarger": "https://p16-sign-useast2a.tiktokcdn.com/tos-useast2a-avt-0068-euttp/4076a1904d416c0589b481a2759cce1e~c5_1080x1080.webp?lk3s=a5d48078&nonce=5132&refresh_token=8ca1a5762aad8fc0d0ca22d027a0ef5b&x-expires=1736262000&x-signature=sYGceSXal7zLfYUh1xnHyGJqX9g%3D&shp=a5d48078&shcp=fdd36af4",
      "avatarMedium": "https://p16-sign-useast2a.tiktokcdn.com/tos-useast2a-avt-0068-euttp/4076a1904d416c0589b481a2759cce1e~c5_720x720.webp?lk3s=a5d48078&nonce=39010&refresh_token=6de48f196168b53e57d1413d269018f7&x-expires=1736262000&x-signature=i5QrL4QKTxNAiw5uCBR4vu8lSd8%3D&shp=a5d48078&shcp=fdd36af4",
      "avatarThumb": "https://p16-sign-useast2a.tiktokcdn.com/tos-useast2a-avt-0068-euttp/4076a1904d416c0589b481a2759cce1e~c5_100x100.webp?lk3s=a5d48078&nonce=75820&refresh_token=3397dd97d99a5dca42dd376a39e1a68b&x-expires=1736262000&x-signature=1GQE2zOUZxoRJUBHDQ9Iz4TBtiQ%3D&shp=a5d48078&shcp=fdd36af4",
      "id": "7049376587855037445",
      "nickname": "Hofzeit Projekt",
      "secUid": "MS4wLjABAAAASkhNIwWSfT0VqvSzBELgKKTQMFAiU6dMH-D-iGUukquhPKk4L1l8VIpruUYmG61w",
      "secret": false,
      "uniqueId": "hofzeitprojekt",
      "verified": false,
      "roomId": "7456412520824310550",
      "signature": "https://linktr.ee/hofzeitprojekt",
      "status": 2,
      "followStatus": 0
    },
    "stats": {
      "followingCount": 2255,
      "followerCount": 20648
    },
    "liveRoom": {
      "coverUrl": "https://p16-sign-useast2a.tiktokcdn.com/tos-useast2a-avt-0068-euttp/4076a1904d416c0589b481a2759cce1e~c5_720x720.webp?lk3s=a5d48078&nonce=36871&refresh_token=6d065495b8669eb65412978b7b9ad24f&x-expires=1736262000&x-signature=i5QrL4QKTxNAiw5uCBR4vu8lSd8%3D&shp=a5d48078&shcp=fdd36af4",
      "squareCoverImg": "",
      "title": "",
      "startTime": 1736081407,
      "status": 2,
      "paidEvent": {
        "event_id": 0,
        "paid_type": 0
      },
      "liveSubOnly": 0,
      "liveRoomMode": 0,
      "gameTagId": 0,
      "liveRoomStats": {
        "userCount": 1187
      }
    }
  },
  "message": "",
  "extra": {
    "id": "202501051559012BF397F177959BD16759"
  },
  "statusCode": 0
}
```

The plugin now handles this correctly and shows the inaccessible error message:
```
$ streamlink tiktok.com/@hofzeitprojekt
[cli][info] Found matching plugin tiktok for URL tiktok.com/@hofzeitprojekt
[plugins.tiktok][error] The stream is inaccessible
error: No playable streams found on this URL: tiktok.com/@hofzeitprojekt
```
bastimeyer: Btw, if someone wants support for authentication, then they'll have to submit a pull request with the necessary changes. I am not interested in this myself at all, or really anything with tiktok/douyin/bytedance etc...

Merging now...

Patch:
diff --git a/src/streamlink/plugins/tiktok.py b/src/streamlink/plugins/tiktok.py
index 52fb554d..1c65d06a 100644
--- a/src/streamlink/plugins/tiktok.py
+++ b/src/streamlink/plugins/tiktok.py
@@ -11,6 +11,7 @@ from __future__ import annotations
 
 import logging
 import re
+import sys
 
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
@@ -21,16 +22,17 @@ log = logging.getLogger(__name__)
 
 
 @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 @@ class TikTok(Plugin):
 
         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 56701fb9..5de8b08b 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 = [