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,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]
|