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,282 @@
1
+ import re
2
+ import time as _time
3
+ from typing import Optional
4
+
5
+ import requests
6
+
7
+ from ..errors import ScraperNetworkError, ScraperNoStreamError, ScraperParseError
8
+ from .base import BaseScraper, ScraperResult
9
+
10
+ from ..player import DEFAULT_UA as UA
11
+ TIMEOUT = 15
12
+
13
+
14
+ class WebStreamScraper(BaseScraper):
15
+ name = "webstream"
16
+ description = "HTTP streams from vixsrc.to and vidlink.pro (no browser needed)"
17
+
18
+ def search(self, query):
19
+ return []
20
+
21
+ def resolve(self, episode_info):
22
+ media_type = episode_info.get("media_type", "tv")
23
+ tmdb_id = episode_info.get("tv_id") or episode_info.get("tmdb_id") or episode_info.get("movie_id")
24
+ show = episode_info.get("show", "?")
25
+ season = episode_info.get("season", 1)
26
+ episode = episode_info.get("episode", 1)
27
+ quality = episode_info.get("quality", "")
28
+
29
+ if not tmdb_id:
30
+ raise ScraperParseError(self.name, "Missing tmdb_id/movie_id/tv_id in episode_info")
31
+
32
+ deadline = _time.time() + 30
33
+
34
+ if media_type == "movie":
35
+ # Try vixsrc.to first
36
+ if _time.time() < deadline:
37
+ try:
38
+ result = _try_vixsrc_movie(tmdb_id, quality)
39
+ if result:
40
+ label = quality.upper() if quality else "vixsrc"
41
+ return ScraperResult(
42
+ title=f"{show} ({label})",
43
+ m3u8_url=result,
44
+ referer=f"https://vixsrc.to/embed/movie/{tmdb_id}",
45
+ user_agent=UA,
46
+ )
47
+ except ScraperNetworkError:
48
+ pass
49
+
50
+ # Try vidlink.pro as fallback
51
+ if _time.time() < deadline:
52
+ try:
53
+ result = _try_vidlink_movie(tmdb_id)
54
+ if result:
55
+ return ScraperResult(
56
+ title=f"{show} (vidlink)",
57
+ m3u8_url=result,
58
+ referer="https://vidlink.pro/",
59
+ user_agent=UA,
60
+ )
61
+ except ScraperNetworkError:
62
+ pass
63
+
64
+ raise ScraperNoStreamError(self.name, f"No HTTP stream found for {show}")
65
+
66
+ # TV
67
+ if _time.time() < deadline:
68
+ try:
69
+ result = _try_vixsrc(tmdb_id, season, episode, quality)
70
+ if result:
71
+ label = quality.upper() if quality else "vixsrc"
72
+ return ScraperResult(
73
+ title=f"{show} S{season:02d}E{episode:02d} ({label})",
74
+ m3u8_url=result,
75
+ referer=f"https://vixsrc.to/embed/tv/{tmdb_id}/{season}/{episode}",
76
+ user_agent=UA,
77
+ )
78
+ except ScraperNetworkError:
79
+ pass
80
+
81
+ if _time.time() < deadline:
82
+ try:
83
+ result = _try_vidlink(tmdb_id, season, episode)
84
+ if result:
85
+ return ScraperResult(
86
+ title=f"{show} S{season:02d}E{episode:02d} (vidlink)",
87
+ m3u8_url=result,
88
+ referer="https://vidlink.pro/",
89
+ user_agent=UA,
90
+ )
91
+ except ScraperNetworkError:
92
+ pass
93
+
94
+ raise ScraperNoStreamError(self.name,
95
+ f"No HTTP stream found for {show} S{season:02d}E{episode:02d}")
96
+
97
+
98
+ _QUALITY_RENDITIONS = {"480p": "480p", "720p": "720p", "1080p": "1080p", "best": "", "worst": "worst"}
99
+
100
+ def _try_vixsrc(tmdb_id, season, episode, quality="") -> Optional[str]:
101
+ session = requests.Session()
102
+ session.headers.update({"User-Agent": UA})
103
+
104
+ api_url = f"https://vixsrc.to/api/tv/{tmdb_id}/{season}/{episode}?lang=en"
105
+ embed_resp = session.get(api_url, timeout=TIMEOUT, headers={
106
+ "Accept": "application/json, text/javascript, */*; q=0.01",
107
+ "Referer": "https://vixsrc.to",
108
+ })
109
+ if embed_resp.status_code != 200:
110
+ raise ScraperNetworkError("vixsrc", f"API returned {embed_resp.status_code}")
111
+ embed_src = embed_resp.json().get("src")
112
+ if not embed_src:
113
+ raise ScraperNetworkError("vixsrc", "No src in API response")
114
+
115
+ full_embed = "https://vixsrc.to" + embed_src
116
+ sep = "&" if "?" in full_embed else "?"
117
+ full_embed += f"{sep}lang=en"
118
+ html = session.get(full_embed, timeout=TIMEOUT, headers={
119
+ "Accept": "text/html,application/xhtml+xml,*/*",
120
+ }).text
121
+
122
+ token = re.search(r'token["\']\s*:\s*["\']([^"\']+)', html)
123
+ expires = re.search(r'expires["\']\s*:\s*["\']([^"\']+)', html)
124
+ playlist = re.search(r'url\s*:\s*["\']([^"\']+)', html)
125
+ if not (token and expires and playlist):
126
+ raise ScraperNetworkError("vixsrc", "Could not extract token/playlist from embed page")
127
+
128
+ sep = "&" if "?" in playlist.group(1) else "?"
129
+ master_url = f'{playlist.group(1)}{sep}token={token.group(1)}&expires={expires.group(1)}&h=1&lang=en'
130
+
131
+ if not quality:
132
+ return master_url
133
+
134
+ # Parse master playlist and filter by quality
135
+ master_resp = session.get(master_url, timeout=TIMEOUT)
136
+ if master_resp.status_code != 200:
137
+ return master_url
138
+
139
+ variants = re.findall(
140
+ r'#EXT-X-STREAM-INF:.*?RESOLUTION=\d+x(\d+).*?\n(https?://\S+)',
141
+ master_resp.text,
142
+ )
143
+ if not variants:
144
+ return master_url
145
+
146
+ target = quality.replace("p", "")
147
+ if quality == "best":
148
+ match = max(variants, key=lambda v: int(v[0]))
149
+ elif quality == "worst":
150
+ match = min(variants, key=lambda v: int(v[0]))
151
+ else:
152
+ matches = [v for v in variants if v[0] == target]
153
+ match = matches[0] if matches else max(variants, key=lambda v: int(v[0]))
154
+
155
+ return match[1]
156
+
157
+
158
+ def _try_vidlink(tmdb_id, season, episode) -> Optional[str]:
159
+ session = requests.Session()
160
+ session.headers.update({"User-Agent": UA})
161
+
162
+ enc = session.get(
163
+ f"https://enc-dec.app/api/enc-vidlink?text={tmdb_id}",
164
+ timeout=TIMEOUT,
165
+ )
166
+ if enc.status_code != 200:
167
+ raise ScraperNetworkError("vidlink", f"Encryption API returned {enc.status_code}")
168
+ try:
169
+ encoded = enc.json().get("result")
170
+ except ValueError:
171
+ raise ScraperNetworkError("vidlink", "Non-JSON response from encryption API")
172
+ if not encoded:
173
+ raise ScraperNetworkError("vidlink", "No encrypted ID from API")
174
+
175
+ stream_url = f"https://vidlink.pro/api/b/tv/{encoded}/{season}/{episode}?multiLang=0&audio=eng&subLang=eng"
176
+ stream_resp = session.get(stream_url, timeout=TIMEOUT, headers={
177
+ "Referer": "https://vidlink.pro/",
178
+ })
179
+ if stream_resp.status_code != 200:
180
+ raise ScraperNetworkError("vidlink", f"Stream API returned {stream_resp.status_code}")
181
+
182
+ try:
183
+ data = stream_resp.json()
184
+ except ValueError:
185
+ raise ScraperNetworkError("vidlink", "Non-JSON response from stream API")
186
+ playlist = data.get("stream", {}).get("playlist")
187
+ if not playlist:
188
+ raise ScraperNetworkError("vidlink", "No playlist in stream response")
189
+
190
+ return playlist
191
+
192
+
193
+ def _try_vixsrc_movie(tmdb_id, quality="") -> Optional[str]:
194
+ session = requests.Session()
195
+ session.headers.update({"User-Agent": UA})
196
+
197
+ api_url = f"https://vixsrc.to/api/movie/{tmdb_id}?lang=en"
198
+ embed_resp = session.get(api_url, timeout=TIMEOUT, headers={
199
+ "Accept": "application/json, text/javascript, */*; q=0.01",
200
+ "Referer": "https://vixsrc.to",
201
+ })
202
+ if embed_resp.status_code != 200:
203
+ raise ScraperNetworkError("vixsrc", f"API returned {embed_resp.status_code}")
204
+ embed_src = embed_resp.json().get("src")
205
+ if not embed_src:
206
+ raise ScraperNetworkError("vixsrc", "No src in API response")
207
+
208
+ full_embed = "https://vixsrc.to" + embed_src
209
+ sep = "&" if "?" in full_embed else "?"
210
+ full_embed += f"{sep}lang=en"
211
+ html = session.get(full_embed, timeout=TIMEOUT, headers={
212
+ "Accept": "text/html,application/xhtml+xml,*/*",
213
+ }).text
214
+
215
+ token = re.search(r'token["\']\s*:\s*["\']([^"\']+)', html)
216
+ expires = re.search(r'expires["\']\s*:\s*["\']([^"\']+)', html)
217
+ playlist = re.search(r'url\s*:\s*["\']([^"\']+)', html)
218
+ if not (token and expires and playlist):
219
+ raise ScraperNetworkError("vixsrc", "Could not extract token/playlist from embed page")
220
+
221
+ sep = "&" if "?" in playlist.group(1) else "?"
222
+ master_url = f'{playlist.group(1)}{sep}token={token.group(1)}&expires={expires.group(1)}&h=1&lang=en'
223
+
224
+ if not quality:
225
+ return master_url
226
+
227
+ master_resp = session.get(master_url, timeout=TIMEOUT)
228
+ if master_resp.status_code != 200:
229
+ return master_url
230
+
231
+ variants = re.findall(
232
+ r'#EXT-X-STREAM-INF:.*?RESOLUTION=\d+x(\d+).*?\n(https?://\S+)',
233
+ master_resp.text,
234
+ )
235
+ if not variants:
236
+ return master_url
237
+
238
+ target = quality.replace("p", "")
239
+ if quality == "best":
240
+ match = max(variants, key=lambda v: int(v[0]))
241
+ elif quality == "worst":
242
+ match = min(variants, key=lambda v: int(v[0]))
243
+ else:
244
+ matches = [v for v in variants if v[0] == target]
245
+ match = matches[0] if matches else max(variants, key=lambda v: int(v[0]))
246
+
247
+ return match[1]
248
+
249
+
250
+ def _try_vidlink_movie(tmdb_id) -> Optional[str]:
251
+ session = requests.Session()
252
+ session.headers.update({"User-Agent": UA})
253
+
254
+ enc = session.get(
255
+ f"https://enc-dec.app/api/enc-vidlink?text={tmdb_id}",
256
+ timeout=TIMEOUT,
257
+ )
258
+ if enc.status_code != 200:
259
+ raise ScraperNetworkError("vidlink", f"Encryption API returned {enc.status_code}")
260
+ try:
261
+ encoded = enc.json().get("result")
262
+ except ValueError:
263
+ raise ScraperNetworkError("vidlink", "Non-JSON response from encryption API")
264
+ if not encoded:
265
+ raise ScraperNetworkError("vidlink", "No encrypted ID from API")
266
+
267
+ stream_url = f"https://vidlink.pro/api/b/movie/{encoded}?multiLang=0&audio=eng&subLang=eng"
268
+ stream_resp = session.get(stream_url, timeout=TIMEOUT, headers={
269
+ "Referer": "https://vidlink.pro/",
270
+ })
271
+ if stream_resp.status_code != 200:
272
+ raise ScraperNetworkError("vidlink", f"Stream API returned {stream_resp.status_code}")
273
+
274
+ try:
275
+ data = stream_resp.json()
276
+ except ValueError:
277
+ raise ScraperNetworkError("vidlink", "Non-JSON response from stream API")
278
+ playlist = data.get("stream", {}).get("playlist")
279
+ if not playlist:
280
+ raise ScraperNetworkError("vidlink", "No playlist in stream response")
281
+
282
+ return playlist
cinnamon/tmdb.py ADDED
@@ -0,0 +1,83 @@
1
+ import time
2
+
3
+ import requests
4
+
5
+ from .config import get_tmdb_api_key
6
+ from .errors import (
7
+ MissingAPIKey,
8
+ TMDBAuthError,
9
+ TMDBConnectionError,
10
+ TMDBError,
11
+ TMDBRateLimitError,
12
+ TMDBNotFoundError,
13
+ TMDBAPIError,
14
+ )
15
+
16
+ BASE_URL = "https://api.themoviedb.org/3"
17
+ MAX_RETRIES = 3
18
+ RETRY_DELAY = 2
19
+
20
+
21
+ class TMDBClient:
22
+ def __init__(self, api_key=None):
23
+ self.api_key = api_key or get_tmdb_api_key()
24
+ if not self.api_key:
25
+ raise MissingAPIKey()
26
+
27
+ def _request(self, path, params=None):
28
+ params = params or {}
29
+ params["api_key"] = self.api_key
30
+ url = f"{BASE_URL}{path}"
31
+
32
+ for attempt in range(MAX_RETRIES):
33
+ try:
34
+ resp = requests.get(url, params=params, timeout=15)
35
+
36
+ if resp.status_code == 429:
37
+ retry_after = int(resp.headers.get("Retry-After", RETRY_DELAY))
38
+ if attempt < MAX_RETRIES - 1:
39
+ time.sleep(retry_after)
40
+ continue
41
+ raise TMDBRateLimitError(retry_after)
42
+
43
+ if resp.status_code == 401:
44
+ raise TMDBAuthError()
45
+ if resp.status_code == 404:
46
+ hint = path.strip("/").replace("/", " > ").title()
47
+ raise TMDBNotFoundError(hint)
48
+
49
+ resp.raise_for_status()
50
+ return resp.json()
51
+
52
+ except TMDBError:
53
+ raise
54
+ except requests.ConnectionError as e:
55
+ if attempt == MAX_RETRIES - 1:
56
+ raise TMDBConnectionError(str(e))
57
+ time.sleep(RETRY_DELAY)
58
+ except requests.Timeout as e:
59
+ if attempt == MAX_RETRIES - 1:
60
+ raise TMDBConnectionError(f"Request timed out: {e}")
61
+ time.sleep(RETRY_DELAY)
62
+ except requests.HTTPError as e:
63
+ raise TMDBAPIError(resp.status_code, str(e))
64
+
65
+ def search_tv(self, query):
66
+ return self._request("/search/tv", {"query": query})
67
+
68
+ def get_tv_details(self, tv_id):
69
+ return self._request(f"/tv/{tv_id}")
70
+
71
+ def get_movie_details(self, movie_id):
72
+ return self._request(f"/movie/{movie_id}")
73
+
74
+ def get_season_details(self, tv_id, season_number):
75
+ return self._request(f"/tv/{tv_id}/season/{season_number}")
76
+
77
+ def get_episode_details(self, tv_id, season_number, episode_number):
78
+ return self._request(
79
+ f"/tv/{tv_id}/season/{season_number}/episode/{episode_number}"
80
+ )
81
+
82
+ def search_movie(self, query):
83
+ return self._request("/search/movie", {"query": query})