streamgrabber 0.1.0__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 @@
1
+ __version__ = "0.1.0"
streamgrabber/cli.py ADDED
@@ -0,0 +1,471 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from urllib.request import Request, urlopen
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from .detector import capture_streams
17
+ from .doctor import check_url_like, run_doctor_checks
18
+ from .downloader import build_streamlink_command, build_ytdlp_command, ensure_parent, run_command, run_command_with_retries
19
+ from .manifest import choose_variant, parse_master_playlist
20
+ from .models import Capture, DEFAULT_USER_AGENT
21
+ from .muxer import mux
22
+ from .naming import output_path_from_title
23
+ from .resolver import normalize_input_url
24
+ from .subtitles import (
25
+ choose_best_subtitle,
26
+ choose_default_subtitle,
27
+ download_default_subtitle_as_vtt,
28
+ download_subtitle_as_vtt,
29
+ language_to_opensubtitles_id,
30
+ search_subtitles,
31
+ )
32
+ from .subsync import prepare_subtitle_for_mux
33
+ from .vaplayer import episode_info, episode_output_name, episodes_for_season, movie_output_name, parse_media_id_from_url
34
+
35
+ app = typer.Typer(help="Capture web streams and download them with preserved browser headers.")
36
+ console = Console()
37
+
38
+
39
+ def fetch_text(url: str, *, user_agent: str, referer: str | None) -> str:
40
+ headers = {"User-Agent": user_agent}
41
+ if referer:
42
+ headers["Referer"] = referer
43
+ req = Request(url, headers=headers)
44
+ with urlopen(req, timeout=30) as res:
45
+ return res.read().decode("utf-8", "replace")
46
+
47
+
48
+ def fetch_file(url: str, output: Path, *, user_agent: str, referer: str | None) -> None:
49
+ headers = {"User-Agent": user_agent}
50
+ if referer:
51
+ headers["Referer"] = referer
52
+ req = Request(url, headers=headers)
53
+ with urlopen(req, timeout=60) as res:
54
+ output.write_bytes(res.read())
55
+
56
+
57
+ def quality_for_streamlink(requested: str, selected_height: int | None) -> str:
58
+ if requested in ("best", "auto", ""):
59
+ return "best"
60
+ if requested == "worst":
61
+ return "worst"
62
+ if selected_height:
63
+ return f"{selected_height}p"
64
+ return requested
65
+
66
+
67
+ def select_hls(captures: list[Capture]) -> Capture | None:
68
+ return next((c for c in captures if c.kind == "hls"), None)
69
+
70
+
71
+ def print_summary(summary: dict) -> None:
72
+ console.print_json(json.dumps(summary, ensure_ascii=False))
73
+
74
+
75
+ @app.command()
76
+ def main(
77
+ url: str = typer.Argument(..., help="Page URL to inspect"),
78
+ list_only: bool = typer.Option(False, "--list", help="List detected streams and exit"),
79
+ print_command: bool = typer.Option(False, "--print-command", "--commands", help="Print download/mux commands and exit"),
80
+ quality: str = typer.Option("best", "--quality", "-q", help="best, worst, 720, 360, etc."),
81
+ output: Path | None = typer.Option(None, "--output", "-o", help="Output MKV path"),
82
+ downloader: str = typer.Option("streamlink", "--downloader", help="streamlink or yt-dlp"),
83
+ duration: str | None = typer.Option(None, "--duration", help="Limit segmented download duration, e.g. 00:00:30"),
84
+ headed: bool = typer.Option(False, "--headed", help="Show browser window instead of headless"),
85
+ no_fallback: bool = typer.Option(False, "--no-fallback", help="Do not retry with a visible automated browser if headless capture is blocked"),
86
+ episodes: bool = typer.Option(False, "--episodes", help="List available seasons/episodes when supported"),
87
+ season: int | None = typer.Option(None, "--season", help="TV season number for supported players"),
88
+ episode: int | None = typer.Option(None, "--episode", help="TV episode number for supported players"),
89
+ all_episodes: bool = typer.Option(False, "--all-episodes", help="Download all episodes in --season when supported"),
90
+ subtitle_lang: str | None = typer.Option(None, "--subtitle-lang", "--sub-lang", help="Download and mux best matching subtitle language, e.g. tr/tur/turkish"),
91
+ sync_subtitles: bool = typer.Option(True, "--sync-subtitles/--no-sync-subtitles", help="Auto-sync downloaded subtitles to the video audio with ffsubsync"),
92
+ subtitle_offset: float = typer.Option(0.0, "--subtitle-offset", help="Apply an extra fixed subtitle offset in seconds after/while syncing; negative = earlier"),
93
+ subtitle_sync_max_duration: int | None = typer.Option(None, "--subtitle-sync-max-duration", help="Limit ffsubsync analysis duration in seconds for faster tests"),
94
+ download_retries: int = typer.Option(3, "--download-retries", min=1, help="Retry each episode download on transient stream/CDN failures"),
95
+ stop_on_error: bool = typer.Option(False, "--stop-on-error/--continue-on-error", help="In --all-episodes mode, stop immediately when an episode fails"),
96
+ timeout: int = typer.Option(15000, "--timeout", help="Capture timeout in ms"),
97
+ ) -> None:
98
+ """Give a page URL; automatically capture HLS/VTT, select quality, download, and mux."""
99
+ if url == "doctor":
100
+ run_doctor()
101
+ return
102
+ original_url = url
103
+ try:
104
+ url = normalize_input_url(url)
105
+ except Exception as exc:
106
+ console.print(f"[red]Could not resolve input URL:[/red] {original_url}")
107
+ console.print(str(exc))
108
+ raise typer.Exit(1)
109
+ if not check_url_like(url):
110
+ console.print(f"[red]Invalid URL:[/red] {url}")
111
+ console.print("Usage examples:")
112
+ console.print(" streamgrabber 'https://example.com/embed/video'")
113
+ console.print(" streamgrabber 'https://www.imdb.com/title/tt0096895'")
114
+ console.print(" streamgrabber tt0096895")
115
+ console.print(" streamgrabber doctor")
116
+ raise typer.Exit(2)
117
+ if url != original_url:
118
+ console.print(f"[cyan]Resolved:[/cyan] {original_url} -> {url}")
119
+ if episodes or season is not None or episode is not None or all_episodes or "/embed/movie/" in url:
120
+ run_vaplayer_mode(url, episodes, season, episode, all_episodes, quality, output, duration, print_command, list_only, subtitle_lang, sync_subtitles, subtitle_offset, subtitle_sync_max_duration, download_retries, stop_on_error)
121
+ return
122
+ asyncio.run(_main(url, list_only, print_command, quality, output, downloader, duration, headed, no_fallback, timeout))
123
+
124
+
125
+ def run_vaplayer_mode(
126
+ url: str,
127
+ episodes: bool,
128
+ season: int | None,
129
+ episode: int | None,
130
+ all_episodes: bool,
131
+ quality: str,
132
+ output: Path | None,
133
+ duration: str | None,
134
+ print_command: bool,
135
+ list_only: bool,
136
+ subtitle_lang: str | None,
137
+ sync_subtitles: bool,
138
+ subtitle_offset: float,
139
+ subtitle_sync_max_duration: int | None,
140
+ download_retries: int,
141
+ stop_on_error: bool,
142
+ ) -> None:
143
+ media_type, media_id = parse_media_id_from_url(url)
144
+ base = episode_info(media_id, media_type)
145
+ if episodes:
146
+ print_summary({"media_id": media_id, "media_type": media_type, "title": base.title, "episodes": base.eps})
147
+ return
148
+
149
+ if media_type == "movie":
150
+ out = output or Path.cwd() / movie_output_name(base.title, base.file_name)
151
+ if list_only:
152
+ print_summary({
153
+ "title": base.title,
154
+ "media_id": media_id,
155
+ "media_type": media_type,
156
+ "stream_urls": base.stream_urls,
157
+ "output": str(out),
158
+ })
159
+ return
160
+ download_episode_stream(base.stream_urls[0], out, quality, duration, print_command, subtitle_lang=subtitle_lang, imdb_id=media_id, media_type=media_type, title=base.title, file_name=base.file_name, default_subs=base.default_subs, sync_subtitles=sync_subtitles, subtitle_offset=subtitle_offset, subtitle_sync_max_duration=subtitle_sync_max_duration, download_retries=download_retries)
161
+ return
162
+
163
+ if media_type != "tv":
164
+ console.print("[red]Season/episode options require a TV embed URL.[/red]")
165
+ raise typer.Exit(2)
166
+
167
+ if all_episodes:
168
+ if season is None:
169
+ console.print("[red]--all-episodes requires --season N[/red]")
170
+ raise typer.Exit(2)
171
+ eps = episodes_for_season(base, season)
172
+ if list_only:
173
+ print_summary({"title": base.title, "season": season, "episodes": eps})
174
+ return
175
+ target_dir = output if output and output.suffix == "" else Path.cwd()
176
+ failures: list[tuple[int, str]] = []
177
+ for ep in eps:
178
+ info = episode_info(media_id, media_type, season, ep)
179
+ out = target_dir / episode_output_name(info.title, season, ep, info.file_name)
180
+ try:
181
+ download_episode_stream(
182
+ info.stream_urls[0],
183
+ out,
184
+ quality,
185
+ duration,
186
+ print_command,
187
+ subtitle_lang=subtitle_lang,
188
+ imdb_id=media_id,
189
+ media_type=media_type,
190
+ season=season,
191
+ episode=ep,
192
+ title=info.title,
193
+ file_name=info.file_name,
194
+ default_subs=info.default_subs,
195
+ sync_subtitles=sync_subtitles,
196
+ subtitle_offset=subtitle_offset,
197
+ subtitle_sync_max_duration=subtitle_sync_max_duration,
198
+ download_retries=download_retries,
199
+ stream_url_refresh=lambda ep=ep: episode_info(media_id, media_type, season, ep).stream_urls[0],
200
+ )
201
+ except Exception as exc:
202
+ failures.append((ep, str(exc)))
203
+ console.print(f"[red]Episode S{season:02d}E{ep:02d} failed after retries:[/red] {exc}")
204
+ if stop_on_error:
205
+ raise typer.Exit(1)
206
+ console.print("[yellow]Continuing with next episode. Re-run the same command later to retry failed/missing episodes.[/yellow]")
207
+ if failures:
208
+ console.print(f"[yellow]Season completed with {len(failures)} failed episode(s):[/yellow] " + ", ".join(f"E{ep:02d}" for ep, _ in failures))
209
+ raise typer.Exit(1)
210
+ return
211
+
212
+ if season is None or episode is None:
213
+ console.print("[red]Use --episodes, or provide both --season N --episode N.[/red]")
214
+ raise typer.Exit(2)
215
+ info = episode_info(media_id, media_type, season, episode)
216
+ out = output or Path.cwd() / episode_output_name(info.title, season, episode, info.file_name)
217
+ if list_only:
218
+ print_summary({
219
+ "title": info.title,
220
+ "season": season,
221
+ "episode": episode,
222
+ "stream_urls": info.stream_urls,
223
+ "output": str(out),
224
+ })
225
+ return
226
+ download_episode_stream(info.stream_urls[0], out, quality, duration, print_command, subtitle_lang=subtitle_lang, imdb_id=media_id, media_type=media_type, season=season, episode=episode, title=info.title, file_name=info.file_name, default_subs=info.default_subs, sync_subtitles=sync_subtitles, subtitle_offset=subtitle_offset, subtitle_sync_max_duration=subtitle_sync_max_duration, download_retries=download_retries, stream_url_refresh=lambda: episode_info(media_id, media_type, season, episode).stream_urls[0])
227
+
228
+
229
+ def download_episode_stream(
230
+ url: str,
231
+ output: Path,
232
+ quality: str,
233
+ duration: str | None,
234
+ print_command: bool,
235
+ *,
236
+ subtitle_lang: str | None = None,
237
+ imdb_id: str | None = None,
238
+ media_type: str | None = None,
239
+ season: int | None = None,
240
+ episode: int | None = None,
241
+ title: str = "",
242
+ file_name: str = "",
243
+ default_subs: list[dict] | None = None,
244
+ sync_subtitles: bool = True,
245
+ subtitle_offset: float = 0.0,
246
+ subtitle_sync_max_duration: int | None = None,
247
+ download_retries: int = 3,
248
+ stream_url_refresh: Callable[[], str] | None = None,
249
+ ) -> None:
250
+ with tempfile.TemporaryDirectory(prefix="streamgrabber-") as tmp:
251
+ video_ts = Path(tmp) / "video.ts"
252
+ subtitle_vtt = Path(tmp) / "subtitle.vtt"
253
+ synced_subtitle_vtt = Path(tmp) / "subtitle.synced.vtt"
254
+ streamlink_quality = "best" if quality in ("best", "auto", "") else ("worst" if quality == "worst" else f"{quality.rstrip('p')}p")
255
+ current_url = url
256
+
257
+ def make_streamlink_command(attempt: int) -> list[str]:
258
+ nonlocal current_url
259
+ if attempt > 1 and stream_url_refresh:
260
+ current_url = stream_url_refresh()
261
+ return build_streamlink_command(
262
+ url=current_url,
263
+ quality=streamlink_quality,
264
+ output=str(video_ts),
265
+ user_agent=DEFAULT_USER_AGENT,
266
+ referer="https://nextgencloudfabric.com/",
267
+ duration=duration,
268
+ )
269
+
270
+ sl_cmd = make_streamlink_command(1)
271
+ if print_command:
272
+ console.print(" ".join(shlex_quote(x) for x in sl_cmd))
273
+ if subtitle_lang and imdb_id:
274
+ console.print(f"# subtitle search: imdb={imdb_id} lang={language_to_opensubtitles_id(subtitle_lang)} season={season or ''} episode={episode or ''}")
275
+ console.print(" ".join(shlex_quote(x) for x in ["ffmpeg", "-y", "-i", str(video_ts), "-i", str(subtitle_vtt), "-c", "copy", "-c:s", "webvtt", str(output)]))
276
+ else:
277
+ console.print(" ".join(shlex_quote(x) for x in ["ffmpeg", "-y", "-i", str(video_ts), "-c", "copy", str(output)]))
278
+ return
279
+ ensure_parent(output)
280
+ console.print(f"[green]Downloading {streamlink_quality}[/green] -> {output.name}")
281
+
282
+ def before_retry(attempt: int, exc: BaseException) -> None:
283
+ if video_ts.exists():
284
+ video_ts.unlink()
285
+ console.print(f"[yellow]Download failed on attempt {attempt}/{download_retries}; retrying with a fresh stream URL...[/yellow]")
286
+
287
+ run_command_with_retries(
288
+ make_streamlink_command,
289
+ attempts=download_retries,
290
+ retry_delay_seconds=2,
291
+ before_retry=before_retry,
292
+ )
293
+ subtitle_path: Path | None = None
294
+ if subtitle_lang and imdb_id:
295
+ default_sub = choose_default_subtitle(default_subs or [], subtitle_lang)
296
+ if default_sub:
297
+ console.print(f"[green]Downloading default subtitle[/green] {default_sub.get('lang') or default_sub.get('code')}")
298
+ subtitle_path = download_default_subtitle_as_vtt(default_sub, subtitle_vtt)
299
+ else:
300
+ console.print(f"[green]Searching subtitles[/green] lang={language_to_opensubtitles_id(subtitle_lang)}")
301
+ candidates = search_subtitles(
302
+ imdb_id,
303
+ subtitle_lang,
304
+ season=season if media_type == "tv" else None,
305
+ episode=episode if media_type == "tv" else None,
306
+ )
307
+ selected = choose_best_subtitle(
308
+ candidates,
309
+ title=title,
310
+ file_name=file_name,
311
+ season=season if media_type == "tv" else None,
312
+ episode=episode if media_type == "tv" else None,
313
+ )
314
+ if selected:
315
+ console.print(f"[green]Downloading subtitle[/green] {selected.get('SubFileName') or selected.get('MovieReleaseName')}")
316
+ subtitle_path = download_subtitle_as_vtt(selected, subtitle_vtt)
317
+ else:
318
+ console.print(f"[yellow]No subtitle found for language:[/yellow] {subtitle_lang}")
319
+ if subtitle_path:
320
+ try:
321
+ if sync_subtitles:
322
+ console.print("[green]Syncing subtitles[/green] with video audio via ffsubsync")
323
+ elif subtitle_offset:
324
+ console.print(f"[green]Applying subtitle offset[/green] {subtitle_offset:+.3f}s")
325
+ subtitle_path = prepare_subtitle_for_mux(
326
+ video_ts,
327
+ subtitle_path,
328
+ synced_subtitle_vtt,
329
+ sync=sync_subtitles,
330
+ offset_seconds=subtitle_offset,
331
+ max_duration_seconds=subtitle_sync_max_duration,
332
+ )
333
+ except Exception as exc:
334
+ console.print(f"[yellow]Subtitle sync failed; using unsynced subtitle:[/yellow] {exc}")
335
+ mux(str(video_ts), str(subtitle_path) if subtitle_path else None, str(output))
336
+ console.print(f"[bold green]Saved:[/bold green] {output}")
337
+
338
+
339
+ def run_doctor() -> None:
340
+ table = Table(title="streamgrabber doctor")
341
+ table.add_column("Check")
342
+ table.add_column("Status")
343
+ table.add_column("Path / Version")
344
+ ok = True
345
+
346
+ for result in run_doctor_checks():
347
+ ok = ok and result.ok
348
+ status = "[green]OK[/green]" if result.ok else "[red]MISSING[/red]"
349
+ detail = result.path or result.detail or ""
350
+ if result.version:
351
+ detail = f"{detail}\n{result.version}"
352
+ table.add_row(result.name, status, detail)
353
+
354
+ try:
355
+ from playwright.sync_api import sync_playwright
356
+
357
+ with sync_playwright() as p:
358
+ browser = p.chromium.launch(headless=True)
359
+ browser.close()
360
+ table.add_row("playwright chromium", "[green]OK[/green]", "launch succeeded")
361
+ except Exception as exc:
362
+ ok = False
363
+ table.add_row("playwright chromium", "[red]MISSING/ERROR[/red]", str(exc).splitlines()[0])
364
+
365
+ console.print(table)
366
+ if not ok:
367
+ console.print(
368
+ "[yellow]Tip:[/yellow] install missing dependencies, e.g. "
369
+ "`brew install ffmpeg streamlink yt-dlp`, "
370
+ "`python -m pip install ffsubsync`, and "
371
+ "`python -m playwright install chromium`."
372
+ )
373
+ raise typer.Exit(1)
374
+
375
+
376
+ async def _main(
377
+ url: str,
378
+ list_only: bool,
379
+ print_command: bool,
380
+ quality: str,
381
+ output: Path | None,
382
+ downloader: str,
383
+ duration: str | None,
384
+ headed: bool,
385
+ no_fallback: bool,
386
+ timeout: int,
387
+ ) -> None:
388
+ console.print("[cyan]Capturing stream URLs with Playwright...[/cyan]")
389
+ captures = await capture_streams(url, timeout_ms=timeout, headless=not headed, user_agent=DEFAULT_USER_AGENT)
390
+ hls = select_hls(captures)
391
+ if not hls and not headed and not no_fallback:
392
+ console.print("[yellow]Headless capture did not find a stream; retrying with an automated browser window...[/yellow]")
393
+ captures = await capture_streams(url, timeout_ms=timeout, headless=False, user_agent=DEFAULT_USER_AGENT)
394
+ hls = select_hls(captures)
395
+ if not hls:
396
+ console.print("[red]No HLS/m3u8 stream captured.[/red]")
397
+ raise typer.Exit(1)
398
+
399
+ user_agent = hls.user_agent
400
+ referer = hls.referer
401
+ manifest_text = fetch_text(hls.url, user_agent=user_agent, referer=referer)
402
+ variants = parse_master_playlist(manifest_text, hls.url)
403
+ selected = choose_variant(variants, quality)
404
+ subtitles = [c for c in captures if c.kind in {"vtt", "srt"}]
405
+ subtitle = subtitles[0] if subtitles else None
406
+ page_title = hls.page_title or ""
407
+ out = output or output_path_from_title(page_title, Path.cwd())
408
+
409
+ summary = {
410
+ "page_url": url,
411
+ "hls": hls.url,
412
+ "referer": referer,
413
+ "user_agent": user_agent,
414
+ "variants": [v.__dict__ for v in variants],
415
+ "selected": selected.__dict__ if selected else None,
416
+ "subtitles": [s.url for s in subtitles],
417
+ "page_title": page_title,
418
+ "output": str(out),
419
+ }
420
+
421
+ if list_only:
422
+ print_summary(summary)
423
+ return
424
+
425
+ streamlink_quality = quality_for_streamlink(quality, selected.height if selected else None)
426
+ if downloader == "yt-dlp":
427
+ cmd = build_ytdlp_command(url=hls.url, output=str(out), user_agent=user_agent, referer=referer)
428
+ if print_command:
429
+ console.print(" ".join(shlex_quote(x) for x in cmd))
430
+ return
431
+ ensure_parent(out)
432
+ run_command(cmd)
433
+ return
434
+
435
+ with tempfile.TemporaryDirectory(prefix="streamgrabber-") as tmp:
436
+ tmpdir = Path(tmp)
437
+ video_ts = tmpdir / "video.ts"
438
+ sub_path = tmpdir / ("subtitle.vtt" if subtitle and subtitle.kind == "vtt" else "subtitle.srt") if subtitle else None
439
+ sl_cmd = build_streamlink_command(
440
+ url=hls.url,
441
+ quality=streamlink_quality,
442
+ output=str(video_ts),
443
+ user_agent=user_agent,
444
+ referer=referer,
445
+ duration=duration,
446
+ )
447
+ if print_command:
448
+ console.print(" ".join(shlex_quote(x) for x in sl_cmd))
449
+ if subtitle:
450
+ console.print(f"# subtitle: {subtitle.url}")
451
+ console.print(" ".join(shlex_quote(x) for x in ["ffmpeg", "-y", "-i", str(video_ts), "...", str(out)]))
452
+ return
453
+ ensure_parent(out)
454
+ console.print(f"[green]Downloading {streamlink_quality} with streamlink[/green]")
455
+ run_command(sl_cmd)
456
+ if subtitle and sub_path:
457
+ console.print("[green]Downloading subtitle[/green]")
458
+ fetch_file(subtitle.url, sub_path, user_agent=user_agent, referer=subtitle.referer or referer)
459
+ console.print("[green]Muxing MKV with ffmpeg[/green]")
460
+ mux(str(video_ts), str(sub_path) if sub_path else None, str(out))
461
+ console.print(f"[bold green]Saved:[/bold green] {out}")
462
+
463
+
464
+ def shlex_quote(value: str) -> str:
465
+ import shlex
466
+
467
+ return shlex.quote(value)
468
+
469
+
470
+ if __name__ == "__main__":
471
+ app()
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+
6
+ from playwright.async_api import async_playwright, Request, Response, Page, Browser, Frame
7
+
8
+ from .models import Capture
9
+ from .source_name import choose_source_name
10
+
11
+ MEDIA_RE = re.compile(r"\.(m3u8|mpd|vtt|srt)(?:\?|$)", re.I)
12
+ WEAK_TITLES = {"player", "embed", "video", ""}
13
+
14
+
15
+ def classify_url(url: str, content_type: str = "") -> str | None:
16
+ lower = url.lower()
17
+ ct = content_type.lower()
18
+ if ".vtt" in lower or "text/vtt" in ct:
19
+ return "vtt"
20
+ if ".srt" in lower or "subrip" in ct:
21
+ return "srt"
22
+ if ".mpd" in lower or "dash+xml" in ct:
23
+ return "dash"
24
+ if ".m3u8" in lower or "mpegurl" in ct:
25
+ return "hls"
26
+ return None
27
+
28
+
29
+ async def _click_play_candidates(scope: Page | Frame) -> None:
30
+ selectors = [
31
+ "button:has-text('Play')",
32
+ "[aria-label*='Play' i]",
33
+ "video",
34
+ "button",
35
+ ]
36
+ for selector in selectors:
37
+ try:
38
+ loc = scope.locator(selector).first
39
+ if await loc.count():
40
+ await loc.click(timeout=1000, force=True)
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ async def _frame_source_name(frame: Frame) -> str:
46
+ titles: list[str] = []
47
+ visible_text = ""
48
+ try:
49
+ title = await frame.evaluate("document.title || ''")
50
+ if title:
51
+ titles.append(str(title))
52
+ except Exception:
53
+ pass
54
+ try:
55
+ visible_text = str(await frame.evaluate("document.body ? document.body.innerText : ''") or "")
56
+ except Exception:
57
+ pass
58
+ return choose_source_name(titles=titles, visible_text=visible_text)
59
+
60
+
61
+ async def _best_source_name(page: Page) -> str:
62
+ titles: list[str] = []
63
+ visible_texts: list[str] = []
64
+ for frame in page.frames:
65
+ try:
66
+ title = await frame.evaluate("document.title || ''")
67
+ text = str(title).strip()
68
+ if text:
69
+ titles.append(text)
70
+ except Exception:
71
+ pass
72
+ try:
73
+ body_text = await frame.evaluate("document.body ? document.body.innerText : ''")
74
+ if body_text:
75
+ visible_texts.append(str(body_text))
76
+ except Exception:
77
+ pass
78
+ try:
79
+ title = (await page.title()).strip()
80
+ if title:
81
+ titles.append(title)
82
+ except Exception:
83
+ pass
84
+
85
+ return choose_source_name(titles=titles, visible_text="\n".join(visible_texts))
86
+
87
+
88
+ async def _source_name_for_url(page: Page, source_url: str | None) -> str:
89
+ if not source_url:
90
+ return ""
91
+ for frame in page.frames:
92
+ if frame.url == source_url:
93
+ return await _frame_source_name(frame)
94
+ return ""
95
+
96
+
97
+ async def capture_streams(
98
+ url: str,
99
+ *,
100
+ timeout_ms: int = 15000,
101
+ headless: bool = True,
102
+ user_agent: str | None = None,
103
+ ) -> list[Capture]:
104
+ found: dict[str, Capture] = {}
105
+ request_headers: dict[str, dict[str, str]] = {}
106
+ page_title: str | None = None
107
+
108
+ def remember_request(request: Request) -> None:
109
+ request_headers[request.url] = dict(request.headers)
110
+ kind = classify_url(request.url)
111
+ if kind:
112
+ found[request.url] = Capture(
113
+ url=request.url,
114
+ kind=kind,
115
+ headers=dict(request.headers),
116
+ source_url=request.frame.url if request.frame else url,
117
+ page_title=page_title,
118
+ )
119
+
120
+ async def remember_response(response: Response) -> None:
121
+ headers = dict(response.headers)
122
+ content_type = headers.get("content-type", "")
123
+ kind = classify_url(response.url, content_type)
124
+ if kind:
125
+ found[response.url] = Capture(
126
+ url=response.url,
127
+ kind=kind,
128
+ headers=request_headers.get(response.url, {}),
129
+ response_headers=headers,
130
+ source_url=response.request.frame.url if response.request.frame else url,
131
+ page_title=page_title,
132
+ )
133
+
134
+ async with async_playwright() as p:
135
+ browser: Browser = await p.chromium.launch(headless=headless)
136
+ context = await browser.new_context(user_agent=user_agent) if user_agent else await browser.new_context()
137
+ page = await context.new_page()
138
+ context.on("request", remember_request)
139
+ context.on("response", lambda response: asyncio.create_task(remember_response(response)))
140
+ await page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
141
+ page_title = await _best_source_name(page)
142
+ deadline = asyncio.get_event_loop().time() + timeout_ms / 1000
143
+ while asyncio.get_event_loop().time() < deadline:
144
+ for frame in page.frames:
145
+ try:
146
+ await _click_play_candidates(frame)
147
+ except Exception:
148
+ pass
149
+ await _click_play_candidates(page)
150
+ page_title = await _best_source_name(page) or page_title
151
+ if any(c.kind in {"hls", "dash"} for c in found.values()):
152
+ await page.wait_for_timeout(2500)
153
+ break
154
+ await page.wait_for_timeout(750)
155
+ page_title = await _best_source_name(page) or page_title
156
+ for capture in found.values():
157
+ source_name = await _source_name_for_url(page, capture.source_url)
158
+ if source_name:
159
+ capture.page_title = source_name
160
+ elif not capture.page_title or capture.page_title.lower() in WEAK_TITLES:
161
+ capture.page_title = page_title
162
+ await browser.close()
163
+ return list(found.values())