cinnamon-cli 0.2.19__py3-none-any.whl

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.
@@ -0,0 +1,196 @@
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}")
@@ -0,0 +1,34 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class ScraperResult:
8
+ title: str
9
+ m3u8_url: str
10
+ referer: Optional[str] = None
11
+ user_agent: Optional[str] = None
12
+ headers: dict = field(default_factory=dict)
13
+
14
+ def __post_init__(self):
15
+ if not self.title:
16
+ raise ValueError("ScraperResult.title is required")
17
+ if not self.m3u8_url:
18
+ raise ValueError("ScraperResult.m3u8_url is required")
19
+ if not (self.m3u8_url.startswith("http") or self.m3u8_url.startswith("magnet:")):
20
+ raise ValueError(f"ScraperResult.m3u8_url must be a valid URL, got: {self.m3u8_url}")
21
+
22
+
23
+ class BaseScraper(ABC):
24
+ name: str = ""
25
+ description: str = ""
26
+ config_schema: dict = {}
27
+
28
+ @abstractmethod
29
+ def search(self, query: str) -> list[dict]:
30
+ ...
31
+
32
+ @abstractmethod
33
+ def resolve(self, episode_info: dict) -> Optional[ScraperResult]:
34
+ ...
@@ -0,0 +1,123 @@
1
+ from typing import Optional
2
+
3
+ import requests
4
+
5
+ from ..errors import ScraperNoStreamError, ScraperNetworkError
6
+ from ..player import DEFAULT_UA as UA
7
+ from .base import BaseScraper, ScraperResult
8
+
9
+ TORRENTIO_BASE = "https://torrentio.strem.fun"
10
+ QUALITY_ORDER = ["4k", "2160p", "1080p", "720p", "480p", "360p"]
11
+ TRACKERS = [
12
+ "http://tracker3.itzmx.com:6961/announce",
13
+ "http://tracker1.itzmx.com:8080/announce",
14
+ "http://tracker.itzmx.com:6961/announce",
15
+ "udp://tracker.opentrackr.org:1337/announce",
16
+ "udp://exodus.desync.com:6969/announce",
17
+ "udp://open.demonii.com:1337/announce",
18
+ ]
19
+
20
+
21
+ def _quality_from_name(name: str) -> tuple[int, str]:
22
+ name_lower = name.lower()
23
+ for q in QUALITY_ORDER:
24
+ if q in name_lower:
25
+ return (len(QUALITY_ORDER) - QUALITY_ORDER.index(q), q)
26
+ return (0, "unknown")
27
+
28
+
29
+ def _magnet_from_info(info_hash: str, name: str = "") -> str:
30
+ magnet = f"magnet:?xt=urn:btih:{info_hash}"
31
+ if name:
32
+ import urllib.parse
33
+ magnet += f"&dn={urllib.parse.quote(name)}"
34
+ for tr in TRACKERS:
35
+ magnet += f"&tr={tr}"
36
+ return magnet
37
+
38
+
39
+ class TorrentioScraper(BaseScraper):
40
+ name = "torrentio"
41
+ description = "Finds torrent streams via Torrentio (scrapes 1337x, TPB, RARBG, etc.)"
42
+
43
+ def search(self, query):
44
+ return []
45
+
46
+ def resolve(self, episode_info):
47
+ media_type = episode_info.get("media_type", "tv")
48
+ tmdb_id = episode_info.get("tv_id") or episode_info.get("tmdb_id") or episode_info.get("movie_id")
49
+ season = episode_info.get("season", 1)
50
+ episode = episode_info.get("episode", 1)
51
+ show_name = episode_info.get("show", "?")
52
+
53
+ if not tmdb_id:
54
+ from ..errors import ScraperParseError
55
+ raise ScraperParseError(self.name, "Missing tmdb_id/movie_id/tv_id in episode_info")
56
+
57
+ imdb_id = self._get_imdb_id(tmdb_id, media_type)
58
+ if not imdb_id:
59
+ raise ScraperNoStreamError(self.name,
60
+ f"Could not find IMDb ID for TMDB ID {tmdb_id}")
61
+
62
+ if media_type == "movie":
63
+ streams = self._fetch_movie_streams(imdb_id)
64
+ label = f"{show_name} (movie torrent)"
65
+ else:
66
+ streams = self._fetch_streams(imdb_id, season, episode)
67
+ label = f"{show_name} S{season:02d}E{episode:02d}"
68
+
69
+ if not streams:
70
+ raise ScraperNoStreamError(self.name,
71
+ f"No torrents found for {label}")
72
+
73
+ best = max(streams, key=lambda s: _quality_from_name(s.get("name", "")))
74
+ info_hash = best["infoHash"]
75
+ filename = best.get("behaviorHints", {}).get("filename", "")
76
+
77
+ magnet = _magnet_from_info(info_hash, filename)
78
+ _, quality_label = _quality_from_name(best.get("name", ""))
79
+
80
+ return ScraperResult(
81
+ title=f"{label} ({quality_label} torrent)",
82
+ m3u8_url=magnet,
83
+ )
84
+
85
+ def _get_imdb_id(self, tmdb_id: int, media_type: str = "tv") -> Optional[str]:
86
+ try:
87
+ from ..config import get_tmdb_api_key
88
+ api_key = get_tmdb_api_key()
89
+ headers = {"User-Agent": UA}
90
+ tmdb_type = "movie" if media_type == "movie" else "tv"
91
+ r = requests.get(
92
+ f"https://api.themoviedb.org/3/{tmdb_type}/{tmdb_id}",
93
+ headers=headers,
94
+ params={"api_key": api_key, "append_to_response": "external_ids"},
95
+ timeout=15,
96
+ )
97
+ r.raise_for_status()
98
+ data = r.json()
99
+ return data.get("external_ids", {}).get("imdb_id")
100
+ except requests.RequestException:
101
+ return None
102
+
103
+ def _fetch_streams(self, imdb_id: str, season: int, episode: int) -> list:
104
+ try:
105
+ url = f"{TORRENTIO_BASE}/stream/series/{imdb_id}:{season}:{episode}.json"
106
+ headers = {"User-Agent": UA}
107
+ r = requests.get(url, headers=headers, timeout=15)
108
+ r.raise_for_status()
109
+ data = r.json()
110
+ return data.get("streams", [])
111
+ except requests.RequestException as e:
112
+ raise ScraperNetworkError(self.name, str(e))
113
+
114
+ def _fetch_movie_streams(self, imdb_id: str) -> list:
115
+ try:
116
+ url = f"{TORRENTIO_BASE}/stream/movie/{imdb_id}.json"
117
+ headers = {"User-Agent": UA}
118
+ r = requests.get(url, headers=headers, timeout=15)
119
+ r.raise_for_status()
120
+ data = r.json()
121
+ return data.get("streams", [])
122
+ except requests.RequestException as e:
123
+ raise ScraperNetworkError(self.name, str(e))
@@ -0,0 +1,131 @@
1
+ import asyncio
2
+ import time as _time
3
+ from typing import Optional
4
+
5
+ from ..errors import (
6
+ ScraperNetworkError,
7
+ ScraperNoStreamError,
8
+ ScraperParseError,
9
+ )
10
+ from ..player import DEFAULT_UA as _UA
11
+ from .base import BaseScraper, ScraperResult
12
+
13
+ _DOMAINS = [
14
+ ("vidsrc.pm", "https://vidsrc.pm/embed/tv/{tmdb_id}/{season}/{episode}"),
15
+ ("vidsrc.to", "https://vidsrc.to/embed/tv/{tmdb_id}/{season}/{episode}"),
16
+ ("vidsrc.in", "https://vidsrc.in/embed/tv/{tmdb_id}/{season}/{episode}"),
17
+ ("vidsrc.net", "https://vidsrc.net/embed/tv/{tmdb_id}/{season}/{episode}"),
18
+ ("vidsrc.xyz", "https://vidsrc.xyz/embed/tv/{tmdb_id}/{season}/{episode}"),
19
+ ("vidsrc.cc", "https://vidsrc.cc/embed/tv/{tmdb_id}/{season}/{episode}"),
20
+ ]
21
+
22
+
23
+ class VidSrcScraper(BaseScraper):
24
+ name = "vidsrc"
25
+ description = "Scrapes m3u8 streams from vidsrc domains using Playwright"
26
+
27
+ def search(self, query):
28
+ return []
29
+
30
+ def resolve(self, episode_info):
31
+ media_type = episode_info.get("media_type", "tv")
32
+ tmdb_id = episode_info.get("tv_id") or episode_info.get("tmdb_id") or episode_info.get("movie_id")
33
+ show = episode_info.get("show", "?")
34
+ season = episode_info.get("season", 1)
35
+ episode = episode_info.get("episode", 1)
36
+
37
+ if not tmdb_id:
38
+ raise ScraperParseError(self.name, "Missing tmdb_id/movie_id/tv_id in episode_info")
39
+
40
+ if media_type == "movie":
41
+ embed_urls = [
42
+ f"https://{domain}/embed/movie/{tmdb_id}"
43
+ for domain, _ in _DOMAINS
44
+ ]
45
+ else:
46
+ embed_urls = [
47
+ url_template.format(tmdb_id=tmdb_id, season=season, episode=episode)
48
+ for _, url_template in _DOMAINS
49
+ ]
50
+
51
+ last_error = None
52
+ for embed_url in embed_urls:
53
+ try:
54
+ m3u8_url = _playwright_resolve(embed_url, timeout=30)
55
+ if m3u8_url:
56
+ if media_type == "movie":
57
+ title = f"{show}"
58
+ else:
59
+ title = f"{show} S{season:02d}E{episode:02d}"
60
+ return ScraperResult(
61
+ title=title,
62
+ m3u8_url=m3u8_url,
63
+ referer=embed_url,
64
+ user_agent=_UA,
65
+ )
66
+ except ScraperNoStreamError:
67
+ continue
68
+ except Exception as e:
69
+ last_error = e
70
+ continue
71
+
72
+ if last_error:
73
+ raise last_error if isinstance(last_error, ScraperNoStreamError) else ScraperNoStreamError(self.name)
74
+ raise ScraperNoStreamError(self.name)
75
+
76
+
77
+ def _playwright_resolve(embed_url: str, timeout: int = 30) -> Optional[str]:
78
+ return asyncio.run(_async_resolve(embed_url, timeout))
79
+
80
+
81
+ async def _async_resolve(embed_url: str, timeout: int = 30) -> Optional[str]:
82
+ try:
83
+ from playwright.async_api import async_playwright
84
+ except ImportError:
85
+ raise ScraperNetworkError(
86
+ "vidsrc",
87
+ "Playwright is required for the vidsrc scraper. Install it with: pip install playwright && python -m playwright install chromium"
88
+ )
89
+
90
+ found_urls = []
91
+
92
+ async with async_playwright() as p:
93
+ browser = await p.chromium.launch(
94
+ headless=True,
95
+ args=['--disable-blink-features=AutomationControlled']
96
+ )
97
+ context = await browser.new_context(
98
+ viewport={'width': 1920, 'height': 1080},
99
+ user_agent=_UA,
100
+ )
101
+ page = await context.new_page()
102
+
103
+ await page.add_init_script("""
104
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
105
+ """)
106
+
107
+ all_urls = []
108
+ page.on('response', lambda r: all_urls.append(r.url))
109
+
110
+ try:
111
+ await page.goto(embed_url, wait_until="domcontentloaded", timeout=timeout * 1000)
112
+ except Exception as e:
113
+ await browser.close()
114
+ raise ScraperNetworkError("vidsrc", f"Navigation failed: {e}")
115
+
116
+ deadline = _time.time() + timeout
117
+ while _time.time() < deadline:
118
+ await asyncio.sleep(1)
119
+ for u in all_urls:
120
+ if '.m3u8' in u.lower() and u not in found_urls:
121
+ found_urls.append(u)
122
+ if found_urls:
123
+ break
124
+
125
+ await browser.close()
126
+
127
+ if not found_urls:
128
+ raise ScraperNoStreamError("vidsrc")
129
+
130
+ master = [u for u in found_urls if 'master.m3u8' in u]
131
+ return master[0] if master else found_urls[0]