cinnamon-cli 0.2.37__tar.gz → 0.2.39__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.37 → cinnamon_cli-0.2.39}/PKG-INFO +16 -10
  2. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/README.md +12 -8
  3. cinnamon_cli-0.2.39/cinnamon/__init__.py +1 -0
  4. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/cli.py +335 -98
  5. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/config.py +1 -0
  6. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/history.py +22 -9
  7. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/player.py +209 -9
  8. cinnamon_cli-0.2.39/cinnamon/scrapers/_vidlink_crypto.py +163 -0
  9. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/scrapers/anime.py +311 -20
  10. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/scrapers/webstream.py +38 -18
  11. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon_cli.egg-info/PKG-INFO +16 -10
  12. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
  13. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon_cli.egg-info/requires.txt +3 -0
  14. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/pyproject.toml +5 -2
  15. cinnamon_cli-0.2.37/cinnamon/__init__.py +0 -1
  16. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/LICENSE +0 -0
  17. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/anilist.py +0 -0
  18. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/downloads.py +0 -0
  19. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/embedapi.py +0 -0
  20. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/errors.py +0 -0
  21. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/scrapers/__init__.py +0 -0
  22. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/scrapers/base.py +0 -0
  23. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/scrapers/torrentio.py +0 -0
  24. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/scrapers/vidsrc.py +0 -0
  25. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/tmdb.py +0 -0
  26. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon/tui.py +0 -0
  27. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  28. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  29. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/cinnamon_cli.egg-info/top_level.txt +0 -0
  30. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/setup.cfg +0 -0
  31. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_directstream.py +0 -0
  32. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_directstream2.py +0 -0
  33. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_directstream3.py +0 -0
  34. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_directstream4.py +0 -0
  35. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_directstream5.py +0 -0
  36. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_directstream6.py +0 -0
  37. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.39}/tests/test_ui_smoke.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.37
4
- Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
3
+ Version: 0.2.39
4
+ Summary: Watch Movies, TV shows and anime from your terminal.
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
7
7
  Project-URL: Homepage, https://github.com/pizza-droid/cinnamon
@@ -23,11 +23,13 @@ Requires-Dist: questionary>=2
23
23
  Requires-Dist: pycryptodome>=3
24
24
  Provides-Extra: scrapers
25
25
  Requires-Dist: playwright>=1.60; extra == "scrapers"
26
+ Provides-Extra: vidlink
27
+ Requires-Dist: pynacl>=1.5; extra == "vidlink"
26
28
  Provides-Extra: download
27
29
  Requires-Dist: yt-dlp>=2024.1.0; extra == "download"
28
30
  Dynamic: license-file
29
31
 
30
- # cinnamon
32
+ # Cinnamon | Beta
31
33
 
32
34
  Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
33
35
 
@@ -47,7 +49,7 @@ pip install cinnamon-cli
47
49
  cinnamon setup
48
50
  ```
49
51
 
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.
52
+ > 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, it will use an **experimental** 2embed search (no key needed), it works but is less reliable.
51
53
 
52
54
 
53
55
 
@@ -68,7 +70,7 @@ cinnamon search "Breaking Bad"
68
70
  cinnamon search "Inception"
69
71
  ```
70
72
 
71
- For Anime you can run (Works even if you dont have an api key):
73
+ For Anime you can run:
72
74
 
73
75
  ```bash
74
76
  cinnamon anime "Chainsaw Man"
@@ -88,7 +90,7 @@ you can also run it like this:
88
90
  ```bash
89
91
  cinnamon Inception
90
92
  cinnamon Breaking bad
91
- cinnamon chainsaw man
93
+ cinnamon chainsaw man # Not reccomended to search an anime like this
92
94
  ```
93
95
 
94
96
  ### Download episodes
@@ -117,8 +119,12 @@ cinnamon search "Breaking Bad" -d -e 1-5
117
119
  | `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
118
120
  | `cinnamon play-url <url>` | Play any m3u8/mp4 link |
119
121
  | `cinnamon resume` | Continue an interrupted download |
120
- | `cinnamon history` | Show watch history and resume from the next unwatched episode |
121
- | `cinnamon history --clear` | Clear all watch history |
122
+ | `cinnamon history` | Show watch history |
123
+ | `cinnamon history <query>` | Show episodes played for a show and continue from next |
124
+ | `cinnamon history clear` | Clear all watch history |
125
+ | `cinnamon history toggle [on/off]` | Enable or disable history tracking |
126
+ | `cinnamon config history show` | Show whether history tracking is enabled |
127
+ | `cinnamon config history toggle [on/off]` | Enable or disable via config |
122
128
  | `cinnamon scrapers` | See available streaming sources |
123
129
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
124
130
  | `cinnamon update` | Check for and install the latest version |
@@ -178,9 +184,9 @@ cinnamon config set-api-key YOUR_KEY
178
184
 
179
185
  ## Notes
180
186
 
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
187
+ Anime movies now work (both sub and dub). Use `--sub` or `--dub` to pick your translation, or just run the command and it will ask.
182
188
 
183
- This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
189
+ This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines of code.
184
190
  feel free to give us feedback so we can improve this project and make it as good as possible.
185
191
 
186
192
  inspired by [ani-cli](https://github.com/pystardust/ani-cli)
@@ -1,4 +1,4 @@
1
- # cinnamon
1
+ # Cinnamon | Beta
2
2
 
3
3
  Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
4
4
 
@@ -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. If you skip it, `search` & `watch` fall back to an **experimental** 2embed metadata proxy (no key needed) it works but is less reliable.
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, it will use an **experimental** 2embed search (no key needed), it works but is less reliable.
22
22
 
23
23
 
24
24
 
@@ -39,7 +39,7 @@ cinnamon search "Breaking Bad"
39
39
  cinnamon search "Inception"
40
40
  ```
41
41
 
42
- For Anime you can run (Works even if you dont have an api key):
42
+ For Anime you can run:
43
43
 
44
44
  ```bash
45
45
  cinnamon anime "Chainsaw Man"
@@ -59,7 +59,7 @@ you can also run it like this:
59
59
  ```bash
60
60
  cinnamon Inception
61
61
  cinnamon Breaking bad
62
- cinnamon chainsaw man
62
+ cinnamon chainsaw man # Not reccomended to search an anime like this
63
63
  ```
64
64
 
65
65
  ### Download episodes
@@ -88,8 +88,12 @@ cinnamon search "Breaking Bad" -d -e 1-5
88
88
  | `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
89
89
  | `cinnamon play-url <url>` | Play any m3u8/mp4 link |
90
90
  | `cinnamon resume` | Continue an interrupted download |
91
- | `cinnamon history` | Show watch history and resume from the next unwatched episode |
92
- | `cinnamon history --clear` | Clear all watch history |
91
+ | `cinnamon history` | Show watch history |
92
+ | `cinnamon history <query>` | Show episodes played for a show and continue from next |
93
+ | `cinnamon history clear` | Clear all watch history |
94
+ | `cinnamon history toggle [on/off]` | Enable or disable history tracking |
95
+ | `cinnamon config history show` | Show whether history tracking is enabled |
96
+ | `cinnamon config history toggle [on/off]` | Enable or disable via config |
93
97
  | `cinnamon scrapers` | See available streaming sources |
94
98
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
95
99
  | `cinnamon update` | Check for and install the latest version |
@@ -149,9 +153,9 @@ cinnamon config set-api-key YOUR_KEY
149
153
 
150
154
  ## Notes
151
155
 
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
156
+ Anime movies now work (both sub and dub). Use `--sub` or `--dub` to pick your translation, or just run the command and it will ask.
153
157
 
154
- This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
158
+ This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines of code.
155
159
  feel free to give us feedback so we can improve this project and make it as good as possible.
156
160
 
157
161
  inspired by [ani-cli](https://github.com/pystardust/ani-cli)
@@ -0,0 +1 @@
1
+ __version__ = "0.2.39"
@@ -459,8 +459,6 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
459
459
  questionary.Choice(title="Previous episode", value="prev"),
460
460
  questionary.Choice(title="Replay", value="replay"),
461
461
  ]
462
- if scraper == "anime":
463
- choices.append(questionary.Choice(title="Next season \u2192", value="next_season"))
464
462
  choices += [
465
463
  questionary.Choice(title="Change quality", value="quality"),
466
464
  questionary.Choice(title="Quit", value="quit"),
@@ -480,29 +478,6 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
480
478
  ep_name = f"S{season_num:02d}E{ep_num:02d}"
481
479
  elif choice == "replay":
482
480
  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))
506
481
  elif choice == "quality":
507
482
  try:
508
483
  quality = _select(
@@ -746,9 +721,7 @@ def cli(ctx, setup):
746
721
  ))
747
722
  console.print()
748
723
  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):
750
- ctx.invoke(setup_cmd)
751
- return
724
+ console.print(" [dim]Optional: set a TMDB key later with [cyan]cinnamon config set-api-key <key>[/cyan].[/dim]")
752
725
  theme = get_theme()
753
726
  console.clear()
754
727
  console.print(Panel.fit(
@@ -1008,11 +981,21 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
1008
981
  # Explicit id given: skip TMDB entirely (no API key needed) and let the
1009
982
  # scraper resolve the stream straight from the id.
1010
983
  if tmdb_id and media_type == "movie":
1011
- show = {"title": f"Movie {tmdb_id}", "id": tmdb_id}
984
+ try:
985
+ details = _get_tmdb().get_movie_details(tmdb_id)
986
+ title = details.get("title", f"Movie {tmdb_id}")
987
+ except Exception:
988
+ title = f"Movie {tmdb_id}"
989
+ show = {"title": title, "id": tmdb_id}
1012
990
  _play_movie(show, scraper, player, quality, info_only, download)
1013
991
  return
1014
992
  if tmdb_id and media_type == "tv":
1015
- show = {"name": f"Show {tmdb_id}", "id": tmdb_id}
993
+ try:
994
+ details = _get_tmdb().get_tv_details(tmdb_id)
995
+ name = details.get("name", f"Show {tmdb_id}")
996
+ except Exception:
997
+ name = f"Show {tmdb_id}"
998
+ show = {"name": name, "id": tmdb_id}
1016
999
  if season is not None and ep_start is not None:
1017
1000
  _play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
1018
1001
  else:
@@ -1158,27 +1141,27 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
1158
1141
 
1159
1142
  if ep_start is None:
1160
1143
  from .history import get_history as _get_history
1161
- last = _get_history(show_name)
1162
- if last and last.get("season") == season_num and last.get("episode"):
1163
- # Suggest the next unwatched episode (last watched + 1) when it
1164
- # exists in this season; otherwise fall back to the last watched.
1165
- resume_ep = last["episode"] + 1
1166
- if not any(
1167
- isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
1168
- for ep in episodes
1169
- ):
1170
- resume_ep = last["episode"]
1171
- try:
1172
- answer = Prompt.ask(
1173
- f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
1174
- default="y",
1175
- show_default=False,
1176
- ).strip().lower()
1177
- resume = answer in ("", "y", "yes")
1178
- except (EOFError, KeyboardInterrupt):
1179
- resume = True
1180
- if resume:
1181
- ep_start = resume_ep
1144
+ entries = _get_history(show_name)
1145
+ if entries:
1146
+ last = entries[0]
1147
+ if last.get("season") == season_num and last.get("episode"):
1148
+ resume_ep = last["episode"] + 1
1149
+ if not any(
1150
+ isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
1151
+ for ep in episodes
1152
+ ):
1153
+ resume_ep = last["episode"]
1154
+ try:
1155
+ answer = Prompt.ask(
1156
+ f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
1157
+ default="y",
1158
+ show_default=False,
1159
+ ).strip().lower()
1160
+ resume = answer in ("", "y", "yes")
1161
+ except (EOFError, KeyboardInterrupt):
1162
+ resume = True
1163
+ if resume:
1164
+ ep_start = resume_ep
1182
1165
 
1183
1166
  if ep_start is not None:
1184
1167
  ep_name = f"S{season_num:02d}E{ep_start:02d}"
@@ -1467,6 +1450,34 @@ def config_default_player(player):
1467
1450
  _print_success(f"Default player set to [bold]{player}[/bold].")
1468
1451
 
1469
1452
 
1453
+ @config.group("history")
1454
+ def config_history():
1455
+ """View or change watch history tracking."""
1456
+
1457
+
1458
+ @config_history.command("show")
1459
+ def config_history_show():
1460
+ """Show whether watch history tracking is enabled."""
1461
+ cfg = load_config()
1462
+ enabled = cfg.get("history_enabled", True)
1463
+ status = "enabled" if enabled else "disabled"
1464
+ console.print(f" Watch history tracking: [bold]{status}[/bold]")
1465
+
1466
+
1467
+ @config_history.command("toggle")
1468
+ @click.argument("state", type=click.Choice(["on", "off"]), required=False)
1469
+ def config_history_toggle(state):
1470
+ """Enable or disable watch history tracking."""
1471
+ cfg = load_config()
1472
+ if state:
1473
+ cfg["history_enabled"] = (state == "on")
1474
+ else:
1475
+ cfg["history_enabled"] = not cfg.get("history_enabled", True)
1476
+ save_config(cfg)
1477
+ status = "enabled" if cfg["history_enabled"] else "disabled"
1478
+ _print_success(f"Watch history tracking {status}.")
1479
+
1480
+
1470
1481
  @config.group("scraper")
1471
1482
  def config_scraper():
1472
1483
  """Configure a specific scraper."""
@@ -1610,26 +1621,75 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
1610
1621
 
1611
1622
  show_name = _title(show)
1612
1623
  theme = get_theme()
1624
+
1625
+ if show.get("format") == "MOVIE":
1626
+ console.print(f" [{theme['info']}]Anime movie — playing directly[/{theme['info']}]")
1627
+ show_dict = {"name": show_name, "id": 0}
1628
+
1629
+ if prefer_sub and prefer_dub:
1630
+ _print_info("Both --sub and --dub given; ignoring and asking.")
1631
+ prefer_sub = prefer_dub = False
1632
+
1633
+ from .scrapers.anime import _find_show, _allanime_episodes
1634
+ import requests as _req
1635
+ session = _req.Session()
1636
+ session.headers.update({"User-Agent": "Mozilla/5.0"})
1637
+ allanime_id = _find_show(session, show_name)
1638
+ tt = "sub"
1639
+ if allanime_id:
1640
+ eps = _allanime_episodes(session, allanime_id)
1641
+ tt_keys = [k for k in eps if eps[k]]
1642
+ if prefer_sub and "sub" in tt_keys:
1643
+ tt = "sub"
1644
+ elif prefer_dub and "dub" in tt_keys:
1645
+ tt = "dub"
1646
+ elif "sub" in tt_keys and len(tt_keys) > 1:
1647
+ try:
1648
+ tt = _select("Translation:", choices=[
1649
+ questionary.Choice(title=k, value=k) for k in tt_keys
1650
+ ], default="sub")
1651
+ except Exception:
1652
+ tt = "sub"
1653
+ elif tt_keys:
1654
+ tt = tt_keys[0]
1655
+
1656
+ proc = _resolve_and_play(show_dict, None, 1, show_name, "anime", player, quality, info_only, download, translation=tt)
1657
+ if proc is None and not info_only and not download:
1658
+ tmdb_id = _anime_movie_tmdb_id(show_name, year=(show.get("startDate") or {}).get("year"))
1659
+ if tmdb_id:
1660
+ _print_info(f"allanime has no direct stream — using [bold]webstream[/bold] for the movie.")
1661
+ proc = _resolve_and_play(
1662
+ {"name": show_name, "id": tmdb_id}, None, None, show_name,
1663
+ "webstream", player, quality, info_only, download,
1664
+ )
1665
+ if proc is not None and not info_only and not download:
1666
+ try:
1667
+ proc.wait()
1668
+ except AttributeError:
1669
+ pass
1670
+ return
1671
+
1613
1672
  console.clear()
1614
1673
  console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
1615
1674
 
1616
1675
  from .history import get_history as _get_history
1617
1676
  if not ep_str:
1618
- last = _get_history(show_name)
1619
- if last and last.get("episode"):
1620
- # Suggest the next unwatched episode (last watched + 1).
1621
- resume_ep = last["episode"] + 1
1622
- try:
1623
- answer = Prompt.ask(
1624
- f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
1625
- default="y",
1626
- show_default=False,
1627
- ).strip().lower()
1628
- resume = answer in ("", "y", "yes")
1629
- except (EOFError, KeyboardInterrupt):
1630
- resume = True
1631
- if resume:
1632
- ep_str = str(resume_ep)
1677
+ entries = _get_history(show_name)
1678
+ if entries:
1679
+ last = entries[0]
1680
+ if last.get("episode"):
1681
+ resume_ep = last["episode"] + 1
1682
+ try:
1683
+ answer = Prompt.ask(
1684
+ f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
1685
+ default="y",
1686
+ show_default=False,
1687
+ ).strip().lower()
1688
+ resume = answer in ("", "y", "yes")
1689
+ except (EOFError, KeyboardInterrupt):
1690
+ resume = True
1691
+ if resume:
1692
+ ep_str = str(resume_ep)
1633
1693
 
1634
1694
  from .scrapers.anime import _find_show, _allanime_episodes
1635
1695
  import requests as _req
@@ -1654,6 +1714,85 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
1654
1714
  _run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download, translation=translation)
1655
1715
 
1656
1716
 
1717
+ def _anime_movie_tmdb_id(show_name, year=None):
1718
+ """Best-effort lookup of a movie's TMDB id by title, for webstream fallback.
1719
+
1720
+ allanime only carries the anime's name, not its TMDB id, so we search TMDB
1721
+ for the closest movie match and return its id. When the AniList year is
1722
+ known it is used as a tie-breaker.
1723
+
1724
+ The keyless 2embed proxy (EmbedClient) returns the same real TMDB ids that
1725
+ vixsrc/vidlink use, so it is safe to fall back to it when no TMDB API key
1726
+ is configured — the returned id is genuine, not a guessed/renamed one. The
1727
+ final safety net is webstream itself: it verifies the id against vixsrc's
1728
+ playlist and falls back to vidlink, raising ScraperNoStreamError if neither
1729
+ serves the film. We additionally keep the vixsrc API check below as a
1730
+ pre-filter so we don't hand the player a dead id."""
1731
+ try:
1732
+ tmdb = _get_tmdb()
1733
+ results = tmdb.search_movie(show_name).get("results", [])
1734
+ except Exception:
1735
+ results = []
1736
+ if not results:
1737
+ # Try a normalized title (drop collection suffixes) — vixsrc indexes the
1738
+ # canonical TMDB entry, not the "...: The Movie" / " Movie" rename.
1739
+ norm = _normalize_movie_title(show_name)
1740
+ if norm and norm.lower() != show_name.lower():
1741
+ try:
1742
+ results = tmdb.search_movie(norm).get("results", [])
1743
+ except Exception:
1744
+ results = []
1745
+ if not results:
1746
+ return None
1747
+ q = show_name.lower().strip()
1748
+ nq = _normalize_movie_title(show_name).lower()
1749
+ scored = []
1750
+ for r in results:
1751
+ title = (r.get("title") or r.get("name") or "").lower().strip()
1752
+ if title != q and title != nq and q not in title and nq not in title and title not in q:
1753
+ continue
1754
+ score = 0
1755
+ if title in (q, nq):
1756
+ score = 3
1757
+ elif title.startswith(q) or q.startswith(title):
1758
+ score = 2
1759
+ elif q in title or nq in title or title in q:
1760
+ score = 1
1761
+ ryear = (r.get("release_date") or "")[:4]
1762
+ if year and ryear and str(year) == str(ryear):
1763
+ score += 2
1764
+ scored.append((score, r.get("popularity") or 0, r))
1765
+ if not scored:
1766
+ return None
1767
+ scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
1768
+ # Return the best-scored TMDB id. Do NOT pre-filter against vixsrc's API:
1769
+ # vixsrc's movie id space diverges from TMDB for many anime films (the top
1770
+ # pick may 404 on vixsrc yet play fine via vidlink), and webstream already
1771
+ # verifies the id end-to-end (vixsrc playlist + vidlink fallback) and raises
1772
+ # ScraperNoStreamError if neither source serves it. Letting webstream do the
1773
+ # verification keeps the correct film instead of falling back to a wrong one
1774
+ # that merely happens to be indexed by vixsrc.
1775
+ for _, _, r in scored:
1776
+ mid = r.get("id")
1777
+ if mid:
1778
+ return mid
1779
+ return None
1780
+
1781
+
1782
+ def _normalize_movie_title(name):
1783
+ """Strip collection/rename suffixes so the canonical TMDB id is found.
1784
+
1785
+ e.g. "A Silent Voice: The Movie" -> "A Silent Voice". vixsrc indexes the
1786
+ base title, not the renamed TMDB entry."""
1787
+ import re
1788
+ n = (name or "").strip()
1789
+ n = re.sub(r"\s*:\s*the\s+movie$", "", n, flags=re.I)
1790
+ n = re.sub(r"\s*:\s*the\s+motion\s+picture$", "", n, flags=re.I)
1791
+ n = re.sub(r"\s*:\s*movie$", "", n, flags=re.I)
1792
+ n = re.sub(r"\s+movie$", "", n, flags=re.I)
1793
+ return n
1794
+
1795
+
1657
1796
  def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player=None, quality=None, info_only=False, download=False, translation=None):
1658
1797
  """Drive the episode picker from allanime's *available* episodes (not TMDB's
1659
1798
  full list), so we never offer an episode the anime source doesn't have."""
@@ -1803,39 +1942,111 @@ def update():
1803
1942
  # ---------------------------------------------------------------------------
1804
1943
 
1805
1944
 
1806
- @cli.command()
1945
+ class HistoryGroup(click.Group):
1946
+ def resolve_command(self, ctx, args):
1947
+ if not args:
1948
+ return super().resolve_command(ctx, args)
1949
+ parent = super()
1950
+ cmd = parent.get_command(ctx, args[0])
1951
+ if cmd is not None:
1952
+ return super().resolve_command(ctx, args)
1953
+ cmd = parent.get_command(ctx, "list")
1954
+ return "list", cmd, args
1955
+
1956
+
1957
+ @cli.group(cls=HistoryGroup, invoke_without_command=True)
1958
+ @click.pass_context
1959
+ def history(ctx):
1960
+ """View or manage watch history."""
1961
+ if ctx.invoked_subcommand is None:
1962
+ ctx.invoke(history_list, query=None)
1963
+
1964
+
1965
+ @history.command("list")
1807
1966
  @click.argument("query", nargs=-1, required=False)
1808
- @click.option("--clear", is_flag=True, help="Clear all watch history")
1809
1967
  @_handle_errors
1810
- def history(query, clear):
1968
+ def history_list(query):
1811
1969
  """Show watch history and resume from last episode."""
1812
- from .history import clear_history, get_history, list_history
1813
-
1814
- if clear:
1815
- clear_history()
1816
- _print_success("Watch history cleared.")
1817
- return
1970
+ from .history import get_history, list_history
1818
1971
 
1819
1972
  q = " ".join(query) if query else None
1820
1973
  if q:
1821
- entry = get_history(q)
1822
- if not entry:
1823
- _print_info(f"No history found for \"{q}\".")
1824
- return
1825
- _print_info(f"Last watched [bold]{q}[/bold]: S{entry.get('season', 1)}E{entry.get('episode')}")
1826
- _play_with_menu(
1827
- {"name": q, "id": 0},
1828
- entry.get("season", 1),
1829
- entry.get("episode"),
1830
- None,
1831
- "",
1832
- entry.get("scraper") or "anime",
1833
- None,
1834
- entry.get("quality"),
1835
- False,
1836
- False,
1837
- translation=entry.get("translation"),
1838
- )
1974
+ entries = get_history(q)
1975
+ if not entries:
1976
+ tmdb = _get_tmdb()
1977
+ results = tmdb.search_tv(q).get("results", [])
1978
+ if not results:
1979
+ results = tmdb.search_movie(q).get("results", [])
1980
+ if results:
1981
+ title_key = "name" if results[0].get("name") else "title"
1982
+ matched = results[0].get(title_key, "")
1983
+ if matched:
1984
+ entries = get_history(matched)
1985
+ if not entries:
1986
+ from .anilist import search_anime
1987
+ anilist_results = search_anime(q)
1988
+ if anilist_results:
1989
+ def _title(m):
1990
+ return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
1991
+ matched = _title(anilist_results[0])
1992
+ if matched:
1993
+ entries = get_history(matched)
1994
+ if not entries:
1995
+ _print_info(f"No history found for \"{q}\".")
1996
+ return
1997
+
1998
+ table = Table(border_style="dim")
1999
+ table.add_column("#", style="cyan")
2000
+ table.add_column("Episode", style="green")
2001
+ table.add_column("Date", style="yellow")
2002
+ import datetime
2003
+ for i, e in enumerate(entries, 1):
2004
+ ts = e.get("timestamp", 0)
2005
+ date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
2006
+ season = e.get("season")
2007
+ episode = e.get("episode")
2008
+ if season is not None and episode is not None:
2009
+ ep_label = f"S{season}E{episode}"
2010
+ else:
2011
+ ep_label = "[dim]—[/dim]"
2012
+ table.add_row(str(i), ep_label, date_label)
2013
+
2014
+ console.print(table)
2015
+
2016
+ last = entries[0]
2017
+ resume_ep = (last.get("episode") or 0) + 1
2018
+ try:
2019
+ answer = Prompt.ask(
2020
+ f" Continue from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
2021
+ default="y",
2022
+ show_default=False,
2023
+ ).strip().lower()
2024
+ if answer in ("", "y", "yes"):
2025
+ show_name = q
2026
+ show_id = 0
2027
+ tmdb = _get_tmdb()
2028
+ try:
2029
+ results = tmdb.search_tv(q).get("results", [])
2030
+ if results:
2031
+ show_name = results[0].get("name", q)
2032
+ show_id = results[0]["id"]
2033
+ except Exception:
2034
+ pass
2035
+ _play_with_menu(
2036
+ {"name": show_name, "id": show_id},
2037
+ last.get("season", 1),
2038
+ resume_ep,
2039
+ None,
2040
+ "",
2041
+ last.get("scraper") or load_config().get("default_scraper", "webstream"),
2042
+ None,
2043
+ last.get("quality"),
2044
+ False,
2045
+ False,
2046
+ translation=last.get("translation"),
2047
+ )
2048
+ except (EOFError, KeyboardInterrupt):
2049
+ pass
1839
2050
  return
1840
2051
 
1841
2052
  entries = list_history()
@@ -1849,16 +2060,42 @@ def history(query, clear):
1849
2060
  table.add_column("Last watched", style="yellow")
1850
2061
 
1851
2062
  import datetime
1852
- for name, e in entries:
2063
+ for e in entries:
1853
2064
  ts = e.get("timestamp", 0)
1854
2065
  date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
1855
- table.add_row(name, f"S{e.get('season', 1)}E{e.get('episode')}", date_label)
2066
+ season = e.get("season")
2067
+ episode = e.get("episode")
2068
+ if season is not None and episode is not None:
2069
+ ep_label = f"S{season}E{episode}"
2070
+ else:
2071
+ ep_label = "[dim]—[/dim]"
2072
+ table.add_row(e.get("title", "?"), ep_label, date_label)
1856
2073
 
1857
2074
  console.print(table)
1858
2075
 
1859
- if _prompt(" Clear all history?", default="n").strip().lower() in ("y", "yes"):
1860
- clear_history()
1861
- _print_success("Watch history cleared.")
2076
+
2077
+ @history.command("clear")
2078
+ @_handle_errors
2079
+ def history_clear():
2080
+ """Clear all watch history."""
2081
+ from .history import clear_history
2082
+ clear_history()
2083
+ _print_success("Watch history cleared.")
2084
+
2085
+
2086
+ @history.command("toggle")
2087
+ @click.argument("state", type=click.Choice(["on", "off"]), required=False)
2088
+ @_handle_errors
2089
+ def history_toggle(state):
2090
+ """Enable or disable watch history tracking."""
2091
+ cfg = load_config()
2092
+ if state:
2093
+ cfg["history_enabled"] = (state == "on")
2094
+ else:
2095
+ cfg["history_enabled"] = not cfg.get("history_enabled", True)
2096
+ save_config(cfg)
2097
+ status = "enabled" if cfg["history_enabled"] else "disabled"
2098
+ _print_success(f"Watch history tracking {status}.")
1862
2099
 
1863
2100
 
1864
2101
  # ---------------------------------------------------------------------------