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.
- streamgrab-0.1.0/PKG-INFO +101 -0
- streamgrab-0.1.0/README.md +83 -0
- streamgrab-0.1.0/pyproject.toml +36 -0
- streamgrab-0.1.0/setup.cfg +4 -0
- streamgrab-0.1.0/streamgrab/__init__.py +0 -0
- streamgrab-0.1.0/streamgrab/app.py +38 -0
- streamgrab-0.1.0/streamgrab/core/__init__.py +0 -0
- streamgrab-0.1.0/streamgrab/core/downloader.py +380 -0
- streamgrab-0.1.0/streamgrab/resources/logo.png +0 -0
- streamgrab-0.1.0/streamgrab/ui/__init__.py +0 -0
- streamgrab-0.1.0/streamgrab/ui/main_window.py +379 -0
- streamgrab-0.1.0/streamgrab.egg-info/PKG-INFO +101 -0
- streamgrab-0.1.0/streamgrab.egg-info/SOURCES.txt +15 -0
- streamgrab-0.1.0/streamgrab.egg-info/dependency_links.txt +1 -0
- streamgrab-0.1.0/streamgrab.egg-info/entry_points.txt +2 -0
- streamgrab-0.1.0/streamgrab.egg-info/requires.txt +3 -0
- streamgrab-0.1.0/streamgrab.egg-info/top_level.txt +1 -0
|
@@ -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"]
|
|
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
|
|
Binary file
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
streamgrab
|