Repository: streamlink/streamlink

Issue:
Funimation Login
- [X] This is a feature request.

### Description
Is it possible to add a login feature for funimation, like in the crunchyroll plugin?



Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1721?src=pr&el=h1) Report
> Merging [#1721](https://codecov.io/gh/streamlink/streamlink/pull/1721?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/ec7e427d6b3f6024018b9d2116eadd918f1a57c8?src=pr&el=desc) will **decrease** coverage by `0.03%`.
> The diff coverage is `25.71%`.


```diff
@@            Coverage Diff             @@
##           master    #1721      +/-   ##
==========================================
- Coverage   36.95%   36.92%   -0.04%     
==========================================
  Files         237      237              
  Lines       13784    13838      +54     
==========================================
+ Hits         5094     5109      +15     
- Misses       8690     8729      +39
```

beardypig: The incapsula cookies are saved between sessions now :)

Patch:
diff --git a/src/streamlink/plugins/facebook.py b/src/streamlink/plugins/facebook.py
index ee6e4885..2f7776ff 100644
--- a/src/streamlink/plugins/facebook.py
+++ b/src/streamlink/plugins/facebook.py
@@ -8,7 +8,7 @@ from streamlink.utils import parse_json
 
 class Facebook(Plugin):
     _url_re = re.compile(r"https?://(?:www\.)?facebook\.com/[^/]+/videos")
-    _mpd_re = re.compile(r'''(sd|hd)_src["']?\s*:\s*(?P<quote>["'])(?P<url>.+?)(?P=quote)''')
+    _src_re = re.compile(r'''(sd|hd)_src["']?\s*:\s*(?P<quote>["'])(?P<url>.+?)(?P=quote)''')
     _playlist_re = re.compile(r'''video:\[({url:".+?}\])''')
     _plurl_re = re.compile(r'''url:"(.*?)"''')
 
@@ -19,19 +19,35 @@ class Facebook(Plugin):
     def _get_streams(self):
         res = http.get(self.url, headers={"User-Agent": useragents.CHROME})
 
-        for match in self._mpd_re.finditer(res.text):
-            manifest_url = match.group("url")
-            if "\\/" in manifest_url:
+        streams = {}
+        vod_urls = set([])
+
+        for match in self._src_re.finditer(res.text):
+            stream_url = match.group("url")
+            if "\\/" in stream_url:
                 # if the URL is json encoded, decode it
-                manifest_url = parse_json("\"{}\"".format(manifest_url))
-            for s in DASHStream.parse_manifest(self.session, manifest_url).items():
-                yield s
-        else:
-            match = self._playlist_re.search(res.text)
-            playlist = match and match.group(1)
-            if playlist:
-                for url in {url.group(1) for url in self._plurl_re.finditer(playlist)}:
-                    yield "live", HTTPStream(self.session, url)
+                stream_url = parse_json("\"{}\"".format(stream_url))
+            if ".mpd" in stream_url:
+                streams.update(DASHStream.parse_manifest(self.session, stream_url))
+            elif ".mp4" in stream_url:
+                streams[match.group(1)] = HTTPStream(self.session, stream_url)
+                vod_urls.add(stream_url)
+            else:
+                self.logger.debug("Non-dash/mp4 stream: {0}".format(stream_url))
+
+        if streams:
+            return streams
+
+        # fallback on to playlist
+        self.logger.debug("Falling back to playlist regex")
+        match = self._playlist_re.search(res.text)
+        playlist = match and match.group(1)
+        if playlist:
+            for url in {url.group(1) for url in self._plurl_re.finditer(playlist)}:
+                if url not in vod_urls:
+                    streams["sd"] = HTTPStream(self.session, url)
+
+        return streams
 
 
 __plugin__ = Facebook
diff --git a/src/streamlink/plugins/funimationnow.py b/src/streamlink/plugins/funimationnow.py
index 24cd150f..b3c003cc 100644
--- a/src/streamlink/plugins/funimationnow.py
+++ b/src/streamlink/plugins/funimationnow.py
@@ -1,23 +1,39 @@
 from __future__ import print_function
 
+import logging
 import random
 import re
 
+from streamlink.compat import urljoin
 from streamlink.plugin import Plugin, PluginArguments, PluginArgument
 from streamlink.plugin.api import http
 from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 from streamlink.stream import HTTPStream
 from streamlink.stream.ffmpegmux import MuxedStream
 
+log = logging.getLogger(__name__)
+
 
 class Experience(object):
-    api_base = "https://prod-api-funimationnow.dadcdigital.com/api"
-    show_api_url = api_base + "/source/catalog/title/experience/{experience_id}/"
-    sources_api_url = api_base + "/source/catalog/video/{experience_id}/signed/"
+    CSRF_NAME = "csrfmiddlewaretoken"
+    login_url = "https://www.funimation.com/log-in/"
+    api_base = "https://www.funimation.com/api"
+    login_api_url = "https://prod-api-funimationnow.dadcdigital.com/api/auth/login/"
+    show_api_url = api_base + "/experience/{experience_id}/"
+    sources_api_url = api_base + "/showexperience/{experience_id}/"
     languages = ["english", "japanese"]
     alphas = ["uncut", "simulcast"]
 
+    login_schema = validate.Schema(validate.any(
+        {"success": False,
+         "error": validate.text},
+        {"token": validate.text,
+         "user": {"id": int}}
+    ))
+
     def __init__(self, experience_id):
         """
         :param experience_id: starting experience_id, may be changed later
@@ -25,6 +41,23 @@ class Experience(object):
         self.experience_id = experience_id
         self._language = None
         self.cache = {}
+        self.token = None
+
+    def request(self, method, url, *args, **kwargs):
+        headers = kwargs.pop("headers", {})
+        if self.token:
+            headers.update({"Authorization": "Token {0}".format(self.token)})
+            http.cookies.update({"src_token": self.token})
+
+        log.debug("Making {0}request to {1}".format("authorized " if self.token else "", url))
+
+        return http.request(method, url, *args, headers=headers, **kwargs)
+
+    def get(self, *args, **kwargs):
+        return self.request("GET", *args, **kwargs)
+
+    def post(self, *args, **kwargs):
+        return self.request("POST", *args, **kwargs)
 
     @property
     def pinst_id(self):
@@ -34,7 +67,8 @@ class Experience(object):
 
     def _update(self):
         api_url = self.show_api_url.format(experience_id=self.experience_id)
-        res = http.get(api_url)
+        log.debug("Requesting experience data: {0}".format(api_url))
+        res = self.get(api_url)
         data = http.json(res)
         self.cache[self.experience_id] = data
 
@@ -91,19 +125,47 @@ class Experience(object):
         :return: sources dict
         """
         api_url = self.sources_api_url.format(experience_id=self.experience_id)
-        res = http.get(api_url, params=dict(pinstId=self.pinst_id))
+        res = self.get(api_url, params={"pinst_id": self.pinst_id})
         return http.json(res)
 
+    def login_csrf(self):
+        r = http.get(self.login_url)
+        for input in itertags(r.text, "input"):
+            if input.attributes.get("name") == self.CSRF_NAME:
+                return input.attributes.get("value")
+
+    def login(self, email, password):
+        log.debug("Attempting to login as {0}".format(email))
+        r = self.post(self.login_api_url,
+                      data={'username': email, 'password': password, self.CSRF_NAME: self.login_csrf()},
+                      raise_for_status=False,
+                      headers={"Referer": "https://www.funimation.com/log-in/"})
+        d = http.json(r, schema=self.login_schema)
+        self.token = d.get("token", None)
+        return self.token is not None
+
 
 class FunimationNow(Plugin):
     arguments = PluginArguments(
+        PluginArgument(
+            "email",
+            argument_name="funimation-email",
+            requires=["password"],
+            help="Email address for your Funimation account."
+        ),
+        PluginArgument(
+            "password",
+            argument_name="funimation-password",
+            sensitive=True,
+            help="Password for your Funimation account."
+        ),
         PluginArgument(
             "language",
             argument_name="funimation-language",
             choices=["en", "ja", "english", "japanese"],
             default="english",
             help="""
-            The audio language to use for Funimation streams; japanese or english.
+            The audio language to use for the stream; japanese or english.
 
             Default is "english".
             """
@@ -135,31 +197,39 @@ class FunimationNow(Plugin):
         # remap en to english, and ja to japanese
         rlanguage = {"en": "english", "ja": "japanese"}.get(self.get_option("language").lower(),
                                                             self.get_option("language").lower())
+        if "_Incapsula_Resource" in res.text:
+            self.bypass_incapsula(res)
+            res = http.get(self.url)
 
         id_m = self.experience_id_re.search(res.text)
         experience_id = id_m and int(id_m.group(1))
         if experience_id:
-            self.logger.debug("Found experience ID: {0}", experience_id)
+            log.debug("Found experience ID: {0}", experience_id)
             exp = Experience(experience_id)
+            if self.get_option("email") and self.get_option("password"):
+                if exp.login(self.get_option("email"), self.get_option("password")):
+                    log.info("Logged in to Funimation as {0}", self.get_option("email"))
+                else:
+                    log.warning("Failed to login")
 
-            self.logger.debug("Found episode: {0}", exp.episode_info["episodeTitle"])
-            self.logger.debug("  has languages: {0}", ", ".join(exp.episode_info["languages"].keys()))
-            self.logger.debug("  requested language: {0}", rlanguage)
-            self.logger.debug("  current language:   {0}", exp.language)
+            log.debug("Found episode: {0}", exp.episode_info["episodeTitle"])
+            log.debug("  has languages: {0}", ", ".join(exp.episode_info["languages"].keys()))
+            log.debug("  requested language: {0}", rlanguage)
+            log.debug("  current language:   {0}", exp.language)
             if rlanguage != exp.language:
-                self.logger.debug("switching language to: {0}", rlanguage)
+                log.debug("switching language to: {0}", rlanguage)
                 exp.set_language(rlanguage)
                 if exp.language != rlanguage:
-                    self.logger.warning("Requested language {0} is not available, continuing with {1}",
-                                        rlanguage, exp.language)
+                    log.warning("Requested language {0} is not available, continuing with {1}",
+                                rlanguage, exp.language)
                 else:
-                    self.logger.debug("New experience ID: {0}", exp.experience_id)
+                    log.debug("New experience ID: {0}", exp.experience_id)
 
             subtitles = None
             stream_metadata = {}
             disposition = {}
             for subtitle in exp.subtitles():
-                self.logger.info("Subtitles: {0}", subtitle["src"])
+                log.debug("Subtitles: {0}", subtitle["src"])
                 if subtitle["src"].endswith(".vtt") or subtitle["src"].endswith(".srt"):
                     sub_lang = {"en": "eng", "ja": "jpn"}[subtitle["language"]]
                     # pick the first suitable subtitle stream
@@ -167,7 +237,13 @@ class FunimationNow(Plugin):
                     stream_metadata["s:s:0"] = ["language={0}".format(sub_lang)]
                 stream_metadata["s:a:0"] = ["language={0}".format(exp.language_code)]
 
-            for item in exp.sources()["items"]:
+            sources = exp.sources()
+            if 'errors' in sources:
+                for error in sources['errors']:
+                    log.error("{0} : {1}".format(error['title'], error['detail']))
+                return
+
+            for item in sources["items"]:
                 url = item["src"]
                 if ".m3u8" in url:
                     for q, s in HLSStream.parse_variant_playlist(self.session, url).items():
@@ -186,7 +262,24 @@ class FunimationNow(Plugin):
                         yield self.mp4_quality, s
 
         else:
-            self.logger.error("Could not find experience ID?!")
+            log.error("Could not find experience ID?!")
+
+    def bypass_incapsula(self, res):
+        log.info("Attempting to by-pass Incapsula...")
+        self.clear_cookies(lambda c: "incap" in c.name)
+        for m in re.finditer(r'''"([A-Z0-9]+)"''', res.text):
+            d = m.group(1)
+            # decode the encoded blob to text
+            js = "".join(map(lambda i: chr(int(i, 16)), [d[x:x + 2] for x in range(0, len(d), 2)]))
+            jsm = re.search(r'''"GET","([^"]+)''', js)
+            url = jsm and jsm.group(1)
+            if url:
+                log.debug("Found Incapsula auth URL: {0}", url)
+                res = http.get(urljoin(self.url, url))
+                success = res.status_code == 200
+                if success:
+                    self.save_cookies(lambda c: "incap" in c.name)
+                return success
 
 
 __plugin__ = FunimationNow


Test patch:
diff --git a/tests/test_plugin_funimationnow.py b/tests/test_plugin_funimationnow.py
index ca59eed2..7786204a 100644
--- a/tests/test_plugin_funimationnow.py
+++ b/tests/test_plugin_funimationnow.py
@@ -38,9 +38,11 @@ class TestPluginFunimationNow(unittest.TestCase):
         }
 
         setup_plugin_args(session, parser)
-
         self.assertSequenceEqual(plugin_parser.add_argument.mock_calls,
-                                 [call('--funimation-language',
+                                 [call('--funimation-email', help=ANY),
+                                  call('--funimation-password', help=ANY),
+                                  call('--funimation-language',
                                        choices=["en", "ja", "english", "japanese"],
                                        default="english", help=ANY),
                                   call('--funimation-mux-subtitles', action="store_true", help=ANY)])
+