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
cinnamon/player.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .errors import PlayerNotFoundError, PlayerLaunchError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _in_termux():
|
|
12
|
+
return bool(os.environ.get("TERMUX_VERSION")) or os.path.isdir("/data/data/com.termux")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _platform_os():
|
|
16
|
+
if _in_termux():
|
|
17
|
+
return "termux"
|
|
18
|
+
return sys.platform
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _platform_ua():
|
|
22
|
+
if _platform_os() == "win32":
|
|
23
|
+
return "Windows NT 10.0; Win64; x64"
|
|
24
|
+
if _platform_os() == "darwin":
|
|
25
|
+
return "Macintosh; Intel Mac OS X 10_15_7"
|
|
26
|
+
if _platform_os() == "termux":
|
|
27
|
+
return "Linux; Android 10; Termux"
|
|
28
|
+
return "X11; Linux x86_64"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_UA = f"Mozilla/5.0 ({_platform_ua()}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _search_path(names):
|
|
35
|
+
for name in names:
|
|
36
|
+
path = shutil.which(name)
|
|
37
|
+
if path:
|
|
38
|
+
if _in_termux():
|
|
39
|
+
# Running the absolute Termux path (e.g. /data/data/com.termux/...)
|
|
40
|
+
# can fail with Exec format error; invoke via PATH name instead.
|
|
41
|
+
return name
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
common = []
|
|
45
|
+
|
|
46
|
+
if _platform_os() == "win32":
|
|
47
|
+
common += [
|
|
48
|
+
r"C:\Program Files\mpv\mpv.exe",
|
|
49
|
+
r"C:\Tools\mpv\mpv.exe",
|
|
50
|
+
os.path.expandvars(r"%USERPROFILE%\scoop\apps\mpv\current\mpv.exe"),
|
|
51
|
+
os.path.expandvars(r"%USERPROFILE%\mpv\mpv.exe"),
|
|
52
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Programs\mpv\mpv.exe"),
|
|
53
|
+
os.path.expandvars(r"%HOMEDRIVE%%HOMEPATH%\mpv\mpv.exe"),
|
|
54
|
+
os.path.expandvars(r"%ProgramFiles%\VideoLAN\VLC\vlc.exe"),
|
|
55
|
+
os.path.expandvars(r"%ProgramFiles(x86)%\VideoLAN\VLC\vlc.exe"),
|
|
56
|
+
]
|
|
57
|
+
elif _platform_os() == "darwin":
|
|
58
|
+
home = os.path.expanduser("~")
|
|
59
|
+
common += [
|
|
60
|
+
"/Applications/VLC.app/Contents/MacOS/VLC",
|
|
61
|
+
"/usr/local/bin/mpv",
|
|
62
|
+
"/usr/local/bin/vlc",
|
|
63
|
+
"/opt/homebrew/bin/mpv",
|
|
64
|
+
"/opt/homebrew/bin/vlc",
|
|
65
|
+
"/opt/homebrew/bin/yt-dlp",
|
|
66
|
+
os.path.join(home, "homebrew", "bin", "mpv"),
|
|
67
|
+
os.path.join(home, "homebrew", "bin", "vlc"),
|
|
68
|
+
os.path.join(home, "homebrew", "bin", "yt-dlp"),
|
|
69
|
+
]
|
|
70
|
+
elif _platform_os() == "termux":
|
|
71
|
+
prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
|
|
72
|
+
common += [
|
|
73
|
+
os.path.join(prefix, "bin", "mpv"),
|
|
74
|
+
os.path.join(prefix, "bin", "vlc"),
|
|
75
|
+
os.path.join(prefix, "bin", "yt-dlp"),
|
|
76
|
+
]
|
|
77
|
+
else:
|
|
78
|
+
common += [
|
|
79
|
+
"/usr/bin/mpv",
|
|
80
|
+
"/usr/bin/vlc",
|
|
81
|
+
"/usr/bin/yt-dlp",
|
|
82
|
+
"/usr/local/bin/mpv",
|
|
83
|
+
"/usr/local/bin/vlc",
|
|
84
|
+
"/usr/local/bin/yt-dlp",
|
|
85
|
+
"/snap/bin/mpv",
|
|
86
|
+
"/snap/bin/vlc",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
for candidate in common:
|
|
90
|
+
if os.path.isfile(candidate):
|
|
91
|
+
return candidate
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _vlc_path():
|
|
96
|
+
return _search_path(["vlc.exe", "vlc"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _mpv_path():
|
|
100
|
+
return _search_path(["mpv.exe", "mpv"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ytdlp_path():
|
|
104
|
+
for name in ("yt-dlp.exe", "yt-dlp"):
|
|
105
|
+
path = shutil.which(name)
|
|
106
|
+
if path:
|
|
107
|
+
if _in_termux():
|
|
108
|
+
return name
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
candidates = []
|
|
112
|
+
if _platform_os() == "win32":
|
|
113
|
+
candidates = [
|
|
114
|
+
os.path.expandvars(r"%USERPROFILE%\scoop\apps\yt-dlp\current\yt-dlp.exe"),
|
|
115
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Programs\yt-dlp\yt-dlp.exe"),
|
|
116
|
+
]
|
|
117
|
+
elif _platform_os() == "darwin":
|
|
118
|
+
candidates = [
|
|
119
|
+
"/opt/homebrew/bin/yt-dlp",
|
|
120
|
+
"/usr/local/bin/yt-dlp",
|
|
121
|
+
]
|
|
122
|
+
elif _platform_os() == "termux":
|
|
123
|
+
prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
|
|
124
|
+
candidates = [os.path.join(prefix, "bin", "yt-dlp")]
|
|
125
|
+
else:
|
|
126
|
+
candidates = [
|
|
127
|
+
"/usr/bin/yt-dlp",
|
|
128
|
+
"/usr/local/bin/yt-dlp",
|
|
129
|
+
"/snap/bin/yt-dlp",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
for c in candidates:
|
|
133
|
+
if os.path.isfile(c):
|
|
134
|
+
return c
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ytdlp_install_hint():
|
|
139
|
+
if _platform_os() == "win32":
|
|
140
|
+
return "scoop install yt-dlp"
|
|
141
|
+
if _platform_os() == "darwin":
|
|
142
|
+
return "brew install yt-dlp"
|
|
143
|
+
if _platform_os() == "termux":
|
|
144
|
+
return "pkg install yt-dlp"
|
|
145
|
+
return "pip install yt-dlp"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _termux_ensure_mpv_conf():
|
|
149
|
+
"""Create mpv-android's config so English audio + subtitles are on by default.
|
|
150
|
+
|
|
151
|
+
mpv-android (Termux) does not accept CLI flags via the VIEW intent, so the
|
|
152
|
+
only reliable way to make subtitles show automatically is its mpv.conf.
|
|
153
|
+
True pixel-burn-in is not possible for live HLS playback; auto-selecting
|
|
154
|
+
the English subtitle track is the practical equivalent.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
home = os.path.expanduser("~")
|
|
158
|
+
conf_dir = os.path.join(home, ".config", "mpv")
|
|
159
|
+
os.makedirs(conf_dir, exist_ok=True)
|
|
160
|
+
conf_path = os.path.join(conf_dir, "mpv.conf")
|
|
161
|
+
desired = "alang=eng\nslang=eng\nsub-auto=all\n"
|
|
162
|
+
if os.path.isfile(conf_path):
|
|
163
|
+
with open(conf_path, "r", encoding="utf-8") as f:
|
|
164
|
+
existing = f.read()
|
|
165
|
+
if "slang=eng" in existing and "sub-auto=all" in existing:
|
|
166
|
+
return
|
|
167
|
+
with open(conf_path, "a", encoding="utf-8") as f:
|
|
168
|
+
f.write("\n" + desired)
|
|
169
|
+
else:
|
|
170
|
+
with open(conf_path, "w", encoding="utf-8") as f:
|
|
171
|
+
f.write(desired)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _termux_open(url, app, referer=None, user_agent=None):
|
|
177
|
+
"""Launch an Android media app via an explicit `am start` VIEW intent.
|
|
178
|
+
|
|
179
|
+
On Termux, mpv/vlc are Android apps, not CLI binaries. Relying on the
|
|
180
|
+
pkg wrapper scripts is unreliable (VLC in particular never opens), so we
|
|
181
|
+
invoke the activity manager directly.
|
|
182
|
+
|
|
183
|
+
When a referer (or custom UA) is required, the URL is served through a
|
|
184
|
+
tiny local proxy that injects those headers — many direct hosts
|
|
185
|
+
(e.g. mp4upload) block hotlinked requests that lack the Referer, which
|
|
186
|
+
would otherwise make the Android player fail to open the file.
|
|
187
|
+
"""
|
|
188
|
+
components = {
|
|
189
|
+
"mpv": "is.xyz.mpv/.MPVActivity",
|
|
190
|
+
"vlc": "org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
|
|
191
|
+
}
|
|
192
|
+
comp = components.get(app)
|
|
193
|
+
if not comp:
|
|
194
|
+
raise PlayerLaunchError(app, f"Unknown Termux app: {app}")
|
|
195
|
+
if app == "mpv":
|
|
196
|
+
_termux_ensure_mpv_conf()
|
|
197
|
+
|
|
198
|
+
if referer or user_agent:
|
|
199
|
+
url = _termux_proxy_url(url, referer, user_agent)
|
|
200
|
+
|
|
201
|
+
cmd = f'am start --user 0 -a android.intent.action.VIEW -d "{url}" -n {comp}'
|
|
202
|
+
try:
|
|
203
|
+
return subprocess.Popen(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
204
|
+
except OSError as e:
|
|
205
|
+
raise PlayerLaunchError(app, str(e))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _termux_proxy_url(target_url, referer=None, user_agent=None):
|
|
209
|
+
"""Start a local HTTP proxy that forwards to target_url with injected
|
|
210
|
+
headers, and return the local URL to hand to the Android player."""
|
|
211
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
212
|
+
import threading
|
|
213
|
+
import urllib.request
|
|
214
|
+
|
|
215
|
+
_PROXY_HEADERS = {}
|
|
216
|
+
if referer:
|
|
217
|
+
_PROXY_HEADERS["Referer"] = referer
|
|
218
|
+
if user_agent:
|
|
219
|
+
_PROXY_HEADERS["User-Agent"] = user_agent
|
|
220
|
+
|
|
221
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
222
|
+
def do_GET(self):
|
|
223
|
+
try:
|
|
224
|
+
req = urllib.request.Request(target_url, headers=_PROXY_HEADERS)
|
|
225
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
226
|
+
self.send_response(resp.status)
|
|
227
|
+
length = resp.headers.get("Content-Length")
|
|
228
|
+
ctype = resp.headers.get("Content-Type", "application/octet-stream")
|
|
229
|
+
self.send_header("Content-Type", ctype)
|
|
230
|
+
if length:
|
|
231
|
+
self.send_header("Content-Length", length)
|
|
232
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
233
|
+
self.end_headers()
|
|
234
|
+
while True:
|
|
235
|
+
chunk = resp.read(65536)
|
|
236
|
+
if not chunk:
|
|
237
|
+
break
|
|
238
|
+
try:
|
|
239
|
+
self.wfile.write(chunk)
|
|
240
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
241
|
+
break
|
|
242
|
+
except Exception:
|
|
243
|
+
try:
|
|
244
|
+
self.send_error(502)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def log_message(self, *args):
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
252
|
+
port = server.server_address[1]
|
|
253
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
254
|
+
return f"http://127.0.0.1:{port}/video.mp4"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _launch(player_name, cmd):
|
|
258
|
+
"""Launch a desktop player and verify it actually started.
|
|
259
|
+
|
|
260
|
+
If the process exits within a short grace period (e.g. no $DISPLAY on a
|
|
261
|
+
headless/WSL box, or missing libs), capture its stderr and raise a clear
|
|
262
|
+
PlayerLaunchError instead of returning a dead process that the caller then
|
|
263
|
+
blocks on forever."""
|
|
264
|
+
try:
|
|
265
|
+
proc = subprocess.Popen(
|
|
266
|
+
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
|
|
267
|
+
)
|
|
268
|
+
except OSError as e:
|
|
269
|
+
hint = (
|
|
270
|
+
f" — try reinstalling with: pkg install {player_name}"
|
|
271
|
+
if "Exec format" in str(e) or "No such file" in str(e)
|
|
272
|
+
else ""
|
|
273
|
+
)
|
|
274
|
+
raise PlayerLaunchError(player_name, str(e) + hint)
|
|
275
|
+
|
|
276
|
+
# Give the player a moment to fail (missing display, codec, etc.).
|
|
277
|
+
try:
|
|
278
|
+
proc.wait(timeout=2.0)
|
|
279
|
+
except subprocess.TimeoutExpired:
|
|
280
|
+
# Still alive after 2s — it started successfully. Detach stderr.
|
|
281
|
+
if proc.stderr and not proc.stderr.closed:
|
|
282
|
+
try:
|
|
283
|
+
proc.stderr.close()
|
|
284
|
+
except OSError:
|
|
285
|
+
pass
|
|
286
|
+
return proc
|
|
287
|
+
|
|
288
|
+
# Exited during the grace period: it failed to start.
|
|
289
|
+
err = ""
|
|
290
|
+
try:
|
|
291
|
+
if proc.stderr:
|
|
292
|
+
err = proc.stderr.read().decode("utf-8", "replace").strip()
|
|
293
|
+
except (OSError, ValueError):
|
|
294
|
+
pass
|
|
295
|
+
msg = f"{player_name} exited immediately"
|
|
296
|
+
if "DISPLAY" in err or "display" in err.lower() or "X11" in err or "Wayland" in err:
|
|
297
|
+
msg += " — no display found. Set $DISPLAY (e.g. an X server on :0) or run from a desktop session."
|
|
298
|
+
elif err:
|
|
299
|
+
msg += f": {err[:300]}"
|
|
300
|
+
else:
|
|
301
|
+
msg += " (no error output — likely no display or missing dependencies)."
|
|
302
|
+
raise PlayerLaunchError(player_name, msg)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def play_vlc(url, title="", referer=None):
|
|
306
|
+
exe = _vlc_path()
|
|
307
|
+
if not exe:
|
|
308
|
+
raise PlayerNotFoundError("vlc")
|
|
309
|
+
if _in_termux():
|
|
310
|
+
return _termux_open(url, "vlc", referer=referer, user_agent=DEFAULT_UA)
|
|
311
|
+
cmd = [exe, "--play-and-exit", f"--meta-title={title}"]
|
|
312
|
+
if referer:
|
|
313
|
+
cmd.append(f"--http-referrer={referer}")
|
|
314
|
+
cmd.append(url)
|
|
315
|
+
return _launch("VLC", cmd)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def play_mpv(url, title="", referer=None):
|
|
319
|
+
exe = _mpv_path()
|
|
320
|
+
if not exe:
|
|
321
|
+
raise PlayerNotFoundError("mpv")
|
|
322
|
+
if _in_termux():
|
|
323
|
+
return _termux_open(url, "mpv", referer=referer, user_agent=DEFAULT_UA)
|
|
324
|
+
cmd = [exe, f"--title={title}", "--alang=eng", "--slang=eng", "--subs-with-matching-audio=yes"]
|
|
325
|
+
if referer:
|
|
326
|
+
cmd += ["--http-header-fields=Referer: " + referer]
|
|
327
|
+
cmd.append(url)
|
|
328
|
+
return _launch("mpv", cmd)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _streamer_path():
|
|
332
|
+
return os.path.join(os.path.dirname(__file__), "webtorrent_stream.mjs")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _play_magnet(url, title="", player="auto", season=None, episode=None):
|
|
336
|
+
mpv = _mpv_path()
|
|
337
|
+
vlc = _vlc_path()
|
|
338
|
+
|
|
339
|
+
if player == "mpv":
|
|
340
|
+
if not mpv:
|
|
341
|
+
raise PlayerNotFoundError("mpv")
|
|
342
|
+
player_fn = lambda u: play_mpv(u, title)
|
|
343
|
+
exe_dir = os.path.dirname(mpv)
|
|
344
|
+
elif player == "vlc":
|
|
345
|
+
if not vlc:
|
|
346
|
+
raise PlayerNotFoundError("vlc")
|
|
347
|
+
player_fn = lambda u: play_vlc(u, title)
|
|
348
|
+
exe_dir = os.path.dirname(vlc)
|
|
349
|
+
elif player == "auto":
|
|
350
|
+
if mpv:
|
|
351
|
+
player_fn = lambda u: play_mpv(u, title)
|
|
352
|
+
exe_dir = os.path.dirname(mpv)
|
|
353
|
+
elif vlc:
|
|
354
|
+
player_fn = lambda u: play_vlc(u, title)
|
|
355
|
+
exe_dir = os.path.dirname(vlc)
|
|
356
|
+
else:
|
|
357
|
+
raise PlayerNotFoundError("auto")
|
|
358
|
+
else:
|
|
359
|
+
raise ValueError(f"Unknown player: {player}")
|
|
360
|
+
|
|
361
|
+
env = os.environ.copy()
|
|
362
|
+
env["PATH"] = exe_dir + os.pathsep + env["PATH"]
|
|
363
|
+
|
|
364
|
+
node = shutil.which("node") or shutil.which("node.exe")
|
|
365
|
+
if not node:
|
|
366
|
+
raise PlayerNotFoundError("node")
|
|
367
|
+
|
|
368
|
+
port = 8888
|
|
369
|
+
args = [node, _streamer_path(), url, str(port)]
|
|
370
|
+
if season is not None:
|
|
371
|
+
args.append(str(season))
|
|
372
|
+
if episode is not None:
|
|
373
|
+
args.append(str(episode))
|
|
374
|
+
|
|
375
|
+
proc = subprocess.Popen(
|
|
376
|
+
args,
|
|
377
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
378
|
+
bufsize=1, text=True, env=env,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
stream_url = None
|
|
382
|
+
deadline = time.monotonic() + 60
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
for line in proc.stdout:
|
|
386
|
+
if time.monotonic() > deadline:
|
|
387
|
+
proc.kill()
|
|
388
|
+
raise PlayerLaunchError("streamer", "Timed out waiting for stream URL")
|
|
389
|
+
line = line.strip()
|
|
390
|
+
if not line:
|
|
391
|
+
continue
|
|
392
|
+
try:
|
|
393
|
+
msg = json.loads(line)
|
|
394
|
+
except json.JSONDecodeError:
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
status = msg.get("status")
|
|
398
|
+
if status == "dht_bootstrapping":
|
|
399
|
+
print(" DHT bootstrapping...", file=sys.stderr)
|
|
400
|
+
elif status == "dht_ready":
|
|
401
|
+
print(f" DHT ready ({msg.get('nodes', 0)} nodes)", file=sys.stderr)
|
|
402
|
+
elif status == "adding_torrent":
|
|
403
|
+
print(" Adding torrent...", file=sys.stderr)
|
|
404
|
+
elif status == "metadata":
|
|
405
|
+
print(f" Torrent: {msg.get('name')}", file=sys.stderr)
|
|
406
|
+
print(f" Files: {msg.get('numFiles')}", file=sys.stderr)
|
|
407
|
+
print(f" Selected: {msg.get('selectedFile')}", file=sys.stderr)
|
|
408
|
+
elif status == "ready":
|
|
409
|
+
stream_url = msg.get("streamUrl")
|
|
410
|
+
port = msg.get("port")
|
|
411
|
+
file_name = msg.get("file")
|
|
412
|
+
had_peer = msg.get("hadPeer")
|
|
413
|
+
print(f" Streaming: {file_name}", file=sys.stderr)
|
|
414
|
+
print(f" URL: {stream_url}", file=sys.stderr)
|
|
415
|
+
if not had_peer:
|
|
416
|
+
print(" Waiting for peers...", file=sys.stderr)
|
|
417
|
+
break
|
|
418
|
+
elif status == "progress":
|
|
419
|
+
downloaded = msg.get("downloaded", 0)
|
|
420
|
+
num_peers = msg.get("numPeers", 0)
|
|
421
|
+
print(f" Progress: {msg.get('progress', 0)*100:.0f}% ({downloaded//1048576} MB) peers: {num_peers}", file=sys.stderr)
|
|
422
|
+
elif msg.get("type") == "warn":
|
|
423
|
+
print(f" Warn: {msg.get('msg')}", file=sys.stderr)
|
|
424
|
+
elif msg.get("type") == "error":
|
|
425
|
+
print(f" Error: {msg.get('msg')}", file=sys.stderr)
|
|
426
|
+
|
|
427
|
+
if not stream_url:
|
|
428
|
+
proc.kill()
|
|
429
|
+
proc.wait()
|
|
430
|
+
raise PlayerLaunchError(
|
|
431
|
+
"streamer",
|
|
432
|
+
"No peers found for this torrent. The source may have no seeders right now.\n"
|
|
433
|
+
" Try again later, pick a different episode, or use --scraper vidsrc for HTTP streaming."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
player_fn(stream_url)
|
|
437
|
+
|
|
438
|
+
# Keep reading progress lines until the player exits (or streamer dies)
|
|
439
|
+
for line in proc.stdout:
|
|
440
|
+
try:
|
|
441
|
+
msg = json.loads(line.strip())
|
|
442
|
+
except (json.JSONDecodeError, AttributeError):
|
|
443
|
+
continue
|
|
444
|
+
if msg.get("status") == "progress":
|
|
445
|
+
downloaded = msg.get("downloaded", 0)
|
|
446
|
+
num_peers = msg.get("numPeers", 0)
|
|
447
|
+
print(f" Progress: {msg.get('progress', 0)*100:.0f}% ({downloaded//1048576} MB) peers: {num_peers}", file=sys.stderr)
|
|
448
|
+
except:
|
|
449
|
+
try:
|
|
450
|
+
proc.kill()
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
453
|
+
proc.wait()
|
|
454
|
+
raise
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def download_video(url, title="", referer=None, output_dir=".", track_id=None):
|
|
458
|
+
"""Download an HLS stream using yt-dlp with a clean progress bar."""
|
|
459
|
+
from .downloads import update as _track_update
|
|
460
|
+
|
|
461
|
+
exe = _ytdlp_path()
|
|
462
|
+
if not exe:
|
|
463
|
+
raise PlayerNotFoundError("yt-dlp")
|
|
464
|
+
|
|
465
|
+
safe = "".join(c if c.isalnum() or c in " .-_()" else "_" for c in title) or "video"
|
|
466
|
+
outtmpl = os.path.join(output_dir, f"{safe}.%(ext)s")
|
|
467
|
+
|
|
468
|
+
cmd = [exe, "--no-mtime", "--no-warnings", "-o", outtmpl]
|
|
469
|
+
if referer:
|
|
470
|
+
cmd += ["--referer", referer]
|
|
471
|
+
cmd.append(url)
|
|
472
|
+
|
|
473
|
+
if track_id:
|
|
474
|
+
_track_update(track_id, status="downloading")
|
|
475
|
+
|
|
476
|
+
print(f"Downloading to {os.path.abspath(output_dir)}", file=sys.stderr)
|
|
477
|
+
|
|
478
|
+
proc = subprocess.Popen(
|
|
479
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
480
|
+
text=True, bufsize=1,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
import re
|
|
484
|
+
last_pct = -1
|
|
485
|
+
try:
|
|
486
|
+
for line in proc.stdout:
|
|
487
|
+
if "[download]" not in line:
|
|
488
|
+
continue
|
|
489
|
+
m = re.search(r"(\d+\.?\d*)%\s+of\s+~?\s*([\d.]+)(\w+).*ETA\s+(\S+)", line)
|
|
490
|
+
if not m:
|
|
491
|
+
m = re.search(r"(\d+\.?\d*)%\s+of\s+~?\s*([\d.]+)(\w+)", line)
|
|
492
|
+
if m:
|
|
493
|
+
pct = float(m.group(1))
|
|
494
|
+
if int(pct) == last_pct:
|
|
495
|
+
continue
|
|
496
|
+
last_pct = int(pct)
|
|
497
|
+
size_val = float(m.group(2))
|
|
498
|
+
unit = m.group(3)
|
|
499
|
+
eta = m.group(4) if m.lastindex >= 4 else ""
|
|
500
|
+
bar_width = 25
|
|
501
|
+
filled = int(bar_width * pct / 100)
|
|
502
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
503
|
+
eta_str = f" ETA {eta}" if eta else ""
|
|
504
|
+
print(f"\r {bar} {pct:>5.1f}% {size_val:.1f}{unit}{eta_str} ", end="", file=sys.stderr, flush=True)
|
|
505
|
+
else:
|
|
506
|
+
print(f"\r {line.rstrip():<50}", file=sys.stderr, flush=True)
|
|
507
|
+
|
|
508
|
+
proc.wait()
|
|
509
|
+
print(file=sys.stderr)
|
|
510
|
+
|
|
511
|
+
if proc.returncode == 0:
|
|
512
|
+
if track_id:
|
|
513
|
+
_track_update(track_id, status="completed")
|
|
514
|
+
print(f" Done — {safe}", file=sys.stderr)
|
|
515
|
+
else:
|
|
516
|
+
raise PlayerLaunchError("yt-dlp", f"exit code {proc.returncode}")
|
|
517
|
+
|
|
518
|
+
except KeyboardInterrupt:
|
|
519
|
+
print(file=sys.stderr)
|
|
520
|
+
if track_id:
|
|
521
|
+
_track_update(track_id, status="interrupted")
|
|
522
|
+
proc.kill()
|
|
523
|
+
proc.wait()
|
|
524
|
+
raise
|
|
525
|
+
except PlayerLaunchError:
|
|
526
|
+
if track_id:
|
|
527
|
+
_track_update(track_id, status="error")
|
|
528
|
+
raise
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def play(url, title="", player="auto", season=None, episode=None, referer=None):
|
|
532
|
+
if url.startswith("magnet:"):
|
|
533
|
+
return _play_magnet(url, title, player, season, episode)
|
|
534
|
+
if player == "auto":
|
|
535
|
+
mpv = _mpv_path()
|
|
536
|
+
vlc = _vlc_path()
|
|
537
|
+
if mpv:
|
|
538
|
+
try:
|
|
539
|
+
return play_mpv(url, title, referer)
|
|
540
|
+
except PlayerLaunchError:
|
|
541
|
+
if not vlc:
|
|
542
|
+
raise
|
|
543
|
+
if vlc:
|
|
544
|
+
return play_vlc(url, title, referer)
|
|
545
|
+
raise PlayerNotFoundError("auto")
|
|
546
|
+
if player == "vlc":
|
|
547
|
+
return play_vlc(url, title, referer)
|
|
548
|
+
if player == "mpv":
|
|
549
|
+
return play_mpv(url, title, referer)
|
|
550
|
+
raise ValueError(f"Unknown player: {player}. Use vlc, mpv, or auto.")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import BaseScraper
|
|
9
|
+
from .webstream import WebStreamScraper
|
|
10
|
+
from .anime import AnimeScraper
|
|
11
|
+
|
|
12
|
+
_BUILTIN_SCRAPERS = [WebStreamScraper, AnimeScraper]
|
|
13
|
+
|
|
14
|
+
_OPTIONAL_SCRAPERS = {
|
|
15
|
+
"vidsrc": {
|
|
16
|
+
"description": "Streams from vidsrc domains via Playwright",
|
|
17
|
+
"deps": "playwright install chromium",
|
|
18
|
+
},
|
|
19
|
+
"torrentio": {
|
|
20
|
+
"description": "Torrent streams via Torrentio (1337x, TPB, RARBG)",
|
|
21
|
+
"deps": "npm install (for WebTorrent playback)",
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def install_optional(name):
|
|
27
|
+
from ..config import SCRAPERS_DIR
|
|
28
|
+
|
|
29
|
+
if name not in _OPTIONAL_SCRAPERS:
|
|
30
|
+
raise ValueError(f"Unknown optional scraper: {name}")
|
|
31
|
+
|
|
32
|
+
SCRAPERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
src = Path(__file__).parent / f"{name}.py"
|
|
34
|
+
dst = SCRAPERS_DIR / f"{name}.py"
|
|
35
|
+
shutil.copy2(str(src), str(dst))
|
|
36
|
+
return dst
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _discover_user_scrapers(scrapers_dir):
|
|
40
|
+
discovered = []
|
|
41
|
+
if not scrapers_dir or not os.path.isdir(scrapers_dir):
|
|
42
|
+
return discovered
|
|
43
|
+
|
|
44
|
+
sys.path.insert(0, str(scrapers_dir))
|
|
45
|
+
try:
|
|
46
|
+
for f in sorted(os.listdir(scrapers_dir)):
|
|
47
|
+
if f.endswith(".py") and f != "__init__.py":
|
|
48
|
+
mod_name = f[:-3]
|
|
49
|
+
try:
|
|
50
|
+
mod = importlib.import_module(mod_name)
|
|
51
|
+
for name, obj in inspect.getmembers(mod, inspect.isclass):
|
|
52
|
+
if (
|
|
53
|
+
issubclass(obj, BaseScraper)
|
|
54
|
+
and obj is not BaseScraper
|
|
55
|
+
and obj not in _BUILTIN_SCRAPERS
|
|
56
|
+
):
|
|
57
|
+
discovered.append(obj)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
finally:
|
|
61
|
+
if sys.path and sys.path[0] == str(scrapers_dir):
|
|
62
|
+
sys.path.pop(0)
|
|
63
|
+
|
|
64
|
+
return discovered
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_all_scraper_classes():
|
|
68
|
+
from ..config import SCRAPERS_DIR
|
|
69
|
+
|
|
70
|
+
classes = list(_BUILTIN_SCRAPERS)
|
|
71
|
+
classes.extend(_discover_user_scrapers(SCRAPERS_DIR))
|
|
72
|
+
|
|
73
|
+
custom_path = os.getenv("CINNAMON_SCRAPERS_PATH")
|
|
74
|
+
if custom_path:
|
|
75
|
+
classes.extend(_discover_user_scrapers(custom_path))
|
|
76
|
+
|
|
77
|
+
seen = set()
|
|
78
|
+
unique = []
|
|
79
|
+
for cls in classes:
|
|
80
|
+
if cls.name not in seen:
|
|
81
|
+
seen.add(cls.name)
|
|
82
|
+
unique.append(cls)
|
|
83
|
+
return unique
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_scraper(name):
|
|
87
|
+
for cls in _get_all_scraper_classes():
|
|
88
|
+
if cls.name == name:
|
|
89
|
+
return cls()
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def list_scrapers():
|
|
94
|
+
return [
|
|
95
|
+
{"name": cls.name, "description": cls.description, "builtin": cls in _BUILTIN_SCRAPERS}
|
|
96
|
+
for cls in _get_all_scraper_classes()
|
|
97
|
+
]
|