Repository: streamlink/streamlink

Issue:
plugins.tvp: No playable streams found on stream.tvp.pl
### 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

Most likely a recent change on the portal is causing this.

### Debug log

```text
$ streamlink stream.tvp.pl --loglevel=debug
[session][debug] Loading plugin: tvp
[cli][debug] OS:         Linux-6.11.0-21-generic-x86_64-with-glibc2.40
[cli][debug] Python:     3.12.7
[cli][debug] OpenSSL:    OpenSSL 3.3.1 4 Jun 2024
[cli][debug] Streamlink: 7.3.0
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.12.14
[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=stream.tvp.pl
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin tvp for URL stream.tvp.pl
[plugins.tvp][error] Could not find video ID
error: No playable streams found on this URL: stream.tvp.pl
```


Discussion:
> stream.tvp.pl

This address doesn't provide any content anymore, same as `tvpstream.vod.tvp.pl` (same matcher). All the live streams have moved to vod.tvp.pl. Their live-stream web player unfortunately doesn't update the URL when selecting a channel from the menu, which means that canonical live stream URLs need to be retrieved by clicking the info button on a specific show that's scheduled on a live stream.

Example

- https://vod.tvp.pl/live,1/tvp-info,399699/2025-05-19/serwis-info-wieczor,2084904
- https://vod.tvp.pl/live,1/tvp-info,399699/2025-05-20/sportowy-wieczor,2084906

Fixing the plugin requires updating the VOD matcher and handling live streams, because live streams use a different query string parameter compared to actual VODs. Requesting the HLS playlist URL from their API when the live stream ID was found works.

No idea if past shows are still available as VODs. That would require additional changes, because the availability of the show, which is the second ID in the URLs shown above, needs to be checked first.

There's also a validation schema issue with paid/inaccessible content that needs to be fixed.



Patch:
diff --git a/src/streamlink/plugins/tvp.py b/src/streamlink/plugins/tvp.py
index 2b6cca0da1c..9561915eaf3 100644
--- a/src/streamlink/plugins/tvp.py
+++ b/src/streamlink/plugins/tvp.py
@@ -1,8 +1,6 @@
 """
 $description Live TV channels and VODs from TVP, a Polish public, state-owned broadcaster.
-$url stream.tvp.pl
 $url vod.tvp.pl
-$url tvpstream.vod.tvp.pl
 $url tvp.info
 $url sport.tvp.pl
 $type live, vod
@@ -28,15 +26,15 @@
 
 
 @pluginmatcher(
-    name="default",
+    name="live",
     pattern=re.compile(
-        r"https?://(?:stream|tvpstream\.vod)\.tvp\.pl(?:/(?:\?channel_id=(?P<channel_id>\d+))?)?$",
+        r"https?://vod\.tvp\.pl/live,\d+/[^,]+,(?P<channel_id>\d+)(?:/.+,(?P<show_id>\d+)$)?",
     ),
 )
 @pluginmatcher(
     name="vod",
     pattern=re.compile(
-        r"https?://vod\.tvp\.pl/[^/]+/.+,(?P<vod_id>\d+)$",
+        r"https?://vod\.tvp\.pl/(?!live,)[^/]+/.+,(?P<vod_id>\d+)$",
     ),
 )
 @pluginmatcher(
@@ -55,6 +53,8 @@ class TVP(Plugin):
     _URL_VOD = "https://vod.tvp.pl/api/products/{vod_id}/videos/playlist"
     _URL_INFO_API_TOKEN = "https://api.tvp.pl/tokenizer/token/{token}"
     _URL_INFO_API_NEWS = "https://www.tvp.info/api/info/news?device=www&id={id}"
+    _URL_INFO_API_BLOCK = "https://www.tvp.info/api/info/block?device=www&id={id}"
+    _URL_INFO_API_BLOCK_LIST = "https://www.tvp.info/api/info/block/list?device=www&id={id}"
 
     def _get_formats_from_api(self, token):
         is_geo_blocked, self.title, formats = self.session.http.get(
@@ -88,68 +88,18 @@ def _get_formats_from_api(self, token):
             if mime_type == "application/dash+xml":
                 yield from DASHStream.parse_manifest(self.session, url).items()
 
-    def _get_video_id(self, channel_id: str | None):
-        items: list[tuple[int, int]] = self.session.http.get(
-            self.url,
-            headers={
-                # required, otherwise the next request for retrieving the HLS URL will be aborted by the server
-                "Connection": "close",
-            },
-            schema=validate.Schema(
-                validate.regex(re.compile(r"window\.__channels\s*=\s*(?P<json>\[.+?])\s*;", re.DOTALL)),
-                validate.none_or_all(
-                    validate.get("json"),
-                    validate.parse_json(),
-                    [
-                        validate.all(
-                            {
-                                "id": int,
-                                "items": validate.none_or_all(
-                                    [
-                                        {
-                                            "video_id": int,
-                                        },
-                                    ],
-                                    validate.get((0, "video_id")),
-                                ),
-                            },
-                            validate.union_get("id", "items"),
-                        ),
-                    ],
-                ),
-            ),
-        )
-
-        if channel_id is not None:
-            channel_id_number = int(channel_id)
-            try:
-                return next(item[1] for item in items if item[0] == channel_id_number)
-            except StopIteration:
-                pass
-
-        return items[0][1] if items else None
-
-    def _get_live(self, channel_id: str | None):
-        self.id = self._get_video_id(channel_id)
-        if not self.id:
-            log.error("Could not find video ID")
-            return
-
-        log.debug(f"video ID: {self.id}")
-        yield from self._get_formats_from_api(self.id)
-
-    def _get_vod(self, vod_id):
+    def _get_vod(self, vod_id: str, video_type: str = "MOVIE"):
         data = self.session.http.get(
             self._URL_VOD.format(vod_id=vod_id),
             params={
                 "platform": "BROWSER",
-                "videoType": "MOVIE",
+                "videoType": video_type,
             },
             acceptable_status=(200, 403),
             schema=validate.Schema(
                 validate.parse_json(),
                 validate.any(
-                    {"code": "GEOIP_FILTER_FAILED"},
+                    {"code": str},
                     validate.all(
                         {
                             "sources": {
@@ -170,10 +120,14 @@ def _get_vod(self, vod_id):
             log.error("The content is not available in your region")
             return
 
+        if data.get("code") == "ITEM_NOT_PAID":
+            log.error("The content is not accessible")
+            return
+
         if data.get("HLS"):
             return HLSStream.parse_variant_playlist(self.session, data["HLS"][0]["src"])
 
-    def _get_tvp_info_vod(self):
+    def _get_tvp_info(self):
         data = self.session.http.get(
             self.url,
             schema=validate.Schema(
@@ -185,7 +139,7 @@ def _get_tvp_info_vod(self):
                     validate.get("json"),
                     validate.parse_json(),
                     {
-                        "type": validate.any("VIDEO", "NEWS"),
+                        "type": str,
                         "id": int,
                     },
                     validate.union_get("type", "id"),
@@ -213,17 +167,69 @@ def _get_tvp_info_vod(self):
                     validate.union_get("_id", "title"),
                 ),
             )
+        elif vod_type == "SG":
+            self.id = self.session.http.get(
+                self._URL_INFO_API_BLOCK_LIST.format(id=self.id),
+                schema=validate.Schema(
+                    validate.parse_json(),
+                    {
+                        "data": {
+                            "items": validate.all(
+                                [
+                                    {
+                                        "_id": int,
+                                        "type": str,
+                                    },
+                                ],
+                                validate.filter(lambda obj: obj["type"] == "LIVE"),
+                                validate.get(0),
+                                validate.none_or_all(validate.get("_id")),
+                            ),
+                        },
+                    },
+                    validate.get(("data", "items")),
+                ),
+            )
+            if not self.id:
+                return
+            self.id = self.session.http.get(
+                self._URL_INFO_API_BLOCK.format(id=self.id),
+                schema=validate.Schema(
+                    validate.parse_json(),
+                    {
+                        "data": {
+                            "live_items": validate.all(
+                                [
+                                    {
+                                        "_id": int,
+                                        "type": str,
+                                    },
+                                ],
+                                validate.filter(lambda obj: obj["type"] == "VIDEO"),
+                                validate.get(0),
+                                validate.none_or_all(validate.get("_id")),
+                            ),
+                        },
+                    },
+                    validate.get(("data", "live_items")),
+                ),
+            )
+            if not self.id:
+                return
 
         yield from self._get_formats_from_api(self.id)
 
     def _get_streams(self):
-        if self.matches["tvp_info"]:
-            return self._get_tvp_info_vod()
+        if self.matches["live"]:
+            return self._get_vod(self.match["channel_id"], "LIVE")
         if self.matches["vod"]:
-            return self._get_vod(self.match["vod_id"])
+            return self._get_vod(self.match["vod_id"], "MOVIE")
+        if self.matches["tvp_info"]:
+            return self._get_tvp_info()
         if self.matches["tvp_sport"]:
             return self._get_formats_from_api(self.match["stream_id"])
-        return self._get_live(self.match["channel_id"])
+
+        return None
 
 
 __plugin__ = TVP


Test patch:
diff --git a/tests/plugins/test_tvp.py b/tests/plugins/test_tvp.py
index 36e1a9f7acc..1592e8e67a5 100644
--- a/tests/plugins/test_tvp.py
+++ b/tests/plugins/test_tvp.py
@@ -7,30 +7,40 @@ class TestPluginCanHandleUrlTVP(PluginCanHandleUrl):
 
     should_match_groups = [
         # live
-        (("default", "https://stream.tvp.pl"), {}),
-        (("default", "https://stream.tvp.pl/"), {}),
-        (("default", "https://stream.tvp.pl/?channel_id=1455"), {"channel_id": "1455"}),  # TVP info
-        (("default", "https://stream.tvp.pl/?channel_id=51656487"), {"channel_id": "51656487"}),  # TVP world
-        # old live URLs
-        (("default", "https://tvpstream.vod.tvp.pl"), {}),
-        (("default", "https://tvpstream.vod.tvp.pl/"), {}),
-        (("default", "https://tvpstream.vod.tvp.pl/?channel_id=1455"), {"channel_id": "1455"}),
-        (("default", "https://tvpstream.vod.tvp.pl/?channel_id=51656487"), {"channel_id": "51656487"}),
+        (
+            ("live", "https://vod.tvp.pl/live,1/tvp-info,399699/2025-05-20/serwis-info-dzien,2086908"),
+            {"channel_id": "399699", "show_id": "2086908"},
+        ),
+        (
+            ("live", "https://vod.tvp.pl/live,1/tvp-world,399731/2025-05-20/interview-from-vilnius---ep-146,2088070"),
+            {"channel_id": "399731", "show_id": "2088070"},
+        ),
         # VOD
         (
-            ("vod", "https://vod.tvp.pl/filmy-dokumentalne,163/krolowa-wladczyni-i-matka,284734"),
-            {"vod_id": "284734"},
+            ("vod", "https://vod.tvp.pl/filmy-fabularne,136/podroz-ksiecia,852411"),
+            {"vod_id": "852411"},
+        ),
+        (
+            ("vod", "https://vod.tvp.pl/filmy-fabularne,136/bambi-opowiesc-lesna,2053604"),
+            {"vod_id": "2053604"},
         ),
         # VOD episode
         (
-            ("vod", "https://vod.tvp.pl/programy,88/eurowizja-odcinki,276170/odcinek-4,S05E04,1302944"),
-            {"vod_id": "1302944"},
+            ("vod", "https://vod.tvp.pl/seriale,18/dzikie-korytarze-odcinki,2092682/odcinek-1,S01E01,2092684"),
+            {"vod_id": "2092684"},
         ),
         # tvp.info
         (
             (
                 "tvp_info",
-                "https://www.tvp.info/78213165/euro-2024-polska-holandia-12-bartosz-salamon-czuje-sie-winny-za-te-porazke-wideo",
+                "https://www.tvp.info/ogladaj-na-zywo",
+            ),
+            {},
+        ),
+        (
+            (
+                "tvp_info",
+                "https://www.tvp.info/86792059/kot-przemytnik-utknal-na-wieziennym-ogrodzeniu",
             ),
             {},
         ),
@@ -38,13 +48,15 @@ class TestPluginCanHandleUrlTVP(PluginCanHandleUrl):
         (
             (
                 "tvp_sport",
-                "https://sport.tvp.pl/79514191/paryz-2024-magda-linette-mirra-andriejewa-1-runda-na-zywo-transmisja-online-live-stream-igrzyska-olimpijskie-2872024",
+                "https://sport.tvp.pl/86453177/snooker-polska-liga-snookera-top16-polfinal-i-final-zapis",
             ),
-            {"stream_id": "79514191"},
+            {"stream_id": "86453177"},
         ),
     ]
 
     should_not_match = [
         "https://tvp.pl/",
+        "https://stream.tvp.pl/",
         "https://vod.tvp.pl/",
+        "https://tvpstream.vod.tvp.pl/",
     ]