cinnamon-cli 0.2.23__tar.gz → 0.2.25__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 (36) hide show
  1. {cinnamon_cli-0.2.23/cinnamon_cli.egg-info → cinnamon_cli-0.2.25}/PKG-INFO +2 -2
  2. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/README.md +1 -1
  3. cinnamon_cli-0.2.25/cinnamon/__init__.py +1 -0
  4. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/cli.py +4 -4
  5. cinnamon_cli-0.2.25/cinnamon/scrapers/anime.py +315 -0
  6. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25/cinnamon_cli.egg-info}/PKG-INFO +2 -2
  7. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/pyproject.toml +1 -1
  8. cinnamon_cli-0.2.23/cinnamon/__init__.py +0 -1
  9. cinnamon_cli-0.2.23/cinnamon/scrapers/anime.py +0 -196
  10. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/LICENSE +0 -0
  11. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/anilist.py +0 -0
  12. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/config.py +0 -0
  13. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/downloads.py +0 -0
  14. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/errors.py +0 -0
  15. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/history.py +0 -0
  16. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/player.py +0 -0
  17. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/__init__.py +0 -0
  18. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/base.py +0 -0
  19. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/torrentio.py +0 -0
  20. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/vidsrc.py +0 -0
  21. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/webstream.py +0 -0
  22. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/tmdb.py +0 -0
  23. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/tui.py +0 -0
  24. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/SOURCES.txt +0 -0
  25. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  26. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  27. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/requires.txt +0 -0
  28. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/top_level.txt +0 -0
  29. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/setup.cfg +0 -0
  30. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream.py +0 -0
  31. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream2.py +0 -0
  32. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream3.py +0 -0
  33. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream4.py +0 -0
  34. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream5.py +0 -0
  35. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream6.py +0 -0
  36. {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/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.23
3
+ Version: 0.2.25
4
4
  Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
@@ -31,7 +31,7 @@ Dynamic: license-file
31
31
 
32
32
  > Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
33
33
 
34
- [![Version](https://img.shields.io/badge/version-0.2.23-blue)](#)
34
+ [![Version](https://img.shields.io/badge/version-0.2.25-blue)](#)
35
35
  [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](#)
36
36
  [![License](https://img.shields.io/badge/license-CC%20BY--NC%204.0-green)](#)
37
37
  [![Platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux%20%7C%20termux-lightgrey)](#)
@@ -2,7 +2,7 @@
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
 
5
- [![Version](https://img.shields.io/badge/version-0.2.23-blue)](#)
5
+ [![Version](https://img.shields.io/badge/version-0.2.25-blue)](#)
6
6
  [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](#)
7
7
  [![License](https://img.shields.io/badge/license-CC%20BY--NC%204.0-green)](#)
8
8
  [![Platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux%20%7C%20termux-lightgrey)](#)
@@ -0,0 +1 @@
1
+ __version__ = "0.2.25"
@@ -1697,7 +1697,7 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
1697
1697
 
1698
1698
  @cli.command()
1699
1699
  def update():
1700
- """Check for and install the latest version of cinnamon from GitHub."""
1700
+ """Check for and install the latest version of cinnamon from PyPI."""
1701
1701
  theme = get_theme()
1702
1702
 
1703
1703
  latest = _latest_version()
@@ -1716,10 +1716,10 @@ def update():
1716
1716
  console.print(f" [{theme['info']}]Updating:[/] {current} → [bold]{latest}[/bold]")
1717
1717
  console.print()
1718
1718
 
1719
- url = f"https://github.com/{_UPDATE_REPO}/archive/refs/tags/v{latest}.tar.gz"
1719
+ target = "cinnamon-cli"
1720
1720
  try:
1721
1721
  proc = subprocess.Popen(
1722
- [sys.executable, "-m", "pip", "install", "--upgrade", url],
1722
+ [sys.executable, "-m", "pip", "install", "--upgrade", target],
1723
1723
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1724
1724
  text=True, bufsize=1,
1725
1725
  )
@@ -1744,7 +1744,7 @@ def update():
1744
1744
  _print_error(
1745
1745
  f"pip reported success but version is still {current}.",
1746
1746
  "You may be installing into a different Python environment. Try: "
1747
- f"python -m pip install --upgrade {url}",
1747
+ f"python -m pip install --upgrade {target}",
1748
1748
  )
1749
1749
  else:
1750
1750
  _print_error(f"Update failed (exit code {proc.returncode}).")
@@ -0,0 +1,315 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import re
5
+ import time as _time
6
+ from typing import Optional, Tuple
7
+
8
+ import requests
9
+
10
+ from ..errors import ScraperNetworkError, ScraperNoStreamError, ScraperParseError
11
+ from ..player import DEFAULT_UA as UA
12
+ from .base import BaseScraper, ScraperResult
13
+
14
+ TIMEOUT = 15
15
+ API = "https://api.allanime.day/api"
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # AllAnime "aaReq" crypto gate.
19
+ #
20
+ # AllAnime now protects the episode-source GraphQL query behind an AES-GCM
21
+ # signed token (aaReq) that must be sent inside the request's `extensions`
22
+ # object, otherwise the API returns `AA_CRYPTO_MISSING`. The token's epoch and
23
+ # key rotate every few days and live in the frontend's `window.__aaCrypto` plus
24
+ # the app JS chunk, so we fetch them at runtime and fall back to the last known
25
+ # good hardcoded values when that fails. The tobeparsed response is encrypted
26
+ # with the same key (or a static legacy key), so decoding tries both.
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _MKISSA_URL = "https://mkissa.to/"
30
+ _CDN_IMMUTABLE = "https://cdn.allanime.day/all/mk/_app/immutable/"
31
+ _BROWSER_UA = (
32
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
33
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
34
+ )
35
+ # Last known good values, only used when the runtime fetch fails.
36
+ # Rotated 2026-07; the runtime fetch (_aa_fetch) overrides these with live
37
+ # values from the site so the token stays valid between code updates.
38
+ _FALLBACK_EPOCH = 4130
39
+ _FALLBACK_MASK = "5264513ba898cb78c5c646bc1c12f2965a53a99891d91e83a2bf9244c36cca41"
40
+ _FALLBACK_PART_B = "nSMmjt8SIaRRj6ebdfimy1qXlUBuvMoBlPoUiSFoORg="
41
+ _FALLBACK_QUERY_HASH = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
42
+ # Static legacy key AllAnime also signs tobeparsed with, depending on rotation.
43
+ _RESP_STATIC_KEY = hashlib.sha256(b"Xot36i3lK3:v1").digest()
44
+
45
+ _crypto_cache = None # (expires_ms, epoch, key, mask, query_hash)
46
+
47
+
48
+ def _aa_key(mask_hex: str, part_b: str) -> bytes:
49
+ return bytes(a ^ b for a, b in zip(bytes.fromhex(mask_hex), base64.b64decode(part_b)))
50
+
51
+
52
+ def _aa_source_query_hash(chunk_js: str) -> Optional[str]:
53
+ template = next(
54
+ (t for t in re.findall(r"`([^`]*)`", chunk_js)
55
+ if "sourceUrls" in t and "episode(" in t),
56
+ None,
57
+ )
58
+ if template is None:
59
+ return None
60
+
61
+ def resolve(tmpl: str, depth: int = 0) -> str:
62
+ if depth > 6:
63
+ return tmpl
64
+ for name in re.findall(r"\$\{([^}]+)\}", tmpl):
65
+ if name.endswith("()"):
66
+ fn = re.search(
67
+ r"\b" + re.escape(name[:-2]) +
68
+ r"\s*=\s*\w+\s*=>\s*\w+\s*\?\s*`[^`]*`\s*:\s*`([^`]*)`",
69
+ chunk_js,
70
+ )
71
+ repl = fn.group(1) if fn else ""
72
+ else:
73
+ var = re.search(r"\b" + re.escape(name) + r"\s*=\s*`([^`]*)`", chunk_js)
74
+ repl = resolve(var.group(1), depth + 1) if var else ""
75
+ tmpl = tmpl.replace("${" + name + "}", repl)
76
+ return tmpl
77
+
78
+ query = resolve(template)
79
+ if "${" in query:
80
+ return None
81
+ return hashlib.sha256(query.encode()).hexdigest()
82
+
83
+
84
+ def _aa_fetch():
85
+ """Fetch (expires_ms, epoch, key, mask, query_hash) from the live site."""
86
+ try:
87
+ s = requests.Session()
88
+ s.headers.update({"User-Agent": _BROWSER_UA})
89
+ html = s.get(_MKISSA_URL, timeout=10).text
90
+ aa = json.loads(re.search(r"window\.__aaCrypto\s*=\s*(\{.*?\})", html).group(1))
91
+ part_b, epoch = aa["partB"], int(aa["epoch"])
92
+ expires = max(aa.get("switchAt", 0) + aa.get("graceMs", 0),
93
+ _time.time() * 1000 + 3600_000)
94
+ mask = None
95
+ qh = _FALLBACK_QUERY_HASH
96
+
97
+ app = re.search(r"_app/immutable/(entry/app\.[^\"']+\.js)", html)
98
+ if app:
99
+ app_js = s.get(_CDN_IMMUTABLE + app.group(1), timeout=10).text
100
+ for chunk in re.findall(
101
+ r"chunks/[A-Za-z0-9_\-]+\.js", app_js
102
+ ):
103
+ js = s.get(_CDN_IMMUTABLE + chunk, timeout=10).text
104
+ if "__aaCrypto" not in js:
105
+ continue
106
+ masks = re.findall(r"[0-9a-f]{64}", js)
107
+ if len(masks) == 1:
108
+ mask = masks[0]
109
+ qh = _aa_source_query_hash(js) or _FALLBACK_QUERY_HASH
110
+ break
111
+
112
+ # If the mask could not be scraped, fall back to the last known good
113
+ # mask but keep the freshly fetched epoch/partB so the token is at
114
+ # least current (server returns STALE, not MISSING, and the next code
115
+ # update can ship the new mask).
116
+ if mask is None:
117
+ mask = _FALLBACK_MASK
118
+ return expires, epoch, _aa_key(mask, part_b), mask, qh
119
+ except Exception:
120
+ return None
121
+
122
+
123
+ def _aa_current() -> Tuple[int, bytes, str]:
124
+ global _crypto_cache
125
+ if _crypto_cache is None or _crypto_cache[0] <= _time.time() * 1000:
126
+ fetched = _aa_fetch()
127
+ if fetched is not None:
128
+ _crypto_cache = fetched
129
+ if _crypto_cache is not None:
130
+ return _crypto_cache[1], _crypto_cache[2], _crypto_cache[4]
131
+ return _FALLBACK_EPOCH, _aa_key(_FALLBACK_MASK, _FALLBACK_PART_B), _FALLBACK_QUERY_HASH
132
+
133
+
134
+ def _aa_build_token() -> Tuple[str, str, bytes]:
135
+ epoch, key, query_hash = _aa_current()
136
+ ts = int(_time.time() * 1000) // 300000 * 300000
137
+ payload = {"v": 1, "ts": ts, "epoch": epoch, "qh": query_hash}
138
+ iv = hashlib.sha256(f"{epoch}:{query_hash}:{ts}".encode()).digest()[:12]
139
+ from Crypto.Cipher import AES
140
+ cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
141
+ ciphertext, tag = cipher.encrypt_and_digest(
142
+ json.dumps(payload, separators=(",", ":")).encode()
143
+ )
144
+ token = base64.b64encode(b"\x01" + iv + ciphertext + tag).decode()
145
+ return query_hash, token, key
146
+
147
+
148
+ def _decrypt_tobeparsed(tp: str, key: bytes):
149
+ try:
150
+ raw = base64.b64decode(tp)
151
+ iv, ciphertext, tag = raw[1:13], raw[13:-16], raw[-16:]
152
+ from Crypto.Cipher import AES
153
+ for candidate in (key, _RESP_STATIC_KEY):
154
+ try:
155
+ cipher = AES.new(candidate, AES.MODE_GCM, nonce=iv)
156
+ plain = cipher.decrypt_and_verify(ciphertext, tag)
157
+ return json.loads(plain.decode("utf-8"))
158
+ except ValueError:
159
+ continue
160
+ return None
161
+ except Exception:
162
+ return None
163
+
164
+ _SEARCH_GQL = """query ($search: SearchInput) {
165
+ shows(search: $search, limit: 10, page: 1, translationType: sub, countryOrigin: ALL) {
166
+ edges { _id name availableEpisodes __typename }
167
+ }
168
+ }"""
169
+
170
+ _EP_GQL = "query ($showId: String!) { show(_id: $showId) { _id availableEpisodesDetail } }"
171
+
172
+ _SRC_GQL = "query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episode(showId: $showId translationType: $translationType episodeString: $episodeString) { episodeString sourceUrls } }"
173
+
174
+ _LAST_REQUEST = 0.0
175
+
176
+ def _gql(session, query, variables):
177
+ global _LAST_REQUEST
178
+ elapsed = _time.time() - _LAST_REQUEST
179
+ if elapsed < 3.5:
180
+ _time.sleep(3.5 - elapsed)
181
+ body = json.dumps({"variables": json.dumps(variables), "query": query}, ensure_ascii=False, separators=(",", ":"))
182
+ resp = session.post(API, data=body, timeout=TIMEOUT, headers={"Content-Type": "application/json"})
183
+ _LAST_REQUEST = _time.time()
184
+ if resp.status_code != 200:
185
+ raise ScraperNetworkError("allanime", f"GraphQL returned {resp.status_code}")
186
+ return resp.json()
187
+
188
+ def _gql_src(session, variables, query_hash=None, aa_req=None):
189
+ global _LAST_REQUEST
190
+ elapsed = _time.time() - _LAST_REQUEST
191
+ if elapsed < 3.5:
192
+ _time.sleep(3.5 - elapsed)
193
+ if query_hash is None:
194
+ query_hash = _FALLBACK_QUERY_HASH
195
+ extensions = {"persistedQuery": {"version": 1, "sha256Hash": query_hash}}
196
+ if aa_req is not None:
197
+ extensions["aaReq"] = aa_req
198
+ body = json.dumps(
199
+ {"extensions": extensions, "variables": json.dumps(variables)},
200
+ ensure_ascii=False, separators=(",", ":"),
201
+ )
202
+ resp = session.post(API, data=body, timeout=TIMEOUT, headers={"Content-Type": "application/json"})
203
+ _LAST_REQUEST = _time.time()
204
+ if resp.status_code != 200:
205
+ raise ScraperNetworkError("allanime", f"Source GQL returned {resp.status_code}")
206
+ return resp.json()
207
+
208
+ def _allanime_search(session, query):
209
+ data = _gql(session, _SEARCH_GQL, {"search": {"allowAdult": False, "allowUnknown": False, "query": query}})
210
+ return data.get("data", {}).get("shows", {}).get("edges", [])
211
+
212
+ def _allanime_episodes(session, show_id):
213
+ data = _gql(session, _EP_GQL, {"showId": show_id})
214
+ return data.get("data", {}).get("show", {}).get("availableEpisodesDetail", {})
215
+
216
+ def _allanime_sources(session, show_id, ep_str, tt="sub"):
217
+ query_hash, token, key = _aa_build_token()
218
+ variables = {"showId": show_id, "translationType": tt, "episodeString": ep_str}
219
+ data = _gql_src(session, variables, query_hash, token)
220
+ if isinstance(data, dict):
221
+ tp = data.get("data", {}).get("tobeparsed", "")
222
+ if not tp and data.get("errors"):
223
+ err = data["errors"][0].get("message", "unknown error") if data["errors"] else "unknown error"
224
+ raise ScraperNetworkError("allanime", f"source query failed: {err}")
225
+ else:
226
+ tp = ""
227
+ if not tp:
228
+ raise ScraperNetworkError("allanime", "No tobeparsed data in response")
229
+ parsed = _decrypt_tobeparsed(tp, key)
230
+ if not parsed:
231
+ raise ScraperNoStreamError("allanime", "Failed to decrypt stream sources (source format may have changed)")
232
+ episode = parsed.get("episode")
233
+ if not isinstance(episode, dict):
234
+ raise ScraperNoStreamError("allanime", "No stream data for this episode (source may be unavailable)")
235
+ return episode.get("sourceUrls", [])
236
+
237
+ def _find_show(session, name):
238
+ results = _allanime_search(session, name)
239
+ if not results:
240
+ return None
241
+ q = name.lower().strip()
242
+ for r in results:
243
+ rn = r.get("name", "").lower().strip()
244
+ if q == rn:
245
+ return r["_id"]
246
+ for r in results:
247
+ rn = r.get("name", "").lower().strip()
248
+ if q in rn or rn in q:
249
+ return r["_id"]
250
+ return results[0]["_id"]
251
+
252
+ def _extract_mp4upload(session, embed_url):
253
+ resp = session.get(embed_url, timeout=TIMEOUT)
254
+ if resp.status_code != 200:
255
+ return None
256
+ m = re.search(r'src:\s*"([^"]+)"', resp.text)
257
+ return m.group(1) if m else None
258
+
259
+
260
+ class AnimeScraper(BaseScraper):
261
+ name = "anime"
262
+ description = "Anime streams from allanime.day via mp4upload (no browser needed)"
263
+
264
+ def search(self, query):
265
+ return []
266
+
267
+ def resolve(self, episode_info):
268
+ show_name = episode_info.get("show", "")
269
+ episode = episode_info.get("episode", 1)
270
+ quality = episode_info.get("quality", "")
271
+ translation = episode_info.get("translation", "sub")
272
+
273
+ if not show_name:
274
+ raise ScraperParseError(self.name, "Missing show name in episode_info")
275
+
276
+ deadline = _time.time() + 30
277
+ session = requests.Session()
278
+ session.headers.update({"User-Agent": UA, "Origin": "https://youtu-chan.com"})
279
+
280
+ if _time.time() >= deadline:
281
+ raise ScraperNoStreamError(self.name, "Timeout searching allanime")
282
+ show_id = _find_show(session, show_name)
283
+ if not show_id:
284
+ raise ScraperNoStreamError(self.name, f'No allanime match for "{show_name}"')
285
+
286
+ if _time.time() >= deadline:
287
+ raise ScraperNoStreamError(self.name, "Timeout fetching sources")
288
+ sources = _allanime_sources(session, show_id, str(episode), translation)
289
+
290
+ if not sources:
291
+ raise ScraperNoStreamError(self.name, f"No sources for episode {episode}")
292
+
293
+ for src in sources:
294
+ if _time.time() >= deadline:
295
+ break
296
+ url = src.get("sourceUrl", "")
297
+ if not url or "mp4upload" not in url:
298
+ continue
299
+ if url.startswith("//"):
300
+ url = "https:" + url
301
+ try:
302
+ direct = _extract_mp4upload(session, url)
303
+ if direct:
304
+ label = quality.upper() if quality else "Mp4upload"
305
+ return ScraperResult(
306
+ title=f"{show_name} E{episode:02d} ({label})",
307
+ m3u8_url=direct,
308
+ referer="https://mp4upload.com/",
309
+ user_agent=UA,
310
+ )
311
+ except Exception:
312
+ pass
313
+
314
+ raise ScraperNoStreamError(self.name,
315
+ f"No playable stream found for {show_name} E{episode}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.23
3
+ Version: 0.2.25
4
4
  Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
@@ -31,7 +31,7 @@ Dynamic: license-file
31
31
 
32
32
  > Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
33
33
 
34
- [![Version](https://img.shields.io/badge/version-0.2.23-blue)](#)
34
+ [![Version](https://img.shields.io/badge/version-0.2.25-blue)](#)
35
35
  [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](#)
36
36
  [![License](https://img.shields.io/badge/license-CC%20BY--NC%204.0-green)](#)
37
37
  [![Platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux%20%7C%20termux-lightgrey)](#)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cinnamon-cli"
7
- version = "0.2.23"
7
+ version = "0.2.25"
8
8
  description = "CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv"
9
9
  license = { text = "CC BY-NC 4.0" }
10
10
  readme = "README.md"
@@ -1 +0,0 @@
1
- __version__ = "0.2.23"
@@ -1,196 +0,0 @@
1
- import json
2
- import re
3
- import time as _time
4
- from typing import Optional
5
-
6
- import requests
7
-
8
- from ..errors import ScraperNetworkError, ScraperNoStreamError, ScraperParseError
9
- from ..player import DEFAULT_UA as UA
10
- from .base import BaseScraper, ScraperResult
11
-
12
- TIMEOUT = 15
13
- API = "https://api.allanime.day/api"
14
-
15
- _ALLANIME_KEY = None
16
-
17
- def _get_key():
18
- global _ALLANIME_KEY
19
- if not _ALLANIME_KEY:
20
- import hashlib
21
- _ALLANIME_KEY = hashlib.sha256(b"Xot36i3lK3:v1").digest()
22
- return _ALLANIME_KEY
23
-
24
- _HEX_MAP = {
25
- "79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E", "7e": "F", "7f": "G",
26
- "70": "H", "71": "I", "72": "J", "73": "K", "74": "L", "75": "M", "76": "N",
27
- "77": "O", "68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T", "6d": "U",
28
- "6e": "V", "6f": "W", "60": "X", "61": "Y", "62": "Z",
29
- "59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e", "5e": "f", "5f": "g",
30
- "50": "h", "51": "i", "52": "j", "53": "k", "54": "l", "55": "m", "56": "n",
31
- "57": "o", "48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t", "4d": "u",
32
- "4e": "v", "4f": "w", "40": "x", "41": "y", "42": "z",
33
- "08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4", "0d": "5", "0e": "6",
34
- "0f": "7", "00": "8", "01": "9",
35
- "15": "-", "16": ".", "67": "_", "46": "~", "02": ":", "17": "/", "07": "?",
36
- "1b": "#", "63": "[", "65": "]", "78": "@", "19": "!", "1c": "$", "1e": "&",
37
- "10": "(", "11": ")", "12": "*", "13": "+", "14": ",", "03": ";", "05": "=",
38
- "1d": "%",
39
- }
40
-
41
- def _decode_custom_hex(encoded):
42
- out = []
43
- for i in range(0, len(encoded), 2):
44
- out.append(_HEX_MAP.get(encoded[i:i+2], "?"))
45
- return "".join(out)
46
-
47
- def _decrypt_tobeparsed(tp):
48
- try:
49
- from Crypto.Cipher import AES
50
-
51
- raw = __import__("base64").b64decode(tp)
52
- iv, ct = raw[1:13], raw[13:-16]
53
- ctr_iv = iv + b"\x00\x00\x00\x02"
54
- cipher = AES.new(_get_key(), AES.MODE_CTR, nonce=b"", initial_value=ctr_iv)
55
- return json.loads(cipher.decrypt(ct).decode("utf-8"))
56
- except Exception:
57
- return None
58
-
59
- _SEARCH_GQL = """query ($search: SearchInput) {
60
- shows(search: $search, limit: 10, page: 1, translationType: sub, countryOrigin: ALL) {
61
- edges { _id name availableEpisodes __typename }
62
- }
63
- }"""
64
-
65
- _EP_GQL = "query ($showId: String!) { show(_id: $showId) { _id availableEpisodesDetail } }"
66
-
67
- _SRC_GQL = "query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episode(showId: $showId translationType: $translationType episodeString: $episodeString) { episodeString sourceUrls } }"
68
-
69
- _QUERY_HASH = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
70
-
71
- _LAST_REQUEST = 0.0
72
-
73
- def _gql(session, query, variables):
74
- global _LAST_REQUEST
75
- elapsed = _time.time() - _LAST_REQUEST
76
- if elapsed < 3.5:
77
- _time.sleep(3.5 - elapsed)
78
- body = json.dumps({"variables": json.dumps(variables), "query": query}, ensure_ascii=False, separators=(",", ":"))
79
- resp = session.post(API, data=body, timeout=TIMEOUT, headers={"Content-Type": "application/json"})
80
- _LAST_REQUEST = _time.time()
81
- if resp.status_code != 200:
82
- raise ScraperNetworkError("allanime", f"GraphQL returned {resp.status_code}")
83
- return resp.json()
84
-
85
- def _gql_src(session, variables):
86
- global _LAST_REQUEST
87
- elapsed = _time.time() - _LAST_REQUEST
88
- if elapsed < 3.5:
89
- _time.sleep(3.5 - elapsed)
90
- body = json.dumps({"extensions": {"persistedQuery": {"version": 1, "sha256Hash": _QUERY_HASH}}, "variables": json.dumps(variables)}, ensure_ascii=False, separators=(",", ":"))
91
- resp = session.post(API, data=body, timeout=TIMEOUT, headers={"Content-Type": "application/json"})
92
- _LAST_REQUEST = _time.time()
93
- if resp.status_code != 200:
94
- raise ScraperNetworkError("allanime", f"Source GQL returned {resp.status_code}")
95
- return resp.json()
96
-
97
- def _allanime_search(session, query):
98
- data = _gql(session, _SEARCH_GQL, {"search": {"allowAdult": False, "allowUnknown": False, "query": query}})
99
- return data.get("data", {}).get("shows", {}).get("edges", [])
100
-
101
- def _allanime_episodes(session, show_id):
102
- data = _gql(session, _EP_GQL, {"showId": show_id})
103
- return data.get("data", {}).get("show", {}).get("availableEpisodesDetail", {})
104
-
105
- def _allanime_sources(session, show_id, ep_str, tt="sub"):
106
- data = _gql_src(session, {"showId": show_id, "translationType": tt, "episodeString": ep_str})
107
- tp = data.get("data", {}).get("tobeparsed", "") if isinstance(data, dict) else ""
108
- if not tp:
109
- raise ScraperNetworkError("allanime", "No tobeparsed data in response")
110
- parsed = _decrypt_tobeparsed(tp)
111
- if not parsed:
112
- raise ScraperNoStreamError("allanime", "Failed to decrypt stream sources (source format may have changed)")
113
- episode = parsed.get("episode")
114
- if not isinstance(episode, dict):
115
- raise ScraperNoStreamError("allanime", "No stream data for this episode (source may be unavailable)")
116
- return episode.get("sourceUrls", [])
117
-
118
- def _find_show(session, name):
119
- results = _allanime_search(session, name)
120
- if not results:
121
- return None
122
- q = name.lower().strip()
123
- for r in results:
124
- rn = r.get("name", "").lower().strip()
125
- if q == rn:
126
- return r["_id"]
127
- for r in results:
128
- rn = r.get("name", "").lower().strip()
129
- if q in rn or rn in q:
130
- return r["_id"]
131
- return results[0]["_id"]
132
-
133
- def _extract_mp4upload(session, embed_url):
134
- resp = session.get(embed_url, timeout=TIMEOUT)
135
- if resp.status_code != 200:
136
- return None
137
- m = re.search(r'src:\s*"([^"]+)"', resp.text)
138
- return m.group(1) if m else None
139
-
140
-
141
- class AnimeScraper(BaseScraper):
142
- name = "anime"
143
- description = "Anime streams from allanime.day via mp4upload (no browser needed)"
144
-
145
- def search(self, query):
146
- return []
147
-
148
- def resolve(self, episode_info):
149
- show_name = episode_info.get("show", "")
150
- episode = episode_info.get("episode", 1)
151
- quality = episode_info.get("quality", "")
152
- translation = episode_info.get("translation", "sub")
153
-
154
- if not show_name:
155
- raise ScraperParseError(self.name, "Missing show name in episode_info")
156
-
157
- deadline = _time.time() + 30
158
- session = requests.Session()
159
- session.headers.update({"User-Agent": UA, "Origin": "https://youtu-chan.com"})
160
-
161
- if _time.time() >= deadline:
162
- raise ScraperNoStreamError(self.name, "Timeout searching allanime")
163
- show_id = _find_show(session, show_name)
164
- if not show_id:
165
- raise ScraperNoStreamError(self.name, f'No allanime match for "{show_name}"')
166
-
167
- if _time.time() >= deadline:
168
- raise ScraperNoStreamError(self.name, "Timeout fetching sources")
169
- sources = _allanime_sources(session, show_id, str(episode), translation)
170
-
171
- if not sources:
172
- raise ScraperNoStreamError(self.name, f"No sources for episode {episode}")
173
-
174
- for src in sources:
175
- if _time.time() >= deadline:
176
- break
177
- url = src.get("sourceUrl", "")
178
- if not url or "mp4upload" not in url:
179
- continue
180
- if url.startswith("//"):
181
- url = "https:" + url
182
- try:
183
- direct = _extract_mp4upload(session, url)
184
- if direct:
185
- label = quality.upper() if quality else "Mp4upload"
186
- return ScraperResult(
187
- title=f"{show_name} E{episode:02d} ({label})",
188
- m3u8_url=direct,
189
- referer="https://mp4upload.com/",
190
- user_agent=UA,
191
- )
192
- except Exception:
193
- pass
194
-
195
- raise ScraperNoStreamError(self.name,
196
- f"No playable stream found for {show_name} E{episode}")
File without changes
File without changes