Repository: streamlink/streamlink
Pull request #1840 (2018-06-23 22:23:46)

USTV Now
Fix for missing NBC channel and better logging of the station codes. 

As discussed in #1772.

@Ordval @aykuter

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1840?src=pr&el=h1) Report
> Merging [#1840](https://codecov.io/gh/streamlink/streamlink/pull/1840?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/254315e85a1848078ede3857178426946ca404c3?src=pr&el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `33.33%`.


```diff
@@            Coverage Diff             @@
##           master    #1840      +/-   ##
==========================================
+ Coverage   50.18%   50.19%   +0.01%     
==========================================
  Files         236      236              
  Lines       13993    13997       +4     
==========================================
+ Hits         7022     7026       +4     
  Misses       6971     6971
```


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index e567d934..112bd9c7 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -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/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
diff --git a/src/streamlink/plugins/ustvnow.py b/src/streamlink/plugins/ustvnow.py
index 12a395e1..1e0eb913 100644
--- a/src/streamlink/plugins/ustvnow.py
+++ b/src/streamlink/plugins/ustvnow.py
@@ -1,10 +1,16 @@
+from __future__ import unicode_literals
+
+import logging
 import re
+from collections import OrderedDict
 
 from streamlink.plugin import Plugin, PluginArguments, PluginArgument
 from streamlink.plugin.api import http, useragents
 from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 
+log = logging.getLogger(__name__)
+
 
 class USTVNow(Plugin):
     _url_re = re.compile(r"https?://(?:watch\.)?ustvnow\.com(?:/(?:watch|guide)/(?P<scode>\w+))?")
@@ -48,7 +54,7 @@ class USTVNow(Plugin):
             if input.attributes['name'] == "csrf_ustvnow":
                 csrf = input.attributes['value']
 
-        self.logger.debug("CSRF: {0}", csrf)
+        log.debug("CSRF: {0}", csrf)
 
         r = http.post(self._login_url, data={'csrf_ustvnow': csrf,
                                              'signin_email': username,
@@ -65,20 +71,20 @@ class USTVNow(Plugin):
         m = self._url_re.match(self.url)
         scode = m and m.group("scode") or self.get_option("station_code")
 
-        res = http.get(self._guide_url)
+        res = http.get(self._guide_url, params=dict(token=token))
 
-        channels = {}
+        channels = OrderedDict()
         for t in itertags(res.text, "a"):
             if t.attributes.get('cs'):
-                channels[t.attributes.get('cs').lower()] = t.attributes.get('title').replace("Watch ", "")
+                channels[t.attributes.get('cs').lower()] = t.attributes.get('title').replace("Watch ", "").strip()
 
         if not scode:
-            self.logger.error("Station code not provided, use --ustvnow-station-code.")
-            self.logger.error("Available stations are: {0}", ", ".join(channels.keys()))
+            log.error("Station code not provided, use --ustvnow-station-code.")
+            log.info("Available stations are: \n{0} ".format('\n'.join('    {0} ({1})'.format(c, n) for c, n in channels.items())))
             return
 
         if scode in channels:
-            self.logger.debug("Finding streams for: {0}", channels.get(scode))
+            log.debug("Finding streams for: {0}", channels.get(scode))
 
             r = http.get(self._stream_url, params={"scode": scode,
                                                    "token": token,
@@ -90,7 +96,7 @@ class USTVNow(Plugin):
             data = http.json(r)
             return HLSStream.parse_variant_playlist(self.session, data["stream"])
         else:
-            self.logger.error("Invalid station-code: {0}", scode)
+            log.error("Invalid station-code: {0}", scode)
 
 
 __plugin__ = USTVNow


Test patch:
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'))