sni-cli 1.2.0__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {sni_cli-1.2.0 → sni_cli-1.2.2}/PKG-INFO +5 -5
  2. {sni_cli-1.2.0 → sni_cli-1.2.2}/README.md +4 -4
  3. {sni_cli-1.2.0 → sni_cli-1.2.2}/pyproject.toml +1 -1
  4. sni_cli-1.2.2/sni/__init__.py +1 -0
  5. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/cli.py +20 -13
  6. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/exceptions.py +2 -1
  7. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/allanime.py +128 -93
  8. sni_cli-1.2.0/sni/__init__.py +0 -1
  9. {sni_cli-1.2.0 → sni_cli-1.2.2}/.gitignore +0 -0
  10. {sni_cli-1.2.0 → sni_cli-1.2.2}/LICENSE +0 -0
  11. {sni_cli-1.2.0 → sni_cli-1.2.2}/install.ps1 +0 -0
  12. {sni_cli-1.2.0 → sni_cli-1.2.2}/install.sh +0 -0
  13. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/__main__.py +0 -0
  14. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/config.py +0 -0
  15. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/logger.py +0 -0
  16. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/player.py +0 -0
  17. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/__init__.py +0 -0
  18. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/base.py +0 -0
  19. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/cache.py +0 -0
  20. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/extractors/__init__.py +0 -0
  21. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/extractors/megacloud.py +0 -0
  22. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/extractors/vixcloud.py +0 -0
  23. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/providers/registry.py +0 -0
  24. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/__init__.py +0 -0
  25. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/app.py +0 -0
  26. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/bridge.py +0 -0
  27. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/screens/__init__.py +0 -0
  28. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/screens/help.py +0 -0
  29. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/screens/history.py +0 -0
  30. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/screens/home.py +0 -0
  31. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/screens/player.py +0 -0
  32. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/widgets/__init__.py +0 -0
  33. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/widgets/ascii_art.py +0 -0
  34. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/tui/widgets/info_box.py +0 -0
  35. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/ui.py +0 -0
  36. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/watch_history.py +0 -0
  37. {sni_cli-1.2.0 → sni_cli-1.2.2}/sni/wizard.py +0 -0
  38. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/__init__.py +0 -0
  39. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/conftest.py +0 -0
  40. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/test_allanime_captcha.py +0 -0
  41. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/test_cache.py +0 -0
  42. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/test_config.py +0 -0
  43. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/test_exceptions.py +0 -0
  44. {sni_cli-1.2.0 → sni_cli-1.2.2}/tests/test_providers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sni-cli
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Stream Ninja Interface — terminal-based anime streaming CLI + TUI
5
5
  Project-URL: Homepage, https://github.com/sundeepyt2/SNI
6
6
  Project-URL: Source, https://github.com/sundeepyt2/SNI
@@ -356,9 +356,9 @@ sni tui
356
356
 
357
357
  ## AllAnime captcha fix
358
358
 
359
- **First: you probably don't need any of this.** SNI now automatically tries multiple AllAnime API mirrors (`api.allanime.day` + `api.allmanga.to`) before giving up. Most users can just run `sni play "one piece"` directly and it works — if one mirror is captcha-walled, SNI silently falls back to the other.
359
+ **SNI v1.2.1+ auto-fixes captcha for you.** When `api.allanime.day` captcha-walls your IP, SNI automatically retries the request through a free public CORS proxy (`proxy.cors.sh`). This happens silently, with zero setup. Most users can just run `sni play "one piece"` and it works — no cookies, no CF Worker, no config changes.
360
360
 
361
- If you DO hit a `NEED_CAPTCHA` error (all mirrors failed), try these in order. Run `sni config --cookie-info` to see all options in a single panel.
361
+ If you STILL see `NEED_CAPTCHA` (rare — means all proxies were also captcha-walled), try these in order. Run `sni config --cookie-info` to see all options in a single panel.
362
362
 
363
363
  ### Option 1 — Browser cookies (if your IP isn't permanently flagged)
364
364
 
@@ -380,9 +380,9 @@ echo 'cf_clearance=...;' > ~/.config/sni/allanime_cookies.txt
380
380
  sni play "one piece" --cookie 'cf_clearance=...;'
381
381
  ```
382
382
 
383
- ### Option 2 — Cloudflare Worker (only for VPN/shared IPs that are permanently captcha-walled AND cookies don't work)
383
+ ### Option 2 — Cloudflare Worker (last resort for VPN/shared IPs that are permanently captcha-walled AND cookies + public proxies don't work)
384
384
 
385
- This is the most powerful bypass — it proxies AllAnime API requests through Cloudflare's own IPs, which AllAnime rarely challenges. Only set this up if Option 1 fails.
385
+ This is the most powerful bypass — it proxies AllAnime API requests through Cloudflare's own IPs, which AllAnime rarely challenges. Only set this up if Option 1 fails AND SNI's built-in proxy fallback doesn't work for you.
386
386
 
387
387
  **Deploy via Cloudflare** (free, requires a Cloudflare account):
388
388
 
@@ -303,9 +303,9 @@ sni tui
303
303
 
304
304
  ## AllAnime captcha fix
305
305
 
306
- **First: you probably don't need any of this.** SNI now automatically tries multiple AllAnime API mirrors (`api.allanime.day` + `api.allmanga.to`) before giving up. Most users can just run `sni play "one piece"` directly and it works — if one mirror is captcha-walled, SNI silently falls back to the other.
306
+ **SNI v1.2.1+ auto-fixes captcha for you.** When `api.allanime.day` captcha-walls your IP, SNI automatically retries the request through a free public CORS proxy (`proxy.cors.sh`). This happens silently, with zero setup. Most users can just run `sni play "one piece"` and it works — no cookies, no CF Worker, no config changes.
307
307
 
308
- If you DO hit a `NEED_CAPTCHA` error (all mirrors failed), try these in order. Run `sni config --cookie-info` to see all options in a single panel.
308
+ If you STILL see `NEED_CAPTCHA` (rare — means all proxies were also captcha-walled), try these in order. Run `sni config --cookie-info` to see all options in a single panel.
309
309
 
310
310
  ### Option 1 — Browser cookies (if your IP isn't permanently flagged)
311
311
 
@@ -327,9 +327,9 @@ echo 'cf_clearance=...;' > ~/.config/sni/allanime_cookies.txt
327
327
  sni play "one piece" --cookie 'cf_clearance=...;'
328
328
  ```
329
329
 
330
- ### Option 2 — Cloudflare Worker (only for VPN/shared IPs that are permanently captcha-walled AND cookies don't work)
330
+ ### Option 2 — Cloudflare Worker (last resort for VPN/shared IPs that are permanently captcha-walled AND cookies + public proxies don't work)
331
331
 
332
- This is the most powerful bypass — it proxies AllAnime API requests through Cloudflare's own IPs, which AllAnime rarely challenges. Only set this up if Option 1 fails.
332
+ This is the most powerful bypass — it proxies AllAnime API requests through Cloudflare's own IPs, which AllAnime rarely challenges. Only set this up if Option 1 fails AND SNI's built-in proxy fallback doesn't work for you.
333
333
 
334
334
  **Deploy via Cloudflare** (free, requires a Cloudflare account):
335
335
 
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
9
9
  # just `sni` (defined below in [project.scripts]), so users run `sni play`,
10
10
  # `sni tui`, etc. — only `pip install sni-cli` differs from the bare name.
11
11
  name = "sni-cli"
12
- version = "1.2.0"
12
+ version = "1.2.2"
13
13
  description = "Stream Ninja Interface — terminal-based anime streaming CLI + TUI"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "1.2.2"
@@ -142,6 +142,8 @@ async def _search(
142
142
  results.append((provider_name, hits))
143
143
  except CaptchaRequiredError:
144
144
  raise # let the caller print the helpful panel
145
+ except ProviderError:
146
+ raise # let the caller print the error
145
147
  except Exception as e:
146
148
  logger.exception(f"Search failed for '{query}' with provider '{provider_name}'")
147
149
  typer.echo(f"Search failed: {type(e).__name__}: {e}")
@@ -192,6 +194,9 @@ async def _play(
192
194
  except CaptchaRequiredError as e:
193
195
  _print_captcha_help(e)
194
196
  return
197
+ except ProviderError as e:
198
+ typer.echo(f"Error: {e}")
199
+ return
195
200
  if not results:
196
201
  typer.echo("No results found.")
197
202
  return
@@ -257,6 +262,9 @@ async def _watch(
257
262
  except CaptchaRequiredError as e:
258
263
  _print_captcha_help(e)
259
264
  return
265
+ except ProviderError as e:
266
+ typer.echo(f"Error: {e}")
267
+ return
260
268
  if not results:
261
269
  typer.echo("No results found.")
262
270
  return
@@ -305,6 +313,9 @@ async def _search_cmd(
305
313
  except CaptchaRequiredError as e:
306
314
  _print_captcha_help(e)
307
315
  raise typer.Exit(code=1)
316
+ except ProviderError as e:
317
+ typer.echo(f"Error: {e}")
318
+ raise typer.Exit(code=1)
308
319
 
309
320
  if not results:
310
321
  typer.echo("No results found.")
@@ -502,12 +513,12 @@ def config(
502
513
  if cookie_info:
503
514
  from sni.config import DEFAULT_COOKIES_PATH
504
515
  console.print(Panel(
505
- "[bold green]First: try SNI without any of this.[/bold green]\n"
506
- "SNI now tries multiple AllAnime API mirrors automatically\n"
507
- "(api.allanime.day + api.allmanga.to) before giving up. Most users\n"
508
- "never need cookies OR a CF Worker. Just run `sni play \"one piece\"`\n"
509
- "directly — if it works, you're done.\n\n"
510
- "[bold]If you DO hit a NEED_CAPTCHA error (all mirrors failed),[/bold]\n"
516
+ "[bold green]SNI now auto-fixes captcha you probably don't need this.[/bold green]\n"
517
+ "When api.allanime.day captcha-walls your IP, SNI automatically\n"
518
+ "retries the request through a free public CORS proxy\n"
519
+ "(proxy.cors.sh). This happens silently, with zero setup. Just\n"
520
+ "run `sni play \"one piece\"` — if it works, you're done.\n\n"
521
+ "[bold]If you STILL hit a NEED_CAPTCHA error (all proxies failed),[/bold]\n"
511
522
  "[bold]try these in order:[/bold]\n\n"
512
523
  "[bold cyan]Option 1 - Browser cookies:[/bold cyan]\n"
513
524
  " Get cookies from a working allanime mirror (NOT allanime.day which\n"
@@ -518,8 +529,8 @@ def config(
518
529
  " sni config --update allanime_cookies='cf_clearance=...;'\n"
519
530
  " Or write to file (easier to refresh):\n"
520
531
  f" echo 'cf_clearance=...;' > {DEFAULT_COOKIES_PATH}\n\n"
521
- "[bold yellow]Option 2 - Cloudflare Worker (only if cookies don't work[/bold yellow]\n"
522
- "[bold yellow]and you're on a VPN/shared IP):[/bold yellow]\n"
532
+ "[bold yellow]Option 2 - Cloudflare Worker[/bold yellow]\n"
533
+ "[bold yellow](last resort for VPN/shared IPs):[/bold yellow]\n"
523
534
  " Deploy the XAN CF Worker (free, requires Cloudflare account):\n"
524
535
  " 1. https://dash.cloudflare.com -> Workers & Pages -> Create\n"
525
536
  " 2. Paste XAN/cf-worker/worker.js from the XAN repo\n"
@@ -528,11 +539,7 @@ def config(
528
539
  " Can't create a Cloudflare account? Deploy the same worker.js to:\n"
529
540
  " - Deno Deploy (https://dash.deno.com) - free, no card required\n"
530
541
  " - Vercel Edge Functions (https://vercel.com) - free tier\n"
531
- " - Netlify Functions (https://netlify.com) - free tier\n\n"
532
- "[dim]Note: hianime and animepahe providers were removed in v1.2.0[/dim]\n"
533
- "[dim](both were dead — hianime.to domain gone, animepahe API deprecated).[/dim]\n"
534
- "[dim]AllAnime is now the only provider; the mirror fallback handles[/dim]\n"
535
- "[dim]most captcha cases automatically.[/dim]",
542
+ " - Netlify Functions (https://netlify.com) - free tier\n",
536
543
  title="AllAnime captcha bypass",
537
544
  border_style="cyan",
538
545
  ))
@@ -22,7 +22,8 @@ class CaptchaRequiredError(ProviderError):
22
22
 
23
23
  def __init__(self, message: str = "Provider requires captcha.", hint: str = ""):
24
24
  self.hint = hint or (
25
- "All AllAnime API mirrors were captcha-walled. Two options:\n"
25
+ "All AllAnime endpoints + public proxy fallbacks were captcha-walled.\n"
26
+ "Two options:\n"
26
27
  " 1. Browser cookies from a working mirror (NOT allanime.day which\n"
27
28
  " is broken with a redirect loop). Use allmanga.to or\n"
28
29
  " allanime.uns.bio, then:\n"
@@ -58,8 +58,8 @@ USER_AGENT = (
58
58
  REFERER = "https://youtu-chan.com"
59
59
  ORIGIN = "https://youtu-chan.com"
60
60
 
61
- REQUEST_TIMEOUT = 15.0
62
- CLOCK_TIMEOUT = 20.0
61
+ REQUEST_TIMEOUT = 30.0 # increased from 15s — proxy connections can be slow
62
+ CLOCK_TIMEOUT = 30.0
63
63
 
64
64
  # Regex used by the embed scraper to pull HLS / MP4 URLs out of HTML pages.
65
65
  # Mirrors XAN's scrapeEmbedPage regexes.
@@ -146,33 +146,66 @@ def _is_captcha_response(resp: httpx.Response) -> bool:
146
146
  return "just a moment" in body_start or "cf-mitigated" in resp.headers
147
147
 
148
148
 
149
+ def _wrap_fallback_error(last_error: Optional[Exception], endpoint: str) -> Exception:
150
+ """Wrap a network/captcha error in a user-friendly ProviderError.
151
+
152
+ If the last error was already a CaptchaRequiredError, return it as-is
153
+ (it already has a helpful hint). If it was a network error (ConnectTimeout,
154
+ ReadTimeout, etc.), wrap it in a ProviderError with a clear message
155
+ explaining that SNI couldn't reach AllAnime and suggesting fixes.
156
+ """
157
+ if last_error is None:
158
+ return CaptchaRequiredError(f"AllAnime {endpoint} failed with no specific error")
159
+
160
+ if isinstance(last_error, CaptchaRequiredError):
161
+ return last_error
162
+
163
+ # Network errors (ConnectTimeout, ReadTimeout, ConnectError, etc.)
164
+ if isinstance(last_error, (httpx.TimeoutException, httpx.HTTPError)):
165
+ err_name = type(last_error).__name__
166
+ return ProviderError(
167
+ f"SNI could not connect to AllAnime ({err_name} on {endpoint}).\n\n"
168
+ f"This is a NETWORK issue, not a captcha. SNI tried direct + proxy.cors.sh "
169
+ f"but couldn't reach any of them.\n\n"
170
+ f"Possible causes:\n"
171
+ f" 1. Your internet is down or unstable\n"
172
+ f" 2. Your ISP/firewall blocks api.allanime.day or proxy.cors.sh\n"
173
+ f" 3. Your DNS can't resolve these hosts\n"
174
+ f" 4. You're on a restrictive network (school/work/country firewall)\n\n"
175
+ f"Try:\n"
176
+ f" - Check your internet: curl -I https://api.allanime.day\n"
177
+ f" - Try a different network (mobile hotspot, VPN)\n"
178
+ f" - If on a VPN, try WITHOUT it (some VPNs are blocked)\n"
179
+ f" - Check DNS: nslookup api.allanime.day\n"
180
+ f" - Try again in a few minutes (temporary outage)"
181
+ )
182
+
183
+ # Any other exception — wrap it
184
+ return ProviderError(f"AllAnime {endpoint} failed: {type(last_error).__name__}: {last_error}")
185
+
186
+
149
187
  class AllAnimeProvider(Provider):
150
188
  name = "allanime"
151
189
  domain = "allanime.day"
152
190
  supports_sub = True
153
191
  supports_dub = True
154
192
 
155
- # AllAnime API mirrors. When the primary api.allanime.day captcha-walls
156
- # the user's IP, SNI automatically retries the same request against each
157
- # mirror in order. Mirrors are tried BEFORE the CF Worker fallback because
158
- # they require zero setup from the user.
159
- #
160
- # Format: (api_graphql_url, api_url, allanime_base_url)
161
- API_MIRRORS = [
162
- # Primary — most reliable when not captcha-walled
163
- ("https://api.allanime.day/api/graphql",
164
- "https://api.allanime.day/api",
165
- "https://allanime.day"),
166
- # Mirror 1 — allmanga.to (sister site, shared backend)
167
- ("https://api.allmanga.to/api/graphql",
168
- "https://api.allmanga.to/api",
169
- "https://allmanga.to"),
170
- ]
171
-
193
+ # AllAnime API endpoints (XAN pattern).
194
+ GRAPHQL_URL = "https://api.allanime.day/api/graphql"
195
+ API_URL = "https://api.allanime.day/api"
172
196
  ALLANIME_BASE = "https://allanime.day"
173
197
  REFERER = REFERER
174
198
  ORIGIN = ORIGIN
175
199
 
200
+ # Free public CORS proxies that support POST + JSON bodies. SNI tries
201
+ # each one in order when the direct request to api.allanime.day is
202
+ # captcha-walled. As of 2026, most public CORS proxies have shut down
203
+ # or block POST. proxy.cors.sh is the only reliable one left.
204
+ # Format: a format string with a single {url} placeholder.
205
+ PUBLIC_PROXIES = [
206
+ "https://proxy.cors.sh/{url}",
207
+ ]
208
+
176
209
  def __init__(self, cookies: str = "", cf_worker_url: str = ""):
177
210
  self.cookies_str = cookies
178
211
  self._cookies: Dict[str, str] = {}
@@ -183,12 +216,10 @@ class AllAnimeProvider(Provider):
183
216
  k, v = part.split("=", 1)
184
217
  self._cookies[k.strip()] = v.strip()
185
218
  self.cf_worker_url = (cf_worker_url or "").rstrip("/")
186
- # Tracks which mirror actually worked, so we can reuse it on subsequent
187
- # calls instead of trying the primary every time.
188
- self._working_mirror_idx: Optional[int] = None
189
-
190
- def _mirror(self, idx: int) -> tuple:
191
- return self.API_MIRRORS[idx]
219
+ # Tracks which proxy actually worked, so we can reuse it on subsequent
220
+ # calls instead of trying the dead primary every time.
221
+ # None = direct (no proxy), 0 = first public proxy, etc.
222
+ self._working_proxy_idx: Optional[int] = None
192
223
 
193
224
  def _base_headers(self, *, json_body: bool = False) -> Dict[str, str]:
194
225
  h = {
@@ -201,27 +232,39 @@ class AllAnimeProvider(Provider):
201
232
  h["Content-Type"] = "application/json"
202
233
  return h
203
234
 
235
+ def _proxy_url(self, idx: int, target: str) -> str:
236
+ """Wrap an upstream URL with the i-th public proxy from PUBLIC_PROXIES."""
237
+ return self.PUBLIC_PROXIES[idx].format(url=target)
238
+
204
239
  async def _post_graphql(self, query: str, variables: dict) -> dict:
205
240
  """POST a regular GraphQL query to /api/graphql.
206
241
 
207
- Tries each API mirror in order. If a mirror has previously worked
208
- during this session, that mirror is tried first (faster). On the
209
- first captcha from a mirror, SNI silently moves to the next mirror.
210
- Only when ALL mirrors fail does it raise CaptchaRequiredError.
242
+ Fallback order:
243
+ 1. Direct request to api.allanime.day (fastest when not captcha-walled)
244
+ 2. Each public CORS proxy in PUBLIC_PROXIES (no user config needed)
245
+ 3. CF Worker (only if user configured allanime_cf_worker_url — but
246
+ CF Workers are GET-only, so this raises a clear error instead)
247
+
248
+ CaptchaRequiredError is raised only when ALL fallbacks fail.
211
249
  """
212
250
  payload = {"query": query, "variables": variables}
213
251
  headers = self._base_headers(json_body=True)
214
252
 
215
- # Build the list of mirror indices to try, starting with the
216
- # previously-working one (if any) so we don't repeat failures.
217
- order = list(range(len(self.API_MIRRORS)))
218
- if self._working_mirror_idx is not None:
219
- order.remove(self._working_mirror_idx)
220
- order.insert(0, self._working_mirror_idx)
253
+ # Build the list of fallbacks to try.
254
+ # Each entry: ("direct"|"proxy"|"worker", url)
255
+ fallbacks: List[tuple] = [("direct", self.GRAPHQL_URL)]
256
+ for i in range(len(self.PUBLIC_PROXIES)):
257
+ fallbacks.append(("proxy", self._proxy_url(i, self.GRAPHQL_URL)))
258
+
259
+ # If a proxy previously worked, try it first (skip the dead direct)
260
+ if self._working_proxy_idx is not None:
261
+ # Move the working proxy to the front
262
+ working = fallbacks[self._working_proxy_idx + 1] # +1 because [0] is direct
263
+ fallbacks.remove(working)
264
+ fallbacks.insert(0, working)
221
265
 
222
266
  last_error: Optional[Exception] = None
223
- for idx in order:
224
- graphql_url, _api_url, _base_url = self._mirror(idx)
267
+ for kind, url in fallbacks:
225
268
  async with httpx.AsyncClient(
226
269
  headers=headers,
227
270
  cookies=self._cookies,
@@ -229,17 +272,14 @@ class AllAnimeProvider(Provider):
229
272
  timeout=REQUEST_TIMEOUT,
230
273
  ) as client:
231
274
  try:
232
- resp = await client.post(graphql_url, json=payload)
275
+ resp = await client.post(url, json=payload)
233
276
  except (httpx.HTTPError, httpx.TimeoutException) as e:
234
- # Network error — try next mirror
235
277
  last_error = e
236
278
  continue
237
279
 
238
280
  if _is_captcha_response(resp):
239
- # This mirror is captcha-walled — try the next one
240
281
  last_error = CaptchaRequiredError(
241
- f"AllAnime mirror {idx} ({graphql_url}) returned "
242
- f"HTTP {resp.status_code}",
282
+ f"AllAnime {kind} request returned HTTP {resp.status_code}",
243
283
  )
244
284
  continue
245
285
 
@@ -247,7 +287,7 @@ class AllAnimeProvider(Provider):
247
287
  raw = resp.json()
248
288
  except (ValueError, json.JSONDecodeError):
249
289
  last_error = ProviderError(
250
- f"Mirror {idx} returned non-JSON: {resp.text[:200]}",
290
+ f"{kind} returned non-JSON: {resp.text[:200]}",
251
291
  )
252
292
  continue
253
293
 
@@ -257,38 +297,53 @@ class AllAnimeProvider(Provider):
257
297
  last_error = e
258
298
  continue
259
299
 
260
- # Success — remember this mirror for next time
261
- self._working_mirror_idx = idx
300
+ # Success — remember which fallback worked
301
+ if kind == "direct":
302
+ self._working_proxy_idx = None
303
+ elif kind == "proxy":
304
+ self._working_proxy_idx = self.PUBLIC_PROXIES.index(
305
+ next(p for p in self.PUBLIC_PROXIES if url.startswith(p.split("{")[0]))
306
+ )
262
307
  return raw
263
308
 
264
- # All mirrors failed
265
- raise last_error or CaptchaRequiredError(
266
- "All AllAnime API mirrors failed for /api/graphql",
267
- )
309
+ # All fallbacks failed — wrap the error in a user-friendly message
310
+ raise _wrap_fallback_error(last_error, "/api/graphql")
268
311
 
269
312
  async def _get_persisted(self, variables: dict) -> dict:
270
313
  """GET /api?variables=...&extensions=persistedQuery...
271
314
 
272
- Tries each API mirror in order (same as _post_graphql), then falls
273
- back to the CF Worker if configured. CaptchaRequiredError is raised
274
- only when all mirrors AND the CF Worker fail.
315
+ Fallback order:
316
+ 1. Direct request to api.allanime.day
317
+ 2. Each public CORS proxy in PUBLIC_PROXIES
318
+ 3. CF Worker (only if user configured allanime_cf_worker_url)
319
+
320
+ CaptchaRequiredError is raised only when ALL fallbacks fail.
275
321
  """
276
322
  extensions = {"persistedQuery": {"version": 1, "sha256Hash": EPISODE_QUERY_HASH}}
277
323
  params = {
278
324
  "variables": json.dumps(variables),
279
325
  "extensions": json.dumps(extensions),
280
326
  }
281
-
282
- order = list(range(len(self.API_MIRRORS)))
283
- if self._working_mirror_idx is not None:
284
- order.remove(self._working_mirror_idx)
285
- order.insert(0, self._working_mirror_idx)
327
+ direct_url = f"{self.API_URL}?{urlencode(params)}"
328
+
329
+ # Build the list of fallbacks to try
330
+ fallbacks: List[tuple] = [("direct", direct_url)]
331
+ for i in range(len(self.PUBLIC_PROXIES)):
332
+ fallbacks.append(("proxy", self._proxy_url(i, direct_url)))
333
+ if self.cf_worker_url:
334
+ fallbacks.append(("worker", _build_cf_worker_url(
335
+ self.cf_worker_url, direct_url,
336
+ extra_headers={"Referer": self.REFERER, "Origin": self.ORIGIN},
337
+ )))
338
+
339
+ # If a proxy previously worked, try it first
340
+ if self._working_proxy_idx is not None:
341
+ working = fallbacks[self._working_proxy_idx + 1]
342
+ fallbacks.remove(working)
343
+ fallbacks.insert(0, working)
286
344
 
287
345
  last_error: Optional[Exception] = None
288
- for idx in order:
289
- _gql_url, api_url, _base_url = self._mirror(idx)
290
- direct_url = f"{api_url}?{urlencode(params)}"
291
-
346
+ for kind, url in fallbacks:
292
347
  async with httpx.AsyncClient(
293
348
  headers=self._base_headers(),
294
349
  cookies=self._cookies,
@@ -296,33 +351,14 @@ class AllAnimeProvider(Provider):
296
351
  timeout=REQUEST_TIMEOUT,
297
352
  ) as client:
298
353
  try:
299
- resp = await client.get(direct_url)
354
+ resp = await client.get(url)
300
355
  except (httpx.HTTPError, httpx.TimeoutException) as e:
301
356
  last_error = e
302
357
  continue
303
358
 
304
359
  if _is_captcha_response(resp):
305
- # Try CF Worker fallback for this mirror (only the
306
- # persisted-query GET can go through a CF Worker — the
307
- # GraphQL POST can't because Workers are GET-only).
308
- if self.cf_worker_url:
309
- wrapped = _build_cf_worker_url(
310
- self.cf_worker_url, direct_url,
311
- extra_headers={"Referer": self.REFERER, "Origin": self.ORIGIN},
312
- )
313
- try:
314
- cf_resp = await client.get(wrapped)
315
- cf_ct = cf_resp.headers.get("content-type", "").lower()
316
- if cf_resp.status_code < 400 and "json" in cf_ct:
317
- raw = cf_resp.json()
318
- self._check_graphql_errors(raw)
319
- self._working_mirror_idx = idx
320
- return raw
321
- except (httpx.HTTPError, json.JSONDecodeError):
322
- pass
323
360
  last_error = CaptchaRequiredError(
324
- f"AllAnime mirror {idx} ({api_url}) returned "
325
- f"HTTP {resp.status_code}",
361
+ f"AllAnime {kind} request returned HTTP {resp.status_code}",
326
362
  )
327
363
  continue
328
364
 
@@ -330,7 +366,7 @@ class AllAnimeProvider(Provider):
330
366
  raw = resp.json()
331
367
  except (ValueError, json.JSONDecodeError):
332
368
  last_error = ProviderError(
333
- f"Mirror {idx} returned non-JSON: {resp.text[:200]}",
369
+ f"{kind} returned non-JSON: {resp.text[:200]}",
334
370
  )
335
371
  continue
336
372
 
@@ -340,12 +376,15 @@ class AllAnimeProvider(Provider):
340
376
  last_error = e
341
377
  continue
342
378
 
343
- self._working_mirror_idx = idx
379
+ if kind == "direct":
380
+ self._working_proxy_idx = None
381
+ elif kind == "proxy":
382
+ self._working_proxy_idx = self.PUBLIC_PROXIES.index(
383
+ next(p for p in self.PUBLIC_PROXIES if url.startswith(p.split("{")[0]))
384
+ )
344
385
  return raw
345
386
 
346
- raise last_error or CaptchaRequiredError(
347
- "All AllAnime API mirrors failed for /api persisted query",
348
- )
387
+ raise _wrap_fallback_error(last_error, "/api persisted query")
349
388
 
350
389
  def _check_graphql_errors(self, raw: dict) -> None:
351
390
  """Inspect a parsed GraphQL response for errors, raising the right
@@ -593,14 +632,10 @@ class AllAnimeProvider(Provider):
593
632
  (HLS or MP4) with resolution labels. We pick the highest-priority one.
594
633
  """
595
634
  full_path = path.replace("/clock", "/clock.json")
596
- # Use the working mirror's base URL (or primary if none worked yet)
597
- if self._working_mirror_idx is not None:
598
- _g, _a, base_url = self._mirror(self._working_mirror_idx)
599
- else:
600
- base_url = self.ALLANIME_BASE
635
+ # clock.json paths are relative to allanime.day itself (not the API)
601
636
  full_url = (
602
637
  full_path if full_path.startswith("http")
603
- else f"{base_url}{full_path}"
638
+ else f"{self.ALLANIME_BASE}{full_path}"
604
639
  )
605
640
 
606
641
  async with httpx.AsyncClient(
@@ -1 +0,0 @@
1
- __version__ = "1.2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes