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.
- cinnamon/__init__.py +1 -0
- cinnamon/anilist.py +33 -0
- cinnamon/cli.py +1822 -0
- cinnamon/config.py +121 -0
- cinnamon/downloads.py +83 -0
- cinnamon/errors.py +145 -0
- cinnamon/history.py +66 -0
- cinnamon/player.py +550 -0
- cinnamon/scrapers/__init__.py +97 -0
- cinnamon/scrapers/anime.py +196 -0
- cinnamon/scrapers/base.py +34 -0
- cinnamon/scrapers/torrentio.py +123 -0
- cinnamon/scrapers/vidsrc.py +131 -0
- cinnamon/scrapers/webstream.py +282 -0
- cinnamon/tmdb.py +83 -0
- cinnamon/tui.py +543 -0
- cinnamon_cli-0.2.19.dist-info/METADATA +166 -0
- cinnamon_cli-0.2.19.dist-info/RECORD +22 -0
- cinnamon_cli-0.2.19.dist-info/WHEEL +5 -0
- cinnamon_cli-0.2.19.dist-info/entry_points.txt +2 -0
- cinnamon_cli-0.2.19.dist-info/licenses/LICENSE +38 -0
- cinnamon_cli-0.2.19.dist-info/top_level.txt +1 -0
|
@@ -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})
|