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/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
+ ]