cinnamon-cli 0.2.19__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.
cinnamon/cli.py ADDED
@@ -0,0 +1,1822 @@
1
+ import concurrent.futures
2
+ import subprocess
3
+ import sys
4
+ import time
5
+ import webbrowser
6
+ from functools import wraps
7
+
8
+ import click
9
+ import questionary
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.prompt import Confirm, Prompt
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from . import __version__
17
+ from .config import (
18
+ get_ensured_dirs,
19
+ get_scraper_config,
20
+ get_theme,
21
+ get_tmdb_api_key,
22
+ load_config,
23
+ save_config,
24
+ set_scraper_config,
25
+ THEMES,
26
+ )
27
+ from .errors import (
28
+ CinnamonError,
29
+ MissingAPIKey,
30
+ PlayerLaunchError,
31
+ PlayerNotFoundError,
32
+ ScraperError,
33
+ TMDBAuthError,
34
+ TMDBConnectionError,
35
+ TMDBError,
36
+ TMDBNotFoundError,
37
+ TMDBRateLimitError,
38
+ )
39
+ from .player import download_video, play, ytdlp_install_hint
40
+ from .scrapers import get_scraper, list_scrapers
41
+ from .tmdb import TMDBClient
42
+
43
+ console = Console()
44
+
45
+ RESOLVE_TIMEOUT = 90
46
+
47
+ _ANIME_GENRE_ID = 16
48
+
49
+
50
+ def _is_anime(show):
51
+ origin = show.get("origin_country") or []
52
+ if "JP" not in origin:
53
+ return False
54
+ if show.get("original_language") == "ja":
55
+ return True
56
+ ids = show.get("genre_ids") or [g["id"] for g in show.get("genres", []) if "id" in g]
57
+ if _ANIME_GENRE_ID in ids:
58
+ return True
59
+ return False
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # helpers
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _print_error(title, detail=None):
68
+ t = get_theme()
69
+ if detail:
70
+ console.print(Panel(f"[{t['error']}]{title}[/{t['error']}]\n[{t['dim']}]{detail}[/{t['dim']}]", border_style=t["error"]))
71
+ else:
72
+ console.print(Panel(f"[{t['error']}]{title}[/{t['error']}]", border_style=t["error"]))
73
+
74
+
75
+ def _print_success(title, detail=None):
76
+ t = get_theme()
77
+ if detail:
78
+ console.print(Panel(f"[{t['success']}]{title}[/{t['success']}]\n[{t['dim']}]{detail}[/{t['dim']}]", border_style=t["success"]))
79
+ else:
80
+ console.print(Panel(f"[{t['success']}]{title}[/{t['success']}]", border_style=t["success"]))
81
+
82
+
83
+ def _print_info(title, detail=None):
84
+ t = get_theme()
85
+ if detail:
86
+ console.print(Panel(f"[{t['info']}]{title}[/{t['info']}]\n[{t['dim']}]{detail}[/{t['dim']}]", border_style=t["info"]))
87
+ else:
88
+ console.print(Panel(f"[{t['info']}]{title}[/{t['info']}]", border_style=t["info"]))
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # interactive selection UI
93
+ #
94
+ # One focus point: the highlighted row (class:highlighted) is bright, every
95
+ # other row (class:text) is dimmed grey, and the question line (class:question)
96
+ # is the anchor. All pickers route through _select so the look is consistent.
97
+ # ---------------------------------------------------------------------------
98
+
99
+ _POINTER = "❯"
100
+
101
+ # prompt_toolkit needs hex colors, not rich color names. Map the theme accents
102
+ # to hex so the picker's focus color matches the active theme.
103
+ _Q_HEX = {
104
+ "orange1": "#ffaf00",
105
+ "deep_sky_blue1": "#00afff",
106
+ "blue": "#0000ff",
107
+ "white": "#ffffff",
108
+ "bright_white": "#ffffff",
109
+ "grey50": "#7f7f7f",
110
+ "grey35": "#595959",
111
+ }
112
+
113
+
114
+ def _q_hex(name):
115
+ return _Q_HEX.get(name, "#ffaf00")
116
+
117
+
118
+ def _q_style():
119
+ """questionary Style that makes the focused choice the clear focus point."""
120
+ t = get_theme()
121
+ accent = _q_hex(t.get("accent", "orange1"))
122
+ dim = _q_hex(t.get("dim", "grey50"))
123
+ return questionary.Style(
124
+ [
125
+ ("qmark", f"fg:{accent}"),
126
+ ("question", f"bold fg:{accent}"),
127
+ ("pointer", f"bold fg:{accent}"),
128
+ ("highlighted", f"bold fg:{accent}"),
129
+ ("text", f"fg:{dim}"),
130
+ ("separator", f"fg:{dim}"),
131
+ ("instruction", f"fg:{dim}"),
132
+ ("answer", f"bold fg:{accent}"),
133
+ ]
134
+ )
135
+
136
+
137
+ def _select(message, choices, default=None, **kwargs):
138
+ """questionary.select with cinnamon's focus style applied."""
139
+ kwargs.setdefault("pointer", _POINTER)
140
+ try:
141
+ return questionary.select(
142
+ message, choices=choices, default=default, style=_q_style(), **kwargs
143
+ ).unsafe_ask()
144
+ except Exception:
145
+ return None
146
+
147
+
148
+ def _prompt(message, default=None, password=False):
149
+ """rich Prompt.ask that degrades gracefully when stdin is closed (EOF) or
150
+ not a TTY — returns the default instead of aborting the whole command.
151
+
152
+ This is what keeps the CLI from crashing under automation, Termux, or a
153
+ closed pipe (where Prompt.ask raises EOFError and click turns it into a
154
+ hard SystemExit)."""
155
+ try:
156
+ return Prompt.ask(message, default=default, password=password)
157
+ except (EOFError, KeyboardInterrupt):
158
+ return default
159
+ except Exception:
160
+ return default
161
+
162
+
163
+ _UPDATE_CHECK_CACHE = 86400 # 24 hours
164
+
165
+
166
+ _UPDATE_REPO = "pizza-droid/cinnamon"
167
+
168
+
169
+ _UPDATE_CHECK_FALLBACK_DAYS = 7
170
+
171
+
172
+ def _latest_version():
173
+ import requests
174
+
175
+ # Primary: GitHub Tags API (includes lightweight tags, not just releases).
176
+ try:
177
+ resp = requests.get(
178
+ f"https://api.github.com/repos/{_UPDATE_REPO}/tags",
179
+ timeout=8,
180
+ headers={"Accept": "application/vnd.github+json"},
181
+ )
182
+ if resp.status_code == 200:
183
+ tags = resp.json()
184
+ if tags:
185
+ tag = tags[0].get("name", "")
186
+ if tag:
187
+ return tag[1:] if tag.startswith("v") else tag
188
+ except Exception:
189
+ pass
190
+
191
+ # Fallback: parse version from pyproject.toml on the default branch.
192
+ try:
193
+ resp = requests.get(
194
+ f"https://raw.githubusercontent.com/{_UPDATE_REPO}/master/pyproject.toml",
195
+ timeout=8,
196
+ )
197
+ if resp.status_code == 200:
198
+ for line in resp.text.splitlines():
199
+ line = line.strip()
200
+ if line.startswith("version = "):
201
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
202
+ except Exception:
203
+ pass
204
+ return None
205
+
206
+
207
+ def _check_for_updates():
208
+ try:
209
+ cfg = load_config()
210
+ last_check = cfg.get("_update_check", 0)
211
+ if time.time() - last_check < _UPDATE_CHECK_CACHE:
212
+ return
213
+
214
+ latest = _latest_version()
215
+ if not latest:
216
+ # Network failed — don't cache, retry next run.
217
+ return
218
+
219
+ current = __version__
220
+ try:
221
+ cur = tuple(map(int, current.split(".")))
222
+ lat = tuple(map(int, latest.split(".")))
223
+ except ValueError:
224
+ cfg["_update_check"] = time.time()
225
+ save_config(cfg)
226
+ return
227
+
228
+ if lat <= cur:
229
+ # Up to date: record check so we don't nag for a while.
230
+ cfg["_update_check"] = time.time()
231
+ save_config(cfg)
232
+ return
233
+
234
+ # Update available: remind every run (don't cache the timestamp).
235
+ t = get_theme()
236
+ console.print(
237
+ f" [{t['info']}]Update available:[/] {current} → [bold]{latest}[/] "
238
+ f"[dim](run[/dim] [cyan]cinnamon update[/cyan][dim])[/dim]"
239
+ )
240
+ except Exception:
241
+ pass
242
+
243
+
244
+ def _get_tmdb():
245
+ try:
246
+ return TMDBClient()
247
+ except MissingAPIKey:
248
+ _print_error(
249
+ "TMDB API key not configured.",
250
+ 'Run "cinnamon setup" for a step-by-step guide to get a free key.',
251
+ )
252
+ raise SystemExit(1)
253
+
254
+
255
+ def _handle_tmdb_error(fn):
256
+ @wraps(fn)
257
+ def wrapper(*a, **kw):
258
+ try:
259
+ return fn(*a, **kw)
260
+ except TMDBAuthError as e:
261
+ _print_error(str(e))
262
+ except TMDBRateLimitError:
263
+ _print_error("TMDB rate limit reached. Wait a moment and try again.")
264
+ except TMDBConnectionError as e:
265
+ _print_error(str(e))
266
+ except TMDBNotFoundError as e:
267
+ _print_error(str(e))
268
+ except TMDBError as e:
269
+ _print_error(str(e))
270
+ except MissingAPIKey:
271
+ _get_tmdb()
272
+
273
+ return wrapper
274
+
275
+
276
+ def _pick_combined(items, message):
277
+ """Picker for mixed TV/movie results; tags each entry with its type."""
278
+ if not items:
279
+ return None
280
+ try:
281
+ choices = []
282
+ for item in items:
283
+ mtype = item.get("_media_type", "tv")
284
+ title = item.get("title") or item.get("name") or "?"
285
+ date = item.get("release_date") or item.get("first_air_date") or ""
286
+ year = (date or "")[:4]
287
+ tag = "Movie" if mtype == "movie" else "TV"
288
+ label = f"{title} ({year}) [{tag}]"
289
+ choices.append(questionary.Choice(title=label, value=item))
290
+ return _select(message, choices)
291
+ except Exception:
292
+ # Fallback to a numbered table
293
+ table = Table(border_style="dim")
294
+ table.add_column("#", style="cyan")
295
+ table.add_column("Title", style="white")
296
+ table.add_column("Year", style="green")
297
+ table.add_column("Type", style="yellow")
298
+ for i, item in enumerate(items, 1):
299
+ mtype = item.get("_media_type", "tv")
300
+ title = item.get("title") or item.get("name") or "?"
301
+ date = item.get("release_date") or item.get("first_air_date") or ""
302
+ table.add_row(str(i), title, (date or "")[:4], "Movie" if mtype == "movie" else "TV")
303
+ console.print(table)
304
+ attempts = 0
305
+ while True:
306
+ try:
307
+ choice = int(_prompt(f"{message} (enter number)", default="1"))
308
+ if 1 <= choice <= len(items):
309
+ return items[choice - 1]
310
+ console.print(f"[red]Pick a number between 1 and {len(items)}.[/red]")
311
+ except ValueError:
312
+ console.print("[red]Invalid input. Enter a number.[/red]")
313
+ attempts += 1
314
+ if attempts >= 10:
315
+ console.print("[red]Too many invalid attempts.[/red]")
316
+ return None
317
+
318
+
319
+ def _pick_with_arrows(items, title_key, subtitle_key, message):
320
+ if not items:
321
+ return None
322
+ try:
323
+ choices = []
324
+ for item in items:
325
+ if callable(title_key):
326
+ title = str(title_key(item) or "?")
327
+ else:
328
+ title = str(item.get(title_key, "?") or "?")
329
+ subtitle = ""
330
+ if subtitle_key:
331
+ if callable(subtitle_key):
332
+ subtitle = str(subtitle_key(item) or "")
333
+ else:
334
+ subtitle = str(item.get(subtitle_key, "") or "")
335
+ label = title if not subtitle else f"{title} ({subtitle})"
336
+ choices.append(questionary.Choice(title=label, value=item))
337
+ return _select(message, choices)
338
+ except Exception:
339
+ return _pick_numbered(items, title_key, subtitle_key, message)
340
+
341
+
342
+ def _pick_numbered(items, title_key, subtitle_key, message):
343
+ if not items:
344
+ return None
345
+ table = Table(border_style="dim")
346
+ table.add_column("#", style="cyan", no_wrap=True)
347
+ table.add_column("Title", style="white")
348
+ if subtitle_key:
349
+ table.add_column(subtitle_key.capitalize() if isinstance(subtitle_key, str) else "", style="green")
350
+
351
+ for i, item in enumerate(items, 1):
352
+ if callable(title_key):
353
+ title = str(title_key(item) or "?")
354
+ else:
355
+ title = str(item.get(title_key, "?") or "?")
356
+ row = [str(i), title]
357
+ if subtitle_key:
358
+ if callable(subtitle_key):
359
+ row.append(str(subtitle_key(item) or ""))
360
+ else:
361
+ row.append(str(item.get(subtitle_key, "") or ""))
362
+ table.add_row(*row)
363
+
364
+ console.print(table)
365
+ attempts = 0
366
+ while True:
367
+ try:
368
+ choice = int(_prompt(f"{message} (enter number)", default="1"))
369
+ if 1 <= choice <= len(items):
370
+ return items[choice - 1]
371
+ console.print(f"[red]Pick a number between 1 and {len(items)}.[/red]")
372
+ except ValueError:
373
+ console.print("[red]Invalid input. Enter a number.[/red]")
374
+ attempts += 1
375
+ if attempts >= 10:
376
+ console.print("[red]Too many invalid attempts.[/red]")
377
+ return None
378
+
379
+
380
+ def _parse_episode(ep_str):
381
+ """Parse episode string. Returns (start, end) or (None, None)."""
382
+ if not ep_str:
383
+ return None, None
384
+ parts = str(ep_str).split("-", 1)
385
+ try:
386
+ start = int(parts[0])
387
+ except ValueError:
388
+ return None, None
389
+ if len(parts) == 2:
390
+ try:
391
+ end = int(parts[1])
392
+ except ValueError:
393
+ return None, None
394
+ if end < start:
395
+ return None, None
396
+ return start, end
397
+ return start, None
398
+
399
+
400
+ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download=False, translation=None):
401
+ """Play episode(s), then show interactive menu until user quits."""
402
+
403
+ from .history import set_history as _set_history
404
+
405
+ def _save_history(ep):
406
+ _set_history(show.get("name", "?"), season_num, ep, scraper=scraper, translation=translation, quality=quality)
407
+
408
+ if ep_end is not None and ep_end > ep_start:
409
+ if not download:
410
+ _print_error("Episode ranges are only supported with --download.")
411
+ return
412
+ from .downloads import create as _track_create
413
+ show_name = show.get("name", "?")
414
+ range_track_id = _track_create({
415
+ "title": show_name,
416
+ "url": "",
417
+ "tv_id": show.get("id", 0),
418
+ "season": season_num,
419
+ "episode": f"{ep_start}-{ep_end}",
420
+ "quality": quality or "",
421
+ "referer": "",
422
+ "scraper": scraper,
423
+ "translation": translation or "",
424
+ })
425
+ from .downloads import update as _track_update
426
+ try:
427
+ for ep_num in range(ep_start, ep_end + 1):
428
+ ep_name = f"S{season_num:02d}E{ep_num:02d}"
429
+ _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, quality, info_only, download, translation=translation, range_track_id=range_track_id)
430
+ _save_history(ep_num)
431
+ console.print()
432
+ except KeyboardInterrupt:
433
+ _track_update(range_track_id, status="interrupted")
434
+ raise
435
+ _track_update(range_track_id, status="completed")
436
+ return
437
+
438
+ ep_num = ep_start
439
+ while True:
440
+ proc = _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, quality, info_only, download, translation=translation)
441
+
442
+ if proc is None or info_only or download:
443
+ return
444
+
445
+ try:
446
+ proc.wait()
447
+ except AttributeError:
448
+ return
449
+
450
+ _save_history(ep_num)
451
+
452
+ theme = get_theme()
453
+ console.print()
454
+
455
+ try:
456
+ choice = _select(
457
+ "Options",
458
+ choices=[
459
+ questionary.Choice(title="Next episode", value="next"),
460
+ questionary.Choice(title="Previous episode", value="prev"),
461
+ questionary.Choice(title="Replay", value="replay"),
462
+ questionary.Choice(title="Change quality", value="quality"),
463
+ questionary.Choice(title="Quit", value="quit"),
464
+ ],
465
+ )
466
+ except Exception:
467
+ return
468
+
469
+ if not choice or choice == "quit":
470
+ return
471
+ elif choice == "next":
472
+ ep_num += 1
473
+ ep_name = f"S{season_num:02d}E{ep_num:02d}"
474
+ elif choice == "prev":
475
+ ep_num = max(1, ep_num - 1)
476
+ ep_name = f"S{season_num:02d}E{ep_num:02d}"
477
+ elif choice == "replay":
478
+ pass
479
+ elif choice == "quality":
480
+ try:
481
+ quality = _select(
482
+ "Quality",
483
+ choices=["480p", "720p", "1080p", "best", "worst"],
484
+ default=quality or "best",
485
+ )
486
+ except Exception:
487
+ quality = Prompt.ask("Quality (480p, 720p, 1080p, best, worst)", default=quality or "best")
488
+
489
+
490
+ def _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, quality, info_only, download=False, translation=None, range_track_id=None):
491
+ show_name = show.get("name", "?")
492
+ show_id = show["id"]
493
+
494
+ config = load_config()
495
+ scraper_name = scraper or config.get("default_scraper", "example")
496
+ scraper_instance = get_scraper(scraper_name)
497
+ if not scraper_instance:
498
+ available = [s["name"] for s in list_scrapers()]
499
+ _print_error(
500
+ f"Unknown scraper: [bold]{scraper_name}[/bold]",
501
+ f"Available: {', '.join(available)}",
502
+ )
503
+ return None
504
+
505
+ # Fallback chain: try the chosen scraper, then other builtin scrapers.
506
+ # Only auto-fallback when no scraper was explicitly requested.
507
+ builtins = [s["name"] for s in list_scrapers() if not s.get("optional")]
508
+ if scraper:
509
+ fallback_names = [scraper_name]
510
+ else:
511
+ fallback_names = [scraper_name] + [n for n in builtins if n != scraper_name]
512
+
513
+ console.print()
514
+
515
+ last_error = None
516
+ for attempt_name in fallback_names:
517
+ attempt = get_scraper(attempt_name)
518
+ if not attempt:
519
+ continue
520
+ try:
521
+ with console.status(f"Resolving via [bold]{attempt_name}[/bold]... (timeout {RESOLVE_TIMEOUT}s)", spinner="dots"):
522
+ with concurrent.futures.ThreadPoolExecutor() as pool:
523
+ info = {"show": show_name, "tv_id": show_id, "season": season_num, "episode": ep_num}
524
+ if quality:
525
+ info["quality"] = quality
526
+ if translation:
527
+ info["translation"] = translation
528
+ future = pool.submit(attempt.resolve, info)
529
+ result = future.result(timeout=RESOLVE_TIMEOUT)
530
+ if result:
531
+ scraper_name = attempt_name
532
+ break
533
+ last_error = f"Scraper [bold]{attempt_name}[/bold] returned nothing."
534
+ except concurrent.futures.TimeoutError:
535
+ last_error = f"Scraper [bold]{attempt_name}[/bold] timed out after {RESOLVE_TIMEOUT}s."
536
+ except ScraperError as e:
537
+ last_error = str(e)
538
+ continue
539
+ else:
540
+ if last_error:
541
+ _print_error(last_error)
542
+ else:
543
+ _print_error(f"No streaming source found for {show_name} {ep_name}.")
544
+ return None
545
+
546
+ if not result:
547
+ _print_error(f"Scraper [bold]{scraper_name}[/bold] returned nothing.")
548
+ return None
549
+
550
+ theme = get_theme()
551
+ console.clear()
552
+ console.print(Panel(
553
+ f"[{theme['success']}]Stream ready![/{theme['success']}] [bold]{result.title}[/bold]",
554
+ border_style=theme["success"],
555
+ ))
556
+
557
+ if info_only:
558
+ console.print(f" [{theme['dim']}]URL:[/{theme['dim']}] {result.m3u8_url}")
559
+ return None
560
+
561
+ if download:
562
+ if result.m3u8_url.startswith("magnet:"):
563
+ _print_error("Download not supported for magnet links.")
564
+ return None
565
+ if range_track_id:
566
+ track_id = range_track_id
567
+ else:
568
+ from .downloads import create as _track_create
569
+ track_id = _track_create({
570
+ "title": result.title,
571
+ "url": result.m3u8_url,
572
+ "tv_id": show_id,
573
+ "season": season_num,
574
+ "episode": ep_num,
575
+ "quality": quality,
576
+ "referer": result.referer,
577
+ })
578
+ try:
579
+ download_video(result.m3u8_url, title=result.title, referer=result.referer, track_id=track_id)
580
+ except PlayerNotFoundError:
581
+ _print_error(f"yt-dlp not found. Install it with: {ytdlp_install_hint()}")
582
+ from .downloads import remove as _track_remove
583
+ _track_remove(track_id)
584
+ except PlayerLaunchError as e:
585
+ _print_error(str(e))
586
+ from .downloads import update as _track_update
587
+ _track_update(track_id, status="error")
588
+ except KeyboardInterrupt:
589
+ raise
590
+ return None
591
+
592
+ player_choice = player or config.get("default_player", "auto")
593
+ try:
594
+ if result.m3u8_url.startswith("magnet:"):
595
+ console.print(f" [{theme['info']}]Connecting to torrent swarm...[/{theme['info']}]")
596
+ play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer)
597
+ return None # magnets: can't wait for process
598
+ else:
599
+ console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
600
+ return play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer)
601
+ except PlayerNotFoundError as e:
602
+ _print_error(str(e))
603
+ return None
604
+ except Exception as e:
605
+ _print_error("Failed to launch player.", str(e))
606
+ return None
607
+
608
+
609
+ def _play_movie(show, scraper, player, quality, info_only, download=False):
610
+ """Resolve and play a movie (single stream, no season/episode)."""
611
+ from .history import set_history as _set_history
612
+
613
+ show_name = show.get("title", "?")
614
+ show_id = show["id"]
615
+
616
+ config = load_config()
617
+ scraper_name = scraper or config.get("default_scraper", "webstream")
618
+ scraper_instance = get_scraper(scraper_name)
619
+ if not scraper_instance:
620
+ available = [s["name"] for s in list_scrapers()]
621
+ _print_error(
622
+ f"Unknown scraper: [bold]{scraper_name}[/bold]",
623
+ f"Available: {', '.join(available)}",
624
+ )
625
+ return
626
+
627
+ console.print()
628
+ try:
629
+ with console.status(f"Resolving via [bold]{scraper_name}[/bold]... (timeout {RESOLVE_TIMEOUT}s)", spinner="dots"):
630
+ with concurrent.futures.ThreadPoolExecutor() as pool:
631
+ info = {"show": show_name, "movie_id": show_id, "media_type": "movie"}
632
+ if quality:
633
+ info["quality"] = quality
634
+ future = pool.submit(scraper_instance.resolve, info)
635
+ result = future.result(timeout=RESOLVE_TIMEOUT)
636
+ except concurrent.futures.TimeoutError:
637
+ _print_error(f"Scraper [bold]{scraper_name}[/bold] timed out after {RESOLVE_TIMEOUT}s.")
638
+ return
639
+ except ScraperError as e:
640
+ _print_error(str(e))
641
+ return
642
+
643
+ if not result:
644
+ _print_error(f"Scraper [bold]{scraper_name}[/bold] returned nothing.")
645
+ return
646
+
647
+ theme = get_theme()
648
+ console.clear()
649
+ console.print(Panel(
650
+ f"[{theme['success']}]Stream ready![/{theme['success']}] [bold]{result.title}[/bold]",
651
+ border_style=theme["success"],
652
+ ))
653
+
654
+ _set_history(show_name, None, None, scraper=scraper_name, quality=quality)
655
+
656
+ if info_only:
657
+ console.print(f" [{theme['dim']}]URL:[/{theme['dim']}] {result.m3u8_url}")
658
+ return
659
+
660
+ if download:
661
+ if result.m3u8_url.startswith("magnet:"):
662
+ _print_error("Download not supported for torrent/magnet links.")
663
+ return
664
+ from .downloads import create as _track_create
665
+ from .downloads import remove as _track_remove
666
+ from .downloads import update as _track_update
667
+ track_id = _track_create({
668
+ "title": result.title,
669
+ "url": result.m3u8_url,
670
+ "tv_id": show_id,
671
+ "season": None,
672
+ "episode": None,
673
+ "quality": quality or "",
674
+ "referer": result.referer,
675
+ "scraper": scraper_name,
676
+ })
677
+ try:
678
+ download_video(result.m3u8_url, title=result.title, referer=result.referer, track_id=track_id)
679
+ except PlayerNotFoundError:
680
+ _print_error(f"yt-dlp not found. Install it with: {ytdlp_install_hint()}")
681
+ _track_remove(track_id)
682
+ except PlayerLaunchError as e:
683
+ _print_error(str(e))
684
+ _track_update(track_id, status="error")
685
+ except KeyboardInterrupt:
686
+ raise
687
+ return
688
+
689
+ player_choice = player or config.get("default_player", "auto")
690
+ try:
691
+ console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
692
+ play(result.m3u8_url, title=result.title, player=player_choice, referer=result.referer)
693
+ except PlayerNotFoundError as e:
694
+ _print_error(str(e))
695
+ except Exception as e:
696
+ _print_error("Failed to launch player.", str(e))
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # custom group (allows `cinnamon <query>` as shortcut for search)
701
+ # ---------------------------------------------------------------------------
702
+
703
+
704
+ class CinnamonGroup(click.Group):
705
+ def resolve_command(self, ctx, args):
706
+ if not args:
707
+ return super().resolve_command(ctx, args)
708
+ parent = super()
709
+ cmd = parent.get_command(ctx, args[0])
710
+ if cmd is not None:
711
+ return super().resolve_command(ctx, args)
712
+ cmd = parent.get_command(ctx, "search")
713
+ return "search", cmd, args
714
+
715
+
716
+ # ---------------------------------------------------------------------------
717
+ # CLI
718
+ # ---------------------------------------------------------------------------
719
+
720
+
721
+ @click.group(cls=CinnamonGroup, invoke_without_command=True)
722
+ @click.option("--setup", is_flag=True, help="Run the setup wizard")
723
+ @click.version_option(__version__, "--version", "-V")
724
+ @click.pass_context
725
+ def cli(ctx, setup):
726
+ """Cinnamon - Stream TV shows, movies & anime from the command line."""
727
+ _check_for_updates()
728
+ if setup:
729
+ ctx.invoke(setup_cmd)
730
+ return
731
+ if ctx.invoked_subcommand is None:
732
+ if not get_tmdb_api_key():
733
+ console.clear()
734
+ console.print(Panel.fit(
735
+ "[bold orange1]Welcome to Cinnamon![/bold orange1]",
736
+ border_style="bright_yellow",
737
+ ))
738
+ console.print()
739
+ if Confirm.ask("No API key found. Run setup?", default=True):
740
+ ctx.invoke(setup_cmd)
741
+ return
742
+ theme = get_theme()
743
+ console.clear()
744
+ console.print(Panel.fit(
745
+ f"[bold {theme['accent']}]Cinna[/bold {theme['accent']}][bold]mon[/bold] [dim]v{__version__}[/dim]",
746
+ border_style=theme["panel"],
747
+ ))
748
+ console.print()
749
+ query = _prompt(f"[bold]Search for a[/bold] [{theme['info']}]show or movie[/{theme['info']}]")
750
+ if query and query.strip():
751
+ ctx.invoke(search, query=(query.strip(),))
752
+
753
+
754
+ # ---------------------------------------------------------------------------
755
+ # setup
756
+ # ---------------------------------------------------------------------------
757
+
758
+
759
+ @cli.command("setup")
760
+ def setup_cmd():
761
+ """Run the setup wizard."""
762
+ _setup_wizard()
763
+
764
+
765
+ def _setup_wizard():
766
+ cfg = load_config()
767
+ theme = get_theme()
768
+
769
+ console.clear()
770
+ console.print(Panel.fit(
771
+ "[bold]⚙ Cinnamon Setup[/bold]",
772
+ border_style=theme["panel"],
773
+ ))
774
+ console.print()
775
+
776
+ # --- TMDB API Key ---
777
+ existing_key = get_tmdb_api_key()
778
+ if existing_key:
779
+ console.print(f" [{theme['success']}]✓[/] TMDB API key found")
780
+ if not Confirm.ask(" Replace it?", default=False):
781
+ api_key = existing_key
782
+ else:
783
+ api_key = _setup_api_key()
784
+ else:
785
+ api_key = _setup_api_key()
786
+
787
+ if api_key and api_key != existing_key:
788
+ cfg["tmdb_api_key"] = api_key
789
+ save_config(cfg)
790
+
791
+ # --- Default player ---
792
+ console.print()
793
+ try:
794
+ player_choice = _select(
795
+ "Default player:",
796
+ choices=[
797
+ questionary.Choice(title="auto [dim]– detect mpv then VLC[/dim]", value="auto"),
798
+ questionary.Choice(title="vlc", value="vlc"),
799
+ questionary.Choice(title="mpv", value="mpv"),
800
+ ],
801
+ default=cfg.get("default_player", "auto"),
802
+ )
803
+ if player_choice:
804
+ cfg["default_player"] = player_choice
805
+ except Exception:
806
+ pass
807
+
808
+ # --- Theme ---
809
+ console.print()
810
+ try:
811
+ theme_names = list(THEMES.keys())
812
+ theme_choice = _select(
813
+ "Color theme:",
814
+ choices=[
815
+ questionary.Choice(
816
+ title=_theme_preview(name),
817
+ value=name,
818
+ ) for name in theme_names
819
+ ],
820
+ default=cfg.get("theme", "cinnamon"),
821
+ )
822
+ if theme_choice:
823
+ cfg["theme"] = theme_choice
824
+ except Exception:
825
+ pass
826
+
827
+ save_config(cfg)
828
+ theme = get_theme()
829
+
830
+ console.clear()
831
+ console.print(Panel.fit(
832
+ f"[{theme['success']}]✓ Setup complete![/]",
833
+ border_style=theme["panel"],
834
+ ))
835
+ console.print(f" [{theme['info']}]cinnamon[/] – quick search")
836
+ console.print(f" [{theme['info']}]cinnamon <show>[/] – search & play")
837
+ console.print(f" [{theme['info']}]cinnamon watch[/] – browse episodes")
838
+ console.print()
839
+
840
+
841
+ def _setup_api_key():
842
+ console.print()
843
+ steps = [
844
+ ("1", "Sign up", "https://www.themoviedb.org/signup"),
845
+ ("2", "Verify your email"),
846
+ ("3", "Visit", "https://www.themoviedb.org/settings/api"),
847
+ ("4", 'Click "Create" under "Request an API Key"'),
848
+ ("5", 'Choose "Developer"'),
849
+ ("6", 'Fill in the form (any app name - e.g. "cinnamon")'),
850
+ ("7", 'Copy the "API Key (v3 auth)" value'),
851
+ ]
852
+
853
+ for num, action, *rest in steps:
854
+ url = rest[0] if rest else None
855
+ if url:
856
+ console.print(f" [cyan]{num}.[/cyan] {action} [underline]{url}[/underline]")
857
+ else:
858
+ console.print(f" [cyan]{num}.[/cyan] {action}")
859
+
860
+ if Confirm.ask("\nOpen step 1 in your browser?", default=True):
861
+ webbrowser.open("https://www.themoviedb.org/signup")
862
+
863
+ console.print()
864
+ api_key = Prompt.ask("[bold]Paste your TMDB API Key[/bold]")
865
+ if not api_key.strip():
866
+ return None
867
+
868
+ from .config import set_tmdb_api_key
869
+
870
+ set_tmdb_api_key(api_key.strip())
871
+ try:
872
+ TMDBClient().search_tv("test")
873
+ console.print(f" [green]✓ API key verified![/green]")
874
+ return api_key.strip()
875
+ except CinnamonError:
876
+ console.print(f" [red]✗ Verification failed.[/red] Check key at https://www.themoviedb.org/settings/api")
877
+ return api_key.strip()
878
+
879
+
880
+ def _theme_preview(name):
881
+ descs = {"cinnamon": "warm orange & gold", "ocean": "cool blue & teal", "mono": "minimal white"}
882
+ desc = descs.get(name, "")
883
+ return f"{name} — {desc}"
884
+
885
+
886
+ # ---------------------------------------------------------------------------
887
+ # search (also invoked via `cinnamon <query>` fallback)
888
+ # ---------------------------------------------------------------------------
889
+
890
+
891
+ @cli.command()
892
+ @click.argument("query", nargs=-1, required=False)
893
+ @click.option("-t", "--type", "media_type", type=click.Choice(["tv", "movie"]), default=None, help="tv or movie (defaults to both)")
894
+ @click.option("-s", "--season", type=int, help="Season number")
895
+ @click.option("-e", "--episode", "ep_str", help="Episode number or range (e.g. 1 or 1-10)")
896
+ @click.option("--scraper", help="Scraper name")
897
+ @click.option("--player", help="Player: vlc, mpv, or auto")
898
+ @click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
899
+ @click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
900
+ @click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
901
+ @_handle_tmdb_error
902
+ def search(query, media_type, season, ep_str, scraper, player, quality, download, info_only):
903
+ """Search for a show or movie, pick one, and watch it."""
904
+ _check_for_updates()
905
+ tmdb = _get_tmdb()
906
+
907
+ ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
908
+
909
+ query_str = " ".join(query) if query else None
910
+ if not query_str:
911
+ console.clear()
912
+ query_str = Prompt.ask("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
913
+
914
+ if media_type == "tv":
915
+ combined = [dict(r, _media_type="tv") for r in tmdb.search_tv(query_str).get("results", [])]
916
+ elif media_type == "movie":
917
+ combined = [dict(r, _media_type="movie") for r in tmdb.search_movie(query_str).get("results", [])]
918
+ else:
919
+ tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query_str).get("results", [])]
920
+ movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query_str).get("results", [])]
921
+ combined = tv + movie
922
+
923
+ if not combined:
924
+ _print_info(f"No results found for \"{query_str}\".")
925
+ return
926
+
927
+ show = _pick_combined(combined, "Select a title:")
928
+
929
+ if not show:
930
+ return
931
+
932
+ mtype = show.get("_media_type", "tv")
933
+ title_key = "name" if mtype == "tv" else "title"
934
+ date_key = "first_air_date" if mtype == "tv" else "release_date"
935
+ show_name = show.get(title_key, "?")
936
+ show_id = show["id"]
937
+
938
+ if scraper is None and mtype == "tv" and _is_anime(show):
939
+ # Route anime through allanime's available-episode list so we never
940
+ # offer an episode the source doesn't have (same as `cinnamon anime`).
941
+ from .scrapers.anime import _find_show, _allanime_episodes
942
+ import requests as _req
943
+ _as = _req.Session()
944
+ _as.headers.update({"User-Agent": "Mozilla/5.0"})
945
+ _aid = _find_show(_as, show_name)
946
+ if _aid:
947
+ _ed = _allanime_episodes(_as, _aid)
948
+ if _ed:
949
+ _run_anime_flow(show_name, _ed, season, ep_str, player, quality, info_only, download)
950
+ return
951
+ # Fall through to generic TV flow if allanime lookup fails.
952
+ scraper = "anime"
953
+ _print_info(f"Detected anime — using [bold]anime[/bold] scraper")
954
+
955
+ if mtype == "movie":
956
+ _play_movie(show, scraper, player, quality, info_only, download)
957
+ return
958
+
959
+ if season is not None and ep_start is not None:
960
+ _play_with_menu(show, season, ep_start, ep_end, f"S{season:02d}E{ep_start:02d}", scraper, player, quality, info_only, download)
961
+ return
962
+
963
+ theme = get_theme()
964
+ console.clear()
965
+ console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
966
+
967
+ _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download, ep_start, ep_end)
968
+
969
+
970
+ # ---------------------------------------------------------------------------
971
+ # watch
972
+ # ---------------------------------------------------------------------------
973
+
974
+
975
+ @cli.command()
976
+ @click.option("--query", help="Search query (omit for interactive)")
977
+ @click.option("--id", "tmdb_id", type=int, help="TMDB show/movie ID")
978
+ @click.option("-t", "--type", "media_type", type=click.Choice(["tv", "movie"]), default=None, help="tv or movie (defaults to both)")
979
+ @click.option("-s", "--season", type=int, help="Season number")
980
+ @click.option("-e", "--episode", "ep_str", help="Episode number or range (e.g. 1 or 1-10)")
981
+ @click.option("--scraper", help="Scraper name")
982
+ @click.option("--player", help="Player: vlc, mpv, or auto")
983
+ @click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
984
+ @click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
985
+ @click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
986
+ @_handle_tmdb_error
987
+ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download, info_only, quality):
988
+ """Browse episodes interactively and play one."""
989
+ _check_for_updates()
990
+ tmdb = _get_tmdb()
991
+
992
+ ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
993
+
994
+ # Explicit movie request.
995
+ if media_type == "movie":
996
+ if not tmdb_id and query:
997
+ results = tmdb.search_movie(query).get("results", [])
998
+ show = _pick_with_arrows(results, "title", "release_date", "Select a movie:")
999
+ if not show:
1000
+ return
1001
+ tmdb_id = show["id"]
1002
+ elif tmdb_id:
1003
+ show = tmdb.get_movie_details(tmdb_id)
1004
+ else:
1005
+ query = _prompt("Search for a movie")
1006
+ results = tmdb.search_movie(query).get("results", [])
1007
+ show = _pick_with_arrows(results, "title", "release_date", "Select a movie:")
1008
+ if not show:
1009
+ return
1010
+ _play_movie(show, scraper, player, quality, info_only, download)
1011
+ return
1012
+
1013
+ # Explicit TV request (or a TMDB id with no type hint).
1014
+ if media_type == "tv" or (tmdb_id and media_type is None):
1015
+ if tmdb_id:
1016
+ show = tmdb.get_tv_details(tmdb_id)
1017
+ elif query:
1018
+ results = tmdb.search_tv(query).get("results", [])
1019
+ show = _pick_with_arrows(results, "name", "first_air_date", "Select a show:")
1020
+ if not show:
1021
+ return
1022
+ else:
1023
+ query = _prompt("Search for a show")
1024
+ results = tmdb.search_tv(query).get("results", [])
1025
+ show = _pick_with_arrows(results, "name", "first_air_date", "Select a show:")
1026
+ if not show:
1027
+ return
1028
+ if scraper is None and _is_anime(show):
1029
+ scraper = "anime"
1030
+ _print_info(f"Detected anime — using [bold]anime[/bold] scraper")
1031
+ if season is not None and ep_start is not None:
1032
+ _play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
1033
+ return
1034
+ _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
1035
+ return
1036
+
1037
+ # No type given: search both TV and movies, then dispatch by what is picked.
1038
+ if tmdb_id:
1039
+ # Ambiguous id without a type — try TV first, fall back to movie.
1040
+ try:
1041
+ show = tmdb.get_tv_details(tmdb_id)
1042
+ mtype = "tv"
1043
+ except TMDBNotFoundError:
1044
+ show = tmdb.get_movie_details(tmdb_id)
1045
+ mtype = "movie"
1046
+ elif query:
1047
+ tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query).get("results", [])]
1048
+ movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query).get("results", [])]
1049
+ combined = tv + movie
1050
+ if not combined:
1051
+ _print_info(f"No results found for \"{query}\".")
1052
+ return
1053
+ show = _pick_combined(combined, "Select a title:")
1054
+ if not show:
1055
+ return
1056
+ mtype = show.get("_media_type", "tv")
1057
+ else:
1058
+ query = _prompt("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
1059
+ if not query or not query.strip():
1060
+ return
1061
+ query = query.strip()
1062
+ tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query).get("results", [])]
1063
+ movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query).get("results", [])]
1064
+ combined = tv + movie
1065
+ if not combined:
1066
+ _print_info(f"No results found for \"{query}\".")
1067
+ return
1068
+ show = _pick_combined(combined, "Select a title:")
1069
+ if not show:
1070
+ return
1071
+ mtype = show.get("_media_type", "tv")
1072
+
1073
+ if mtype == "movie":
1074
+ _play_movie(show, scraper, player, quality, info_only, download)
1075
+ return
1076
+
1077
+ if scraper is None and _is_anime(show):
1078
+ scraper = "anime"
1079
+ _print_info(f"Detected anime — using [bold]anime[/bold] scraper")
1080
+ if season is not None and ep_start is not None:
1081
+ _play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
1082
+ return
1083
+ _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
1084
+
1085
+
1086
+ # ---------------------------------------------------------------------------
1087
+ # shared: interactive episode picker + resolve + play
1088
+ # ---------------------------------------------------------------------------
1089
+
1090
+
1091
+ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download=False, ep_start=None, ep_end=None):
1092
+ show_name = show.get("name", "?")
1093
+ show_id = show["id"]
1094
+ details = tmdb.get_tv_details(show_id)
1095
+ total = details.get("number_of_seasons", 0)
1096
+ if total == 0:
1097
+ _print_error(f"No season data for {show_name}.")
1098
+ return
1099
+
1100
+ if total == 1:
1101
+ season_num = 1
1102
+ console.print(f" [dim]Only one season — using Season 1[/dim]")
1103
+ else:
1104
+ try:
1105
+ season_choices = [questionary.Choice(title=f"Season {i}", value=i) for i in range(1, total + 1)]
1106
+ season_chosen = _select("Select a season:", choices=season_choices)
1107
+ if not season_chosen:
1108
+ return
1109
+ season_num = season_chosen
1110
+ except Exception:
1111
+ season_num = Prompt.ask("Season", default="1")
1112
+ try:
1113
+ season_num = int(season_num)
1114
+ except ValueError:
1115
+ _print_error("Invalid season number.")
1116
+ return
1117
+
1118
+ try:
1119
+ season_data = tmdb.get_season_details(show_id, season_num)
1120
+ except TMDBNotFoundError:
1121
+ _print_error(f"Season {season_num} not found for {show_name}.")
1122
+ return
1123
+
1124
+ episodes = season_data.get("episodes", [])
1125
+ if not episodes:
1126
+ _print_error(f"No episodes found for S{season_num}.")
1127
+ return
1128
+
1129
+ if ep_start is None:
1130
+ from .history import get_history as _get_history
1131
+ last = _get_history(show_name)
1132
+ if last and last.get("season") == season_num and last.get("episode"):
1133
+ # Suggest the next unwatched episode (last watched + 1) when it
1134
+ # exists in this season; otherwise fall back to the last watched.
1135
+ resume_ep = last["episode"] + 1
1136
+ if not any(
1137
+ isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
1138
+ for ep in episodes
1139
+ ):
1140
+ resume_ep = last["episode"]
1141
+ try:
1142
+ answer = Prompt.ask(
1143
+ f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
1144
+ default="y",
1145
+ show_default=False,
1146
+ ).strip().lower()
1147
+ resume = answer in ("", "y", "yes")
1148
+ except (EOFError, KeyboardInterrupt):
1149
+ resume = True
1150
+ if resume:
1151
+ ep_start = resume_ep
1152
+
1153
+ if ep_start is not None:
1154
+ ep_name = f"S{season_num:02d}E{ep_start:02d}"
1155
+ _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download)
1156
+ return
1157
+
1158
+ try:
1159
+ ep_choices = []
1160
+ for ep in episodes:
1161
+ ep_num = ep.get("episode_number", "?")
1162
+ ep_title = ep.get("name", f"Episode {ep_num}")
1163
+ label = f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
1164
+ ep_choices.append(questionary.Choice(title=label, value=ep))
1165
+ ep_chosen = _select(
1166
+ f"Season {season_num} - Select an episode:",
1167
+ choices=ep_choices,
1168
+ )
1169
+ if not ep_chosen:
1170
+ return
1171
+ except Exception:
1172
+ ep_chosen = _pick_numbered(episodes, "name", "air_date", "Select an episode")
1173
+ if not ep_chosen:
1174
+ return
1175
+
1176
+ ep_num = ep_chosen["episode_number"]
1177
+ ep_name = ep_chosen.get("name", f"E{ep_num}")
1178
+
1179
+ _play_with_menu(show, season_num, ep_num, None, ep_name, scraper, player, quality, info_only, download)
1180
+
1181
+
1182
+ # ---------------------------------------------------------------------------
1183
+ # play-url
1184
+ # ---------------------------------------------------------------------------
1185
+
1186
+
1187
+ @cli.command()
1188
+ @click.argument("url")
1189
+ @click.option("--player", help="Player: vlc, mpv, or auto")
1190
+ @click.option("--title", default="", help="Media title")
1191
+ def play_url(url, player, title):
1192
+ """Play a direct m3u8 URL in VLC or mpv."""
1193
+ config = load_config()
1194
+ player_choice = player or config.get("default_player", "auto")
1195
+
1196
+ try:
1197
+ play(url, title=title, player=player_choice)
1198
+ _print_success(f"Launched in {player_choice.upper()}.")
1199
+ except PlayerNotFoundError as e:
1200
+ _print_error(str(e))
1201
+ except ValueError as e:
1202
+ _print_error(str(e))
1203
+
1204
+
1205
+ # ---------------------------------------------------------------------------
1206
+ # resume
1207
+ # ---------------------------------------------------------------------------
1208
+
1209
+
1210
+ @cli.command()
1211
+ @_handle_tmdb_error
1212
+ def resume():
1213
+ """List interrupted downloads and resume one."""
1214
+ from .downloads import get, list_all, update as _track_update
1215
+
1216
+ dls = list_all(status="interrupted")
1217
+ if not dls:
1218
+ _print_info("No interrupted downloads found.")
1219
+ return
1220
+
1221
+ console.print()
1222
+ table = Table(border_style="dim")
1223
+ table.add_column("#", style="cyan")
1224
+ table.add_column("Title")
1225
+ table.add_column("Episodes")
1226
+ table.add_column("Quality", style="yellow")
1227
+ table.add_column("Progress", style="green")
1228
+
1229
+ resume_indices = []
1230
+ for i, d in enumerate(dls, 1):
1231
+ ep = d.get("episode", "")
1232
+ season = d.get("season")
1233
+ ep_label = f"S{season}E{ep}" if season and ep else str(ep)
1234
+ if d.get("url") or (not d.get("url") and "-" in str(ep)):
1235
+ resume_indices.append(i)
1236
+ else:
1237
+ ep_label += " [dim](can't resume)[/dim]"
1238
+ table.add_row(str(i), d.get("title", "?"), ep_label, d.get("quality", "-"), "[yellow]interrupted[/yellow]")
1239
+
1240
+ console.print(table)
1241
+
1242
+ if not resume_indices:
1243
+ console.print(" [dim]No resumable entries found.[/dim]")
1244
+ if _prompt(" Remove these entries?", default="y").strip().lower() in ("y", "yes"):
1245
+ for d in dls:
1246
+ _track_remove(d["id"])
1247
+ _print_success("Cleaned up.")
1248
+ return
1249
+
1250
+ choice = _prompt("Resume which download?", default=str(resume_indices[0]))
1251
+ try:
1252
+ d = dls[int(choice) - 1]
1253
+ except (ValueError, IndexError):
1254
+ return
1255
+
1256
+ track_id = d["id"]
1257
+ url = d.get("url")
1258
+ title = d.get("title", "?")
1259
+ season = d.get("season")
1260
+ ep_raw = d.get("episode", "")
1261
+
1262
+ if not url and "-" in str(ep_raw):
1263
+ ep_parts = str(ep_raw).split("-", 1)
1264
+ try:
1265
+ ep_start, ep_end = int(ep_parts[0]), int(ep_parts[1])
1266
+ except ValueError:
1267
+ _print_error("Invalid episode range in download entry.")
1268
+ return
1269
+ show_dict = {"name": title, "id": d.get("tv_id", 0)}
1270
+ scraper = d.get("scraper", "anime")
1271
+ quality = d.get("quality")
1272
+ translation = d.get("translation") or None
1273
+ _track_update(track_id, status="queued")
1274
+ _play_with_menu(show_dict, season or 1, ep_start, ep_end, "", scraper, None, quality, False, True, translation=translation)
1275
+ return
1276
+
1277
+ if not url:
1278
+ _print_error("Cannot resume this download entry (no URL).")
1279
+ return
1280
+ referer = d.get("referer")
1281
+
1282
+ _track_update(track_id, status="queued")
1283
+
1284
+ try:
1285
+ download_video(url, title=title, referer=referer, track_id=track_id)
1286
+ except PlayerNotFoundError:
1287
+ _print_error(f"yt-dlp not found. Install it with: {ytdlp_install_hint()}")
1288
+ _track_update(track_id, status="interrupted")
1289
+ except PlayerLaunchError as e:
1290
+ _print_error(str(e))
1291
+ _track_update(track_id, status="error")
1292
+ except KeyboardInterrupt:
1293
+ pass
1294
+
1295
+
1296
+ # ---------------------------------------------------------------------------
1297
+ # download
1298
+ # ---------------------------------------------------------------------------
1299
+
1300
+
1301
+ @cli.group()
1302
+ def download():
1303
+ """Manage downloads."""
1304
+
1305
+
1306
+ @download.command("list")
1307
+ def download_list():
1308
+ """Show all tracked downloads."""
1309
+ from .downloads import list_all, remove as _track_remove
1310
+
1311
+ all_dls = list_all()
1312
+ if not all_dls:
1313
+ _print_info("No downloads tracked.")
1314
+ return
1315
+
1316
+ table = Table(border_style="dim")
1317
+ table.add_column("ID", style="cyan")
1318
+ table.add_column("Title")
1319
+ table.add_column("Episodes")
1320
+ table.add_column("Quality", style="yellow")
1321
+ table.add_column("Status")
1322
+ table.add_column("Date")
1323
+
1324
+ for d in all_dls:
1325
+ ep = d.get("episode", "")
1326
+ season = d.get("season")
1327
+ ep_label = f"S{season}E{ep}" if season and ep else str(ep)
1328
+ ts = d.get("timestamp", 0)
1329
+ import datetime
1330
+ date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
1331
+ table.add_row(
1332
+ d.get("id", "?"),
1333
+ d.get("title", "?"),
1334
+ ep_label,
1335
+ d.get("quality", "-"),
1336
+ d.get("status", "?"),
1337
+ date_label,
1338
+ )
1339
+
1340
+ console.print(table)
1341
+
1342
+ if _prompt("Clear completed/interrupted?", default="n").strip().lower() in ("y", "yes"):
1343
+ for d in all_dls:
1344
+ if d["status"] in ("completed", "error"):
1345
+ _track_remove(d["id"])
1346
+ _print_success("Cleaned up.")
1347
+
1348
+
1349
+ # ---------------------------------------------------------------------------
1350
+ # config
1351
+ # ---------------------------------------------------------------------------
1352
+
1353
+
1354
+ @cli.group()
1355
+ def config():
1356
+ """View or change settings."""
1357
+
1358
+
1359
+ @config.command("show")
1360
+ def config_show():
1361
+ """Show current configuration."""
1362
+ cfg = load_config()
1363
+ masked = cfg.get("tmdb_api_key", "")
1364
+ if masked:
1365
+ if len(masked) > 10:
1366
+ masked = masked[:6] + "*" * (len(masked) - 6)
1367
+ else:
1368
+ masked = masked[:3] + "***"
1369
+
1370
+ table = Table(border_style="dim")
1371
+ table.add_column("Setting", style="cyan")
1372
+ table.add_column("Value", style="white")
1373
+
1374
+ table.add_row("TMDB API Key", masked or "[dim]not set[/dim]")
1375
+ table.add_row("Default scraper", cfg.get("default_scraper", "example"))
1376
+ table.add_row("Default player", cfg.get("default_player", "auto"))
1377
+ table.add_row("Theme", cfg.get("theme", "cinnamon"))
1378
+
1379
+ scrapers_cfg = cfg.get("scrapers", {})
1380
+ for name, sc in scrapers_cfg.items():
1381
+ for k, v in sc.items():
1382
+ table.add_row(f"scraper.{name}.{k}", str(v))
1383
+
1384
+ console.print(table)
1385
+
1386
+
1387
+ @config.command("set-api-key")
1388
+ @click.argument("api_key")
1389
+ def config_set_api_key(api_key):
1390
+ """Set your TMDB API key."""
1391
+ from .config import set_tmdb_api_key
1392
+
1393
+ set_tmdb_api_key(api_key)
1394
+ _print_success("TMDB API key saved.")
1395
+
1396
+
1397
+ @config.command("show-api-key")
1398
+ def config_show_api_key():
1399
+ """Show the active TMDB API key (env var or saved config) and its source."""
1400
+ import os
1401
+ env_key = os.getenv("TMDB_API_KEY")
1402
+ if env_key:
1403
+ console.print(f" Source: [cyan]TMDB_API_KEY[/cyan] env var")
1404
+ console.print(f" Key: [bold]{env_key}[/bold]")
1405
+ return
1406
+ from .config import get_tmdb_api_key
1407
+ key = get_tmdb_api_key()
1408
+ if key:
1409
+ console.print(" Source: [yellow]config.json[/yellow]")
1410
+ console.print(f" Key: [bold]{key}[/bold]")
1411
+ else:
1412
+ _print_info("No TMDB API key set (env var or config).")
1413
+
1414
+
1415
+ @config.command("default-scraper")
1416
+ @click.argument("name")
1417
+ def config_default_scraper(name):
1418
+ """Set the default scraper."""
1419
+ available = [s["name"] for s in list_scrapers()]
1420
+ if name not in available:
1421
+ _print_error(f"Unknown scraper: {name}", f"Available: {', '.join(available)}")
1422
+ return
1423
+ cfg = load_config()
1424
+ cfg["default_scraper"] = name
1425
+ save_config(cfg)
1426
+ _print_success(f"Default scraper set to [bold]{name}[/bold].")
1427
+
1428
+
1429
+ @config.command("default-player")
1430
+ @click.argument("player", type=click.Choice(["auto", "vlc", "mpv"]))
1431
+ def config_default_player(player):
1432
+ """Set the default player (auto, vlc, or mpv)."""
1433
+ cfg = load_config()
1434
+ cfg["default_player"] = player
1435
+ save_config(cfg)
1436
+ _print_success(f"Default player set to [bold]{player}[/bold].")
1437
+
1438
+
1439
+ @config.group("scraper")
1440
+ def config_scraper():
1441
+ """Configure a specific scraper."""
1442
+
1443
+
1444
+ @config_scraper.command("set")
1445
+ @click.argument("name")
1446
+ @click.argument("key")
1447
+ @click.argument("value")
1448
+ def config_scraper_set(name, key, value):
1449
+ """Set a config option for a scraper."""
1450
+ available = [s["name"] for s in list_scrapers()]
1451
+ if name not in available:
1452
+ _print_error(f"Unknown scraper: {name}", f"Available: {', '.join(available)}")
1453
+ return
1454
+ set_scraper_config(name, key, value)
1455
+ _print_success(f"Scraper [bold]{name}[/bold] {key} = {value}")
1456
+
1457
+
1458
+ @config_scraper.command("show")
1459
+ @click.argument("name")
1460
+ def config_scraper_show(name):
1461
+ """Show config for a specific scraper."""
1462
+ sc_cfg = get_scraper_config(name)
1463
+ if not sc_cfg:
1464
+ _print_info(f"No config set for scraper [bold]{name}[/bold].")
1465
+ return
1466
+ table = Table(border_style="dim")
1467
+ table.add_column("Key", style="cyan")
1468
+ table.add_column("Value", style="white")
1469
+ for k, v in sc_cfg.items():
1470
+ table.add_row(k, str(v))
1471
+ console.print(table)
1472
+
1473
+
1474
+ # ---------------------------------------------------------------------------
1475
+ # scrapers
1476
+ # ---------------------------------------------------------------------------
1477
+
1478
+
1479
+ @cli.command()
1480
+ def scrapers():
1481
+ """List available scrapers."""
1482
+ all_scrapers = list_scrapers()
1483
+ if not all_scrapers:
1484
+ _print_info("No scrapers found.")
1485
+ return
1486
+
1487
+ table = Table(border_style="dim")
1488
+ table.add_column("Name", style="cyan")
1489
+ table.add_column("Description")
1490
+ table.add_column("Source", style="yellow")
1491
+
1492
+ for s in all_scrapers:
1493
+ source = "built-in" if s.get("builtin") else "user"
1494
+ table.add_row(s["name"], s["description"], source)
1495
+
1496
+ console.print(table)
1497
+
1498
+ from .scrapers import _OPTIONAL_SCRAPERS
1499
+ if _OPTIONAL_SCRAPERS:
1500
+ console.print()
1501
+ console.print("[dim]Optional (install via[/dim] [cyan]cinnamon install <name>[/cyan][dim]):[/dim]")
1502
+ for name, info in _OPTIONAL_SCRAPERS.items():
1503
+ console.print(f" [cyan]{name}[/cyan] {info['description']} [dim](needs: {info['deps']})[/dim]")
1504
+ console.print(
1505
+ "\n[dim]Tip: Drop a .py scraper in[/dim]"
1506
+ f" [cyan]{get_ensured_dirs()[1]}[/cyan]"
1507
+ "\n[dim]or set[/dim] [cyan]CINNAMON_SCRAPERS_PATH[/cyan] [dim]env var.[/dim]"
1508
+ )
1509
+
1510
+
1511
+ @cli.command()
1512
+ @click.argument("name")
1513
+ def install(name):
1514
+ """Install an optional scraper (vidsrc, torrentio)."""
1515
+ from .scrapers import _OPTIONAL_SCRAPERS, install_optional
1516
+
1517
+ if name not in _OPTIONAL_SCRAPERS:
1518
+ available = ", ".join(_OPTIONAL_SCRAPERS)
1519
+ _print_error(f"Unknown optional scraper: {name}", f"Available: {available}")
1520
+ return
1521
+
1522
+ try:
1523
+ dst = install_optional(name)
1524
+ _print_success(f"Installed [bold]{name}[/bold] scraper", f"Saved to {dst}")
1525
+ except Exception as e:
1526
+ _print_error(f"Failed to install {name}", str(e))
1527
+
1528
+
1529
+ @cli.command()
1530
+ @click.argument("query", nargs=-1, required=False)
1531
+ @click.option("-s", "--season", type=int, help="Season number")
1532
+ @click.option("-e", "--episode", "ep_str", help="Episode number or range (e.g. 1 or 1-10)")
1533
+ @click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
1534
+ @click.option("--player", help="Player: vlc, mpv, or auto")
1535
+ @click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
1536
+ @click.option("--info-only", is_flag=True, help="Show the stream URL without playing")
1537
+ def anime(query, season, ep_str, download, player, quality, info_only):
1538
+ """Search anime via AniList (no API key needed) and stream from allanime."""
1539
+ _check_for_updates()
1540
+
1541
+ query_str = " ".join(query) if query else None
1542
+ if not query_str:
1543
+ query_str = Prompt.ask("[bold]Search for an[/bold] [magenta]anime[/magenta]")
1544
+ from .anilist import search_anime
1545
+
1546
+ results = search_anime(query_str)
1547
+ if not results:
1548
+ _print_info(f"No anime found for \"{query_str}\".")
1549
+ return
1550
+
1551
+ def _title(m):
1552
+ return m.get("title", {}).get("romaji") or m.get("title", {}).get("english") or m.get("title", {}).get("native", "?")
1553
+ def _year(m):
1554
+ sd = m.get("startDate") or {}
1555
+ return str(sd.get("year", "")) if sd.get("year") else ""
1556
+
1557
+ ql = query_str.lower().strip()
1558
+ def _relevance(m):
1559
+ t = _title(m).lower()
1560
+ if t == ql:
1561
+ return 0
1562
+ if t.startswith(ql):
1563
+ return 1
1564
+ if ql in t:
1565
+ return 2
1566
+ return 3
1567
+
1568
+ results.sort(key=lambda m: (_relevance(m), _title(m).lower()))
1569
+
1570
+ show = _pick_with_arrows(results, _title, _year, "Select an anime:")
1571
+
1572
+ if not show:
1573
+ return
1574
+
1575
+ show_name = _title(show)
1576
+ theme = get_theme()
1577
+ console.clear()
1578
+ console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
1579
+
1580
+ from .history import get_history as _get_history
1581
+ if not ep_str:
1582
+ last = _get_history(show_name)
1583
+ if last and last.get("episode"):
1584
+ # Suggest the next unwatched episode (last watched + 1).
1585
+ resume_ep = last["episode"] + 1
1586
+ try:
1587
+ answer = Prompt.ask(
1588
+ f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
1589
+ default="y",
1590
+ show_default=False,
1591
+ ).strip().lower()
1592
+ resume = answer in ("", "y", "yes")
1593
+ except (EOFError, KeyboardInterrupt):
1594
+ resume = True
1595
+ if resume:
1596
+ ep_str = str(resume_ep)
1597
+
1598
+ from .scrapers.anime import _find_show, _allanime_episodes
1599
+ import requests as _req
1600
+
1601
+ session = _req.Session()
1602
+ session.headers.update({"User-Agent": "Mozilla/5.0"})
1603
+
1604
+ allanime_id = _find_show(session, show_name)
1605
+ if not allanime_id:
1606
+ _print_error(f"No match for \"{show_name}\" on allanime.")
1607
+ return
1608
+
1609
+ episodes_detail = _allanime_episodes(session, allanime_id)
1610
+ if not episodes_detail:
1611
+ _print_error("No episode data from allanime.")
1612
+ return
1613
+
1614
+ _run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download)
1615
+
1616
+
1617
+ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player=None, quality=None, info_only=False, download=False):
1618
+ """Drive the episode picker from allanime's *available* episodes (not TMDB's
1619
+ full list), so we never offer an episode the anime source doesn't have."""
1620
+ parsed = {}
1621
+ for key, eps in episodes_detail.items():
1622
+ if "|" in key:
1623
+ parts = key.split("|", 1)
1624
+ s = int(parts[0]) if parts[0].isdigit() else 1
1625
+ tt = parts[1] if len(parts) > 1 else "sub"
1626
+ else:
1627
+ s = 1
1628
+ tt = key
1629
+ parsed.setdefault(s, {})[tt] = eps
1630
+
1631
+ if season is None:
1632
+ season_keys = sorted(parsed.keys())
1633
+ if len(season_keys) > 1:
1634
+ try:
1635
+ season = _select("Select a season:", choices=[
1636
+ questionary.Choice(title=f"Season {s}", value=s) for s in season_keys
1637
+ ])
1638
+ except Exception:
1639
+ season = season_keys[0]
1640
+ else:
1641
+ season = season_keys[0]
1642
+ if season != 1:
1643
+ console.print(f" [dim]Using Season {season}[/dim]")
1644
+
1645
+ season_data = parsed.get(season)
1646
+ if not season_data:
1647
+ _print_error(f"No episodes for season {season}.")
1648
+ return
1649
+
1650
+ tt_keys = list(season_data.keys())
1651
+ if "sub" in tt_keys and len(tt_keys) > 1:
1652
+ try:
1653
+ tt = _select("Translation:", choices=[
1654
+ questionary.Choice(title=k, value=k) for k in tt_keys
1655
+ ], default="sub")
1656
+ except Exception:
1657
+ tt = "sub"
1658
+ else:
1659
+ tt = tt_keys[0]
1660
+ if tt != "sub":
1661
+ console.print(f" [dim]Using {tt}[/dim]")
1662
+
1663
+ episodes = [int(e) for e in season_data[tt]]
1664
+ max_ep = max(episodes)
1665
+ ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
1666
+
1667
+ if ep_start is not None:
1668
+ if ep_start not in episodes:
1669
+ # The suggested next episode may not exist yet (e.g. unaired) —
1670
+ # fall back to the last available episode instead of hard-erroring.
1671
+ fallback = max_ep
1672
+ _print_info(f"Episode {ep_start} not available yet — using E{fallback}.")
1673
+ ep_start = fallback
1674
+ if ep_end is not None and ep_end > max_ep:
1675
+ ep_end = max_ep
1676
+ else:
1677
+ try:
1678
+ ep_choices = [questionary.Choice(title=f"Episode {e}", value=e) for e in sorted(episodes)]
1679
+ ep_chosen = _select("Select an episode:", choices=ep_choices)
1680
+ if not ep_chosen:
1681
+ return
1682
+ ep_start = int(ep_chosen)
1683
+ except Exception:
1684
+ ep_start = Prompt.ask("Episode", default="1")
1685
+ try:
1686
+ ep_start = int(ep_start)
1687
+ except ValueError:
1688
+ _print_error("Invalid episode number.")
1689
+ return
1690
+
1691
+ scraper = "anime"
1692
+
1693
+ show_dict = {"name": show_name, "id": 0}
1694
+ ep_name = f"S{season:02d}E{ep_start:02d}"
1695
+ _play_with_menu(show_dict, season, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download, translation=tt)
1696
+
1697
+
1698
+ @cli.command()
1699
+ def update():
1700
+ """Check for and install the latest version of cinnamon from GitHub."""
1701
+ theme = get_theme()
1702
+
1703
+ latest = _latest_version()
1704
+ if not latest:
1705
+ _print_error("Could not determine latest version from GitHub.", "Make sure you have internet access.")
1706
+ return
1707
+
1708
+ current = __version__
1709
+ cur = tuple(map(int, current.split(".")))
1710
+ lat = tuple(map(int, latest.split(".")))
1711
+
1712
+ if lat <= cur:
1713
+ _print_success(f"Already up to date ({current}).")
1714
+ return
1715
+
1716
+ console.print(f" [{theme['info']}]Updating:[/] {current} → [bold]{latest}[/bold]")
1717
+ console.print()
1718
+
1719
+ url = f"https://github.com/{_UPDATE_REPO}/archive/refs/tags/v{latest}.tar.gz"
1720
+ try:
1721
+ proc = subprocess.Popen(
1722
+ [sys.executable, "-m", "pip", "install", "--upgrade", url],
1723
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1724
+ text=True, bufsize=1,
1725
+ )
1726
+ for line in proc.stdout:
1727
+ console.print(f" {line.rstrip()}")
1728
+ proc.wait()
1729
+
1730
+ if proc.returncode == 0:
1731
+ cfg = load_config()
1732
+ cfg["_update_check"] = 0
1733
+ save_config(cfg)
1734
+ try:
1735
+ import importlib
1736
+ import cinnamon as _pkg
1737
+ importlib.reload(_pkg)
1738
+ new_version = _pkg.__version__
1739
+ except Exception:
1740
+ new_version = None
1741
+ if new_version and new_version != current:
1742
+ _print_success(f"Updated to {new_version}!")
1743
+ else:
1744
+ _print_error(
1745
+ f"pip reported success but version is still {current}.",
1746
+ "You may be installing into a different Python environment. Try: "
1747
+ f"python -m pip install --upgrade {url}",
1748
+ )
1749
+ else:
1750
+ _print_error(f"Update failed (exit code {proc.returncode}).")
1751
+ except Exception as e:
1752
+ _print_error("Update failed.", str(e))
1753
+
1754
+
1755
+ # ---------------------------------------------------------------------------
1756
+ # history
1757
+ # ---------------------------------------------------------------------------
1758
+
1759
+
1760
+ @cli.command()
1761
+ @click.argument("query", nargs=-1, required=False)
1762
+ @click.option("--clear", is_flag=True, help="Clear all watch history")
1763
+ def history(query, clear):
1764
+ """Show watch history and resume from last episode."""
1765
+ from .history import clear_history, get_history, list_history
1766
+
1767
+ if clear:
1768
+ clear_history()
1769
+ _print_success("Watch history cleared.")
1770
+ return
1771
+
1772
+ q = " ".join(query) if query else None
1773
+ if q:
1774
+ entry = get_history(q)
1775
+ if not entry:
1776
+ _print_info(f"No history found for \"{q}\".")
1777
+ return
1778
+ _print_info(f"Last watched [bold]{q}[/bold]: S{entry.get('season', 1)}E{entry.get('episode')}")
1779
+ _play_with_menu(
1780
+ {"name": q, "id": 0},
1781
+ entry.get("season", 1),
1782
+ entry.get("episode"),
1783
+ None,
1784
+ "",
1785
+ entry.get("scraper") or "anime",
1786
+ None,
1787
+ entry.get("quality"),
1788
+ False,
1789
+ False,
1790
+ translation=entry.get("translation"),
1791
+ )
1792
+ return
1793
+
1794
+ entries = list_history()
1795
+ if not entries:
1796
+ _print_info("No watch history yet.")
1797
+ return
1798
+
1799
+ table = Table(border_style="dim")
1800
+ table.add_column("Show", style="cyan")
1801
+ table.add_column("Episode", style="green")
1802
+ table.add_column("Last watched", style="yellow")
1803
+
1804
+ import datetime
1805
+ for name, e in entries:
1806
+ ts = e.get("timestamp", 0)
1807
+ date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
1808
+ table.add_row(name, f"S{e.get('season', 1)}E{e.get('episode')}", date_label)
1809
+
1810
+ console.print(table)
1811
+
1812
+ if _prompt(" Clear all history?", default="n").strip().lower() in ("y", "yes"):
1813
+ clear_history()
1814
+ _print_success("Watch history cleared.")
1815
+
1816
+
1817
+ # ---------------------------------------------------------------------------
1818
+ # entry point
1819
+ # ---------------------------------------------------------------------------
1820
+
1821
+ if __name__ == "__main__":
1822
+ cli()