Repository: streamlink/streamlink

Issue:
Stadium plugin request
### Checklist

- [x ] This is a feature request.

### Description

Can anyone help with a plugin for Stadium TV here:

https://watchstadium.com/live/


Cheers!


Comments:

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 0bb62885..e0b05c65 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -164,6 +164,7 @@ rtve                    rtve.es              Yes   No
 rtvs                    rtvs.sk              Yes   No    Streams may be geo-restricted to Slovakia.
 ruv                     ruv.is               Yes   Yes   Streams may be geo-restricted to Iceland.
 schoolism               schoolism.com        --    Yes   Requires a login and a subscription.
+senategov               senate.gov           --    Yes   Supports hearing streams.
 showroom                showroom-live.com    Yes   No    Only RTMP streams are available.
 skai                    skai.gr              Yes   No    Only embedded youtube live streams are supported
 sportal                 sportal.bg           Yes   No
@@ -173,6 +174,7 @@ srgssr                  - srf.ch             Yes   No    Streams are geo-restric
                         - rsi.ch
                         - rtr.ch
 ssh101                  ssh101.com           Yes   No
+stadium                 watchstadium.com     Yes   No
 startv                  startv.com.tr        Yes   No
 steam                   steamcommunity.com   Yes   No    Some streams will require a Steam account.
 streamable              streamable.com       -     Yes
diff --git a/src/streamlink/plugins/senategov.py b/src/streamlink/plugins/senategov.py
new file mode 100644
index 00000000..2942b288
--- /dev/null
+++ b/src/streamlink/plugins/senategov.py
@@ -0,0 +1,113 @@
+import re
+import logging
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api.utils import itertags
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
+from streamlink.compat import urlparse, parse_qsl
+from streamlink.utils.times import hours_minutes_seconds
+
+log = logging.getLogger(__name__)
+
+
+class SenateGov(Plugin):
+    url_re = re.compile(r"""https?://(?:.+\.)?senate\.gov/(isvp)?""")
+    streaminfo_re = re.compile(r"""var\s+streamInfo\s+=\s+new\s+Array\s*\(\s*(\[.*\])\);""")
+    stt_re = re.compile(r"""^(?:(?P<hours>\d+):)?(?P<minutes>\d+):(?P<seconds>\d+)$""")
+    url_lookup = {
+        "ag": ["76440", "https://ag-f.akamaihd.net"],
+        "aging": ["76442", "https://aging-f.akamaihd.net"],
+        "approps": ["76441", "https://approps-f.akamaihd.net"],
+        "armed": ["76445", "https://armed-f.akamaihd.net"],
+        "banking": ["76446", "https://banking-f.akamaihd.net"],
+        "budget": ["76447", "https://budget-f.akamaihd.net"],
+        "cecc": ["76486", "https://srs-f.akamaihd.net"],
+        "commerce": ["80177", "https://commerce1-f.akamaihd.net"],
+        "csce": ["75229", "https://srs-f.akamaihd.net"],
+        "dpc": ["76590", "https://dpc-f.akamaihd.net"],
+        "energy": ["76448", "https://energy-f.akamaihd.net"],
+        "epw": ["76478", "https://epw-f.akamaihd.net"],
+        "ethics": ["76449", "https://ethics-f.akamaihd.net"],
+        "finance": ["76450", "https://finance-f.akamaihd.net"],
+        "foreign": ["76451", "https://foreign-f.akamaihd.net"],
+        "govtaff": ["76453", "https://govtaff-f.akamaihd.net"],
+        "help": ["76452", "https://help-f.akamaihd.net"],
+        "indian": ["76455", "https://indian-f.akamaihd.net"],
+        "intel": ["76456", "https://intel-f.akamaihd.net"],
+        "intlnarc": ["76457", "https://intlnarc-f.akamaihd.net"],
+        "jccic": ["85180", "https://jccic-f.akamaihd.net"],
+        "jec": ["76458", "https://jec-f.akamaihd.net"],
+        "judiciary": ["76459", "https://judiciary-f.akamaihd.net"],
+        "rpc": ["76591", "https://rpc-f.akamaihd.net"],
+        "rules": ["76460", "https://rules-f.akamaihd.net"],
+        "saa": ["76489", "https://srs-f.akamaihd.net"],
+        "smbiz": ["76461", "https://smbiz-f.akamaihd.net"],
+        "srs": ["75229", "https://srs-f.akamaihd.net"],
+        "uscc": ["76487", "https://srs-f.akamaihd.net"],
+        "vetaff": ["76462", "https://vetaff-f.akamaihd.net"],
+    }
+
+    hls_url = "{base}/i/{filename}_1@{number}/master.m3u8?"
+    hlsarch_url = "https://ussenate-f.akamaihd.net/i/{filename}.mp4/master.m3u8"
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _isvp_to_m3u8(self, url):
+        qs = dict(parse_qsl(urlparse(url).query))
+        if "comm" not in qs:
+            log.error("Missing `comm` value")
+        if "filename" not in qs:
+            log.error("Missing `filename` value")
+
+        d = self.url_lookup.get(qs['comm'])
+        if d:
+            snumber, baseurl = d
+            stream_url = self.hls_url.format(filename=qs['filename'], number=snumber, base=baseurl)
+        else:
+            stream_url = self.hlsarch_url.format(filename=qs['filename'])
+
+        return stream_url, self.parse_stt(qs.get('stt', 0))
+
+    def _get_streams(self):
+        self.session.http.headers.update({
+            "User-Agent": useragents.CHROME,
+        })
+        m = self.url_re.match(self.url)
+        if m and not m.group(1):
+            log.debug("Searching for ISVP URL")
+            isvp_url = self._get_isvp_url()
+        else:
+            isvp_url = self.url
+
+        if not isvp_url:
+            log.error("Could not find the ISVP URL")
+            return
+        else:
+            log.debug("ISVP URL: {0}".format(isvp_url))
+
+        stream_url, start_offset = self._isvp_to_m3u8(isvp_url)
+        log.debug("Start offset is: {0}s".format(start_offset))
+        return HLSStream.parse_variant_playlist(self.session, stream_url, start_offset=start_offset)
+
+    def _get_isvp_url(self):
+        res = self.session.http.get(self.url)
+        for iframe in itertags(res.text, 'iframe'):
+            m = self.url_re.match(iframe.attributes.get('src'))
+            return m and m.group(1) is not None and iframe.attributes.get('src')
+
+    @classmethod
+    def parse_stt(cls, param):
+        m = cls.stt_re.match(param)
+        if m:
+            return int(m.group('hours') or 0) * 3600 + \
+                   int(m.group('minutes')) * 60 + \
+                   int(m.group('seconds'))
+        else:
+            return 0
+
+
+__plugin__ = SenateGov
diff --git a/src/streamlink/plugins/stadium.py b/src/streamlink/plugins/stadium.py
new file mode 100644
index 00000000..2acd4a28
--- /dev/null
+++ b/src/streamlink/plugins/stadium.py
@@ -0,0 +1,42 @@
+import re
+import logging
+
+from streamlink.plugin import Plugin
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
+
+log = logging.getLogger(__name__)
+
+
+class Stadium(Plugin):
+    url_re = re.compile(r"""https?://(?:www\.)?watchstadium\.com/live""")
+    API_URL = "https://player-api.new.livestream.com/accounts/{account_id}/events/{event_id}/stream_info"
+    _stream_data_re = re.compile(r"var StadiumSiteData = (\{.*?});", re.M | re.DOTALL)
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = self.session.http.get(self.url)
+        m = self._stream_data_re.search(res.text)
+        if m:
+            data = parse_json(m.group(1))
+            if data['LivestreamEnabled'] == '1':
+                account_id = data['LivestreamArgs']['account_id']
+                event_id = data['LivestreamArgs']['event_id']
+                log.debug("Found account_id={account_id} and event_id={event_id}".format(account_id=account_id, event_id=event_id))
+
+                url = self.API_URL.format(account_id=account_id, event_id=event_id)
+                api_res = self.session.http.get(url)
+                api_data = self.session.http.json(api_res)
+                stream_url = api_data.get('secure_m3u8_url') or api_data.get('m3u8_url')
+                if stream_url:
+                    return HLSStream.parse_variant_playlist(self.session, stream_url)
+                else:
+                    log.error("Could not find m3u8_url")
+            else:
+                log.error("Stream is offline")
+
+
+__plugin__ = Stadium


Test patch:
diff --git a/tests/plugins/test_senategov.py b/tests/plugins/test_senategov.py
new file mode 100644
index 00000000..6fb5eda8
--- /dev/null
+++ b/tests/plugins/test_senategov.py
@@ -0,0 +1,18 @@
+from streamlink.plugins.senategov import SenateGov
+import unittest
+
+
+class TestPluginSenateGov(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(SenateGov.can_handle_url("https://www.foreign.senate.gov/hearings/business-meeting-082218"))
+        self.assertTrue(SenateGov.can_handle_url("https://www.senate.gov/isvp/?comm=foreign&type=arch&stt=21:50&filename=foreign082218&auto_play=false&wmode=transparent&poster=https%3A%2F%2Fwww%2Eforeign%2Esenate%2Egov%2Fthemes%2Fforeign%2Fimages%2Fvideo-poster-flash-fit%2Epng"))
+
+        # shouldn't match
+        self.assertFalse(SenateGov.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(SenateGov.can_handle_url("http://www.youtube.com/"))
+
+    def test_stt_parse(self):
+        self.assertEqual(600, SenateGov.parse_stt("10:00"))
+        self.assertEqual(3600, SenateGov.parse_stt("01:00:00"))
+        self.assertEqual(70, SenateGov.parse_stt("1:10"))
diff --git a/tests/plugins/test_stadium.py b/tests/plugins/test_stadium.py
new file mode 100644
index 00000000..d0154f07
--- /dev/null
+++ b/tests/plugins/test_stadium.py
@@ -0,0 +1,16 @@
+from streamlink.plugins.stadium import Stadium
+import unittest
+
+
+class TestPluginStadium(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Stadium.can_handle_url("http://www.watchstadium.com/live"))
+        self.assertTrue(Stadium.can_handle_url("https://www.watchstadium.com/live"))
+        self.assertTrue(Stadium.can_handle_url("https://watchstadium.com/live"))
+        self.assertTrue(Stadium.can_handle_url("http://watchstadium.com/live"))
+
+        # shouldn't match
+        self.assertFalse(Stadium.can_handle_url("http://www.watchstadium.com/anything/else"))
+        self.assertFalse(Stadium.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(Stadium.can_handle_url("http://www.youtube.com/"))