Repository: streamlink/streamlink

Issue:
plugins.livestream: query API for hidden channels
<!--
Thanks for opening a pull request!

Before you continue, please make sure that you have read and understood the contribution guidelines, otherwise your changes may be rejected:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

If possible, run the tests, perform code linting and build the documentation locally on your system first to avoid unnecessary build failures:
https://streamlink.github.io/latest/developing.html#validating-changes

Also don't forget to add a meaningful description of your changes, so that the reviewing process is as simple as possible for the maintainers.

Thank you very much!
-->

## Context
In certain cases, the channel is hidden (see : https://api.new.livestream.com/accounts/29565692/events/9321713/), and to make Streamlink work in those kind of links, it needs to be scraped on API links only.

## What this PR does
This PR aims to add another way of scraping Livestream channels. 

## Technical explanation
Using the api.new.livestream.com base API endpoint, it will be possible to get all kinds of links. A condition is put to verify if the stream_info response is available, and another condition is put to verify the nature of given URL (if link is api.new.livestream.com, get JSON and verify if the stream_info part is available. else, contact the API URL available inside the code, and get the stream.)

## PR Classification
This would be considered as an improvement, based off the original plugin.


Discussion:
Putting it as draft for the moment. Will work on it this weekend, thank you for your patience.
Sorry for the long time without any answer, I was busy with work and didn't have the time to do anything on this project.

Now that I have the time to rewrite it, here's my new proposition.

Using the API url will permit you to reduce the work needed to access a link, since it needed a specific regex onto the Livestream page to get the necessary stream_info data.

Resolving patch:
diff --git a/src/streamlink/plugins/livestream.py b/src/streamlink/plugins/livestream.py
--- a/src/streamlink/plugins/livestream.py
+++ b/src/streamlink/plugins/livestream.py
@@ -1,52 +1,122 @@
 """
-$description Global live streaming and video on-demand hosting platform.
+$description Global live-streaming and video on-demand hosting platform.
 $url livestream.com
 $type live
 """
 
 import logging
 import re
+from operator import itemgetter
 
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
-from streamlink.utils.parse import parse_json
 
 log = logging.getLogger(__name__)
 
 
 @pluginmatcher(re.compile(
-    r"https?://(?:www\.)?livestream\.com/"
+    r"""
+        https?://(?P<subdomain>api\.new\.|www\.)?livestream\.com
+        /accounts/(?P<account>\d+)
+        (?:
+            /events/(?P<event>\d+)
+            |
+            /[^/]+
+        )?
+        (?:/videos/(?P<video>\d+))?
+    """,
+    re.VERBOSE,
 ))
 class Livestream(Plugin):
-    _config_re = re.compile(r"window.config = ({.+})")
-    _stream_config_schema = validate.Schema(validate.any({
-        "event": {
-            "stream_info": validate.any({
-                "is_live": bool,
-                "secure_m3u8_url": validate.url(scheme="https"),
-            }, None),
-        }
-    }, {}), validate.get("event", {}), validate.get("stream_info", {}))
+    URL_API_EVENTS = "https://api.new.livestream.com/accounts/{account}/events"
+    URL_API_EVENTS_EVENT = "https://api.new.livestream.com/accounts/{account}/events/{event}"
+    URL_API_VIDEO = "https://api.new.livestream.com/accounts/{account}/events/{event}/videos/{video}"
 
     def _get_streams(self):
-        res = self.session.http.get(self.url)
-        m = self._config_re.search(res.text)
-        if not m:
-            log.debug("Unable to find _config_re")
-            return
-
-        stream_info = parse_json(m.group(1), "config JSON",
-                                 schema=self._stream_config_schema)
-
-        log.trace("stream_info: {0!r}".format(stream_info))
-        if not (stream_info and stream_info["is_live"]):
-            log.debug("Stream might be Off Air")
-            return
-
-        m3u8_url = stream_info.get("secure_m3u8_url")
-        if m3u8_url:
-            yield from HLSStream.parse_variant_playlist(self.session, m3u8_url).items()
+        subdomain, account, event, video = itemgetter("subdomain", "account", "event", "video")(self.match.groupdict())
+
+        if event is None:
+            if video is None or subdomain == "api.new.":
+                event = self.session.http.get(
+                    self.URL_API_EVENTS.format(account=account),
+                    schema=validate.Schema(
+                        validate.parse_json(),
+                        {"data": [dict]},
+                        validate.get(("data", 0)),
+                        validate.any(None, validate.all(
+                            {"id": int},
+                            validate.get("id"),
+                        )),
+                    ),
+                )
+            else:
+                event = self.session.http.get(
+                    self.url,
+                    schema=validate.Schema(
+                        validate.parse_html(),
+                        validate.xml_xpath_string(".//script[contains(text(), 'window.config = ')][1]/text()"),
+                        validate.any(None, validate.all(
+                            str,
+                            validate.transform(re.compile(r"^window\.config\s*=\s*(\{.+});?\s*$").match),
+                            validate.any(None, validate.all(
+                                validate.get(1),
+                                validate.parse_json(),
+                                {"event": {"id": int}},
+                                validate.get(("event", "id")),
+                            ))
+                        )),
+                    ),
+                )
+            if event is None:
+                log.error("Could not find event ID")
+                return
+
+        if video is None:
+            self.id, self.title, is_live, m3u8_url = self.session.http.get(
+                self.URL_API_EVENTS_EVENT.format(account=account, event=event),
+                schema=validate.Schema(
+                    validate.parse_json(),
+                    {
+                        "stream_info": {
+                            "broadcast_id": int,
+                            validate.optional("stream_title"): validate.any(None, str),
+                            "is_live": bool,
+                            "secure_m3u8_url": validate.url(path=validate.endswith(".m3u8")),
+                        },
+                    },
+                    validate.get("stream_info"),
+                    validate.union_get(
+                        "broadcast_id",
+                        "stream_title",
+                        "is_live",
+                        "secure_m3u8_url",
+                    ),
+                ),
+            )
+            if not is_live:
+                log.error("The stream is not available")
+                return
+
+        else:
+            self.id, self.title, m3u8_url = self.session.http.get(
+                self.URL_API_VIDEO.format(account=account, event=event, video=video),
+                schema=validate.Schema(
+                    validate.parse_json(),
+                    {
+                        "id": int,
+                        validate.optional("description"): validate.any(None, str),
+                        "secure_m3u8_url": validate.url(path=validate.endswith(".m3u8")),
+                    },
+                    validate.union_get(
+                        "id",
+                        "description",
+                        "secure_m3u8_url",
+                    ),
+                ),
+            )
+
+        yield from HLSStream.parse_variant_playlist(self.session, m3u8_url).items()
 
 
 __plugin__ = Livestream


Test patch:
diff --git a/tests/plugins/test_livestream.py b/tests/plugins/test_livestream.py
--- a/tests/plugins/test_livestream.py
+++ b/tests/plugins/test_livestream.py
@@ -5,8 +5,58 @@
 class TestPluginCanHandleUrlLivestream(PluginCanHandleUrl):
     __plugin__ = Livestream
 
-    should_match = [
-        'https://livestream.com/',
-        'https://www.livestream.com/',
-        'https://livestream.com/accounts/22300508/events/6675945',
+    should_match_groups = [
+        # no event/video
+        (
+            "https://livestream.com/accounts/12182108/",
+            {"account": "12182108"},
+        ),
+        (
+            "https://livestream.com/accounts/1538473/eaglecam",
+            {"account": "1538473"},
+        ),
+        (
+            "https://www.livestream.com/accounts/12182108/",
+            {"subdomain": "www.", "account": "12182108"},
+        ),
+        # no event/video via API URL
+        (
+            "https://api.new.livestream.com/accounts/12182108/",
+            {"subdomain": "api.new.", "account": "12182108"},
+        ),
+        # event
+        (
+            "https://livestream.com/accounts/12182108/events/4004765",
+            {"account": "12182108", "event": "4004765"},
+        ),
+        (
+            "https://www.livestream.com/accounts/12182108/events/4004765",
+            {"subdomain": "www.", "account": "12182108", "event": "4004765"},
+        ),
+        # event via API URL
+        (
+            "https://api.new.livestream.com/accounts/12182108/events/4004765",
+            {"subdomain": "api.new.", "account": "12182108", "event": "4004765"},
+        ),
+        # video without event
+        (
+            "https://livestream.com/accounts/4175709/neelix/videos/119637915",
+            {"account": "4175709", "video": "119637915"},
+        ),
+        # video with event
+        (
+            "https://livestream.com/accounts/844142/events/5602516/videos/216545361",
+            {"account": "844142", "event": "5602516", "video": "216545361"},
+        ),
+        # video with event via API URL
+        (
+            "https://api.new.livestream.com/accounts/844142/events/5602516/videos/216545361",
+            {"subdomain": "api.new.", "account": "844142", "event": "5602516", "video": "216545361"},
+        ),
+    ]
+
+    should_not_match = [
+        "https://livestream.com/",
+        "https://www.livestream.com/",
+        "https://api.new.livestream.com/",
     ]