Repository: yt-dlp/yt-dlp

Issue:
Downloads from Crunchyroll break if certain Optanon cookies are present
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I remove or skip any mandatory\* field

### Checklist

- [X] I'm reporting a broken site
- [X] I've verified that I'm running yt-dlp version **2022.10.04** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've checked that all URLs and arguments with special characters are [properly quoted or escaped](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#video-url-contains-an-ampersand--and-im-getting-some-strange-output-1-2839-or-v-is-not-recognized-as-an-internal-or-external-command)
- [X] I've searched the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [ ] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

US

### Provide a description that is worded well enough to be understood

Apologies. Apparently I'm cursed to periodically find issues with Crunchyroll downloads.

Here's my (perhaps a little overzealously) redacted cookies.txt. You'll notice that the string yt-dlp breaks on is contained in some of those Optanon cookies. Deleting them from the file and retrying the download works, so I'm guessing it's *just* that. Still, a proper fix would be much appreciated.

```
# Netscape HTTP Cookie File
# This file is generated by yt-dlp.  Do not edit.

.beta.crunchyroll.com	TRUE	/	TRUE	1670409672	OptanonAlertBoxClosed	2021-12-07T10:41:12.992Z
.beta.crunchyroll.com	TRUE	/	TRUE	1670635235	OptanonConsent	isIABGlobal=false&datestamp=Fri+Dec+10+2021+02%3A20%3A35+GMT%2B0100+(Central+European+Standard+Time)&version=6.26.0&hosts=&consentId=-snip-&interactionCount=2&landingPath=NotLandingPage&groups=-snip-&AwaitingReconsent=false&geolocation=DE%3B
.beta.crunchyroll.com	TRUE	/	FALSE	1670409674	OptanonControl	ccc=DE&otvers=6.26.0&reg=gdpr&pctm=Tue Dec 07 2021 11:41:12 GMT+0100 (Central European Standard Time)&vers=3.0.3
.crunchyroll.com	TRUE	/	TRUE	1665269728	__cf_bm	-snip-
.crunchyroll.com	TRUE	/	TRUE	0	__cfruid	-snip-
.crunchyroll.com	TRUE	/	FALSE	1699827826	ab.storage.deviceId.-snip-	-snip-
.crunchyroll.com	TRUE	/	FALSE	1699827856	ab.storage.sessionId.-snip-	-snip-
.crunchyroll.com	TRUE	/	FALSE	1699827826	ab.storage.userId.-snip-	-snip-
.crunchyroll.com	TRUE	/	FALSE	1679595856	c_visitor	-snip-
.crunchyroll.com	TRUE	/	FALSE	1666477421	etp_rt	-snip-
.crunchyroll.com	TRUE	/	TRUE	0	session_id	-snip-
.www.crunchyroll.com	TRUE	/	TRUE	1670410351	OptanonConsent	isIABGlobal=false&datestamp=Tue+Dec+07+2021+11%3A52%3A31+GMT%2B0100+(Central+European+Standard+Time)&version=6.26.0&hosts=&consentId=-snip-&interactionCount=1&landingPath=https%3A%2F%2Fwww.crunchyroll.com%2Flogin&groups=-snip-
.www.crunchyroll.com	TRUE	/	FALSE	1670410341	OptanonControl	ccc=US&otvers=&reg=ccpa&pctm=0&vers=3.0.3
beta.crunchyroll.com	FALSE	/	FALSE	1695580687	_evidon_suppress_notification_cookie	{"date":"2022-09-24T18:38:07.351Z"}
www.crunchyroll.com	FALSE	/	FALSE	1670410345	crunchyroll_beta_hide_banner	1
www.crunchyroll.com	FALSE	/	FALSE	1695579859	initial_referrer	https%3A%2F%2Fwww.google.com%2F
www.crunchyroll.com	FALSE	/forumtopic-1068039	FALSE	1670410300	initial_referrer	https%3A%2F%2Fwww.google.com%2F
```

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[debug] Command-line config: ['-vU', '--cookies', '../crunchyroll.com_cookies.txt', '--write-subs', '--embed-subs', 'https://beta.crunchyroll.com/watch/G9DUEP2JW/pressure']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.10.04 [4e0511f] (pip) API
[debug] Python 3.10.7 (CPython 64bit) - macOS-12.6-arm64-arm-64bit
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: ffmpeg 5.1.2 (setts), ffprobe 5.1.2, rtmpdump 2.4
[debug] Optional libraries: Cryptodome-3.13.0, brotli-1.0.9, certifi-2022.06.15, mutagen-1.45.1, sqlite3-2.6.0, websockets-10.1
[debug] Proxy map: {}
[debug] Loaded 1690 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: 2022.10.04, Current version: 2022.10.04
yt-dlp is up to date (2022.10.04)
[debug] [crunchyroll:beta] Extracting URL: https://beta.crunchyroll.com/watch/G9DUEP2JW/pressure
ERROR: Illegal key 'Time)&vers'
Traceback (most recent call last):
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1477, in wrapper
    return func(self, *args, **kwargs)
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1553, in __extract_info
    ie_result = ie.extract(url)
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/extractor/common.py", line 672, in extract
    ie_result = self._real_extract(url)
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/extractor/crunchyroll.py", line 814, in _real_extract
    api_domain, bucket, params = self._get_params(lang)
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/extractor/crunchyroll.py", line 723, in _get_params
    if self._get_cookies(f'https://beta.crunchyroll.com/{lang}').get('etp_rt'):
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/extractor/common.py", line 3645, in _get_cookies
    return LenientSimpleCookie(self._downloader._calc_cookies(url))
  File "/opt/homebrew/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/http/cookies.py", line 483, in __init__
    self.load(input)
  File "/opt/homebrew/lib/python3.10/site-packages/yt_dlp/cookies.py", line 1087, in load
    morsel.set(key, real_value, coded_value)
  File "/opt/homebrew/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/http/cookies.py", line 353, in set
    raise CookieError('Illegal key %r' % (key,))
http.cookies.CookieError: Illegal key 'Time)&vers'
```


Discussion:

Patch:
diff --git a/yt_dlp/cookies.py b/yt_dlp/cookies.py
index 3032d0712..8ca7cea2c 100644
--- a/yt_dlp/cookies.py
+++ b/yt_dlp/cookies.py
@@ -999,8 +999,9 @@ def _parse_browser_specification(browser_name, profile=None, keyring=None, conta
 class LenientSimpleCookie(http.cookies.SimpleCookie):
     """More lenient version of http.cookies.SimpleCookie"""
     # From https://github.com/python/cpython/blob/v3.10.7/Lib/http/cookies.py
-    _LEGAL_KEY_CHARS = r"\w\d!#%&'~_`><@,:/\$\*\+\-\.\^\|\)\(\?\}\{\="
-    _LEGAL_VALUE_CHARS = _LEGAL_KEY_CHARS + r"\[\]"
+    # We use Morsel's legal key chars to avoid errors on setting values
+    _LEGAL_KEY_CHARS = r'\w\d' + re.escape('!#$%&\'*+-.:^_`|~')
+    _LEGAL_VALUE_CHARS = _LEGAL_KEY_CHARS + re.escape('(),/<=>?@[]{}')
 
     _RESERVED = {
         "expires",
@@ -1046,25 +1047,17 @@ def load(self, data):
             return super().load(data)
 
         morsel = None
-        index = 0
-        length = len(data)
-
-        while 0 <= index < length:
-            match = self._COOKIE_PATTERN.search(data, index)
-            if not match:
-                break
-
-            index = match.end(0)
-            if match.group("bad"):
+        for match in self._COOKIE_PATTERN.finditer(data):
+            if match.group('bad'):
                 morsel = None
                 continue
 
-            key, value = match.group("key", "val")
+            key, value = match.group('key', 'val')
 
-            if key[0] == "$":
-                if morsel is not None:
-                    morsel[key[1:]] = True
-                continue
+            is_attribute = False
+            if key.startswith('$'):
+                key = key[1:]
+                is_attribute = True
 
             lower_key = key.lower()
             if lower_key in self._RESERVED:
@@ -1081,6 +1074,9 @@ def load(self, data):
 
                 morsel[key] = value
 
+            elif is_attribute:
+                morsel = None
+
             elif value is not None:
                 morsel = self.get(key, http.cookies.Morsel())
                 real_value, coded_value = self.value_decode(value)
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index 6f153bb3c..35e41753a 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -2832,7 +2832,7 @@ def _decrypt_nsig(self, s, video_id, player_url):
             self.report_warning(
                 f'Native nsig extraction failed: Trying with PhantomJS\n'
                 f'         n = {s} ; player = {player_url}', video_id)
-            self.write_debug(e)
+            self.write_debug(e, only_once=True)
 
             args, func_body = func_code
             ret = jsi.execute(
diff --git a/yt_dlp/jsinterp.py b/yt_dlp/jsinterp.py
index 4caad6f74..e25997129 100644
--- a/yt_dlp/jsinterp.py
+++ b/yt_dlp/jsinterp.py
@@ -236,7 +236,7 @@ def _regex_flags(cls, expr):
 
     @staticmethod
     def _separate(expr, delim=',', max_split=None):
-        OP_CHARS = '+-*/%&|^=<>!,;{}:'
+        OP_CHARS = '+-*/%&|^=<>!,;{}:['
         if not expr:
             return
         counters = {k: 0 for k in _MATCHING_PARENS.values()}
@@ -246,7 +246,9 @@ def _separate(expr, delim=',', max_split=None):
             if not in_quote and char in _MATCHING_PARENS:
                 counters[_MATCHING_PARENS[char]] += 1
             elif not in_quote and char in counters:
-                counters[char] -= 1
+                # Something's wrong if we get negative, but ignore it anyway
+                if counters[char]:
+                    counters[char] -= 1
             elif not escaping:
                 if char in _QUOTES and in_quote in (char, None):
                     if in_quote or after_op or char != '/':
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index c2327ae1d..6cfbcdb8d 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -2574,7 +2574,9 @@ def strftime_or_none(timestamp, date_format, default=None):
     datetime_object = None
     try:
         if isinstance(timestamp, (int, float)):  # unix timestamp
-            datetime_object = datetime.datetime.utcfromtimestamp(timestamp)
+            # Using naive datetime here can break timestamp() in Windows
+            # Ref: https://github.com/yt-dlp/yt-dlp/issues/5185, https://github.com/python/cpython/issues/94414
+            datetime_object = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc)
         elif isinstance(timestamp, str):  # assume YYYYMMDD
             datetime_object = datetime.datetime.strptime(timestamp, '%Y%m%d')
         date_format = re.sub(  # Support %s on windows


Test patch:
diff --git a/test/test_cookies.py b/test/test_cookies.py
index 61619df29..4155bcbf5 100644
--- a/test/test_cookies.py
+++ b/test/test_cookies.py
@@ -277,9 +277,24 @@ def test_lenient_parsing(self):
                 "a=b; invalid; Version=1; c=d",
                 {"a": "b", "c": "d"},
             ),
+            (
+                "Reset morsel after invalid to not capture attributes",
+                "a=b; $invalid; $Version=1; c=d",
+                {"a": "b", "c": "d"},
+            ),
             (
                 "Continue after non-flag attribute without value",
                 "a=b; path; Version=1; c=d",
                 {"a": "b", "c": "d"},
             ),
+            (
+                "Allow cookie attributes with `$` prefix",
+                'Customer="WILE_E_COYOTE"; $Version=1; $Secure; $Path=/acme',
+                {"Customer": ("WILE_E_COYOTE", {"version": "1", "secure": True, "path": "/acme"})},
+            ),
+            (
+                "Invalid Morsel keys should not result in an error",
+                "Key=Value; [Invalid]=Value; Another=Value",
+                {"Key": "Value", "Another": "Value"},
+            ),
         )
diff --git a/test/test_jsinterp.py b/test/test_jsinterp.py
index 92ef532f5..3c4391c4a 100644
--- a/test/test_jsinterp.py
+++ b/test/test_jsinterp.py
@@ -392,6 +392,11 @@ def test_regex(self):
         ''')
         self.assertEqual(jsi.call_function('x').pattern, r',][}",],()}(\[)')
 
+        jsi = JSInterpreter(R'''
+        function x() { let a=[/[)\\]/]; return a[0]; }
+        ''')
+        self.assertEqual(jsi.call_function('x').pattern, r'[)\\]')
+
     def test_char_code_at(self):
         jsi = JSInterpreter('function x(i){return "test".charCodeAt(i)}')
         self.assertEqual(jsi.call_function('x', 0), 116)
diff --git a/test/test_youtube_signature.py b/test/test_youtube_signature.py
index c3dcb4d68..6d753fbf0 100644
--- a/test/test_youtube_signature.py
+++ b/test/test_youtube_signature.py
@@ -130,6 +130,10 @@
         'https://www.youtube.com/s/player/5a3b6271/player_ias.vflset/en_US/base.js',
         'B2j7f_UPT4rfje85Lu_e', 'm5DmNymaGQ5RdQ',
     ),
+    (
+        'https://www.youtube.com/s/player/7a062b77/player_ias.vflset/en_US/base.js',
+        'NRcE3y3mVtm_cV-W', 'VbsCYUATvqlt5w',
+    ),
 ]