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.
- {cinnamon_cli-0.2.23/cinnamon_cli.egg-info → cinnamon_cli-0.2.25}/PKG-INFO +2 -2
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/README.md +1 -1
- cinnamon_cli-0.2.25/cinnamon/__init__.py +1 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/cli.py +4 -4
- cinnamon_cli-0.2.25/cinnamon/scrapers/anime.py +315 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25/cinnamon_cli.egg-info}/PKG-INFO +2 -2
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/pyproject.toml +1 -1
- cinnamon_cli-0.2.23/cinnamon/__init__.py +0 -1
- cinnamon_cli-0.2.23/cinnamon/scrapers/anime.py +0 -196
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/LICENSE +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/anilist.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/config.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/downloads.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/errors.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/history.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/player.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/__init__.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/base.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/torrentio.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/vidsrc.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/scrapers/webstream.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/tmdb.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon/tui.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/SOURCES.txt +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/entry_points.txt +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/requires.txt +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/cinnamon_cli.egg-info/top_level.txt +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/setup.cfg +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream2.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream3.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream4.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream5.py +0 -0
- {cinnamon_cli-0.2.23 → cinnamon_cli-0.2.25}/tests/test_directstream6.py +0 -0
- {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.
|
|
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
|
-
[](#)
|
|
35
35
|
[](#)
|
|
36
36
|
[](#)
|
|
37
37
|
[](#)
|
|
@@ -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
|
-
[](#)
|
|
6
6
|
[](#)
|
|
7
7
|
[](#)
|
|
8
8
|
[](#)
|
|
@@ -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
|
|
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
|
-
|
|
1719
|
+
target = "cinnamon-cli"
|
|
1720
1720
|
try:
|
|
1721
1721
|
proc = subprocess.Popen(
|
|
1722
|
-
[sys.executable, "-m", "pip", "install", "--upgrade",
|
|
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 {
|
|
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.
|
|
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
|
-
[](#)
|
|
35
35
|
[](#)
|
|
36
36
|
[](#)
|
|
37
37
|
[](#)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cinnamon-cli"
|
|
7
|
-
version = "0.2.
|
|
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
|
|
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
|