streamgrab 0.1.0__tar.gz

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,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamgrab
3
+ Version: 0.1.0
4
+ Summary: A desktop app for downloading video and audio from YouTube, Instagram, Facebook, and 1000+ other sites, with resolution selection and a live progress bar.
5
+ Author: StreamGrab contributors
6
+ License: MIT
7
+ Keywords: video downloader,yt-dlp,youtube,instagram,facebook,downloader
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: PyQt6>=6.6.0
16
+ Requires-Dist: yt-dlp>=2024.1.0
17
+ Requires-Dist: imageio-ffmpeg>=0.5.1
18
+
19
+ # StreamGrab
20
+
21
+ A desktop app for downloading video and audio from YouTube, Instagram,
22
+ Facebook, and 1000+ other sites supported by `yt-dlp` — with two fully
23
+ independent tabs (Video/MP4 and Audio/MP3), each with its own URL box,
24
+ so a paste in one never conflicts with the other, plus a live progress
25
+ bar and automatic playlist/mix safety so a single-video link never
26
+ accidentally triggers a slow whole-playlist fetch.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install -e .
32
+ ```
33
+
34
+ **ffmpeg is installed automatically** — no separate download or PATH
35
+ setup required. This package depends on `imageio-ffmpeg`, which ships
36
+ a real ffmpeg binary for Windows/Mac/Linux and installs it as a normal
37
+ part of `pip install`. If a system-wide ffmpeg is also present on your
38
+ PATH, that one is used instead (so your own ffmpeg install, if you
39
+ have one, still takes priority); the bundled copy is only a fallback.
40
+
41
+ ## Run
42
+
43
+ ```bash
44
+ streamgrab
45
+ ```
46
+
47
+ Two independent tabs:
48
+ - **🎬 Video (MP4)** — its own URL box, Fetch button, and resolution list.
49
+ - **🎵 Audio (MP3)** — its own URL box, Fetch button, and quality list.
50
+
51
+ Paste a URL into whichever tab matches what you want, click **Fetch**,
52
+ pick a quality, choose a save folder, and click **Download**. The two
53
+ tabs never share state, so you can look up a video in one tab without
54
+ affecting anything you've already fetched in the other.
55
+
56
+ ## Important: legal use
57
+
58
+ `yt-dlp` (the engine this app is built on) is a legitimate, widely used
59
+ open-source tool. However, many platforms' Terms of Service restrict or
60
+ prohibit downloading video content except through their own official
61
+ tools. This app does not bypass any DRM or paywalled content — it only
62
+ works on publicly accessible media the same way `yt-dlp` itself does.
63
+ You are responsible for using this tool in accordance with the terms
64
+ of service of whatever site you use it on, and applicable copyright law
65
+ in your country. This tool is intended for personal use cases such as
66
+ archiving your own content or content you have the right to download.
67
+
68
+ ## What has actually been tested
69
+
70
+ - Filename sanitization and format-label/sorting logic: unit tested.
71
+ - **ffmpeg resolution logic**: directly tested for all three real
72
+ scenarios — system ffmpeg present (preferred), system ffmpeg absent
73
+ but the bundled `imageio-ffmpeg` binary available (used as fallback),
74
+ and neither available (raises a clear error rather than a raw crash).
75
+ - **The playlist/mix hang that caused a real reported issue**: fixed
76
+ with `noplaylist` + `playlist_items='1'`, plus a normalization step
77
+ that resolves to a single video's info even if a site still returns
78
+ a playlist-shaped response. All of this is unit tested directly
79
+ against realistic response shapes (single video, playlist-shaped
80
+ with entries, empty playlist).
81
+ - **A hard 45-second timeout** wraps every metadata fetch, independent
82
+ of yt-dlp's own network timeout, so a stuck extractor can no longer
83
+ leave the "Fetching..." UI state stuck indefinitely — verified by
84
+ forcing an artificial hang and confirming it times out with a clear
85
+ message instead of hanging forever.
86
+ - The GUI: verified to build, show, and wire up all controls correctly
87
+ — including confirming the two tabs' URL inputs are genuinely
88
+ separate objects with no shared state, and that a missing-ffmpeg
89
+ scenario disables only the Audio tab, leaving Video unaffected.
90
+ - **The live download against a real video has NOT been verified in the
91
+ environment this was built in**, because that sandbox blocks outbound
92
+ network access to video platforms by policy. The `yt-dlp` calls
93
+ themselves follow yt-dlp's standard, documented API usage, and the
94
+ fixes above address the specific real failures reported so far, but
95
+ you should still do a real test on your own machine — paste a URL in
96
+ each tab, fetch, and download — before relying on this for anything
97
+ important.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,83 @@
1
+ # StreamGrab
2
+
3
+ A desktop app for downloading video and audio from YouTube, Instagram,
4
+ Facebook, and 1000+ other sites supported by `yt-dlp` — with two fully
5
+ independent tabs (Video/MP4 and Audio/MP3), each with its own URL box,
6
+ so a paste in one never conflicts with the other, plus a live progress
7
+ bar and automatic playlist/mix safety so a single-video link never
8
+ accidentally triggers a slow whole-playlist fetch.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install -e .
14
+ ```
15
+
16
+ **ffmpeg is installed automatically** — no separate download or PATH
17
+ setup required. This package depends on `imageio-ffmpeg`, which ships
18
+ a real ffmpeg binary for Windows/Mac/Linux and installs it as a normal
19
+ part of `pip install`. If a system-wide ffmpeg is also present on your
20
+ PATH, that one is used instead (so your own ffmpeg install, if you
21
+ have one, still takes priority); the bundled copy is only a fallback.
22
+
23
+ ## Run
24
+
25
+ ```bash
26
+ streamgrab
27
+ ```
28
+
29
+ Two independent tabs:
30
+ - **🎬 Video (MP4)** — its own URL box, Fetch button, and resolution list.
31
+ - **🎵 Audio (MP3)** — its own URL box, Fetch button, and quality list.
32
+
33
+ Paste a URL into whichever tab matches what you want, click **Fetch**,
34
+ pick a quality, choose a save folder, and click **Download**. The two
35
+ tabs never share state, so you can look up a video in one tab without
36
+ affecting anything you've already fetched in the other.
37
+
38
+ ## Important: legal use
39
+
40
+ `yt-dlp` (the engine this app is built on) is a legitimate, widely used
41
+ open-source tool. However, many platforms' Terms of Service restrict or
42
+ prohibit downloading video content except through their own official
43
+ tools. This app does not bypass any DRM or paywalled content — it only
44
+ works on publicly accessible media the same way `yt-dlp` itself does.
45
+ You are responsible for using this tool in accordance with the terms
46
+ of service of whatever site you use it on, and applicable copyright law
47
+ in your country. This tool is intended for personal use cases such as
48
+ archiving your own content or content you have the right to download.
49
+
50
+ ## What has actually been tested
51
+
52
+ - Filename sanitization and format-label/sorting logic: unit tested.
53
+ - **ffmpeg resolution logic**: directly tested for all three real
54
+ scenarios — system ffmpeg present (preferred), system ffmpeg absent
55
+ but the bundled `imageio-ffmpeg` binary available (used as fallback),
56
+ and neither available (raises a clear error rather than a raw crash).
57
+ - **The playlist/mix hang that caused a real reported issue**: fixed
58
+ with `noplaylist` + `playlist_items='1'`, plus a normalization step
59
+ that resolves to a single video's info even if a site still returns
60
+ a playlist-shaped response. All of this is unit tested directly
61
+ against realistic response shapes (single video, playlist-shaped
62
+ with entries, empty playlist).
63
+ - **A hard 45-second timeout** wraps every metadata fetch, independent
64
+ of yt-dlp's own network timeout, so a stuck extractor can no longer
65
+ leave the "Fetching..." UI state stuck indefinitely — verified by
66
+ forcing an artificial hang and confirming it times out with a clear
67
+ message instead of hanging forever.
68
+ - The GUI: verified to build, show, and wire up all controls correctly
69
+ — including confirming the two tabs' URL inputs are genuinely
70
+ separate objects with no shared state, and that a missing-ffmpeg
71
+ scenario disables only the Audio tab, leaving Video unaffected.
72
+ - **The live download against a real video has NOT been verified in the
73
+ environment this was built in**, because that sandbox blocks outbound
74
+ network access to video platforms by policy. The `yt-dlp` calls
75
+ themselves follow yt-dlp's standard, documented API usage, and the
76
+ fixes above address the specific real failures reported so far, but
77
+ you should still do a real test on your own machine — paste a URL in
78
+ each tab, fetch, and download — before relying on this for anything
79
+ important.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "streamgrab"
7
+ version = "0.1.0"
8
+ description = "A desktop app for downloading video and audio from YouTube, Instagram, Facebook, and 1000+ other sites, with resolution selection and a live progress bar."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "StreamGrab contributors" }
14
+ ]
15
+ keywords = ["video downloader", "yt-dlp", "youtube", "instagram", "facebook", "downloader"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ ]
23
+ dependencies = [
24
+ "PyQt6>=6.6.0",
25
+ "yt-dlp>=2024.1.0",
26
+ "imageio-ffmpeg>=0.5.1",
27
+ ]
28
+
29
+ [project.scripts]
30
+ streamgrab = "streamgrab.app:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["streamgrab*"]
34
+
35
+ [tool.setuptools.package-data]
36
+ streamgrab = ["resources/*.png"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,38 @@
1
+ """Entry point for the `streamgrab` console command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from PyQt6.QtWidgets import QApplication
9
+ from PyQt6.QtGui import QIcon, QFont
10
+
11
+ from streamgrab.ui.main_window import MainWindow
12
+
13
+ LOGO_PATH = Path(__file__).parent / "resources" / "logo.png"
14
+
15
+
16
+ def main() -> int:
17
+ app = QApplication(sys.argv)
18
+ app.setApplicationName("StreamGrab")
19
+
20
+ # Explicitly set a valid application-wide font with a real point size.
21
+ # PyQt6's default QFont() reports pointSize() == -1 on some platforms
22
+ # (confirmed: Windows commonly hits this), which triggers a harmless
23
+ # but noisy "QFont::setPointSize: Point size <= 0" warning whenever
24
+ # Qt's internal style code queries the default font. Setting a real
25
+ # font here up front means Qt never falls back to that ambiguous
26
+ # default, eliminating the warning at its source rather than just
27
+ # hiding it.
28
+ app.setFont(QFont("Segoe UI", 10))
29
+
30
+ if LOGO_PATH.exists():
31
+ app.setWindowIcon(QIcon(str(LOGO_PATH)))
32
+ window = MainWindow()
33
+ window.show()
34
+ return app.exec()
35
+
36
+
37
+ if __name__ == "__main__":
38
+ sys.exit(main())
File without changes
@@ -0,0 +1,380 @@
1
+ """
2
+ Core video/audio download logic built on yt-dlp.
3
+
4
+ Kept separate from the GUI so this can be tested and used
5
+ independently — the GUI just calls into this module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ import threading
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Callable, Optional
16
+
17
+ import yt_dlp
18
+
19
+ try:
20
+ import imageio_ffmpeg
21
+ _HAS_BUNDLED_FFMPEG = True
22
+ except ImportError:
23
+ _HAS_BUNDLED_FFMPEG = False
24
+
25
+
26
+ def _find_ffmpeg_path() -> Optional[str]:
27
+ """Locate an ffmpeg binary to use, preferring one already on the
28
+ system PATH (so a user's own install/version is respected), and
29
+ falling back to the ffmpeg binary bundled via the imageio-ffmpeg
30
+ pip package if no system install is found. imageio-ffmpeg ships a
31
+ real, statically-linked ffmpeg binary for Windows/Mac/Linux and is
32
+ installed automatically as a normal dependency of this package —
33
+ so a fresh `pip install streamgrab` works out of the box without
34
+ the user needing to separately install ffmpeg themselves.
35
+ Returns None if neither is available.
36
+ """
37
+ system_ffmpeg = shutil.which("ffmpeg")
38
+ if system_ffmpeg:
39
+ return system_ffmpeg
40
+ if _HAS_BUNDLED_FFMPEG:
41
+ return imageio_ffmpeg.get_ffmpeg_exe()
42
+ return None
43
+
44
+
45
+ class _QuietLogger:
46
+ """A yt-dlp logger that drops noise while still surfacing real
47
+ problems.
48
+
49
+ Filtered messages, both confirmed by tracing yt-dlp's actual source
50
+ code (not assumed):
51
+
52
+ 1. "Deprecated Feature: Support for Python version ... has been
53
+ deprecated" — yt-dlp's deprecated_feature() prints this via
54
+ to_stderr(), which — when a logger is configured (as we do) —
55
+ routes to logger.error(), not logger.warning(). Filtered on
56
+ both methods since it can arrive through either.
57
+ 2. "YouTube Music is not directly supported. Redirecting to ..." —
58
+ informational: yt-dlp is auto-converting a music.youtube.com
59
+ URL and continuing normally. Not an error state.
60
+
61
+ Any other message (real errors, real warnings) is still printed
62
+ normally — this is a narrow, specific filter, not a blanket one.
63
+ """
64
+
65
+ _FILTERED_SUBSTRINGS = (
66
+ "Deprecated Feature",
67
+ "has been deprecated",
68
+ "YouTube Music is not directly supported",
69
+ )
70
+
71
+ def _is_filtered(self, msg: str) -> bool:
72
+ return any(s in msg for s in self._FILTERED_SUBSTRINGS)
73
+
74
+ def debug(self, msg):
75
+ pass
76
+
77
+ def info(self, msg):
78
+ pass
79
+
80
+ def warning(self, msg):
81
+ if self._is_filtered(msg):
82
+ return
83
+ print(msg)
84
+
85
+ def error(self, msg):
86
+ if self._is_filtered(msg):
87
+ return
88
+ print(msg)
89
+
90
+
91
+ # noplaylist + playlist_items='1' together are belt-and-suspenders
92
+ # against the exact class of bug that caused a real hang: pasting a
93
+ # single-video URL that also carries a playlist/mix/radio ID in its
94
+ # query string (e.g. YouTube's "&list=RDAMVM..." auto-generated radio
95
+ # mixes). Without these, yt-dlp can interpret such a URL as "fetch the
96
+ # whole playlist" and try to enumerate every entry before returning
97
+ # anything, which from the GUI looks exactly like a frozen "Fetching..."
98
+ # state even though nothing has technically crashed.
99
+ _BASE_YDL_OPTS = {
100
+ "quiet": True,
101
+ "no_warnings": True,
102
+ "logger": _QuietLogger(),
103
+ "noplaylist": True,
104
+ "playlist_items": "1",
105
+ "extract_flat": False,
106
+ "socket_timeout": 20,
107
+ }
108
+
109
+ # Hard ceiling on how long a metadata fetch is allowed to run before we
110
+ # give up and report a timeout, rather than leaving the GUI's
111
+ # "Fetching..." state up indefinitely if some site's extractor hangs
112
+ # for a reason socket_timeout doesn't catch (e.g. stuck in CPU-bound
113
+ # parsing rather than waiting on a socket).
114
+ FETCH_TIMEOUT_SECONDS = 45
115
+
116
+
117
+ def sanitize_filename(name: str) -> str:
118
+ """Remove characters illegal in Windows/Mac/Linux filenames."""
119
+ cleaned = re.sub(r'[<>:"/\\|?*]', "_", name)
120
+ return cleaned.strip()
121
+
122
+
123
+ def is_ffmpeg_available() -> bool:
124
+ """Check whether a usable ffmpeg is available — either a system
125
+ install on PATH, or the bundled binary from the imageio-ffmpeg pip
126
+ package. Audio extraction (converting to MP3) requires ffmpeg;
127
+ plain video downloads do not. Checked up front so the GUI can warn
128
+ clearly before the user picks an option that would fail, instead
129
+ of surfacing yt-dlp's raw error after a failed download."""
130
+ return _find_ffmpeg_path() is not None
131
+
132
+
133
+ @dataclass
134
+ class VideoFormat:
135
+ """A single available quality option for a video."""
136
+ format_id: str
137
+ height: Optional[int] # e.g. 1080, None for audio-only formats
138
+ ext: str
139
+ is_audio_only: bool
140
+ filesize_mb: Optional[float]
141
+
142
+ @property
143
+ def label(self) -> str:
144
+ if self.is_audio_only:
145
+ return f"Audio only ({self.ext})"
146
+ size = f" (~{self.filesize_mb:.0f} MB)" if self.filesize_mb else ""
147
+ return f"{self.height}p{size}"
148
+
149
+ @property
150
+ def download_format_string(self) -> str:
151
+ """The actual yt-dlp -f string to request. Many video formats
152
+ (720p+) are video-only with no audio track — using the bare
153
+ format_id silently produces a silent MP4. This merges in the
154
+ best available audio, falling back to the format alone if
155
+ merging isn't possible."""
156
+ if self.is_audio_only:
157
+ return self.format_id
158
+ return f"{self.format_id}+bestaudio/{self.format_id}"
159
+
160
+
161
+ @dataclass
162
+ class VideoInfo:
163
+ title: str
164
+ video_formats: list[VideoFormat]
165
+ audio_formats: list[VideoFormat]
166
+ duration_seconds: Optional[float]
167
+ thumbnail_url: Optional[str]
168
+
169
+
170
+ def _run_with_timeout(func, timeout_seconds):
171
+ """Run func() in a background thread and enforce a hard wall-clock
172
+ timeout, since yt-dlp itself has no universal 'give up after N
173
+ seconds' option that covers every extractor's internal behavior —
174
+ socket_timeout only bounds network reads, not, e.g., an extractor
175
+ stuck parsing an unusually large playlist response.
176
+
177
+ Raises TimeoutError if the timeout elapses. The worker thread is
178
+ daemonized so it can't prevent the app from exiting even if it
179
+ never returns.
180
+ """
181
+ result = {}
182
+ error = {}
183
+
184
+ def target():
185
+ try:
186
+ result["value"] = func()
187
+ except Exception as e: # noqa: BLE001 - deliberately broad, re-raised below
188
+ error["exc"] = e
189
+
190
+ thread = threading.Thread(target=target, daemon=True)
191
+ thread.start()
192
+ thread.join(timeout_seconds)
193
+
194
+ if thread.is_alive():
195
+ raise TimeoutError(
196
+ f"This took longer than {timeout_seconds} seconds and was "
197
+ "stopped. The site may be slow, blocking automated access, "
198
+ "or the link may point to a large playlist/mix rather than "
199
+ "a single video."
200
+ )
201
+ if "exc" in error:
202
+ raise error["exc"]
203
+ return result.get("value")
204
+
205
+
206
+ def _extract_single_entry(info: dict) -> dict:
207
+ """yt-dlp can return either a single-video dict, or (even with
208
+ noplaylist=True, for some extractors/URL shapes) a playlist-shaped
209
+ dict with an 'entries' list. Normalize to always return a single
210
+ video's info dict, using only the first entry if given a playlist,
211
+ so downstream code never has to guess which shape it received."""
212
+ if info.get("_type") == "playlist" or "entries" in info:
213
+ entries = list(info.get("entries") or [])
214
+ if not entries:
215
+ raise ValueError(
216
+ "This link points to a playlist or mix with no "
217
+ "individual video found. Please paste a link to a "
218
+ "single video instead."
219
+ )
220
+ return entries[0]
221
+ return info
222
+
223
+
224
+ def fetch_video_info(url: str) -> VideoInfo:
225
+ """Query the given URL and return available formats + metadata,
226
+ without downloading anything. Works across any site yt-dlp supports
227
+ (YouTube, Instagram, Facebook, Twitter/X, TikTok, Vimeo, and 1000+
228
+ others), since yt-dlp handles per-site extraction internally.
229
+
230
+ Always resolves to a single video's info, even if the URL also
231
+ carries playlist/mix/radio parameters, and enforces a hard timeout
232
+ so a slow or stuck site can't leave the caller waiting forever.
233
+
234
+ Returns video and audio formats as two separate lists, so the GUI
235
+ can show them in distinct tabs rather than one mixed dropdown.
236
+ """
237
+ ydl_opts = dict(_BASE_YDL_OPTS)
238
+
239
+ def do_fetch():
240
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
241
+ return ydl.extract_info(url, download=False)
242
+
243
+ raw_info = _run_with_timeout(do_fetch, FETCH_TIMEOUT_SECONDS)
244
+ info = _extract_single_entry(raw_info)
245
+
246
+ raw_formats = info.get("formats", [])
247
+ video_formats: list[VideoFormat] = []
248
+ audio_formats: list[VideoFormat] = []
249
+
250
+ seen_heights: set[int] = set()
251
+ for fmt in raw_formats:
252
+ has_video = fmt.get("vcodec") not in (None, "none")
253
+ has_audio = fmt.get("acodec") not in (None, "none")
254
+ height = fmt.get("height")
255
+ filesize = fmt.get("filesize") or fmt.get("filesize_approx")
256
+ filesize_mb = (filesize / (1024 * 1024)) if filesize else None
257
+
258
+ if has_video and height:
259
+ # Only keep one entry per resolution (yt-dlp often lists many
260
+ # near-duplicate video-only streams per height); avoids a
261
+ # cluttered, confusing dropdown in the UI.
262
+ if height in seen_heights:
263
+ continue
264
+ seen_heights.add(height)
265
+ video_formats.append(VideoFormat(
266
+ format_id=fmt["format_id"],
267
+ height=height,
268
+ ext=fmt.get("ext", "mp4"),
269
+ is_audio_only=False,
270
+ filesize_mb=filesize_mb,
271
+ ))
272
+ elif has_audio and not has_video:
273
+ audio_formats.append(VideoFormat(
274
+ format_id=fmt["format_id"],
275
+ height=None,
276
+ ext=fmt.get("ext", "m4a"),
277
+ is_audio_only=True,
278
+ filesize_mb=filesize_mb,
279
+ ))
280
+
281
+ video_formats.sort(key=lambda f: -(f.height or 0))
282
+ audio_formats.sort(key=lambda f: -(f.filesize_mb or 0))
283
+
284
+ if not video_formats and not audio_formats:
285
+ raise ValueError(
286
+ "No downloadable formats were found for this link. It may "
287
+ "be private, region-locked, age-restricted, or not "
288
+ "actually a playable video/audio page."
289
+ )
290
+
291
+ return VideoInfo(
292
+ title=sanitize_filename(info.get("title", "video")),
293
+ video_formats=video_formats,
294
+ audio_formats=audio_formats,
295
+ duration_seconds=info.get("duration"),
296
+ thumbnail_url=info.get("thumbnail"),
297
+ )
298
+
299
+
300
+ def download(
301
+ url: str,
302
+ format_id: str,
303
+ output_dir: str | Path,
304
+ filename: str,
305
+ audio_only: bool = False,
306
+ progress_callback: Optional[Callable[[dict], None]] = None,
307
+ ) -> Path:
308
+ """Download the given format_id from url into output_dir/filename.
309
+
310
+ progress_callback, if given, is called repeatedly by yt-dlp with a
311
+ dict containing at least 'status' ('downloading'/'finished'/'error')
312
+ and, while downloading, '_percent_str' / 'downloaded_bytes' /
313
+ 'total_bytes' — the GUI uses this to drive a live progress bar.
314
+
315
+ Raises RuntimeError with a clear, plain-English message if ffmpeg
316
+ is required (audio_only=True) but not available (neither on the
317
+ system PATH nor bundled via imageio-ffmpeg), instead of letting
318
+ yt-dlp fail deep inside postprocessing with a raw, ANSI-coded error.
319
+ """
320
+ ffmpeg_path = None
321
+ if audio_only:
322
+ ffmpeg_path = _find_ffmpeg_path()
323
+ if ffmpeg_path is None:
324
+ raise RuntimeError(
325
+ "Converting to MP3 requires ffmpeg, and no usable ffmpeg "
326
+ "was found — neither on your system PATH nor bundled "
327
+ "with this app. This shouldn't normally happen since "
328
+ "ffmpeg is installed automatically; try reinstalling "
329
+ "streamgrab. (Video downloads don't need ffmpeg.)"
330
+ )
331
+
332
+ output_dir = Path(output_dir)
333
+ output_dir.mkdir(parents=True, exist_ok=True)
334
+
335
+ outtmpl = str(output_dir / f"{filename}.%(ext)s")
336
+
337
+ # For video downloads, merge in audio (many formats are video-only)
338
+ # and force remux to mp4 so output is always a playable video+audio file.
339
+ actual_format = format_id if audio_only else f"{format_id}+bestaudio/best"
340
+
341
+ ydl_opts = {
342
+ **_BASE_YDL_OPTS,
343
+ "format": actual_format,
344
+ "outtmpl": outtmpl,
345
+ }
346
+ if not audio_only:
347
+ ydl_opts["merge_output_format"] = "mp4"
348
+ system_or_bundled = _find_ffmpeg_path()
349
+ if system_or_bundled:
350
+ ydl_opts["ffmpeg_location"] = system_or_bundled
351
+
352
+ if progress_callback is not None:
353
+ ydl_opts["progress_hooks"] = [progress_callback]
354
+
355
+ if audio_only:
356
+ # Tell yt-dlp exactly which ffmpeg binary to use. This matters
357
+ # specifically when falling back to the bundled imageio-ffmpeg
358
+ # binary, since that binary lives inside the Python package's
359
+ # install location rather than on PATH — yt-dlp has no way to
360
+ # find it unless told explicitly via ffmpeg_location.
361
+ ydl_opts["ffmpeg_location"] = ffmpeg_path
362
+ ydl_opts["postprocessors"] = [{
363
+ "key": "FFmpegExtractAudio",
364
+ "preferredcodec": "mp3",
365
+ "preferredquality": "192",
366
+ }]
367
+
368
+ try:
369
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
370
+ ydl.download([url])
371
+ except yt_dlp.utils.DownloadError as e:
372
+ # Strip yt-dlp's ANSI color codes so the GUI shows a clean
373
+ # message instead of raw escape sequences like "[0;31mERROR".
374
+ clean_msg = re.sub(r"\x1b\[[0-9;]*m", "", str(e))
375
+ raise RuntimeError(clean_msg) from e
376
+
377
+ # Return the most likely resulting file path (extension may have
378
+ # changed due to audio extraction/remuxing, so glob for it).
379
+ matches = list(output_dir.glob(f"{filename}.*"))
380
+ return matches[0] if matches else output_dir / filename
File without changes
@@ -0,0 +1,379 @@
1
+ """
2
+ StreamGrab main window: two fully independent tabs — Video (MP4) and
3
+ Audio (MP3) — each with its own URL input, Fetch button, quality
4
+ dropdown, and Download button. Keeping them independent means pasting
5
+ a link in one tab can never interfere with a fetch/download in the
6
+ other, and each tab's own error/progress state stays local to it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
14
+ from PyQt6.QtGui import QIcon, QPixmap
15
+ from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest
16
+ from PyQt6.QtWidgets import (
17
+ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
18
+ QPushButton, QComboBox, QProgressBar, QFileDialog, QMessageBox,
19
+ QStatusBar, QTabWidget,
20
+ )
21
+
22
+ from streamgrab.core.downloader import (
23
+ fetch_video_info, download, VideoInfo, is_ffmpeg_available,
24
+ )
25
+
26
+ LOGO_PATH = Path(__file__).resolve().parent.parent / "resources" / "logo.png"
27
+
28
+ DARK_STYLESHEET = """
29
+ QMainWindow, QWidget { background-color: #1e1f26; color: #e8e8ec; font-family: 'Segoe UI', sans-serif; }
30
+ QLineEdit, QComboBox { background-color: #262832; border: 1px solid #34364a; border-radius: 6px; padding: 8px; font-size: 13px; }
31
+ QPushButton { background-color: #34364a; border: 1px solid #454864; border-radius: 6px; padding: 8px 16px; font-weight: 600; }
32
+ QPushButton:hover { background-color: #454864; }
33
+ QPushButton:disabled { background-color: #2a2b34; color: #666; }
34
+ QPushButton#fetchBtn { background-color: #2255aa; border: 1px solid #3366cc; }
35
+ QPushButton#fetchBtn:hover { background-color: #3366cc; }
36
+ QPushButton#downloadBtn { background-color: #1f7a4d; border: 1px solid #2ea86b; }
37
+ QPushButton#downloadBtn:hover { background-color: #2ea86b; }
38
+ QPushButton#downloadBtn:disabled { background-color: #2a2b34; color: #666; }
39
+ QLabel#titleLabel { font-size: 20px; font-weight: 700; }
40
+ QLabel#videoTitle { font-size: 14px; color: #9aa0c0; }
41
+ QLabel#warningLabel { color: #ffb84d; font-size: 12px; }
42
+ QProgressBar { background-color: #262832; border: 1px solid #34364a; border-radius: 6px; text-align: center; height: 24px; }
43
+ QProgressBar::chunk { background-color: #2ea86b; border-radius: 6px; }
44
+ QStatusBar { background-color: #262832; }
45
+ QTabWidget::pane { border: 1px solid #34364a; border-radius: 6px; top: -1px; }
46
+ QTabBar::tab { background-color: #262832; padding: 8px 20px; border: 1px solid #34364a; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; margin-right: 2px; }
47
+ QTabBar::tab:selected { background-color: #34364a; font-weight: 600; }
48
+ """
49
+
50
+
51
+ class FetchInfoThread(QThread):
52
+ """Runs the (potentially slow) yt-dlp metadata fetch off the UI
53
+ thread so the window can never appear frozen — fetch_video_info()
54
+ itself now also enforces its own hard timeout internally, so even
55
+ a stuck extractor eventually reports back rather than hanging."""
56
+ finished_ok = pyqtSignal(object) # VideoInfo
57
+ failed = pyqtSignal(str)
58
+
59
+ def __init__(self, url: str):
60
+ super().__init__()
61
+ self.url = url
62
+
63
+ def run(self):
64
+ try:
65
+ info = fetch_video_info(self.url)
66
+ self.finished_ok.emit(info)
67
+ except Exception as e:
68
+ self.failed.emit(str(e))
69
+
70
+
71
+ class DownloadThread(QThread):
72
+ """Runs the actual download off the UI thread, forwarding yt-dlp's
73
+ progress dicts back to the GUI via a signal."""
74
+ progress = pyqtSignal(dict)
75
+ finished_ok = pyqtSignal(str) # resulting file path
76
+ failed = pyqtSignal(str)
77
+
78
+ def __init__(self, url, format_id, output_dir, filename, audio_only):
79
+ super().__init__()
80
+ self.url = url
81
+ self.format_id = format_id
82
+ self.output_dir = output_dir
83
+ self.filename = filename
84
+ self.audio_only = audio_only
85
+
86
+ def run(self):
87
+ try:
88
+ result_path = download(
89
+ self.url,
90
+ self.format_id,
91
+ self.output_dir,
92
+ self.filename,
93
+ audio_only=self.audio_only,
94
+ progress_callback=lambda d: self.progress.emit(d),
95
+ )
96
+ self.finished_ok.emit(str(result_path))
97
+ except Exception as e:
98
+ self.failed.emit(str(e))
99
+
100
+
101
+ class DownloadTab(QWidget):
102
+ """One self-contained tab: its own URL bar, Fetch button, quality
103
+ dropdown, folder picker, Download button, and progress bar. Used
104
+ twice (once for video, once for audio) with completely separate
105
+ state, so nothing in one tab can affect the other."""
106
+
107
+ def __init__(self, *, audio_only: bool, ffmpeg_available: bool, parent=None):
108
+ super().__init__(parent)
109
+ self.audio_only = audio_only
110
+ self.ffmpeg_available = ffmpeg_available
111
+
112
+ self.video_info: VideoInfo | None = None
113
+ self.output_dir: Path = Path.home() / "Downloads"
114
+ self.fetch_thread: FetchInfoThread | None = None
115
+ self.download_thread: DownloadThread | None = None
116
+
117
+ self._build_ui()
118
+
119
+ def _build_ui(self):
120
+ layout = QVBoxLayout(self)
121
+ layout.setSpacing(12)
122
+
123
+ # URL row — this tab's own independent input, never shared.
124
+ url_row = QHBoxLayout()
125
+ self.url_input = QLineEdit()
126
+ placeholder = (
127
+ "Paste a video URL to extract audio from..."
128
+ if self.audio_only else
129
+ "Paste a video URL here..."
130
+ )
131
+ self.url_input.setPlaceholderText(placeholder)
132
+ url_row.addWidget(self.url_input, stretch=1)
133
+ self.fetch_btn = QPushButton("Fetch")
134
+ self.fetch_btn.setObjectName("fetchBtn")
135
+ self.fetch_btn.clicked.connect(self._on_fetch_clicked)
136
+ url_row.addWidget(self.fetch_btn)
137
+ layout.addLayout(url_row)
138
+
139
+ self.title_label = QLabel("")
140
+ self.title_label.setObjectName("videoTitle")
141
+ self.title_label.setWordWrap(True)
142
+ layout.addWidget(self.title_label)
143
+
144
+ # Thumbnail preview so the user can visually confirm this is
145
+ # the right video before downloading. Loaded async via
146
+ # QNetworkAccessManager — works for any site's thumbnail URL,
147
+ # since it's just an image fetch, not site-specific code.
148
+ self.thumbnail_label = QLabel("")
149
+ self.thumbnail_label.setFixedHeight(140)
150
+ self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
151
+ self.thumbnail_label.setVisible(False)
152
+ layout.addWidget(self.thumbnail_label)
153
+ self._network_manager = QNetworkAccessManager(self)
154
+
155
+ # Quality row
156
+ quality_row = QHBoxLayout()
157
+ quality_row.addWidget(QLabel("Audio quality:" if self.audio_only else "Resolution:"))
158
+ self.format_combo = QComboBox()
159
+ self.format_combo.setEnabled(False)
160
+ quality_row.addWidget(self.format_combo, stretch=1)
161
+ layout.addLayout(quality_row)
162
+
163
+ self.ffmpeg_warning_label = QLabel("")
164
+ self.ffmpeg_warning_label.setObjectName("warningLabel")
165
+ self.ffmpeg_warning_label.setWordWrap(True)
166
+ layout.addWidget(self.ffmpeg_warning_label)
167
+
168
+ if self.audio_only and not self.ffmpeg_available:
169
+ self.ffmpeg_warning_label.setText(
170
+ "⚠ ffmpeg was not found on this system. MP3 conversion "
171
+ "requires it — install ffmpeg and add it to your PATH "
172
+ "to enable audio downloads."
173
+ )
174
+ self.url_input.setEnabled(False)
175
+ self.fetch_btn.setEnabled(False)
176
+
177
+ # Output folder row
178
+ folder_row = QHBoxLayout()
179
+ self.folder_label = QLabel(f"Save to: {self.output_dir}")
180
+ self.folder_label.setWordWrap(True)
181
+ folder_row.addWidget(self.folder_label, stretch=1)
182
+ choose_folder_btn = QPushButton("Choose Folder")
183
+ choose_folder_btn.clicked.connect(self._choose_folder)
184
+ folder_row.addWidget(choose_folder_btn)
185
+ layout.addLayout(folder_row)
186
+
187
+ # Download button
188
+ self.download_btn = QPushButton("Download")
189
+ self.download_btn.setObjectName("downloadBtn")
190
+ self.download_btn.setEnabled(False)
191
+ self.download_btn.clicked.connect(self._on_download_clicked)
192
+ layout.addWidget(self.download_btn)
193
+
194
+ # Progress bar
195
+ self.progress_bar = QProgressBar()
196
+ self.progress_bar.setValue(0)
197
+ self.progress_bar.setVisible(False)
198
+ layout.addWidget(self.progress_bar)
199
+
200
+ layout.addStretch()
201
+
202
+ # ---- fetch flow -----------------------------------------------------
203
+
204
+ def _on_fetch_clicked(self):
205
+ url = self.url_input.text().strip()
206
+ if not url:
207
+ QMessageBox.warning(self, "No URL", "Please paste a video URL first.")
208
+ return
209
+
210
+ self.fetch_btn.setEnabled(False)
211
+ self.fetch_btn.setText("Fetching...")
212
+ self.format_combo.clear()
213
+ self.format_combo.setEnabled(False)
214
+ self.download_btn.setEnabled(False)
215
+ self.title_label.setText("")
216
+ self.thumbnail_label.setVisible(False)
217
+ self.window().statusBar().showMessage("Looking up video info...")
218
+
219
+ self.fetch_thread = FetchInfoThread(url)
220
+ self.fetch_thread.finished_ok.connect(self._on_fetch_success)
221
+ self.fetch_thread.failed.connect(self._on_fetch_failed)
222
+ self.fetch_thread.start()
223
+
224
+ def _on_fetch_success(self, info: VideoInfo):
225
+ self.video_info = info
226
+ icon = "🎵" if self.audio_only else "🎬"
227
+ self.title_label.setText(f"{icon} {info.title}")
228
+
229
+ if info.thumbnail_url:
230
+ reply = self._network_manager.get(QNetworkRequest(QUrl(info.thumbnail_url)))
231
+ reply.finished.connect(lambda r=reply: self._on_thumbnail_loaded(r))
232
+ else:
233
+ self.thumbnail_label.setVisible(False)
234
+
235
+ formats = info.audio_formats if self.audio_only else info.video_formats
236
+ self.format_combo.clear()
237
+ for fmt in formats:
238
+ self.format_combo.addItem(fmt.label, userData=fmt)
239
+ self.format_combo.setEnabled(bool(formats))
240
+ self.download_btn.setEnabled(bool(formats))
241
+
242
+ self.fetch_btn.setEnabled(True)
243
+ self.fetch_btn.setText("Fetch")
244
+
245
+ if not formats:
246
+ kind = "audio" if self.audio_only else "video"
247
+ self.window().statusBar().showMessage(f"No {kind} formats found for this link.")
248
+ else:
249
+ self.window().statusBar().showMessage(f"Found {len(formats)} format(s).")
250
+
251
+ def _on_fetch_failed(self, error_msg: str):
252
+ self.fetch_btn.setEnabled(True)
253
+ self.fetch_btn.setText("Fetch")
254
+ self.window().statusBar().showMessage("Failed to fetch video info.")
255
+ QMessageBox.critical(self, "Fetch failed", f"Could not read this URL:\n\n{error_msg}")
256
+
257
+ def _on_thumbnail_loaded(self, reply):
258
+ # Silently skip on failure (no thumbnail, wrong format, network
259
+ # hiccup) — the thumbnail is a nice-to-have, never a blocker.
260
+ data = reply.readAll()
261
+ reply.deleteLater()
262
+ pixmap = QPixmap()
263
+ if pixmap.loadFromData(bytes(data)):
264
+ scaled = pixmap.scaledToHeight(140, Qt.TransformationMode.SmoothTransformation)
265
+ self.thumbnail_label.setPixmap(scaled)
266
+ self.thumbnail_label.setVisible(True)
267
+
268
+ # ---- folder selection -------------------------------------------------
269
+
270
+ def _choose_folder(self):
271
+ folder = QFileDialog.getExistingDirectory(self, "Choose download folder", str(self.output_dir))
272
+ if folder:
273
+ self.output_dir = Path(folder)
274
+ self.folder_label.setText(f"Save to: {self.output_dir}")
275
+
276
+ # ---- download flow ----------------------------------------------------
277
+
278
+ def _on_download_clicked(self):
279
+ if self.video_info is None:
280
+ return
281
+ fmt = self.format_combo.currentData()
282
+ if fmt is None:
283
+ QMessageBox.warning(
284
+ self, "No format selected",
285
+ "Please fetch a video first and choose a quality option."
286
+ )
287
+ return
288
+
289
+ if self.audio_only and not self.ffmpeg_available:
290
+ QMessageBox.warning(
291
+ self, "ffmpeg required",
292
+ "MP3 conversion requires ffmpeg, which isn't installed. "
293
+ "Install ffmpeg and add it to your PATH, then try again."
294
+ )
295
+ return
296
+
297
+ url = self.url_input.text().strip()
298
+ self.download_btn.setEnabled(False)
299
+ self.download_btn.setText("Downloading...")
300
+ self.progress_bar.setVisible(True)
301
+ self.progress_bar.setValue(0)
302
+
303
+ self.download_thread = DownloadThread(
304
+ url=url,
305
+ format_id=fmt.download_format_string,
306
+ output_dir=self.output_dir,
307
+ filename=self.video_info.title,
308
+ audio_only=self.audio_only,
309
+ )
310
+ self.download_thread.progress.connect(self._on_download_progress)
311
+ self.download_thread.finished_ok.connect(self._on_download_success)
312
+ self.download_thread.failed.connect(self._on_download_failed)
313
+ self.download_thread.start()
314
+
315
+ def _on_download_progress(self, progress_dict: dict):
316
+ status = progress_dict.get("status")
317
+ if status == "downloading":
318
+ total = progress_dict.get("total_bytes") or progress_dict.get("total_bytes_estimate")
319
+ downloaded = progress_dict.get("downloaded_bytes", 0)
320
+ if total:
321
+ percent = int((downloaded / total) * 100)
322
+ self.progress_bar.setValue(percent)
323
+ elif status == "finished":
324
+ self.progress_bar.setValue(100)
325
+
326
+ def _on_download_success(self, file_path: str):
327
+ self.download_btn.setEnabled(True)
328
+ self.download_btn.setText("Download")
329
+ self.window().statusBar().showMessage(f"✓ Saved to {file_path}")
330
+ QMessageBox.information(self, "Download complete", f"Saved to:\n{file_path}")
331
+
332
+ def _on_download_failed(self, error_msg: str):
333
+ self.download_btn.setEnabled(True)
334
+ self.download_btn.setText("Download")
335
+ self.progress_bar.setVisible(False)
336
+ self.window().statusBar().showMessage("Download failed.")
337
+ QMessageBox.critical(self, "Download failed", error_msg)
338
+
339
+
340
+ class MainWindow(QMainWindow):
341
+ def __init__(self):
342
+ super().__init__()
343
+ self.setWindowTitle("StreamGrab — video & audio downloader")
344
+ self.resize(760, 520)
345
+ self.setStyleSheet(DARK_STYLESHEET)
346
+ if LOGO_PATH.exists():
347
+ self.setWindowIcon(QIcon(str(LOGO_PATH)))
348
+
349
+ self.ffmpeg_available = is_ffmpeg_available()
350
+
351
+ self._build_ui()
352
+
353
+ def _build_ui(self):
354
+ central = QWidget()
355
+ layout = QVBoxLayout(central)
356
+ layout.setSpacing(14)
357
+ layout.setContentsMargins(24, 24, 24, 24)
358
+
359
+ title = QLabel("StreamGrab")
360
+ title.setObjectName("titleLabel")
361
+ layout.addWidget(title)
362
+
363
+ subtitle = QLabel(
364
+ "Paste a link from YouTube, Instagram, Facebook, or almost "
365
+ "any other site. Each tab has its own URL box, so video and "
366
+ "audio downloads never interfere with each other."
367
+ )
368
+ subtitle.setWordWrap(True)
369
+ layout.addWidget(subtitle)
370
+
371
+ self.tabs = QTabWidget()
372
+ self.video_tab = DownloadTab(audio_only=False, ffmpeg_available=self.ffmpeg_available)
373
+ self.audio_tab = DownloadTab(audio_only=True, ffmpeg_available=self.ffmpeg_available)
374
+ self.tabs.addTab(self.video_tab, "🎬 Video (MP4)")
375
+ self.tabs.addTab(self.audio_tab, "🎵 Audio (MP3)")
376
+ layout.addWidget(self.tabs)
377
+
378
+ self.setCentralWidget(central)
379
+ self.setStatusBar(QStatusBar())
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamgrab
3
+ Version: 0.1.0
4
+ Summary: A desktop app for downloading video and audio from YouTube, Instagram, Facebook, and 1000+ other sites, with resolution selection and a live progress bar.
5
+ Author: StreamGrab contributors
6
+ License: MIT
7
+ Keywords: video downloader,yt-dlp,youtube,instagram,facebook,downloader
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: PyQt6>=6.6.0
16
+ Requires-Dist: yt-dlp>=2024.1.0
17
+ Requires-Dist: imageio-ffmpeg>=0.5.1
18
+
19
+ # StreamGrab
20
+
21
+ A desktop app for downloading video and audio from YouTube, Instagram,
22
+ Facebook, and 1000+ other sites supported by `yt-dlp` — with two fully
23
+ independent tabs (Video/MP4 and Audio/MP3), each with its own URL box,
24
+ so a paste in one never conflicts with the other, plus a live progress
25
+ bar and automatic playlist/mix safety so a single-video link never
26
+ accidentally triggers a slow whole-playlist fetch.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install -e .
32
+ ```
33
+
34
+ **ffmpeg is installed automatically** — no separate download or PATH
35
+ setup required. This package depends on `imageio-ffmpeg`, which ships
36
+ a real ffmpeg binary for Windows/Mac/Linux and installs it as a normal
37
+ part of `pip install`. If a system-wide ffmpeg is also present on your
38
+ PATH, that one is used instead (so your own ffmpeg install, if you
39
+ have one, still takes priority); the bundled copy is only a fallback.
40
+
41
+ ## Run
42
+
43
+ ```bash
44
+ streamgrab
45
+ ```
46
+
47
+ Two independent tabs:
48
+ - **🎬 Video (MP4)** — its own URL box, Fetch button, and resolution list.
49
+ - **🎵 Audio (MP3)** — its own URL box, Fetch button, and quality list.
50
+
51
+ Paste a URL into whichever tab matches what you want, click **Fetch**,
52
+ pick a quality, choose a save folder, and click **Download**. The two
53
+ tabs never share state, so you can look up a video in one tab without
54
+ affecting anything you've already fetched in the other.
55
+
56
+ ## Important: legal use
57
+
58
+ `yt-dlp` (the engine this app is built on) is a legitimate, widely used
59
+ open-source tool. However, many platforms' Terms of Service restrict or
60
+ prohibit downloading video content except through their own official
61
+ tools. This app does not bypass any DRM or paywalled content — it only
62
+ works on publicly accessible media the same way `yt-dlp` itself does.
63
+ You are responsible for using this tool in accordance with the terms
64
+ of service of whatever site you use it on, and applicable copyright law
65
+ in your country. This tool is intended for personal use cases such as
66
+ archiving your own content or content you have the right to download.
67
+
68
+ ## What has actually been tested
69
+
70
+ - Filename sanitization and format-label/sorting logic: unit tested.
71
+ - **ffmpeg resolution logic**: directly tested for all three real
72
+ scenarios — system ffmpeg present (preferred), system ffmpeg absent
73
+ but the bundled `imageio-ffmpeg` binary available (used as fallback),
74
+ and neither available (raises a clear error rather than a raw crash).
75
+ - **The playlist/mix hang that caused a real reported issue**: fixed
76
+ with `noplaylist` + `playlist_items='1'`, plus a normalization step
77
+ that resolves to a single video's info even if a site still returns
78
+ a playlist-shaped response. All of this is unit tested directly
79
+ against realistic response shapes (single video, playlist-shaped
80
+ with entries, empty playlist).
81
+ - **A hard 45-second timeout** wraps every metadata fetch, independent
82
+ of yt-dlp's own network timeout, so a stuck extractor can no longer
83
+ leave the "Fetching..." UI state stuck indefinitely — verified by
84
+ forcing an artificial hang and confirming it times out with a clear
85
+ message instead of hanging forever.
86
+ - The GUI: verified to build, show, and wire up all controls correctly
87
+ — including confirming the two tabs' URL inputs are genuinely
88
+ separate objects with no shared state, and that a missing-ffmpeg
89
+ scenario disables only the Audio tab, leaving Video unaffected.
90
+ - **The live download against a real video has NOT been verified in the
91
+ environment this was built in**, because that sandbox blocks outbound
92
+ network access to video platforms by policy. The `yt-dlp` calls
93
+ themselves follow yt-dlp's standard, documented API usage, and the
94
+ fixes above address the specific real failures reported so far, but
95
+ you should still do a real test on your own machine — paste a URL in
96
+ each tab, fetch, and download — before relying on this for anything
97
+ important.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ streamgrab/__init__.py
4
+ streamgrab/app.py
5
+ streamgrab.egg-info/PKG-INFO
6
+ streamgrab.egg-info/SOURCES.txt
7
+ streamgrab.egg-info/dependency_links.txt
8
+ streamgrab.egg-info/entry_points.txt
9
+ streamgrab.egg-info/requires.txt
10
+ streamgrab.egg-info/top_level.txt
11
+ streamgrab/core/__init__.py
12
+ streamgrab/core/downloader.py
13
+ streamgrab/resources/logo.png
14
+ streamgrab/ui/__init__.py
15
+ streamgrab/ui/main_window.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ streamgrab = streamgrab.app:main
@@ -0,0 +1,3 @@
1
+ PyQt6>=6.6.0
2
+ yt-dlp>=2024.1.0
3
+ imageio-ffmpeg>=0.5.1
@@ -0,0 +1 @@
1
+ streamgrab