Repository: streamlink/streamlink

Issue:
Support for Steam Broadcasting
Hey boys and girls, 
Recently jumped ship over to streamlink and was wondering if you guys could figure out how to enable support for Steam Broadcasting.

The folks over at livestreamer gave it a crack, but development for it soon died out.
(Here's the link to the livestreamer issue thread: https://github.com/chrippa/livestreamer/issues/621)

This may be a bit of an ask because it requires the user to be logged in but I'm fully happy to enter in my steam information to get it working.

Here's a link to the directory of live broadcasts: http://steamcommunity.com/?subsection=broadcasts

Hope this isn't too much of a challenge for you guys.
Cheers.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1717?src=pr&el=h1) Report
> Merging [#1717](https://codecov.io/gh/streamlink/streamlink/pull/1717?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/1307bc19e5c54d7bd8f6bba0d1c84a736a72c7ba?src=pr&el=desc) will **increase** coverage by `0.16%`.
> The diff coverage is `40%`.


```diff
@@            Coverage Diff             @@
##           master    #1717      +/-   ##
==========================================
+ Coverage   36.96%   37.12%   +0.16%     
==========================================
  Files         237      238       +1     
  Lines       13785    13914     +129     
==========================================
+ Hits         5095     5166      +71     
- Misses       8690     8748      +58
```

gravyboat: Is this good to go now after the tests and matrix addition?
beardypig: I still need to work on the 2 factor auth stuff, I'll remove the WIP label once I've done that :) 
gravyboat: Okay cool thanks for the clarification. I wasn't sure if you were planning on working on that here or in another PR.
zer0def: Are public broadcasts (those not requiring being logged in) a consideration in this PR?
beardypig: I hadn’t realised you could access them without a login - should be a simple enough to change though :)
beardypig: @zer0def I have updated the plugin to support public streams :) 
beardypig: @gravyboat I have proposed a possible method to allow this plugin to request user input and maintain the disconnect between `streamlink` and `streamlink_cli`. 

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index a122186d..112bd9c7 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -43,8 +43,7 @@ btsports                sport.bt.com         Yes   Yes   Requires subscription a
 btv                     btv.bg               Yes   No    Requires login, and geo-restricted to Bulgaria.
 cam4                    cam4.com             Yes   No
 camsoda                 camsoda.com          Yes   No
-canalplus               - mycanal.fr         No    Yes   Streams may be geo-restricted to France.
-                        - cnews.fr
+canalplus               mycanal.fr           No    Yes   Streams may be geo-restricted to France.
 cdnbg                   - tv.bnt.bg          Yes   No    Streams may be geo-restricted to Bulgaria.
                         - bgonair.bg
                         - kanal3.bg
@@ -57,6 +56,7 @@ cinergroup              - showtv.com.tr      Yes   No
                         - showmax.com.tr
                         - showturk.com.tr
                         - bloomberght.com
+cnews                   cnews.fr             Yes   Yes
 crunchyroll             crunchyroll.com      --    Yes
 cybergame               cybergame.tv         Yes   Yes
 dailymotion             dailymotion.com      Yes   Yes
@@ -192,6 +192,7 @@ srgssr                  - srf.ch             Yes   No    Streams are geo-restric
                         - rtr.ch
 ssh101                  ssh101.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
 streamboat              streamboat.tv        Yes   No
 streamingvideoprovider  streamingvid... [2]_ Yes   --    RTMP streams requires rtmpdump with
diff --git a/src/streamlink/plugins/canalplus.py b/src/streamlink/plugins/canalplus.py
index b0c4bb23..8279ce0b 100644
--- a/src/streamlink/plugins/canalplus.py
+++ b/src/streamlink/plugins/canalplus.py
@@ -13,10 +13,9 @@ class CanalPlus(Plugin):
     SECRET = 'pqzerjlsmdkjfoiuerhsdlfknaes'
 
     _url_re = re.compile(r'''
-        (https|http)://
+        https?://
         (
-            www.mycanal.fr/(.*)/(.*)/p/(?P<video_id>[0-9]+) |
-            www\.cnews\.fr/.+
+            www.mycanal.fr/(.*)/(.*)/p/(?P<video_id>[0-9]+)
         )
 ''', re.VERBOSE)
     _video_id_re = re.compile(r'(\bdata-video="|<meta property="og:video" content=".+?&videoId=)(?P<video_id>[0-9]+)"')
diff --git a/src/streamlink/plugins/cnews.py b/src/streamlink/plugins/cnews.py
new file mode 100644
index 00000000..824d4d3f
--- /dev/null
+++ b/src/streamlink/plugins/cnews.py
@@ -0,0 +1,24 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, useragents
+
+
+class CNEWS(Plugin):
+    _url_re = re.compile(r'https?://www.cnews.fr/[^ ]+')
+    _embed_video_url_re = re.compile(r'class="dm-video-embed_video" src="(?P<dm_url>.*)"')
+    _embed_live_url_re = re.compile(r'class="wrapper-live-player main-live-player"><iframe src="(?P<dm_url>.*)"')
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url)
+
+    def _get_streams(self):
+        # Retrieve URL page and search for Dailymotion URL
+        res = http.get(self.url, headers={'User-Agent': useragents.CHROME})
+        match = self._embed_live_url_re.search(res.text) or self._embed_video_url_re.search(res.text)
+        if match is not None:
+            return self.session.streams(match.group('dm_url'))
+
+
+__plugin__ = CNEWS
diff --git a/src/streamlink/plugins/steam.py b/src/streamlink/plugins/steam.py
new file mode 100644
index 00000000..fcbf0ce5
--- /dev/null
+++ b/src/streamlink/plugins/steam.py
@@ -0,0 +1,210 @@
+import base64
+import logging
+import re
+import time
+
+from Crypto.Cipher import PKCS1_v1_5
+from Crypto.PublicKey import RSA
+
+import streamlink
+from streamlink.exceptions import FatalPluginError
+from streamlink.plugin import Plugin, PluginArguments, PluginArgument
+from streamlink.plugin.api import http
+from streamlink.plugin.api import validate
+from streamlink.plugin.api.validate import Schema
+from streamlink.stream.dash import DASHStream
+
+log = logging.getLogger(__name__)
+
+
+class SteamLoginFailed(Exception):
+    pass
+
+
+class SteamBroadcastPlugin(Plugin):
+    _url_re = re.compile(r"https?://steamcommunity.com/broadcast/watch/(\d+)")
+    _get_broadcast_url = "https://steamcommunity.com/broadcast/getbroadcastmpd/"
+    _user_agent = "streamlink/{}".format(streamlink.__version__)
+    _broadcast_schema = Schema({
+        "success": validate.any("ready", "unavailable", "waiting", "waiting_to_start", "waiting_for_start"),
+        "retry": int,
+        "broadcastid": validate.any(validate.text, int),
+        validate.optional("url"): validate.url(),
+        validate.optional("viewertoken"): validate.text
+    })
+    _get_rsa_key_url = "https://steamcommunity.com/login/getrsakey/"
+    _rsa_key_schema = validate.Schema({
+        "publickey_exp": validate.all(validate.text, validate.transform(lambda x: int(x, 16))),
+        "publickey_mod": validate.all(validate.text, validate.transform(lambda x: int(x, 16))),
+        "success": True,
+        "timestamp": validate.text,
+        "token_gid": validate.text
+    })
+    _dologin_url = "https://steamcommunity.com/login/dologin/"
+    _dologin_schema = validate.Schema({
+        "success": bool,
+        "requires_twofactor": bool,
+        validate.optional("message"): validate.text,
+        validate.optional("emailauth_needed"): bool,
+        validate.optional("emaildomain"): validate.text,
+        validate.optional("emailsteamid"): validate.text,
+        validate.optional("login_complete"): bool,
+        validate.optional("captcha_needed"): bool,
+        validate.optional("captcha_gid"): validate.any(validate.text, int)
+    })
+    _captcha_url = "https://steamcommunity.com/public/captcha.php?gid={}"
+
+    arguments = PluginArguments(
+        PluginArgument(
+            "email",
+            metavar="EMAIL",
+            requires=["password"],
+            help="""
+            A Steam account email address to access friends/private streams
+            """
+        ),
+        PluginArgument(
+            "password",
+            metavar="PASSWORD",
+            sensitive=True,
+            help="""
+            A Steam account password to use with --steam-email.
+            """
+    ))
+
+    def __init__(self, url):
+        super(SteamBroadcastPlugin, self).__init__(url)
+        http.headers["User-Agent"] = self._user_agent
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    @property
+    def donotcache(self):
+        return str(int(time.time() * 1000))
+
+    def encrypt_password(self, email, password):
+        """
+        Get the RSA key for the user and encrypt the users password
+        :param email: steam account
+        :param password: password for account
+        :return: encrypted password
+        """
+        res = http.get(self._get_rsa_key_url, params=dict(username=email, donotcache=self.donotcache))
+        rsadata = http.json(res, schema=self._rsa_key_schema)
+
+        rsa = RSA.construct((rsadata["publickey_mod"], rsadata["publickey_exp"]))
+        cipher = PKCS1_v1_5.new(rsa)
+        return base64.b64encode(cipher.encrypt(password.encode("utf8"))), rsadata["timestamp"]
+
+    def dologin(self, email, password, emailauth="", emailsteamid="", captchagid="-1", captcha_text="", twofactorcode=""):
+        """
+        Logs in to Steam
+
+        """
+        epassword, rsatimestamp = self.encrypt_password(email, password)
+
+        login_data = {
+            'username': email,
+            "password": epassword,
+            "emailauth": emailauth,
+            "loginfriendlyname": "Streamlink",
+            "captchagid": captchagid,
+            "captcha_text": captcha_text,
+            "emailsteamid": emailsteamid,
+            "rsatimestamp": rsatimestamp,
+            "remember_login": True,
+            "donotcache": self.donotcache,
+            "twofactorcode": twofactorcode
+        }
+
+        res = http.post(self._dologin_url, data=login_data)
+
+        resp = http.json(res, schema=self._dologin_schema)
+
+        if not resp[u"success"]:
+            if resp.get(u"captcha_needed"):
+                # special case for captcha
+                captchagid = resp[u"captcha_gid"]
+                log.error("Captcha result required, open this URL to see the captcha: {}".format(
+                    self._captcha_url.format(captchagid)))
+                try:
+                    captcha_text = self.input_ask("Captcha text")
+                except FatalPluginError:
+                    captcha_text = None
+                if not captcha_text:
+                    return False
+            else:
+                # If the user must enter the code that was emailed to them
+                if resp.get(u"emailauth_needed"):
+                    if not emailauth:
+                        try:
+                            emailauth = self.input_ask("Email auth code required")
+                        except FatalPluginError:
+                            emailauth = None
+                        if not emailauth:
+                            return False
+                    else:
+                        raise SteamLoginFailed("Email auth key error")
+
+                # If the user must enter a two factor auth code
+                if resp.get(u"requires_twofactor"):
+                    try:
+                        twofactorcode = self.input_ask("Two factor auth code required")
+                    except FatalPluginError:
+                        twofactorcode = None
+                    if not twofactorcode:
+                        return False
+
+                if resp.get(u"message"):
+                    raise SteamLoginFailed(resp[u"message"])
+
+            return self.dologin(email, password,
+                                emailauth=emailauth,
+                                emailsteamid=resp.get(u"emailsteamid", u""),
+                                captcha_text=captcha_text,
+                                captchagid=captchagid,
+                                twofactorcode=twofactorcode)
+        elif resp.get("login_complete"):
+            return True
+        else:
+            log.error("Something when wrong when logging in to Steam")
+            return False
+
+    def login(self, email, password):
+        log.info("Attempting to login to Steam as {}".format(email))
+        return self.dologin(email, password)
+
+    def _get_broadcast_stream(self, steamid, viewertoken=0):
+        res = http.get(self._get_broadcast_url,
+                       params=dict(broadcastid=0,
+                                   steamid=steamid,
+                                   viewertoken=viewertoken))
+        return http.json(res, schema=self._broadcast_schema)
+
+    def _get_streams(self):
+        streamdata = None
+        if self.get_option("email"):
+            if self.login(self.get_option("email"), self.get_option("password")):
+                log.info("Logged in as {0}".format(self.get_option("email")))
+                self.save_cookies(lambda c: "steamMachineAuth" in c.name)
+
+        # extract the steam ID from the URL
+        steamid = self._url_re.match(self.url).group(1)
+
+        while streamdata is None or streamdata[u"success"] in ("waiting", "waiting_for_start"):
+            streamdata = self._get_broadcast_stream(steamid)
+
+            if streamdata[u"success"] == "ready":
+                return DASHStream.parse_manifest(self.session, streamdata["url"])
+            elif streamdata[u"success"] == "unavailable":
+                log.error("This stream is currently unavailable")
+                return
+            else:
+                r = streamdata[u"retry"] / 1000.0
+                log.info("Waiting for stream, will retry again in {} seconds...".format(r))
+                time.sleep(r)
+
+
+__plugin__ = SteamBroadcastPlugin


Test patch:
diff --git a/tests/test_plugin_canalplus.py b/tests/test_plugin_canalplus.py
index 62daf272..30646c58 100644
--- a/tests/test_plugin_canalplus.py
+++ b/tests/test_plugin_canalplus.py
@@ -10,10 +10,10 @@ class TestPluginCanalPlus(unittest.TestCase):
         self.assertTrue(CanalPlus.can_handle_url("https://www.mycanal.fr/sport/infosport-laurey-et-claudia/p/1473752"))
         self.assertTrue(CanalPlus.can_handle_url("https://www.mycanal.fr/docus-infos/ses-debuts-a-madrid-extrait-le-k-benzema/p/1469050"))
         self.assertTrue(CanalPlus.can_handle_url("https://www.mycanal.fr/d8-docs-mags/au-revoir-johnny-hallyday-le-doc/p/1473054"))
-        self.assertTrue(CanalPlus.can_handle_url("http://www.cnews.fr/direct"))
-        self.assertTrue(CanalPlus.can_handle_url("http://www.cnews.fr/politique/video/des-electeurs-toujours-autant-indecis-174769"))
-        self.assertTrue(CanalPlus.can_handle_url("http://www.cnews.fr/magazines/plus-de-recul/de-recul-du-14042017-174594"))
         # shouldn't match
+        self.assertFalse(CanalPlus.can_handle_url("http://www.cnews.fr/direct"))
+        self.assertFalse(CanalPlus.can_handle_url("http://www.cnews.fr/politique/video/des-electeurs-toujours-autant-indecis-174769"))
+        self.assertFalse(CanalPlus.can_handle_url("http://www.cnews.fr/magazines/plus-de-recul/de-recul-du-14042017-174594"))
         self.assertFalse(CanalPlus.can_handle_url("http://www.canalplus.fr/"))
         self.assertFalse(CanalPlus.can_handle_url("http://www.c8.fr/"))
         self.assertFalse(CanalPlus.can_handle_url("http://replay.c8.fr/"))
diff --git a/tests/test_plugin_cnews.py b/tests/test_plugin_cnews.py
new file mode 100644
index 00000000..c7bdfd9f
--- /dev/null
+++ b/tests/test_plugin_cnews.py
@@ -0,0 +1,16 @@
+import unittest
+
+from streamlink.plugins.cnews import CNEWS
+
+
+class TestPluginCNEWS(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(CNEWS.can_handle_url("http://www.cnews.fr/le-direct"))
+        self.assertTrue(CNEWS.can_handle_url("http://www.cnews.fr/direct"))
+        self.assertTrue(CNEWS.can_handle_url("http://www.cnews.fr/emission/2018-06-12/meteo-du-12062018-784730"))
+        self.assertTrue(CNEWS.can_handle_url("http://www.cnews.fr/emission/2018-06-12/le-journal-des-faits-divers-du-12062018-784704"))
+        # shouldn't match
+        self.assertFalse(CNEWS.can_handle_url("http://www.cnews.fr/"))
+        self.assertFalse(CNEWS.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(CNEWS.can_handle_url("http://www.youtube.com/"))
diff --git a/tests/test_plugin_steam.py b/tests/test_plugin_steam.py
new file mode 100644
index 00000000..db16601f
--- /dev/null
+++ b/tests/test_plugin_steam.py
@@ -0,0 +1,15 @@
+import unittest
+
+from streamlink.plugins.steam import SteamBroadcastPlugin
+
+
+class TestPluginSteamBroadcastPlugin(unittest.TestCase):
+    def test_can_handle_url(self):
+        self.assertTrue(SteamBroadcastPlugin.can_handle_url('https://steamcommunity.com/broadcast/watch/12432432'))
+        self.assertTrue(SteamBroadcastPlugin.can_handle_url('http://steamcommunity.com/broadcast/watch/342342'))
+
+    def test_can_handle_url_negative(self):
+        # shouldn't match
+        self.assertFalse(SteamBroadcastPlugin.can_handle_url('http://steamcommunity.com/broadcast'))
+        self.assertFalse(SteamBroadcastPlugin.can_handle_url('https://steamcommunity.com'))
+        self.assertFalse(SteamBroadcastPlugin.can_handle_url('https://youtube.com'))