sni-cli 1.1.1__py3-none-any.whl

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.
sni/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.1.1"
sni/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sni.cli import app
2
+
3
+ app()
sni/cli.py ADDED
@@ -0,0 +1,589 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Prompt
10
+
11
+ from sni import __version__
12
+ from sni.config import DEFAULT_CONFIG_PATH, Config
13
+ from sni.exceptions import CaptchaRequiredError, ProviderError, ProviderNotFoundError
14
+ from sni.logger import setup_logger
15
+ from sni.player import Player
16
+ from sni.providers.base import AnimeResult, Provider
17
+ from sni.providers.registry import ProviderRegistry
18
+ from sni.ui import (
19
+ display_episodes,
20
+ display_results,
21
+ format_anime_row,
22
+ prompt_next_episode,
23
+ select_episode_fzf,
24
+ select_with_fzf,
25
+ show_now_playing,
26
+ )
27
+ from sni.watch_history import WatchHistory
28
+ from sni.wizard import run_wizard
29
+
30
+ _IS_DUB = os.path.splitext(os.path.basename(sys.argv[0]))[0] in ("sni-d", "sni_dub")
31
+
32
+ app = typer.Typer(
33
+ name="sni",
34
+ help="Stream Ninja Interface - Anime CLI",
35
+ no_args_is_help=False,
36
+ )
37
+ logger = setup_logger()
38
+ console = Console()
39
+
40
+
41
+ def _resolve_cookies(provider_name: str, cfg: Config, cookie_flag: Optional[str]) -> str:
42
+ """Resolve cookies for ``provider_name``.
43
+
44
+ Precedence: explicit ``--cookie`` flag > config-stored cookies. Currently
45
+ only AllAnime uses cookies; other providers ignore the value.
46
+ """
47
+ if cookie_flag:
48
+ return cookie_flag
49
+ if provider_name == "allanime":
50
+ return cfg.get_allanime_cookies()
51
+ return ""
52
+
53
+
54
+ def _instantiate_provider(provider_name: str, cfg: Config, cookie_flag: Optional[str] = None):
55
+ """Instantiate a provider, injecting config-stored credentials.
56
+
57
+ For AllAnime this means cookies AND the optional CF Worker URL.
58
+ """
59
+ cls = ProviderRegistry.get(provider_name)
60
+ if cls is None:
61
+ return None
62
+ if provider_name == "allanime":
63
+ return cls(
64
+ cookies=_resolve_cookies(provider_name, cfg, cookie_flag),
65
+ cf_worker_url=cfg.get_allanime_cf_worker_url(),
66
+ )
67
+ return cls()
68
+
69
+
70
+ def _print_captcha_help(err: CaptchaRequiredError) -> None:
71
+ console.print(Panel(
72
+ f"[bold red]AllAnime captcha required[/bold red]\n\n{err.hint}",
73
+ title="Action needed",
74
+ border_style="red",
75
+ ))
76
+
77
+
78
+ def version_callback(value: bool):
79
+ if value:
80
+ typer.echo(f"sni v{__version__}")
81
+ raise typer.Exit()
82
+
83
+
84
+ @app.callback(invoke_without_command=True)
85
+ def main(
86
+ ctx: typer.Context,
87
+ version: bool = typer.Option(
88
+ False, "--version", help="Show version", callback=version_callback,
89
+ ),
90
+ debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
91
+ ):
92
+ if debug:
93
+ global logger
94
+ logger = setup_logger(debug=True)
95
+ if ctx.invoked_subcommand is None:
96
+ asyncio.run(_interactive())
97
+
98
+
99
+ async def _search(
100
+ query: str,
101
+ provider_name: Optional[str] = None,
102
+ all_providers: bool = False,
103
+ cookies: Optional[str] = None,
104
+ cf_worker_url: Optional[str] = None,
105
+ ) -> list[tuple[str, list[AnimeResult]]]:
106
+ results: list[tuple[str, list[AnimeResult]]] = []
107
+ cfg = Config.load()
108
+
109
+ def _instantiate(pname: str) -> Optional[Provider]:
110
+ cls = ProviderRegistry.get(pname)
111
+ if cls is None:
112
+ return None
113
+ if pname == "allanime":
114
+ return cls(
115
+ cookies=cookies or (cfg.get_allanime_cookies() if not cookies else ""),
116
+ cf_worker_url=cf_worker_url or cfg.get_allanime_cf_worker_url(),
117
+ )
118
+ return cls()
119
+
120
+ if all_providers:
121
+ for pname in ProviderRegistry.list():
122
+ provider = _instantiate(pname)
123
+ if provider is None:
124
+ continue
125
+ try:
126
+ hits = await provider.search(query)
127
+ results.append((pname, hits))
128
+ except CaptchaRequiredError:
129
+ # Captcha is provider-specific; re-raise only when this is the
130
+ # user's selected provider so the panel shows. Otherwise log.
131
+ logger.warning(f"{pname} returned captcha; skipping in --all-providers mode")
132
+ except Exception as e:
133
+ logger.exception(f"{pname} search failed for '{query}'")
134
+ logger.warning(f"{pname} search failed: {type(e).__name__}: {e}")
135
+ elif provider_name:
136
+ provider = _instantiate(provider_name)
137
+ if provider is None:
138
+ typer.echo(f"Unknown provider: {provider_name}")
139
+ return results
140
+ try:
141
+ hits = await provider.search(query)
142
+ results.append((provider_name, hits))
143
+ except CaptchaRequiredError:
144
+ raise # let the caller print the helpful panel
145
+ except Exception as e:
146
+ logger.exception(f"Search failed for '{query}' with provider '{provider_name}'")
147
+ typer.echo(f"Search failed: {type(e).__name__}: {e}")
148
+ else:
149
+ preferred = cfg.default_provider
150
+ order = ProviderRegistry.get_fallback_order(preferred)
151
+ for pname in order:
152
+ provider = _instantiate(pname)
153
+ if provider is None:
154
+ continue
155
+ try:
156
+ hits = await provider.search(query)
157
+ if hits:
158
+ results.append((pname, hits))
159
+ except CaptchaRequiredError:
160
+ if pname == preferred:
161
+ raise
162
+ logger.warning(f"{pname} returned captcha; skipped")
163
+ except Exception:
164
+ pass
165
+
166
+ return results
167
+
168
+
169
+ async def _interactive():
170
+ mode = Prompt.ask("Mode", choices=["play", "watch"], default="play")
171
+ query = Prompt.ask("Search for anime")
172
+ if mode == "play":
173
+ await _play(query, None, None, _IS_DUB, None, None)
174
+ else:
175
+ await _watch(query, None, None, _IS_DUB, None, None, False)
176
+
177
+
178
+ async def _play(
179
+ query: str,
180
+ provider: Optional[str],
181
+ quality: Optional[str],
182
+ dub: bool,
183
+ episodes: Optional[str],
184
+ cookie: Optional[str],
185
+ ):
186
+ cfg = Config.load()
187
+ provider_name = provider or cfg.default_provider
188
+ resolved_cookie = _resolve_cookies(provider_name, cfg, cookie)
189
+
190
+ try:
191
+ results = await _search(query, provider_name, cookies=resolved_cookie)
192
+ except CaptchaRequiredError as e:
193
+ _print_captcha_help(e)
194
+ return
195
+ if not results:
196
+ typer.echo("No results found.")
197
+ return
198
+
199
+ all_anime = [a for _, hits in results for a in hits]
200
+ selected = await select_with_fzf(all_anime, format_fn=format_anime_row)
201
+ if not selected:
202
+ typer.echo("No selection made.")
203
+ return
204
+
205
+ await _watch_anime(selected, provider_name, dub, quality, episodes, resolved_cookie)
206
+
207
+
208
+ async def _watch(
209
+ query: Optional[str],
210
+ provider: Optional[str],
211
+ quality: Optional[str],
212
+ dub: bool,
213
+ episodes: Optional[str],
214
+ cookie: Optional[str],
215
+ resume: bool = False,
216
+ ):
217
+ cfg = Config.load()
218
+ provider_name = provider or cfg.default_provider
219
+ resolved_cookie = _resolve_cookies(provider_name, cfg, cookie)
220
+
221
+ if resume:
222
+ history = WatchHistory()
223
+ entries = history.get_continue()
224
+ if not entries:
225
+ typer.echo("No watch history found.")
226
+ return
227
+
228
+ display_results(
229
+ [AnimeResult(id=e["anime_id"], title=e["anime_title"]) for e in entries],
230
+ provider="history",
231
+ )
232
+ selected_entry = await select_with_fzf(
233
+ entries,
234
+ format_fn=lambda e, i: f"{i + 1}. {e['anime_title']} (ep {e['last_episode']})",
235
+ )
236
+ if not selected_entry:
237
+ typer.echo("No selection made.")
238
+ return
239
+
240
+ provider_name = selected_entry["provider"]
241
+ selected = AnimeResult(
242
+ id=selected_entry["anime_id"],
243
+ title=selected_entry["anime_title"],
244
+ )
245
+ ep_str = str(selected_entry["last_episode"])
246
+ # Resume uses the original provider; re-resolve cookies for it.
247
+ resolved_cookie = _resolve_cookies(provider_name, cfg, cookie)
248
+ await _watch_anime(selected, provider_name, dub, quality, ep_str, resolved_cookie)
249
+ return
250
+
251
+ if not query:
252
+ typer.echo("Please provide a search query or use --resume.")
253
+ return
254
+
255
+ try:
256
+ results = await _search(query, provider_name, cookies=resolved_cookie)
257
+ except CaptchaRequiredError as e:
258
+ _print_captcha_help(e)
259
+ return
260
+ if not results:
261
+ typer.echo("No results found.")
262
+ return
263
+
264
+ all_anime = [a for _, hits in results for a in hits]
265
+ selected = await select_with_fzf(all_anime, format_fn=format_anime_row)
266
+ if not selected:
267
+ typer.echo("No selection made.")
268
+ return
269
+
270
+ await _watch_anime(selected, provider_name, dub, quality, episodes, resolved_cookie)
271
+
272
+
273
+ @app.command()
274
+ def search(
275
+ query: str = typer.Argument(..., help="Anime title to search"),
276
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Provider to use"),
277
+ all_providers: bool = typer.Option(False, "--all-providers", help="Search all providers"),
278
+ cookie: Optional[str] = typer.Option(
279
+ None, "--cookie", help="Browser cookies for providers that need them (allanime)",
280
+ ),
281
+ ):
282
+ """Search for anime."""
283
+ asyncio.run(_search_cmd(query, provider, all_providers, cookie))
284
+
285
+
286
+ async def _search_cmd(
287
+ query: str,
288
+ provider: Optional[str],
289
+ all_providers: bool,
290
+ cookie: Optional[str] = None,
291
+ ):
292
+ cfg = Config.load()
293
+ if all_providers:
294
+ # When iterating all providers we still want config cookies for allanime.
295
+ results = await _search(
296
+ query,
297
+ all_providers=True,
298
+ cookies=cfg.get_allanime_cookies() if not cookie else cookie,
299
+ )
300
+ else:
301
+ provider_name = provider or cfg.default_provider
302
+ resolved = _resolve_cookies(provider_name, cfg, cookie)
303
+ try:
304
+ results = await _search(query, provider_name=provider_name, cookies=resolved)
305
+ except CaptchaRequiredError as e:
306
+ _print_captcha_help(e)
307
+ raise typer.Exit(code=1)
308
+
309
+ if not results:
310
+ typer.echo("No results found.")
311
+ raise typer.Exit()
312
+
313
+ all_anime = []
314
+ for pname, hits in results:
315
+ display_results(hits, provider=pname)
316
+ all_anime.extend(hits)
317
+
318
+ selected = await select_with_fzf(all_anime, format_fn=format_anime_row)
319
+ if selected:
320
+ typer.echo(f"Selected: {selected.title} (id: {selected.id})")
321
+
322
+
323
+ async def _play_episode(
324
+ player: Player,
325
+ prov,
326
+ episode,
327
+ anime_title: str,
328
+ total_eps: int,
329
+ quality: str,
330
+ dub: bool,
331
+ ):
332
+ try:
333
+ streams = await prov.get_streams(episode.id, quality, dub)
334
+ except CaptchaRequiredError as e:
335
+ _print_captcha_help(e)
336
+ return False
337
+ except ProviderError as e:
338
+ msg = str(e)
339
+ if dub and ("dub" in msg.lower() or "server" in msg.lower()):
340
+ typer.echo("Dub not available for this episode.")
341
+ else:
342
+ typer.echo(f"Stream error: {e}")
343
+ return False
344
+
345
+ if not streams:
346
+ if dub:
347
+ typer.echo("Dub not available for this episode.")
348
+ else:
349
+ typer.echo("No streams available.")
350
+ return False
351
+
352
+ stream = streams[0]
353
+ show_now_playing(anime_title, episode.number, total_eps, stream.quality)
354
+ try:
355
+ player.play(stream, quality, subtitles=True)
356
+ player.wait()
357
+ except Exception as e:
358
+ typer.echo(f"Playback error: {e}")
359
+ return False
360
+ return True
361
+
362
+
363
+ async def _watch_anime(
364
+ selected: AnimeResult,
365
+ provider_name: str,
366
+ dub: bool,
367
+ quality: str,
368
+ episodes: Optional[str],
369
+ cookie: Optional[str] = None,
370
+ ):
371
+ cfg = Config.load()
372
+ q = quality or cfg.quality
373
+ translate_dub = dub or (cfg.translation_type == "dub")
374
+
375
+ provider_cls = ProviderRegistry.get(provider_name)
376
+ if not provider_cls:
377
+ raise ProviderNotFoundError(f"Unknown provider: {provider_name}")
378
+ # Use the shared instantiator so AllAnime gets BOTH cookies AND cf_worker_url.
379
+ prov = _instantiate_provider(provider_name, cfg, cookie)
380
+ if prov is None:
381
+ raise ProviderNotFoundError(f"Unknown provider: {provider_name}")
382
+
383
+ ep_list = await prov.get_episodes(selected.id)
384
+ if not ep_list:
385
+ typer.echo("No episodes found.")
386
+ raise typer.Exit()
387
+
388
+ ep_list.sort(key=lambda e: e.number)
389
+
390
+ display_episodes(ep_list, selected.title)
391
+
392
+ if episodes:
393
+ try:
394
+ parts = episodes.split("-")
395
+ start_num = int(parts[0])
396
+ if len(parts) > 1:
397
+ end_num = int(parts[1])
398
+ ep_list = [e for e in ep_list if start_num <= e.number <= end_num]
399
+ else:
400
+ ep_list = [e for e in ep_list if e.number >= start_num]
401
+ except (ValueError, IndexError):
402
+ pass
403
+ else:
404
+ chosen = await select_episode_fzf(ep_list)
405
+ if not chosen:
406
+ typer.echo("No episode selected.")
407
+ raise typer.Exit()
408
+ start_idx = ep_list.index(chosen)
409
+ ep_list = ep_list[start_idx:]
410
+
411
+ player = Player(player=cfg.player, use_ipc=cfg.use_ipc)
412
+ if not player.available:
413
+ typer.echo(f"{cfg.player} is not installed.")
414
+ raise typer.Exit()
415
+
416
+ history = WatchHistory()
417
+ current_idx = 0
418
+
419
+ while current_idx < len(ep_list):
420
+ ep = ep_list[current_idx]
421
+ last_num = ep_list[-1].number
422
+ ok = await _play_episode(player, prov, ep, selected.title, last_num, q, translate_dub)
423
+ if not ok:
424
+ break
425
+
426
+ history.add_entry(
427
+ anime_title=selected.title,
428
+ anime_id=selected.id,
429
+ provider=provider_name,
430
+ episode_num=ep.number,
431
+ episode_id=ep.id,
432
+ )
433
+
434
+ current_idx += 1
435
+ if current_idx >= len(ep_list):
436
+ typer.echo(f"[green]Finished watching {selected.title}![/green]")
437
+ break
438
+
439
+ action = prompt_next_episode(ep.number, ep_list[-1].number)
440
+ if action == "q":
441
+ break
442
+ elif action == "p":
443
+ current_idx = max(0, current_idx - 2)
444
+ elif action == "s":
445
+ chosen = await select_episode_fzf(ep_list[current_idx:])
446
+ if chosen:
447
+ current_idx = ep_list.index(chosen)
448
+ elif action == "n":
449
+ pass
450
+
451
+
452
+ @app.command()
453
+ def play(
454
+ query: str = typer.Argument(None, help="Anime title to search and play"),
455
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Provider to use"),
456
+ quality: Optional[str] = typer.Option(None, "--quality", "-q", help="Stream quality"),
457
+ dub: bool = typer.Option(_IS_DUB, "--dub", "-d", help="Use dubbed version"),
458
+ episodes: Optional[str] = typer.Option(
459
+ None, "--episodes", "-e", help="Episode range (e.g. 1-12)",
460
+ ),
461
+ cookie: Optional[str] = typer.Option(
462
+ None, "--cookie", help="Browser cookies for providers that need them (allanime)",
463
+ ),
464
+ ):
465
+ """Search and play an anime (ani-cli like interactive flow)."""
466
+ asyncio.run(_play(query, provider, quality, dub, episodes, cookie))
467
+
468
+
469
+ @app.command()
470
+ def watch(
471
+ query: Optional[str] = typer.Argument(None, help="Anime title to search and watch"),
472
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Provider to use"),
473
+ quality: Optional[str] = typer.Option(None, "--quality", "-q", help="Stream quality"),
474
+ dub: bool = typer.Option(_IS_DUB, "--dub", "-d", help="Use dubbed version"),
475
+ episodes: Optional[str] = typer.Option(
476
+ None, "--episodes", "-e", help="Episode range (e.g. 1-12)",
477
+ ),
478
+ resume: bool = typer.Option(False, "--resume", "-r", help="Continue watching from history"),
479
+ cookie: Optional[str] = typer.Option(
480
+ None, "--cookie", help="Browser cookies for providers that need them (allanime)",
481
+ ),
482
+ ):
483
+ """Watch anime with ani-cli like interactive flow. Supports continue/resume."""
484
+ asyncio.run(_watch(query, provider, quality, dub, episodes, cookie, resume))
485
+
486
+
487
+ @app.command()
488
+ def config(
489
+ path: bool = typer.Option(False, "--path", help="Show config path"),
490
+ edit: bool = typer.Option(False, "--edit", help="Open config in editor"),
491
+ interactive: bool = typer.Option(False, "--interactive", help="Interactive config wizard"),
492
+ update: Optional[str] = typer.Option(None, "--update", help="Update config key=value"),
493
+ cookie_info: bool = typer.Option(
494
+ False, "--cookie-info", help="Show how to set AllAnime cookies to bypass captcha",
495
+ ),
496
+ ):
497
+ """Manage configuration."""
498
+ if path:
499
+ typer.echo(str(DEFAULT_CONFIG_PATH))
500
+ return
501
+
502
+ if cookie_info:
503
+ from sni.config import DEFAULT_COOKIES_PATH
504
+ console.print(Panel(
505
+ "[bold]AllAnime captcha bypass — three options[/bold]\n\n"
506
+ "[bold green]Option 1 — Cloudflare Worker (most reliable, recommended):[/bold green]\n"
507
+ " Deploy the XAN CF Worker (free, 2 minutes):\n"
508
+ " 1. Go to https://dash.cloudflare.com -> Workers & Pages -> Create\n"
509
+ " 2. Paste the contents of XAN/cf-worker/worker.js from the XAN repo\n"
510
+ " 3. Deploy, copy the worker URL (e.g. https://xan-proxy.you.workers.dev)\n"
511
+ " 4. Save it: sni config --update allanime_cf_worker_url='https://your-worker.workers.dev'\n"
512
+ " The Worker proxies requests through Cloudflare's own IPs, which AllAnime\n"
513
+ " rarely challenges. This fixes captcha on shared/VPN IPs where cookies fail.\n\n"
514
+ "[bold yellow]Option 2 — Browser cookies "
515
+ "(works if your IP isn't already flagged):[/bold yellow]\n"
516
+ " Option A — config key:\n"
517
+ " sni config --update allanime_cookies='k1=v1; k2=v2'\n"
518
+ " Option B — cookies file (easier to refresh):\n"
519
+ f" echo 'k1=v1; k2=v2' > {DEFAULT_COOKIES_PATH}\n"
520
+ " Option C — one-off flag:\n"
521
+ " sni play 'one piece' --cookie 'k1=v1; k2=v2'\n"
522
+ " Get the cookie string from your browser:\n"
523
+ " 1. Open https://allanime.day, solve any captcha.\n"
524
+ " 2. DevTools -> Application -> Cookies -> allanime.day.\n"
525
+ " 3. Copy the full cookie string.\n\n"
526
+ "[bold]Option 3 — switch providers:[/bold]\n"
527
+ " sni play 'one piece' -p hianime",
528
+ title="AllAnime captcha bypass",
529
+ border_style="cyan",
530
+ ))
531
+ return
532
+
533
+ if interactive:
534
+ run_wizard(DEFAULT_CONFIG_PATH)
535
+ return
536
+
537
+ cfg = Config.load()
538
+ if edit:
539
+ import subprocess
540
+ editor = "vim"
541
+ subprocess.call([editor, str(DEFAULT_CONFIG_PATH)])
542
+ elif update:
543
+ key, _, value = update.partition("=")
544
+ if hasattr(cfg, key):
545
+ current = getattr(cfg, key)
546
+ typed_val: str | int | bool = value
547
+ if isinstance(current, bool):
548
+ typed_val = value.lower() in ("true", "1", "yes")
549
+ elif isinstance(current, int):
550
+ typed_val = int(value)
551
+ setattr(cfg, key, typed_val)
552
+ cfg.save()
553
+ typer.echo(f"Updated {key}={typed_val}")
554
+ if key == "allanime_cookies" and typed_val:
555
+ typer.echo("AllAnime cookies saved. Future sni commands will reuse them.")
556
+ else:
557
+ typer.echo(f"Unknown key: {key}")
558
+ else:
559
+ typer.echo(f"Config path: {DEFAULT_CONFIG_PATH}")
560
+ has_cookies = bool(cfg.get_allanime_cookies())
561
+ typer.echo(f"AllAnime cookies: {'set' if has_cookies else 'not set'}")
562
+
563
+
564
+ @app.command()
565
+ def provider(
566
+ action: str = typer.Argument("list", help="Action: list, status"),
567
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Provider name"),
568
+ ):
569
+ """Manage providers."""
570
+ if action == "list":
571
+ typer.echo("Available providers:")
572
+ for p in ProviderRegistry.list():
573
+ typer.echo(f" - {p}")
574
+ elif action == "status":
575
+ typer.echo("Checking provider health...")
576
+ status = asyncio.run(ProviderRegistry.health_check_all())
577
+ for p, ok in status.items():
578
+ icon = "\u2713" if ok else "\u2717"
579
+ typer.echo(f" {icon} {p}")
580
+
581
+
582
+ @app.command()
583
+ def tui():
584
+ """Launch the Terminal UI."""
585
+ from sni.tui.app import SNIApp
586
+ SNIApp().run()
587
+
588
+ if __name__ == "__main__":
589
+ app()