Repository: streamlink/streamlink

Issue:
Unable to purge crunchyroll credentials or reauthenticate
## Bug Report
- [x] This is a bug report and I have read the contribution guidelines.

### Description

Once your crunchyroll credentials expire, it seems like you can no longer purge them using --crunchyroll-purge-credentials or reauthenticate (if you delete your existing credentials from appdata).

### Expected / Actual behavior

Earlier, both purge and reauthenticating failed with
```error: Unable to validate API response: Unable to validate key 'auth': Type of None should be 'str' but is 'NoneType'```
Now they fail with:
```
Traceback (most recent call last):
  File "runpy.py", line 193, in _run_module_as_main
  File "runpy.py", line 85, in _run_code
  File "C:\Users\kg\AppData\Local\Streamlink\bin\streamlink.exe\__main__.py", line 18, in <module>
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink_cli\main.py", line 1024, in main
    handle_url()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink_cli\main.py", line 585, in handle_url
    streams = fetch_streams(plugin)
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink_cli\main.py", line 465, in fetch_streams
    sorting_excludes=args.stream_sorting_excludes)
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugin\plugin.py", line 317, in streams
    ostreams = self._get_streams()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 306, in _get_streams
    api = self._create_api()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 369, in _create_api
    login = api.authenticate()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 217, in authenticate
    data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 173, in _api_call
    raise CrunchyrollAPIError(err_msg, err_code)
streamlink.plugin.crunchyroll.CrunchyrollAPIError: User could not be found.
```

### Reproduction steps / Explicit stream URLs to test

```streamlink https://www.crunchyroll.com/my-teen-romantic-comedy-snafu/episode-9-a-whiff-of-that-fragrance-will-always-bring-memories-of-that-season-796536 best -o oregairu9.mkv```

I have a username and password in my streamlinkrc, and I successfully downloaded an episode from CR about 2 days ago. I'm logged into the CR website right now so I don't think anything is wrong with my account.

### Log output

```
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.6.6
[cli][debug] Streamlink: 1.5.0
[cli][debug] Requests(2.24.0), Socks(1.7.1), Websocket(0.57.0)
[cli][info] Found matching plugin crunchyroll for URL https://www.crunchyroll.com/my-teen-romantic-comedy-snafu/episode-9-a-whiff-of-that-fragrance-will-always-bring-memories-of-that-season-796536
[cli][debug] Plugin specific arguments:
[cli][debug]  --crunchyroll-username=******** (username)
[cli][debug]  --crunchyroll-password=******** (password)
[utils.l10n][debug] Language code: en_US
[plugin.crunchyroll][debug] Creating session with locale: en_US
[plugin.crunchyroll][debug] Session created with ID: c492ba4c706943f73dbf9819a8237465
[plugin.crunchyroll][debug] Using saved credentials
Traceback (most recent call last):
  File "runpy.py", line 193, in _run_module_as_main
  File "runpy.py", line 85, in _run_code
  File "C:\Users\kg\AppData\Local\Streamlink\bin\streamlink.exe\__main__.py", line 18, in <module>
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink_cli\main.py", line 1024, in main
    handle_url()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink_cli\main.py", line 585, in handle_url
    streams = fetch_streams(plugin)
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink_cli\main.py", line 465, in fetch_streams
    sorting_excludes=args.stream_sorting_excludes)
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugin\plugin.py", line 317, in streams
    ostreams = self._get_streams()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 306, in _get_streams
    api = self._create_api()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 369, in _create_api
    login = api.authenticate()
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 217, in authenticate
    data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
  File "C:\Users\kg\AppData\Local\Streamlink\pkgs\streamlink\plugins\crunchyroll.py", line 173, in _api_call
    raise CrunchyrollAPIError(err_msg, err_code)
streamlink.plugin.crunchyroll.CrunchyrollAPIError: User could not be found.
```

### Additional comments, screenshots, etc.



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -10,7 +10,6 @@
 
 log = logging.getLogger(__name__)
 
-
 STREAM_WEIGHTS = {
     "low": 240,
     "mid": 420,
@@ -72,7 +71,7 @@ def parse_timestamp(ts):
     validate.get("stream_data")
 )
 _login_schema = validate.Schema({
-    "auth": validate.text,
+    "auth": validate.any(validate.text, None),
     "expires": validate.all(
         validate.text,
         validate.transform(parse_timestamp)
@@ -214,9 +213,16 @@ def login(self, username, password):
         return login
 
     def authenticate(self):
-        data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
-        self.auth = data["auth"]
-        self.cache.set("auth", data["auth"], expires_at=data["expires"])
+        try:
+            data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
+        except CrunchyrollAPIError:
+            self.auth = None
+            self.cache.set("auth", None, expires_at=0)
+            log.warning("Saved credentials have expired")
+            return
+
+        log.debug("Credentials expire at: {}".format(data["expires"]))
+        self.cache.set("auth", self.auth, expires_at=data["expires"])
         return data
 
     def get_info(self, media_id, fields=None, schema=None):
@@ -321,7 +327,7 @@ def _get_streams(self):
         # The adaptive quality stream sometimes a subset of all the other streams listed, ultra is no included
         has_adaptive = any([s[u"quality"] == u"adaptive" for s in info[u"streams"]])
         if has_adaptive:
-            self.logger.debug(u"Loading streams from adaptive playlist")
+            log.debug(u"Loading streams from adaptive playlist")
             for stream in filter(lambda x: x[u"quality"] == u"adaptive", info[u"streams"]):
                 for q, s in HLSStream.parse_variant_playlist(self.session, stream[u"url"]).items():
                     # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams
@@ -361,27 +367,28 @@ def _create_api(self):
                              locale=locale)
 
         if not self.get_option("session_id"):
-            self.logger.debug("Creating session with locale: {0}", locale)
+            log.debug("Creating session with locale: {0}", locale)
             api.start_session()
 
             if api.auth:
-                self.logger.debug("Using saved credentials")
+                log.debug("Using saved credentials")
                 login = api.authenticate()
-                self.logger.info("Successfully logged in as '{0}'",
-                                 login["user"]["username"] or login["user"]["email"])
-            elif self.options.get("username"):
+                if login:
+                    log.info("Successfully logged in as '{0}'",
+                             login["user"]["username"] or login["user"]["email"])
+            if not api.auth and self.options.get("username"):
                 try:
-                    self.logger.debug("Attempting to login using username and password")
+                    log.debug("Attempting to login using username and password")
                     api.login(self.options.get("username"),
                               self.options.get("password"))
                     login = api.authenticate()
-                    self.logger.info("Logged in as '{0}'",
-                                     login["user"]["username"] or login["user"]["email"])
+                    log.info("Logged in as '{0}'",
+                             login["user"]["username"] or login["user"]["email"])
 
                 except CrunchyrollAPIError as err:
                     raise PluginError(u"Authentication error: {0}".format(err.msg))
-            else:
-                self.logger.warning(
+            if not api.auth:
+                log.warning(
                     "No authentication provided, you won't be able to access "
                     "premium restricted content"
                 )


Test patch: