cinnamon-cli 0.2.33__tar.gz → 0.2.37__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {cinnamon_cli-0.2.33/cinnamon_cli.egg-info → cinnamon_cli-0.2.37}/PKG-INFO +5 -2
  2. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/README.md +4 -1
  3. cinnamon_cli-0.2.37/cinnamon/__init__.py +1 -0
  4. cinnamon_cli-0.2.37/cinnamon/anilist.py +92 -0
  5. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/cli.py +78 -175
  6. cinnamon_cli-0.2.37/cinnamon/embedapi.py +198 -0
  7. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/player.py +25 -6
  8. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/__init__.py +0 -1
  9. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/anime.py +14 -1
  10. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/base.py +1 -0
  11. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/vidsrc.py +1 -1
  12. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/webstream.py +97 -54
  13. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37/cinnamon_cli.egg-info}/PKG-INFO +5 -2
  14. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
  15. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/pyproject.toml +1 -1
  16. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_ui_smoke.py +1 -1
  17. cinnamon_cli-0.2.33/cinnamon/__init__.py +0 -1
  18. cinnamon_cli-0.2.33/cinnamon/anilist.py +0 -33
  19. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/LICENSE +0 -0
  20. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/config.py +0 -0
  21. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/downloads.py +0 -0
  22. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/errors.py +0 -0
  23. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/history.py +0 -0
  24. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/torrentio.py +0 -0
  25. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/tmdb.py +0 -0
  26. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/tui.py +0 -0
  27. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  28. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  29. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/requires.txt +0 -0
  30. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/top_level.txt +0 -0
  31. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/setup.cfg +0 -0
  32. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream.py +0 -0
  33. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream2.py +0 -0
  34. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream3.py +0 -0
  35. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream4.py +0 -0
  36. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream5.py +0 -0
  37. {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream6.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.33
3
+ Version: 0.2.37
4
4
  Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
@@ -47,7 +47,7 @@ pip install cinnamon-cli
47
47
  cinnamon setup
48
48
  ```
49
49
 
50
- > The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information.
50
+ > The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information. If you skip it, `search` & `watch` fall back to an **experimental** 2embed metadata proxy (no key needed) — it works but is less reliable.
51
51
 
52
52
 
53
53
 
@@ -149,6 +149,7 @@ cinnamon search "Breaking Bad" -d -e 1-5
149
149
  | `webstream` | TV shows & movies from vixsrc.to and vidlink.pro (HLS) |
150
150
  | `anime` | Anime from allanime.day via mp4upload |
151
151
 
152
+
152
153
  **Optional (install with `cinnamon install <name>`):**
153
154
 
154
155
  | Name | Needs |
@@ -177,6 +178,8 @@ cinnamon config set-api-key YOUR_KEY
177
178
 
178
179
  ## Notes
179
180
 
181
+ **Experimental 2embed metadata proxy:** When no TMDB API key is configured, `search` and `watch` fall back to `api.2embed.cc` (no key needed). This works for most popular shows/movies but may be slower, less reliable, or return incomplete results. We reccomend getting a TMDB key for a more reliable experience
182
+
180
183
  This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
181
184
  feel free to give us feedback so we can improve this project and make it as good as possible.
182
185
 
@@ -18,7 +18,7 @@ pip install cinnamon-cli
18
18
  cinnamon setup
19
19
  ```
20
20
 
21
- > The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information.
21
+ > The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information. If you skip it, `search` & `watch` fall back to an **experimental** 2embed metadata proxy (no key needed) — it works but is less reliable.
22
22
 
23
23
 
24
24
 
@@ -120,6 +120,7 @@ cinnamon search "Breaking Bad" -d -e 1-5
120
120
  | `webstream` | TV shows & movies from vixsrc.to and vidlink.pro (HLS) |
121
121
  | `anime` | Anime from allanime.day via mp4upload |
122
122
 
123
+
123
124
  **Optional (install with `cinnamon install <name>`):**
124
125
 
125
126
  | Name | Needs |
@@ -148,6 +149,8 @@ cinnamon config set-api-key YOUR_KEY
148
149
 
149
150
  ## Notes
150
151
 
152
+ **Experimental 2embed metadata proxy:** When no TMDB API key is configured, `search` and `watch` fall back to `api.2embed.cc` (no key needed). This works for most popular shows/movies but may be slower, less reliable, or return incomplete results. We reccomend getting a TMDB key for a more reliable experience
153
+
151
154
  This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
152
155
  feel free to give us feedback so we can improve this project and make it as good as possible.
153
156
 
@@ -0,0 +1 @@
1
+ __version__ = "0.2.37"
@@ -0,0 +1,92 @@
1
+ import requests
2
+
3
+ from .errors import CinnamonError
4
+
5
+ API = "https://graphql.anilist.co"
6
+ UA = "cinnamon/0.1.0"
7
+
8
+ _SEARCH_QUERY = """
9
+ query ($search: String, $page: Int) {
10
+ Page(page: $page, perPage: 20) {
11
+ media(search: $search, type: ANIME) {
12
+ id
13
+ idMal
14
+ title { romaji english native }
15
+ episodes
16
+ status
17
+ format
18
+ startDate { year }
19
+ genres
20
+ }
21
+ }
22
+ }
23
+ """
24
+
25
+
26
+ _RELATION_QUERY = """
27
+ query ($search: String) {
28
+ Page(page: 1, perPage: 5) {
29
+ media(search: $search, type: ANIME) {
30
+ id
31
+ title { romaji english }
32
+ relations {
33
+ edges {
34
+ relationType
35
+ node {
36
+ id
37
+ title { romaji english }
38
+ episodes
39
+ status
40
+ format
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ """
48
+
49
+
50
+ def search_anime(query):
51
+ try:
52
+ resp = requests.post(
53
+ API,
54
+ json={"query": _SEARCH_QUERY, "variables": {"search": query, "page": 1}},
55
+ timeout=15,
56
+ headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
57
+ )
58
+ resp.raise_for_status()
59
+ data = resp.json()
60
+ return data.get("data", {}).get("Page", {}).get("media", [])
61
+ except requests.ConnectionError:
62
+ raise CinnamonError("Could not reach AniList. Check your internet connection.")
63
+ except requests.Timeout:
64
+ raise CinnamonError("AniList request timed out.")
65
+ except requests.HTTPError as e:
66
+ raise CinnamonError(f"AniList returned an error (HTTP {e.response.status_code}).")
67
+
68
+
69
+ def find_sequel(anime_name):
70
+ """Search AniList for an anime and return its first SEQUEL relation, or None."""
71
+ try:
72
+ resp = requests.post(
73
+ API,
74
+ json={"query": _RELATION_QUERY, "variables": {"search": anime_name}},
75
+ timeout=15,
76
+ headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
77
+ )
78
+ resp.raise_for_status()
79
+ data = resp.json()
80
+ media_list = data.get("data", {}).get("Page", {}).get("media", [])
81
+ for m in media_list:
82
+ edges = m.get("relations", {}).get("edges", [])
83
+ for e in edges:
84
+ if e.get("relationType") == "SEQUEL":
85
+ node = e.get("node", {})
86
+ title = node.get("title", {})
87
+ name = title.get("romaji") or title.get("english") or ""
88
+ if name:
89
+ return {"id": node["id"], "name": name}
90
+ return None
91
+ except (requests.RequestException, CinnamonError):
92
+ return None
@@ -27,6 +27,7 @@ from .config import (
27
27
  from .errors import (
28
28
  CinnamonError,
29
29
  MissingAPIKey,
30
+ PlayerError,
30
31
  PlayerLaunchError,
31
32
  PlayerNotFoundError,
32
33
  ScraperError,
@@ -38,6 +39,7 @@ from .errors import (
38
39
  )
39
40
  from .player import download_video, play, ytdlp_install_hint
40
41
  from .scrapers import get_scraper, list_scrapers
42
+ from .embedapi import EmbedClient
41
43
  from .tmdb import TMDBClient
42
44
 
43
45
  console = Console()
@@ -160,141 +162,6 @@ def _prompt(message, default=None, password=False):
160
162
  return default
161
163
 
162
164
 
163
- def _pick_episode_keys(items, label_fn, message):
164
- """Keyboard-driven episode picker.
165
-
166
- Press a number once to *select* (highlight) that episode; press the same
167
- number again to *confirm* and play it. Arrow keys / j / k move the
168
- highlight, Enter confirms the highlighted episode, and q / esc abort.
169
-
170
- Multi-digit entry works too: typing "1" then "2" highlights episode 12,
171
- and repeating the final digit (or pressing Enter) confirms it.
172
- """
173
- if not items:
174
- return None
175
- n = len(items)
176
-
177
- try:
178
- from prompt_toolkit import Application
179
- from prompt_toolkit.layout import Layout, Window
180
- from prompt_toolkit.layout.controls import FormattedTextControl
181
- from prompt_toolkit.key_binding import KeyBindings
182
- except Exception:
183
- return None
184
-
185
- t = get_theme()
186
- accent = _q_hex(t.get("accent", "orange1"))
187
- dim = _q_hex(t.get("dim", "grey50"))
188
-
189
- class _State:
190
- sel = 0
191
- buffer = ""
192
- last_key = None
193
- last_press = 0.0
194
- result = None
195
- abort = False
196
-
197
- state = _State()
198
- app_ref = [None]
199
-
200
- def _render():
201
- lines = [(f"bold fg:{accent}", f"{message}\n")]
202
- lines.append((f"fg:{dim}", " press a number to select · press it again to play · ↑↓/jk move · enter play · q quit\n\n"))
203
- start = max(0, min(state.sel - 7, n - 15))
204
- end = min(n, start + 15)
205
- for i in range(start, end):
206
- ep = items[i]
207
- label = label_fn(ep)
208
- num = f"{i + 1:>3}"
209
- if i == state.sel:
210
- if state.buffer:
211
- lines.append((f"bold fg:{accent}", f" {_POINTER} {num} {label}\n"))
212
- else:
213
- lines.append((f"bold fg:{accent}", f" {_POINTER} {num} {label}\n"))
214
- else:
215
- lines.append((f"fg:{dim}", f" {num} {label}\n"))
216
- return lines
217
-
218
- control = FormattedTextControl(_render)
219
-
220
- def _confirm():
221
- state.result = items[state.sel]
222
- if app_ref[0] is not None:
223
- app_ref[0].exit()
224
-
225
- def _abort():
226
- state.abort = True
227
- if app_ref[0] is not None:
228
- app_ref[0].exit()
229
-
230
- def _press_digit(d):
231
- now = _time.time()
232
- if state.last_key is None or (now - state.last_press) > 1.2:
233
- state.buffer = str(d)
234
- else:
235
- state.buffer += str(d)
236
- state.last_press = now
237
- last_key = state.last_key
238
- state.last_key = d
239
- try:
240
- idx = int(state.buffer) - 1
241
- except ValueError:
242
- idx = -1
243
- if 0 <= idx < n:
244
- state.sel = idx
245
- # Pressing the same digit twice in a row confirms the highlight.
246
- if d == last_key:
247
- _confirm()
248
- return
249
- if app_ref[0] is not None:
250
- app_ref[0].invalidate()
251
-
252
- kb = KeyBindings()
253
-
254
- for d in range(10):
255
- kb.add(str(d))(lambda event, d=d: _press_digit(d))
256
-
257
- @kb.add("up")
258
- @kb.add("k")
259
- def _up(event):
260
- state.buffer = ""
261
- state.last_key = None
262
- state.sel = (state.sel - 1) % n
263
- event.app.invalidate()
264
-
265
- @kb.add("down")
266
- @kb.add("j")
267
- def _down(event):
268
- state.buffer = ""
269
- state.last_key = None
270
- state.sel = (state.sel + 1) % n
271
- event.app.invalidate()
272
-
273
- @kb.add("enter")
274
- def _enter(event):
275
- _confirm()
276
-
277
- @kb.add("q")
278
- @kb.add("escape")
279
- def _quit(event):
280
- _abort()
281
-
282
- app = Application(
283
- layout=Layout(Window(control, always_scroll=True)),
284
- key_bindings=kb,
285
- mouse_support=False,
286
- full_screen=False,
287
- )
288
- app_ref[0] = app
289
- try:
290
- app.run()
291
- except Exception:
292
- return None
293
- if state.abort:
294
- return None
295
- return state.result
296
-
297
-
298
165
  _UPDATE_CHECK_CACHE = 86400 # 24 hours
299
166
 
300
167
 
@@ -380,14 +247,10 @@ def _get_tmdb():
380
247
  try:
381
248
  return TMDBClient()
382
249
  except MissingAPIKey:
383
- _print_error(
384
- "TMDB API key not configured.",
385
- 'Run "cinnamon setup" for a step-by-step guide to get a free key.',
386
- )
387
- raise SystemExit(1)
250
+ return EmbedClient()
388
251
 
389
252
 
390
- def _handle_tmdb_error(fn):
253
+ def _handle_errors(fn):
391
254
  @wraps(fn)
392
255
  def wrapper(*a, **kw):
393
256
  try:
@@ -402,8 +265,12 @@ def _handle_tmdb_error(fn):
402
265
  _print_error(str(e))
403
266
  except TMDBError as e:
404
267
  _print_error(str(e))
268
+ except (ScraperError, PlayerError) as e:
269
+ _print_error(str(e))
405
270
  except MissingAPIKey:
406
271
  _get_tmdb()
272
+ except Exception as e:
273
+ _print_error("Something went wrong.", str(e)[:500])
407
274
 
408
275
  return wrapper
409
276
 
@@ -587,17 +454,19 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
587
454
  theme = get_theme()
588
455
  console.print()
589
456
 
457
+ choices = [
458
+ questionary.Choice(title="Next episode", value="next"),
459
+ questionary.Choice(title="Previous episode", value="prev"),
460
+ questionary.Choice(title="Replay", value="replay"),
461
+ ]
462
+ if scraper == "anime":
463
+ choices.append(questionary.Choice(title="Next season \u2192", value="next_season"))
464
+ choices += [
465
+ questionary.Choice(title="Change quality", value="quality"),
466
+ questionary.Choice(title="Quit", value="quit"),
467
+ ]
590
468
  try:
591
- choice = _select(
592
- "Options",
593
- choices=[
594
- questionary.Choice(title="Next episode", value="next"),
595
- questionary.Choice(title="Previous episode", value="prev"),
596
- questionary.Choice(title="Replay", value="replay"),
597
- questionary.Choice(title="Change quality", value="quality"),
598
- questionary.Choice(title="Quit", value="quit"),
599
- ],
600
- )
469
+ choice = _select("Options", choices=choices)
601
470
  except Exception:
602
471
  return
603
472
 
@@ -611,6 +480,29 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
611
480
  ep_name = f"S{season_num:02d}E{ep_num:02d}"
612
481
  elif choice == "replay":
613
482
  pass
483
+ elif choice == "next_season":
484
+ try:
485
+ from .anilist import find_sequel as _find_sequel
486
+ sequel = _find_sequel(show.get("name", ""))
487
+ if not sequel:
488
+ _print_error("No next season found on AniList.")
489
+ continue
490
+ from .scrapers.anime import _find_show as _aa_find, _allanime_episodes as _aa_eps
491
+ import requests as _req
492
+ _as = _req.Session()
493
+ _as.headers.update({"User-Agent": "Mozilla/5.0"})
494
+ _aid = _aa_find(_as, sequel["name"])
495
+ if not _aid:
496
+ _print_error(f"No match for \"{sequel['name']}\" on allanime.")
497
+ continue
498
+ _ed = _aa_eps(_as, _aid)
499
+ if not _ed:
500
+ _print_error("No episode data from allanime.")
501
+ continue
502
+ _run_anime_flow(sequel["name"], _ed, player=player, quality=quality, info_only=info_only, download=download)
503
+ return
504
+ except Exception as _e:
505
+ _print_error("Failed to load next season.", str(_e))
614
506
  elif choice == "quality":
615
507
  try:
616
508
  quality = _select(
@@ -740,7 +632,7 @@ def _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, qualit
740
632
  return None # magnets: can't wait for process
741
633
  else:
742
634
  console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
743
- return play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer)
635
+ return play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer, subtitle_url=result.subtitle_url)
744
636
  except PlayerNotFoundError as e:
745
637
  _print_error(str(e))
746
638
  return None
@@ -853,7 +745,8 @@ def cli(ctx, setup):
853
745
  border_style="bright_yellow",
854
746
  ))
855
747
  console.print()
856
- if Confirm.ask("No API key found. Run setup?", default=True):
748
+ console.print(" [dim]Search & watch will use an experimental proxy (no key needed).[/dim]")
749
+ if Confirm.ask("Add a TMDB API key for better results?", default=False):
857
750
  ctx.invoke(setup_cmd)
858
751
  return
859
752
  theme = get_theme()
@@ -899,7 +792,12 @@ def _setup_wizard():
899
792
  else:
900
793
  api_key = _setup_api_key()
901
794
  else:
902
- api_key = _setup_api_key()
795
+ console.print(" No TMDB API key found.")
796
+ console.print(" [dim]Without it, search & watch use an experimental 2embed proxy (less reliable).[/dim]")
797
+ if Confirm.ask(" Add a TMDB API key now?", default=True):
798
+ api_key = _setup_api_key()
799
+ else:
800
+ api_key = None
903
801
 
904
802
  if api_key and api_key != existing_key:
905
803
  cfg["tmdb_api_key"] = api_key
@@ -978,7 +876,7 @@ def _setup_api_key():
978
876
  webbrowser.open("https://www.themoviedb.org/signup")
979
877
 
980
878
  console.print()
981
- api_key = Prompt.ask("[bold]Paste your TMDB API Key[/bold]")
879
+ api_key = Prompt.ask("[bold]Paste your TMDB API Key[/bold] [dim](leave empty to skip)[/dim]")
982
880
  if not api_key.strip():
983
881
  return None
984
882
 
@@ -1015,7 +913,7 @@ def _theme_preview(name):
1015
913
  @click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
1016
914
  @click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
1017
915
  @click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
1018
- @_handle_tmdb_error
916
+ @_handle_errors
1019
917
  def search(query, media_type, season, ep_str, scraper, player, quality, download, info_only):
1020
918
  """Search for a show or movie, pick one, and watch it."""
1021
919
  _check_for_updates()
@@ -1100,7 +998,7 @@ def search(query, media_type, season, ep_str, scraper, player, quality, download
1100
998
  @click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
1101
999
  @click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
1102
1000
  @click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
1103
- @_handle_tmdb_error
1001
+ @_handle_errors
1104
1002
  def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download, info_only, quality):
1105
1003
  """Browse episodes interactively and play one."""
1106
1004
  _check_for_updates()
@@ -1118,7 +1016,7 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
1118
1016
  if season is not None and ep_start is not None:
1119
1017
  _play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
1120
1018
  else:
1121
- _interactive_episode_picker(None, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
1019
+ _interactive_episode_picker(_get_tmdb(), show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
1122
1020
  return
1123
1021
 
1124
1022
  tmdb = _get_tmdb()
@@ -1288,15 +1186,15 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
1288
1186
  return
1289
1187
 
1290
1188
  try:
1291
- def _ep_label(ep):
1189
+ ep_choices = []
1190
+ for ep in episodes:
1292
1191
  ep_num = ep.get("episode_number", "?")
1293
1192
  ep_title = ep.get("name", f"Episode {ep_num}")
1294
- return f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
1295
-
1296
- ep_chosen = _pick_episode_keys(
1297
- episodes,
1298
- _ep_label,
1299
- f"Season {season_num} — select an episode",
1193
+ label = f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
1194
+ ep_choices.append(questionary.Choice(title=label, value=ep))
1195
+ ep_chosen = _select(
1196
+ f"Season {season_num} - Select an episode:",
1197
+ choices=ep_choices,
1300
1198
  )
1301
1199
  if not ep_chosen:
1302
1200
  return
@@ -1320,6 +1218,7 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
1320
1218
  @click.argument("url")
1321
1219
  @click.option("--player", help="Player: vlc, mpv, or auto")
1322
1220
  @click.option("--title", default="", help="Media title")
1221
+ @_handle_errors
1323
1222
  def play_url(url, player, title):
1324
1223
  """Play a direct m3u8 URL in VLC or mpv."""
1325
1224
  config = load_config()
@@ -1340,7 +1239,7 @@ def play_url(url, player, title):
1340
1239
 
1341
1240
 
1342
1241
  @cli.command()
1343
- @_handle_tmdb_error
1242
+ @_handle_errors
1344
1243
  def resume():
1345
1244
  """List interrupted downloads and resume one."""
1346
1245
  from .downloads import get, list_all, update as _track_update
@@ -1609,6 +1508,7 @@ def config_scraper_show(name):
1609
1508
 
1610
1509
 
1611
1510
  @cli.command()
1511
+ @_handle_errors
1612
1512
  def scrapers():
1613
1513
  """List available scrapers."""
1614
1514
  all_scrapers = list_scrapers()
@@ -1642,6 +1542,7 @@ def scrapers():
1642
1542
 
1643
1543
  @cli.command()
1644
1544
  @click.argument("name")
1545
+ @_handle_errors
1645
1546
  def install(name):
1646
1547
  """Install an optional scraper (vidsrc, torrentio)."""
1647
1548
  from .scrapers import _OPTIONAL_SCRAPERS, install_optional
@@ -1668,6 +1569,7 @@ def install(name):
1668
1569
  @click.option("--info-only", is_flag=True, help="Show the stream URL without playing")
1669
1570
  @click.option("--sub", "prefer_sub", is_flag=True, help="Prefer subtitled audio")
1670
1571
  @click.option("--dub", "prefer_dub", is_flag=True, help="Prefer dubbed audio")
1572
+ @_handle_errors
1671
1573
  def anime(query, season, ep_str, download, player, quality, info_only, prefer_sub, prefer_dub):
1672
1574
  """Search anime via AniList (no API key needed) and stream from allanime."""
1673
1575
  _check_for_updates()
@@ -1683,7 +1585,7 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
1683
1585
  return
1684
1586
 
1685
1587
  def _title(m):
1686
- return m.get("title", {}).get("romaji") or m.get("title", {}).get("english") or m.get("title", {}).get("native", "?")
1588
+ return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
1687
1589
  def _year(m):
1688
1590
  sd = m.get("startDate") or {}
1689
1591
  return str(sd.get("year", "")) if sd.get("year") else ""
@@ -1785,7 +1687,11 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
1785
1687
  _print_error(f"No episodes for season {season}.")
1786
1688
  return
1787
1689
 
1788
- tt_keys = list(season_data.keys())
1690
+ tt_keys = [k for k in season_data if season_data[k]]
1691
+ if not tt_keys:
1692
+ _print_error(f"No episodes available for season {season}.")
1693
+ return
1694
+
1789
1695
  if translation and translation in tt_keys:
1790
1696
  tt = translation
1791
1697
  elif "sub" in tt_keys and len(tt_keys) > 1:
@@ -1815,13 +1721,9 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
1815
1721
  ep_end = max_ep
1816
1722
  else:
1817
1723
  try:
1818
- sorted_eps = sorted(episodes)
1819
- ep_chosen = _pick_episode_keys(
1820
- sorted_eps,
1821
- lambda e: f"Episode {e}",
1822
- "Select an episode",
1823
- )
1824
- if ep_chosen is None:
1724
+ ep_choices = [questionary.Choice(title=f"Episode {e}", value=e) for e in sorted(episodes)]
1725
+ ep_chosen = _select("Select an episode:", choices=ep_choices)
1726
+ if not ep_chosen:
1825
1727
  return
1826
1728
  ep_start = int(ep_chosen)
1827
1729
  except Exception:
@@ -1904,6 +1806,7 @@ def update():
1904
1806
  @cli.command()
1905
1807
  @click.argument("query", nargs=-1, required=False)
1906
1808
  @click.option("--clear", is_flag=True, help="Clear all watch history")
1809
+ @_handle_errors
1907
1810
  def history(query, clear):
1908
1811
  """Show watch history and resume from last episode."""
1909
1812
  from .history import clear_history, get_history, list_history