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.
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/PKG-INFO +9 -9
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/README.md +8 -8
- cinnamon_cli-0.2.40/cinnamon/__init__.py +1 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/anilist.py +71 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/cli.py +179 -30
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/anime.py +76 -22
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/tmdb.py +3 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/PKG-INFO +9 -9
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/pyproject.toml +1 -1
- cinnamon_cli-0.2.38/cinnamon/__init__.py +0 -1
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/LICENSE +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/config.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/downloads.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/embedapi.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/errors.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/history.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/player.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/__init__.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/_vidlink_crypto.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/base.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/torrentio.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/vidsrc.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/scrapers/webstream.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon/tui.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/SOURCES.txt +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/entry_points.txt +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/requires.txt +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/cinnamon_cli.egg-info/top_level.txt +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/setup.cfg +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream2.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream3.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream4.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream5.py +0 -0
- {cinnamon_cli-0.2.38 → cinnamon_cli-0.2.40}/tests/test_directstream6.py +0 -0
- {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.
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
#
|
|
82
|
-
# values from the site so the token stays valid between
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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()
|
|
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
|
-
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cinnamon-cli
|
|
3
|
-
Version: 0.2.
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.38"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|