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 +1 -0
- sni/__main__.py +3 -0
- sni/cli.py +589 -0
- sni/config.py +124 -0
- sni/exceptions.py +45 -0
- sni/logger.py +14 -0
- sni/player.py +148 -0
- sni/providers/__init__.py +11 -0
- sni/providers/allanime.py +670 -0
- sni/providers/animepahe.py +126 -0
- sni/providers/base.py +60 -0
- sni/providers/cache.py +29 -0
- sni/providers/extractors/__init__.py +3 -0
- sni/providers/extractors/megacloud.py +80 -0
- sni/providers/extractors/vixcloud.py +63 -0
- sni/providers/hianime.py +163 -0
- sni/providers/registry.py +81 -0
- sni/tui/__init__.py +0 -0
- sni/tui/app.py +225 -0
- sni/tui/bridge.py +98 -0
- sni/tui/screens/__init__.py +0 -0
- sni/tui/screens/help.py +49 -0
- sni/tui/screens/history.py +61 -0
- sni/tui/screens/home.py +255 -0
- sni/tui/screens/player.py +124 -0
- sni/tui/widgets/__init__.py +0 -0
- sni/tui/widgets/ascii_art.py +65 -0
- sni/tui/widgets/info_box.py +65 -0
- sni/ui.py +186 -0
- sni/watch_history.py +62 -0
- sni/wizard.py +86 -0
- sni_cli-1.1.1.dist-info/METADATA +495 -0
- sni_cli-1.1.1.dist-info/RECORD +36 -0
- sni_cli-1.1.1.dist-info/WHEEL +4 -0
- sni_cli-1.1.1.dist-info/entry_points.txt +4 -0
- sni_cli-1.1.1.dist-info/licenses/LICENSE +21 -0
sni/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.1"
|
sni/__main__.py
ADDED
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()
|