Repository: streamlink/streamlink

Issue:
plugins.nos: No playable streams found on this URL
### 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)

### Streamlink version

streamlink 6.1.0

### Description

None of the livestreams on NOS.nl seem to work (anymore).

### Debug log

```text
streamlink --loglevel=debug https://nos.nl/livestream/2489911

[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.11.5
[cli][debug] Streamlink: 6.1.0
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.7.22
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.3
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.18.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.22.2
[cli][debug]  trio-websocket: 0.10.3
[cli][debug]  typing-extensions: 4.7.1
[cli][debug]  urllib3: 2.0.4
[cli][debug]  websocket-client: 1.6.1
[cli][debug] Arguments:
[cli][debug]  url=https://nos.nl/livestream/2489911
[cli][debug]  --config=['D:\\Scoop\\apps\\streamlink\\current\\config']
[cli][debug]  --loglevel=debug
[cli][debug]  --ffmpeg-ffmpeg=D:\Scoop\apps\streamlink\current\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin nos for URL https://nos.nl/livestream/2489911
error: No playable streams found on this URL: https://nos.nl/livestream/2489911
```


Discussion:
ltguillaume: Can confirm working:
- https://nos.nl/live
- https://nos.nl/livestream/...
- https://nos.nl/video/...
- https://nos.nl/collectie/*/video/...

So all is well now, thank you!

At first, I thought that perhaps https://nos.nl/collectie/... would have iterated the videos available and play them in order, but that was just an assumption.

Patch:
diff --git a/src/streamlink/plugins/nos.py b/src/streamlink/plugins/nos.py
index 4026e813..1730a9da 100644
--- a/src/streamlink/plugins/nos.py
+++ b/src/streamlink/plugins/nos.py
@@ -2,6 +2,7 @@
 $description Live TV channels and video on-demand service from NOS, a Dutch public, state-owned broadcaster.
 $url nos.nl
 $type live, vod
+$metadata id
 $metadata title
 $region Netherlands
 """
@@ -9,7 +10,7 @@ $region Netherlands
 import logging
 import re
 
-from streamlink.plugin import Plugin, PluginError, pluginmatcher
+from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
 
@@ -18,86 +19,43 @@ log = logging.getLogger(__name__)
 
 
 @pluginmatcher(re.compile(
-    r"https?://(?:\w+\.)?nos\.nl/(?:livestream|collectie|video|uitzendingen)",
+    r"https?://(?:\w+\.)?nos\.nl/(?:live|video|collectie)",
 ))
 class NOS(Plugin):
-    _msg_live_offline = "This livestream is offline."
-    vod_keys = {
-        "pages/Collection/Video/Video": "item",
-        "pages/Video/Video": "video",
-    }
-
     def _get_streams(self):
-        try:
-            scripts = self.session.http.get(self.url, schema=validate.Schema(
+        data = self.session.http.get(
+            self.url,
+            schema=validate.Schema(
                 validate.parse_html(),
-                validate.xml_findall(".//script[@type='application/json'][@data-ssr-name]"),
-                [
-                    validate.union((
-                        validate.get("data-ssr-name"),
-                        validate.all(
-                            validate.getattr("text"),
-                            validate.parse_json(),
-                        ),
-                    )),
-                ],
-            ))
-        except PluginError:
-            log.error("Could not find any stream data")
-            return
-
-        for _data_ssr_name, _data_json in scripts:
-            video_url = None
-            log.trace(f"Found _data_ssr_name={_data_ssr_name}")
-
-            if _data_ssr_name == "pages/Broadcasts/Broadcasts":
-                self.title, video_url, is_live = validate.Schema(
-                    {"currentLivestream": {
-                        "is_live": bool,
-                        "title": str,
-                        "stream": validate.url(),
-                    }},
-                    validate.get("currentLivestream"),
-                    validate.union_get("title", "stream", "is_live"),
-                ).validate(_data_json)
-                if not is_live:
-                    log.error(self._msg_live_offline)
-                    continue
-
-            elif _data_ssr_name == "pages/Livestream/Livestream":
-                self.title, video_url, is_live = validate.Schema(
+                validate.xml_xpath_string(".//script[@type='application/ld+json'][1]/text()"),
+                validate.none_or_all(
+                    validate.parse_json(),
                     {
-                        "streamIsLive": bool,
-                        "title": str,
-                        "stream": validate.url(),
+                        "@type": "VideoObject",
+                        "encodingFormat": "application/vnd.apple.mpegurl",
+                        "contentUrl": validate.url(),
+                        "identifier": validate.any(int, str),
+                        "name": str,
                     },
-                    validate.union_get("title", "stream", "streamIsLive"),
-                ).validate(_data_json)
-                if not is_live:
-                    log.error(self._msg_live_offline)
-                    continue
+                    validate.union_get(
+                        "contentUrl",
+                        "identifier",
+                        "name",
+                    ),
+                ),
+            ),
+        )
+        if not data:
+            return
 
-            elif _data_ssr_name in self.vod_keys.keys():
-                _key = self.vod_keys[_data_ssr_name]
-                self.title, video_url = validate.Schema(
-                    {_key: {
-                        "title": str,
-                        "aspect_ratios": {
-                            "profiles": validate.all(
-                                [{
-                                    "name": str,
-                                    "url": validate.url(),
-                                }],
-                                validate.filter(lambda p: p["name"] == "hls_unencrypted"),
-                            ),
-                        },
-                    }},
-                    validate.get(_key),
-                    validate.union_get("title", ("aspect_ratios", "profiles", 0, "url")),
-                ).validate(_data_json)
+        hls_url, self.id, self.title = data
+
+        res = self.session.http.get(hls_url, raise_for_status=False)
+        if res.status_code >= 400:
+            log.error("Content is inaccessible or may have expired")
+            return
 
-            if video_url is not None:
-                return HLSStream.parse_variant_playlist(self.session, video_url)
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
 
 
 __plugin__ = NOS


Test patch:
diff --git a/tests/plugins/test_nos.py b/tests/plugins/test_nos.py
index c7d69743..5b1ceb44 100644
--- a/tests/plugins/test_nos.py
+++ b/tests/plugins/test_nos.py
@@ -6,15 +6,17 @@ class TestPluginCanHandleUrlNOS(PluginCanHandleUrl):
     __plugin__ = NOS
 
     should_match = [
-        "https://nos.nl/livestream/2220100-wk-sprint-schaatsen-1-000-meter-mannen.html",
+        "https://nos.nl/live",
         "https://nos.nl/collectie/13781/livestream/2385081-ek-voetbal-engeland-schotland",
-        "https://nos.nl/collectie/13781/livestream/2385461-ek-voetbal-voorbeschouwing-italie-wales-18-00-uur",
-        "https://nos.nl/collectie/13781/video/2385846-ek-in-2-21-gosens-show-tegen-portugal-en-weer-volle-bak-in-boedapest",
-        "https://nos.nl/video/2385779-dronebeelden-tonen-spoor-van-vernieling-bij-leersum",
-        "https://nos.nl/uitzendingen",
-        "https://nos.nl/uitzendingen/livestream/2385462",
+        "https://nos.nl/collectie/13951/video/2491092-dit-was-prinsjesdag",
+        "https://nos.nl/video/2490788-meteoor-gespot-boven-noord-nederland",
     ]
 
     should_not_match = [
         "https://nos.nl/artikel/2385784-explosieve-situatie-leidde-tot-verwoeste-huizen-en-omgewaaide-bomen-leersum",
+        "https://nos.nl/sport",
+        "https://nos.nl/sport/videos",
+        "https://nos.nl/programmas",
+        "https://nos.nl/uitzendingen",
+        "https://nos.nl/uitzendingen/livestream/2385462",
     ]