cinnamon-cli 0.2.38__tar.gz → 0.2.40__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.38 → cinnamon_cli-0.2.40}/PKG-INFO +9 -9
  2. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/README.md +8 -8
  3. cinnamon_cli-0.2.40/cinnamon/__init__.py +1 -0
  4. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/anilist.py +71 -0
  5. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/cli.py +179 -30
  6. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/anime.py +76 -22
  7. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/tmdb.py +3 -0
  8. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/PKG-INFO +9 -9
  9. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/pyproject.toml +1 -1
  10. cinnamon_cli-0.2.38/cinnamon/__init__.py +0 -1
  11. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/LICENSE +0 -0
  12. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/config.py +0 -0
  13. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/downloads.py +0 -0
  14. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/embedapi.py +0 -0
  15. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/errors.py +0 -0
  16. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/history.py +0 -0
  17. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/player.py +0 -0
  18. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/__init__.py +0 -0
  19. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/_vidlink_crypto.py +0 -0
  20. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/base.py +0 -0
  21. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/torrentio.py +0 -0
  22. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/vidsrc.py +0 -0
  23. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/webstream.py +0 -0
  24. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/tui.py +0 -0
  25. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/SOURCES.txt +0 -0
  26. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  27. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  28. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/requires.txt +0 -0
  29. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/top_level.txt +0 -0
  30. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/setup.cfg +0 -0
  31. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream.py +0 -0
  32. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream2.py +0 -0
  33. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream3.py +0 -0
  34. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream4.py +0 -0
  35. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream5.py +0 -0
  36. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream6.py +0 -0
  37. {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_ui_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.38
3
+ Version: 0.2.40
4
4
  Summary: Watch Movies, TV shows and anime from your terminal.
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
@@ -29,7 +29,7 @@ Provides-Extra: download
29
29
  Requires-Dist: yt-dlp>=2024.1.0; extra == "download"
30
30
  Dynamic: license-file
31
31
 
32
- # cinnamon
32
+ # Cinnamon | Beta
33
33
 
34
34
  Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
35
35
 
@@ -49,7 +49,7 @@ pip install cinnamon-cli
49
49
  cinnamon setup
50
50
  ```
51
51
 
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, `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.
53
53
 
54
54
 
55
55
 
@@ -70,7 +70,7 @@ cinnamon search "Breaking Bad"
70
70
  cinnamon search "Inception"
71
71
  ```
72
72
 
73
- For Anime you can run (Works even if you dont have an api key):
73
+ For Anime you can run:
74
74
 
75
75
  ```bash
76
76
  cinnamon anime "Chainsaw Man"
@@ -90,7 +90,7 @@ you can also run it like this:
90
90
  ```bash
91
91
  cinnamon Inception
92
92
  cinnamon Breaking bad
93
- cinnamon chainsaw man
93
+ cinnamon chainsaw man # Not reccomended to search an anime like this
94
94
  ```
95
95
 
96
96
  ### Download episodes
@@ -122,9 +122,9 @@ cinnamon search "Breaking Bad" -d -e 1-5
122
122
  | `cinnamon history` | Show watch history |
123
123
  | `cinnamon history <query>` | Show episodes played for a show and continue from next |
124
124
  | `cinnamon history clear` | Clear all watch history |
125
- | `cinnamon history toggle [on|off]` | Enable or disable history tracking |
125
+ | `cinnamon history toggle [on/off]` | Enable or disable history tracking |
126
126
  | `cinnamon config history show` | Show whether history tracking is enabled |
127
- | `cinnamon config history toggle [on|off]` | Enable or disable via config |
127
+ | `cinnamon config history toggle [on/off]` | Enable or disable via config |
128
128
  | `cinnamon scrapers` | See available streaming sources |
129
129
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
130
130
  | `cinnamon update` | Check for and install the latest version |
@@ -184,9 +184,9 @@ cinnamon config set-api-key YOUR_KEY
184
184
 
185
185
  ## Notes
186
186
 
187
- **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.
188
188
 
189
- 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.
190
190
  feel free to give us feedback so we can improve this project and make it as good as possible.
191
191
 
192
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
@@ -91,9 +91,9 @@ cinnamon search "Breaking Bad" -d -e 1-5
91
91
  | `cinnamon history` | Show watch history |
92
92
  | `cinnamon history <query>` | Show episodes played for a show and continue from next |
93
93
  | `cinnamon history clear` | Clear all watch history |
94
- | `cinnamon history toggle [on|off]` | Enable or disable history tracking |
94
+ | `cinnamon history toggle [on/off]` | Enable or disable history tracking |
95
95
  | `cinnamon config history show` | Show whether history tracking is enabled |
96
- | `cinnamon config history toggle [on|off]` | Enable or disable via config |
96
+ | `cinnamon config history toggle [on/off]` | Enable or disable via config |
97
97
  | `cinnamon scrapers` | See available streaming sources |
98
98
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
99
99
  | `cinnamon update` | Check for and install the latest version |
@@ -153,9 +153,9 @@ cinnamon config set-api-key YOUR_KEY
153
153
 
154
154
  ## Notes
155
155
 
156
- **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.
157
157
 
158
- 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.
159
159
  feel free to give us feedback so we can improve this project and make it as good as possible.
160
160
 
161
161
  inspired by [ani-cli](https://github.com/pystardust/ani-cli)
@@ -0,0 +1 @@
1
+ __version__ = "0.2.40"
@@ -1,3 +1,5 @@
1
+ import time as _time
2
+
1
3
  import requests
2
4
 
3
5
  from .errors import CinnamonError
@@ -90,3 +92,72 @@ def find_sequel(anime_name):
90
92
  return None
91
93
  except (requests.RequestException, CinnamonError):
92
94
  return None
95
+
96
+
97
+ _SCHEDULE_QUERY = """
98
+ query ($page: Int, $perPage: Int, $airingAtGreater: Int, $airingAtLesser: Int) {
99
+ Page(page: $page, perPage: $perPage) {
100
+ airingSchedules(
101
+ airingAt_greater: $airingAtGreater
102
+ airingAt_lesser: $airingAtLesser
103
+ sort: TIME
104
+ ) {
105
+ id
106
+ airingAt
107
+ episode
108
+ media {
109
+ id
110
+ title { romaji english }
111
+ format
112
+ status
113
+ episodes
114
+ popularity
115
+ averageScore
116
+ }
117
+ }
118
+ }
119
+ }
120
+ """
121
+
122
+
123
+ def get_schedule(days=7):
124
+ now = int(_time.time())
125
+ end = now + (days * 86400)
126
+ try:
127
+ resp = requests.post(
128
+ API,
129
+ json={
130
+ "query": _SCHEDULE_QUERY,
131
+ "variables": {
132
+ "page": 1,
133
+ "perPage": 50,
134
+ "airingAtGreater": now,
135
+ "airingAtLesser": end,
136
+ },
137
+ },
138
+ timeout=15,
139
+ headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
140
+ )
141
+ resp.raise_for_status()
142
+ data = resp.json()
143
+ schedules = data.get("data", {}).get("Page", {}).get("airingSchedules", [])
144
+ results = []
145
+ for s in schedules:
146
+ media = s.get("media", {})
147
+ title = media.get("title", {}) or {}
148
+ results.append({
149
+ "airing_at": s.get("airingAt", 0),
150
+ "episode": s.get("episode"),
151
+ "title_romaji": title.get("romaji", "") or "",
152
+ "title_english": title.get("english", "") or "",
153
+ "format": media.get("format", ""),
154
+ "popularity": media.get("popularity", 0) or 0,
155
+ "score": media.get("averageScore", 0) or 0,
156
+ })
157
+ return results
158
+ except requests.ConnectionError:
159
+ raise CinnamonError("Could not reach AniList. Check your internet connection.")
160
+ except requests.Timeout:
161
+ raise CinnamonError("AniList request timed out.")
162
+ except requests.HTTPError as e:
163
+ raise CinnamonError(f"AniList returned an error (HTTP {e.response.status_code}).")
@@ -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(
@@ -1650,12 +1625,36 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
1650
1625
  if show.get("format") == "MOVIE":
1651
1626
  console.print(f" [{theme['info']}]Anime movie — playing directly[/{theme['info']}]")
1652
1627
  show_dict = {"name": show_name, "id": 0}
1653
- proc = _resolve_and_play(show_dict, None, 1, show_name, "anime", player, quality, info_only, download)
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)
1654
1657
  if proc is None and not info_only and not download:
1655
- # allanime has no playable source for this movie (e.g. its obfuscated
1656
- # streamer link can't be decoded, or it only offers iframe embeds with
1657
- # no direct mp4). Fall back to the webstream scraper via the movie's
1658
- # TMDB id so the film still plays.
1659
1658
  tmdb_id = _anime_movie_tmdb_id(show_name, year=(show.get("startDate") or {}).get("year"))
1660
1659
  if tmdb_id:
1661
1660
  _print_info(f"allanime has no direct stream — using [bold]webstream[/bold] for the movie.")
@@ -1938,6 +1937,156 @@ def update():
1938
1937
  _print_error("Update failed.", str(e))
1939
1938
 
1940
1939
 
1940
+ # ---------------------------------------------------------------------------
1941
+ # schedule
1942
+ # ---------------------------------------------------------------------------
1943
+
1944
+
1945
+ @cli.group(invoke_without_command=True)
1946
+ @click.pass_context
1947
+ def schedule(ctx):
1948
+ """Show this week's trending TV shows and movies (requires TMDB key)."""
1949
+ if ctx.invoked_subcommand is None:
1950
+ _schedule_impl()
1951
+
1952
+
1953
+ @schedule.command()
1954
+ @click.option("--days", type=int, default=7, help="Days ahead (default 7)")
1955
+ @_handle_errors
1956
+ def anime(days):
1957
+ """Show anime airing schedule from AniList (no API key needed)."""
1958
+ from .anilist import get_schedule
1959
+ import datetime
1960
+
1961
+ entries = get_schedule(days=days)
1962
+ if not entries:
1963
+ _print_info(f"No anime airing in the next {days} day(s).")
1964
+ return
1965
+
1966
+ now = datetime.datetime.now()
1967
+
1968
+ def _day_key(entry):
1969
+ dt = datetime.datetime.fromtimestamp(entry["airing_at"])
1970
+ return dt.date()
1971
+
1972
+ def _day_label(d):
1973
+ diff = (d - now.date()).days
1974
+ if diff == 0:
1975
+ return "Today"
1976
+ if diff == 1:
1977
+ return "Tomorrow"
1978
+ return d.strftime("%a %b %d")
1979
+
1980
+ by_day = {}
1981
+ for e in entries:
1982
+ d = _day_key(e)
1983
+ by_day.setdefault(d, []).append(e)
1984
+
1985
+ for day in by_day:
1986
+ by_day[day].sort(key=lambda x: (-(x.get("score") or 0), -(x.get("popularity") or 0)))
1987
+
1988
+ sorted_days = list(reversed(sorted(by_day.keys())))
1989
+
1990
+ theme = get_theme()
1991
+ heading = f"[bold {theme['accent']}]Anime Schedule — next {days} day{'s' if days != 1 else ''}[/bold {theme['accent']}]"
1992
+ console.print(Panel(heading, border_style=theme["border"]))
1993
+
1994
+ for i, day in enumerate(sorted_days):
1995
+ entries = by_day[day]
1996
+
1997
+ def _est_delay(pop):
1998
+ if pop >= 50000:
1999
+ return 45
2000
+ if pop >= 10000:
2001
+ return 90
2002
+ if pop >= 2000:
2003
+ return 180
2004
+ return 360
2005
+
2006
+ day_table = Table(border_style="dim", show_header=False, box=None, padding=(0, 1, 0, 0))
2007
+ day_table.add_column("Time", no_wrap=True)
2008
+ day_table.add_column("Title", no_wrap=False)
2009
+ day_table.add_column("Ep", style="yellow", width=6, no_wrap=True)
2010
+
2011
+ for e in entries:
2012
+ dt = datetime.datetime.fromtimestamp(e["airing_at"])
2013
+ time_label = dt.strftime("%I:%M %p")
2014
+ title = (e.get("title_english") or e.get("title_romaji") or "?")
2015
+ if len(title) > 55:
2016
+ title = title[:52] + "..."
2017
+ ep = e.get("episode")
2018
+ fmt = e.get("format", "")
2019
+ ep_label = f"E{ep}" if ep and fmt != "MOVIE" else "—"
2020
+ ready_dt = dt + datetime.timedelta(minutes=_est_delay(e.get("popularity", 0)))
2021
+ time_label = f"[cyan]{time_label}[/] ~[dim]{ready_dt.strftime('%I:%M %p')}[/]"
2022
+ day_table.add_row(time_label, title, ep_label)
2023
+
2024
+ day_panel = Panel(
2025
+ day_table,
2026
+ title=f"[bold {theme['accent']}]{_day_label(day)}[/bold {theme['accent']}]",
2027
+ border_style=theme["border"],
2028
+ padding=(0, 1),
2029
+ )
2030
+ console.print(day_panel)
2031
+ if i < len(sorted_days) - 1:
2032
+ console.print()
2033
+
2034
+
2035
+ def _schedule_impl():
2036
+ try:
2037
+ tmdb = _get_tmdb()
2038
+ except MissingAPIKey:
2039
+ _print_info("A TMDB API key is required for the 'schedule' command.")
2040
+ _print_info(" Run: cinnamon config set-api-key <key>")
2041
+ _print_info(" Or use: cinnamon schedule anime (no key needed)")
2042
+ return
2043
+
2044
+ if isinstance(tmdb, EmbedClient):
2045
+ _print_info("A TMDB API key is required for the 'schedule' command.")
2046
+ _print_info(" Run: cinnamon config set-api-key <key>")
2047
+ _print_info(" Or use: cinnamon schedule anime (no key needed)")
2048
+ return
2049
+
2050
+ theme = get_theme()
2051
+ console.print(Panel(
2052
+ f"[bold {theme['accent']}]Trending This Week[/bold {theme['accent']}]",
2053
+ border_style=theme["border"],
2054
+ ))
2055
+
2056
+ table = Table(border_style="dim")
2057
+ table.add_column("Title")
2058
+ table.add_column("Date", style="cyan")
2059
+ table.add_column("Type", style="green")
2060
+ table.add_column("Rating", style="yellow")
2061
+
2062
+ tv_data = tmdb.get_trending("tv", "week")
2063
+ movie_data = tmdb.get_trending("movie", "week")
2064
+
2065
+ rows = []
2066
+ for item in tv_data.get("results", []):
2067
+ rows.append({
2068
+ "title": item.get("name", "?"),
2069
+ "date": item.get("first_air_date", ""),
2070
+ "type": "TV",
2071
+ "rating": item.get("vote_average", 0),
2072
+ })
2073
+ for item in movie_data.get("results", []):
2074
+ rows.append({
2075
+ "title": item.get("title", "?"),
2076
+ "date": item.get("release_date", ""),
2077
+ "type": "Movie",
2078
+ "rating": item.get("vote_average", 0),
2079
+ })
2080
+
2081
+ rows.sort(key=lambda r: r["rating"], reverse=True)
2082
+
2083
+ for r in rows:
2084
+ rating_str = f"{r['rating']:.1f}" if r["rating"] else "—"
2085
+ table.add_row(r["title"], r["date"][:10], r["type"], rating_str)
2086
+
2087
+ console.print(table)
2088
+
2089
+
1941
2090
  # ---------------------------------------------------------------------------
1942
2091
  # history
1943
2092
  # ---------------------------------------------------------------------------
@@ -3,6 +3,7 @@ import hashlib
3
3
  import json
4
4
  import re
5
5
  import socket
6
+ import subprocess
6
7
  import time as _time
7
8
  import contextlib
8
9
  from typing import Optional, Tuple
@@ -40,7 +41,7 @@ def _force_ipv4():
40
41
  socket.getaddrinfo = orig
41
42
 
42
43
 
43
- def _new_session(ua=UA, origin="https://youtu-chan.com"):
44
+ def _new_session(ua=UA, origin="https://mkissa.to"):
44
45
  """Build a requests.Session with retries and IPv4-biased DNS."""
45
46
  s = requests.Session()
46
47
  s.headers.update({"User-Agent": ua, "Origin": origin})
@@ -78,14 +79,16 @@ _BROWSER_UA = (
78
79
  "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
79
80
  )
80
81
  # Last known good values, only used when the runtime fetch fails.
81
- # Rotated 2026-07; the runtime fetch (_aa_fetch) overrides these with live
82
- # values from the site so the token stays valid between code updates.
83
- _FALLBACK_EPOCH = 4130
84
- _FALLBACK_MASK = "4e600edee179ef01e61d9e322afb6418efb0ea4a33e798adcb62edc5e885423b"
85
- _FALLBACK_PART_B = "eu1Ih9XG2JYVqkp8XDkLzBEOIOEUQwCILJLs+ifdpeA="
86
- _FALLBACK_QUERY_HASH = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
82
+ # Updated 2026-07-24 (epoch 6885); the runtime fetch (_aa_fetch) overrides
83
+ # these with live values from the site so the token stays valid between
84
+ # code updates.
85
+ _FALLBACK_EPOCH = 6885
86
+ _FALLBACK_MASK = "70bb5e6260e19a806b3609dc0b6eb718899b09edbd0c23703a5de00e544de128"
87
+ _FALLBACK_PART_B = "j6t9AsXnwTdE9yEr5MuzNTbWvG9YyaQkPd+FnD47XvA="
88
+ _FALLBACK_QUERY_HASH = "1d724f8b7e14b624bf96bd43b88dbb68253e503c40d6d0c6b5a73c9a6d149f2b"
87
89
  # Static legacy key AllAnime also signs tobeparsed with, depending on rotation.
88
90
  _RESP_STATIC_KEY = hashlib.sha256(b"Xot36i3lK3:v1").digest()
91
+ _BUILD_ID = "64"
89
92
 
90
93
  _crypto_cache = None # (expires_ms, epoch, key, mask, query_hash)
91
94
 
@@ -130,7 +133,8 @@ def _aa_fetch():
130
133
  """Fetch (expires_ms, epoch, key, mask, query_hash) from the live site."""
131
134
  try:
132
135
  s = _new_session(ua=_BROWSER_UA, origin="https://mkissa.to")
133
- html = s.get(_MKISSA_URL, timeout=10).text
136
+ with _force_ipv4():
137
+ html = s.get(_MKISSA_URL, timeout=10).text
134
138
  aa = json.loads(re.search(r"window\.__aaCrypto\s*=\s*(\{.*?\})", html).group(1))
135
139
  part_b, epoch = aa["partB"], int(aa["epoch"])
136
140
  expires = max(aa.get("switchAt", 0) + aa.get("graceMs", 0),
@@ -140,17 +144,31 @@ def _aa_fetch():
140
144
 
141
145
  app = re.search(r"_app/immutable/(entry/app\.[^\"']+\.js)", html)
142
146
  if app:
143
- app_js = s.get(_CDN_IMMUTABLE + app.group(1), timeout=10).text
147
+ with _force_ipv4():
148
+ app_js = s.get(_CDN_IMMUTABLE + app.group(1), timeout=10).text
144
149
  for chunk in re.findall(
145
150
  r"chunks/[A-Za-z0-9_\-]+\.js", app_js
146
151
  ):
147
- js = s.get(_CDN_IMMUTABLE + chunk, timeout=10).text
152
+ with _force_ipv4():
153
+ js = s.get(_CDN_IMMUTABLE + chunk, timeout=10).text
148
154
  if "__aaCrypto" not in js:
149
155
  continue
150
156
  masks = re.findall(r"[0-9a-f]{64}", js)
151
157
  if len(masks) == 1:
152
158
  mask = masks[0]
153
- qh = _aa_source_query_hash(js) or _FALLBACK_QUERY_HASH
159
+ # Compute the query hash from the resolved inline query
160
+ # string (which handles whatever template shape the JS
161
+ # chunk currently uses), rather than from regex-extracted
162
+ # template placeholders that may break when the chunk is
163
+ # updated. Fall back to the hardcoded hash if resolution
164
+ # fails (the server will reject the request with
165
+ # AA_CRYPTO_MISSING but that is recoverable).
166
+ try:
167
+ import hashlib
168
+ qstr = _aa_source_query_string()
169
+ qh = hashlib.sha256(qstr.encode()).hexdigest()
170
+ except Exception:
171
+ qh = _FALLBACK_QUERY_HASH
154
172
  break
155
173
 
156
174
  # If the mask could not be scraped, fall back to the last known good
@@ -178,8 +196,8 @@ def _aa_current() -> Tuple[int, bytes, str]:
178
196
  def _aa_build_token() -> Tuple[str, str, bytes]:
179
197
  epoch, key, query_hash = _aa_current()
180
198
  ts = int(_time.time() * 1000) // 300000 * 300000
181
- payload = {"v": 1, "ts": ts, "epoch": epoch, "qh": query_hash}
182
- iv = hashlib.sha256(f"{epoch}:{query_hash}:{ts}".encode()).digest()[:12]
199
+ payload = {"v": 1, "ts": ts, "epoch": epoch, "buildId": _BUILD_ID, "qh": query_hash}
200
+ iv = hashlib.sha256(f"{epoch}:{_BUILD_ID}:{query_hash}:{ts}".encode()).digest()[:12]
183
201
  from Crypto.Cipher import AES
184
202
  cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
185
203
  ciphertext, tag = cipher.encrypt_and_digest(
@@ -272,15 +290,18 @@ def _aa_source_query_string():
272
290
  return _SOURCE_QUERY_CACHE[1]
273
291
  s = _new_session(ua=_BROWSER_UA, origin="https://mkissa.to")
274
292
  try:
275
- html = s.get(_MKISSA_URL, timeout=10).text
293
+ with _force_ipv4():
294
+ html = s.get(_MKISSA_URL, timeout=10).text
276
295
  app = re.search(r"_app/immutable/(entry/app\.[^\"']+\.js)", html)
277
296
  if not app:
278
297
  raise ValueError("no app entry")
279
- app_js = s.get(_CDN_IMMUTABLE + app.group(1), timeout=10).text
298
+ with _force_ipv4():
299
+ app_js = s.get(_CDN_IMMUTABLE + app.group(1), timeout=10).text
280
300
  chunk_urls = re.findall(r"chunks/[A-Za-z0-9_\-]+\.js", app_js)
281
301
  js = ""
282
302
  for cu in chunk_urls:
283
- c = s.get(_CDN_IMMUTABLE + cu, timeout=10).text
303
+ with _force_ipv4():
304
+ c = s.get(_CDN_IMMUTABLE + cu, timeout=10).text
284
305
  if "sourceUrls" in c:
285
306
  js = c
286
307
  break
@@ -331,14 +352,17 @@ def _aa_source_query_string():
331
352
  )
332
353
 
333
354
 
334
- def _gql_src_inline(session, variables, aa_req):
355
+ def _gql_src_inline(session, variables, aa_req, query_hash=None):
335
356
  global _LAST_REQUEST
336
357
  elapsed = _time.time() - _LAST_REQUEST
337
358
  if elapsed < 3.5:
338
359
  _time.sleep(3.5 - elapsed)
339
360
  query = _aa_source_query_string()
361
+ if query_hash is None:
362
+ query_hash = hashlib.sha256(query.encode()).hexdigest()
363
+ extensions = {"persistedQuery": {"version": 1, "sha256Hash": query_hash}, "aaReq": aa_req}
340
364
  body = json.dumps(
341
- {"query": query, "variables": variables, "extensions": {"aaReq": aa_req}},
365
+ {"query": query, "variables": variables, "extensions": extensions},
342
366
  ensure_ascii=False, separators=(",", ":"),
343
367
  )
344
368
  try:
@@ -360,9 +384,9 @@ def _allanime_episodes(session, show_id):
360
384
  return data.get("data", {}).get("show", {}).get("availableEpisodesDetail", {})
361
385
 
362
386
  def _allanime_sources(session, show_id, ep_str, tt="sub"):
363
- token, key = _aa_build_token()[1], _aa_build_token()[2]
387
+ query_hash, token, key = _aa_build_token()
364
388
  variables = {"showId": show_id, "translationType": tt, "episodeString": ep_str}
365
- data = _gql_src_inline(session, variables, token)
389
+ data = _gql_src_inline(session, variables, token, query_hash=query_hash)
366
390
  if isinstance(data, dict):
367
391
  tp = data.get("data", {}).get("tobeparsed", "")
368
392
  if not tp and data.get("errors"):
@@ -497,7 +521,7 @@ def _clock_stream(session, clock_url):
497
521
  if isinstance(links, list) and links:
498
522
  first = links[0]
499
523
  if isinstance(first, dict):
500
- return first.get("url") or first.get("file")
524
+ return first.get("url") or first.get("file") or first.get("link")
501
525
  if isinstance(first, str):
502
526
  return first
503
527
  # Some clocks return the bare URL as text.
@@ -588,7 +612,37 @@ class AnimeScraper(BaseScraper):
588
612
  except Exception:
589
613
  pass
590
614
 
591
- # 3) Any other directly-playable http(s) URL (already-resolved media:
615
+ # 3) yt-dlp fallback for embed pages (ok.ru, …).
616
+ for src in sources:
617
+ if _time.time() >= deadline:
618
+ break
619
+ url = src.get("sourceUrl", "")
620
+ if not url or not url.startswith("http"):
621
+ continue
622
+ low = url.split("?")[0].lower()
623
+ if low.endswith((".m3u8", ".mp4", ".m4v", ".mkv", ".mov", ".webm")) or ".m3u8" in url:
624
+ continue
625
+ try:
626
+ proc = subprocess.run(
627
+ ["yt-dlp", "-g", "--no-warnings", url],
628
+ capture_output=True, text=True, timeout=10,
629
+ )
630
+ out = proc.stdout.strip()
631
+ if out:
632
+ first = out.split("\n")[0]
633
+ stripped_src = url.removeprefix("https://www.").removeprefix("http://www.").removeprefix("https://").removeprefix("http://")
634
+ stripped_dst = first.removeprefix("https://www.").removeprefix("http://www.").removeprefix("https://").removeprefix("http://")
635
+ if stripped_src != stripped_dst:
636
+ return ScraperResult(
637
+ title=f"{show_name} E{episode:02d} ({label}) [ytdlp]",
638
+ m3u8_url=first,
639
+ referer="https://allanime.day/",
640
+ user_agent=UA,
641
+ )
642
+ except Exception:
643
+ pass
644
+
645
+ # 4) Any other directly-playable http(s) URL (already-resolved media:
592
646
  # .m3u8 / .mp4 / .m4v / .mkv). Embed pages are NOT playable, so we
593
647
  # only accept URLs that end in a known media extension (or carry an
594
648
  # HLS query). Bare provider pages (.com/e/...) are skipped so the
@@ -81,3 +81,6 @@ class TMDBClient:
81
81
 
82
82
  def search_movie(self, query):
83
83
  return self._request("/search/movie", {"query": query})
84
+
85
+ def get_trending(self, media_type="all", time_window="week"):
86
+ return self._request(f"/trending/{media_type}/{time_window}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.38
3
+ Version: 0.2.40
4
4
  Summary: Watch Movies, TV shows and anime from your terminal.
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
@@ -29,7 +29,7 @@ Provides-Extra: download
29
29
  Requires-Dist: yt-dlp>=2024.1.0; extra == "download"
30
30
  Dynamic: license-file
31
31
 
32
- # cinnamon
32
+ # Cinnamon | Beta
33
33
 
34
34
  Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
35
35
 
@@ -49,7 +49,7 @@ pip install cinnamon-cli
49
49
  cinnamon setup
50
50
  ```
51
51
 
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, `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.
53
53
 
54
54
 
55
55
 
@@ -70,7 +70,7 @@ cinnamon search "Breaking Bad"
70
70
  cinnamon search "Inception"
71
71
  ```
72
72
 
73
- For Anime you can run (Works even if you dont have an api key):
73
+ For Anime you can run:
74
74
 
75
75
  ```bash
76
76
  cinnamon anime "Chainsaw Man"
@@ -90,7 +90,7 @@ you can also run it like this:
90
90
  ```bash
91
91
  cinnamon Inception
92
92
  cinnamon Breaking bad
93
- cinnamon chainsaw man
93
+ cinnamon chainsaw man # Not reccomended to search an anime like this
94
94
  ```
95
95
 
96
96
  ### Download episodes
@@ -122,9 +122,9 @@ cinnamon search "Breaking Bad" -d -e 1-5
122
122
  | `cinnamon history` | Show watch history |
123
123
  | `cinnamon history <query>` | Show episodes played for a show and continue from next |
124
124
  | `cinnamon history clear` | Clear all watch history |
125
- | `cinnamon history toggle [on|off]` | Enable or disable history tracking |
125
+ | `cinnamon history toggle [on/off]` | Enable or disable history tracking |
126
126
  | `cinnamon config history show` | Show whether history tracking is enabled |
127
- | `cinnamon config history toggle [on|off]` | Enable or disable via config |
127
+ | `cinnamon config history toggle [on/off]` | Enable or disable via config |
128
128
  | `cinnamon scrapers` | See available streaming sources |
129
129
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
130
130
  | `cinnamon update` | Check for and install the latest version |
@@ -184,9 +184,9 @@ cinnamon config set-api-key YOUR_KEY
184
184
 
185
185
  ## Notes
186
186
 
187
- **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.
188
188
 
189
- 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.
190
190
  feel free to give us feedback so we can improve this project and make it as good as possible.
191
191
 
192
192
  inspired by [ani-cli](https://github.com/pystardust/ani-cli)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cinnamon-cli"
7
- version = "0.2.38"
7
+ version = "0.2.40"
8
8
  description = "Watch Movies, TV shows and anime from your terminal."
9
9
  license = { text = "CC BY-NC 4.0" }
10
10
  readme = "README.md"
@@ -1 +0,0 @@
1
- __version__ = "0.2.38"
File without changes
File without changes