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:
I'd like to second this if possible.
Ah, was going to work on but forgot... I'll take a look :-)
I have created a branch on my fork which supports logins for Funimation - it could do with some real world testing: https://github.com/beardypig/streamlink/tree/funimation
@beardypig Has this addition been merged yet?  If not, are there any plans to merge the funimation login functionality any time soon (I just want to be sure before I create an AUR package of your fork)?

@vinnyA3 I just checked the master branch and those changes are not in there.
@vinnyA3 yeah, I plan to merge it. But, I haven’t done any proper testing and no one reported back if they tried it. Have you tested it? 

Resolving patch:
diff --git a/src/streamlink/plugins/funimationnow.py b/src/streamlink/plugins/funimationnow.py
--- 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 @@ def __init__(self, experience_id):
         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 @@ def pinst_id(self):
 
     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 @@ def sources(self):
         :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 @@ def _get_streams(self):
         # 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 @@ def _get_streams(self):
                     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 @@ def _get_streams(self):
                         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
--- a/tests/test_plugin_funimationnow.py
+++ b/tests/test_plugin_funimationnow.py
@@ -38,9 +38,11 @@ def test_arguments(self):
         }
 
         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)])
+