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.
- streamgrabber/__init__.py +1 -0
- streamgrabber/cli.py +471 -0
- streamgrabber/detector.py +163 -0
- streamgrabber/doctor.py +70 -0
- streamgrabber/downloader.py +85 -0
- streamgrabber/manifest.py +55 -0
- streamgrabber/models.py +44 -0
- streamgrabber/muxer.py +27 -0
- streamgrabber/naming.py +22 -0
- streamgrabber/resolver.py +68 -0
- streamgrabber/source_name.py +49 -0
- streamgrabber/subsync.py +129 -0
- streamgrabber/subtitles.py +364 -0
- streamgrabber/vaplayer.py +84 -0
- streamgrabber-0.1.0.dist-info/METADATA +548 -0
- streamgrabber-0.1.0.dist-info/RECORD +19 -0
- streamgrabber-0.1.0.dist-info/WHEEL +4 -0
- streamgrabber-0.1.0.dist-info/entry_points.txt +2 -0
- streamgrabber-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|