udown 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- udown-0.0.2.dist-info/METADATA +234 -0
- udown-0.0.2.dist-info/RECORD +11 -0
- udown-0.0.2.dist-info/WHEEL +5 -0
- udown-0.0.2.dist-info/entry_points.txt +2 -0
- udown-0.0.2.dist-info/licenses/LICENSE +21 -0
- udown-0.0.2.dist-info/top_level.txt +1 -0
- ytdl/__init__.py +11 -0
- ytdl/cli.py +98 -0
- ytdl/downloader.py +126 -0
- ytdl/profiler.py +56 -0
- ytdl/utils.py +46 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: udown
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A fast YouTube video downloader with parallel processing
|
|
5
|
+
Author-email: Dipanjal Maitra <dipanjalmaitra@gmail.com>
|
|
6
|
+
Maintainer-email: Dipanjal Maitra <dipanjalmaitra@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/dipanjal/udown
|
|
9
|
+
Project-URL: Documentation, https://github.com/dipanjal/udown#readme
|
|
10
|
+
Project-URL: Repository, https://github.com/dipanjal/udown
|
|
11
|
+
Project-URL: Bug Tracker, https://github.com/dipanjal/udown/issues
|
|
12
|
+
Keywords: youtube,download,video,audio,parallel,processing
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Multimedia :: Video
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: pytubefix==9.4.1
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest~=7.1.2; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov~=4.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: black~=24.3.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pylint~=3.1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy~=1.9.0; extra == "dev"
|
|
33
|
+
Requires-Dist: build~=1.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: twine~=6.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: isort~=5.0.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# UDown
|
|
39
|
+
|
|
40
|
+
A fast YouTube video downloader with parallel processing capabilities. Downloads audio and video streams simultaneously for optimal performance.
|
|
41
|
+
|
|
42
|
+
[](https://codecov.io/gh/dipanjal/udown)
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- ⚡ **Parallel Processing**: Downloads audio and video streams simultaneously
|
|
47
|
+
- 📊 **Performance Profiling**: Detailed timing reports for download operations
|
|
48
|
+
- 🎯 **High Quality**: Downloads best available audio and video streams
|
|
49
|
+
- 📝 **Caption Support**: Optional subtitle/caption downloads
|
|
50
|
+
- 🔧 **FFmpeg Integration**: Automatic audio-video merging
|
|
51
|
+
- 🖥️ **Cross-Platform**: Works on Windows, macOS, and Linux
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### From PyPI (Recommended)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install udown
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### From Source
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/dipanjal/udown.git
|
|
65
|
+
cd udown
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Prerequisites
|
|
70
|
+
|
|
71
|
+
- **Python 3.8+**
|
|
72
|
+
- **FFmpeg**: Required for audio-video merging
|
|
73
|
+
|
|
74
|
+
### Installing FFmpeg
|
|
75
|
+
|
|
76
|
+
#### macOS
|
|
77
|
+
```bash
|
|
78
|
+
brew install ffmpeg
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
#### Ubuntu/Debian
|
|
82
|
+
```bash
|
|
83
|
+
sudo apt update
|
|
84
|
+
sudo apt install ffmpeg
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
#### Windows
|
|
88
|
+
Download from [FFmpeg official website](https://ffmpeg.org/download.html) or install via Chocolatey:
|
|
89
|
+
```bash
|
|
90
|
+
choco install ffmpeg
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
### Basic Usage
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
udown -o ~/Downloads [youtube_url]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Advanced Usage
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Download to custom directory
|
|
105
|
+
udown -o ~/Downloads [youtube_url]
|
|
106
|
+
|
|
107
|
+
# Download with captions
|
|
108
|
+
udown -c -o ~/Downloads [youtube_url]
|
|
109
|
+
|
|
110
|
+
# Enable debug mode with detailed timing
|
|
111
|
+
udown -d -o ~/Downloads [youtube_url]
|
|
112
|
+
|
|
113
|
+
# Combine options
|
|
114
|
+
udown -o ~/Videos -c -d [youtube_url]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Command Line Options
|
|
118
|
+
|
|
119
|
+
| Option | Description |
|
|
120
|
+
|--------|-------------|
|
|
121
|
+
| `-o, --output` | Output directory (default: `./downloads`) |
|
|
122
|
+
| `-c, --caption` | Download captions/subtitles if available |
|
|
123
|
+
| `-d, --debug` | Enable debug mode with detailed timing |
|
|
124
|
+
| `--version` | Show version information |
|
|
125
|
+
| `-h, --help` | Show help message |
|
|
126
|
+
|
|
127
|
+
### Examples
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Download a video
|
|
131
|
+
udown -o ~/Downloads https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
132
|
+
|
|
133
|
+
# Download with captions to Downloads folder
|
|
134
|
+
udown -c -o ~/Downloads https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
135
|
+
|
|
136
|
+
# Debug mode to see performance metrics
|
|
137
|
+
udown -d -o ~/Downloads https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Performance
|
|
141
|
+
|
|
142
|
+
The parallel processing approach provides significant time savings:
|
|
143
|
+
|
|
144
|
+
- **Sequential Download**: Audio + Video + Merge = Total Time
|
|
145
|
+
- **Parallel Download**: Max(Audio, Video) + Merge = Reduced Time
|
|
146
|
+
|
|
147
|
+
### Sample Output
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Downloading: Example Video Title
|
|
151
|
+
Downloading Audio File
|
|
152
|
+
Downloading Video File
|
|
153
|
+
Video downloaded successfully to: ./downloads/Example Video Title.mp4
|
|
154
|
+
|
|
155
|
+
==================================================
|
|
156
|
+
DOWNLOAD PERFORMANCE REPORT
|
|
157
|
+
==================================================
|
|
158
|
+
Audio Took: 12.34 seconds
|
|
159
|
+
Video Took: 15.67 seconds
|
|
160
|
+
Merging Took: 2.45 seconds
|
|
161
|
+
Expected Duration: 30.46 seconds
|
|
162
|
+
Total Process Time: 18.23 seconds
|
|
163
|
+
==================================================
|
|
164
|
+
Time Saved: 12.23 seconds
|
|
165
|
+
==================================================
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
### Setup Development Environment
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
git clone https://github.com/dipanjal/udown.git
|
|
174
|
+
cd udown
|
|
175
|
+
make install
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Running Tests
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pytest
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Code Formatting
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
black ytdl/
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Architecture
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
udown/
|
|
194
|
+
├── ytdl/
|
|
195
|
+
│ ├── __init__.py # Package initialization
|
|
196
|
+
│ ├── cli.py # Command-line interface
|
|
197
|
+
│ ├── downloader.py # Core download logic
|
|
198
|
+
│ ├── profiler.py # Performance timing
|
|
199
|
+
│ └── utils.py # Utility functions
|
|
200
|
+
├── setup.py # Package setup
|
|
201
|
+
├── pyproject.toml # Modern packaging config
|
|
202
|
+
└── README.md # This file
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## How It Works
|
|
206
|
+
|
|
207
|
+
1. **URL Validation**: Validates YouTube URL format
|
|
208
|
+
2. **Stream Analysis**: Identifies best audio and video streams
|
|
209
|
+
3. **Parallel Downloads**: Downloads audio and video simultaneously
|
|
210
|
+
4. **FFmpeg Merge**: Combines streams into final video file
|
|
211
|
+
5. **Performance Profiling**: Tracks timing for optimization
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
1. Fork the repository
|
|
216
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
217
|
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
218
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
219
|
+
5. Open a Pull Request
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
224
|
+
|
|
225
|
+
## Acknowledgments
|
|
226
|
+
|
|
227
|
+
- [pytubefix](https://github.com/pytubefix/pytubefix) - YouTube data extraction
|
|
228
|
+
- [FFmpeg](https://ffmpeg.org/) - Audio/video processing
|
|
229
|
+
|
|
230
|
+
## Support
|
|
231
|
+
|
|
232
|
+
- 📧 **Email**: dipanjalmaitra@gmail.com
|
|
233
|
+
- 🐛 **Issues**: [GitHub Issues](https://github.com/dipanjalmaitra/udown/issues)
|
|
234
|
+
- 📖 **Documentation**: [GitHub Wiki](https://github.com/dipanjalmaitra/udown/wiki)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
udown-0.0.2.dist-info/licenses/LICENSE,sha256=BNnWV5PsQHnuMltsEKuIuFsAHkR_ZUZWH3QAWVdenh0,1072
|
|
2
|
+
ytdl/__init__.py,sha256=GGAGfModazmRoKb-I2Bauc5oY2FqjN0mrtR0m3_zsQw,297
|
|
3
|
+
ytdl/cli.py,sha256=ExdVy-BVhu_VZKm3L2cAQn6oHFxJrTlhZZ0mJDZ2J0I,2864
|
|
4
|
+
ytdl/downloader.py,sha256=ebZpMMqHraruKAT2LFBzdkMJ0HpBx6s9JvqnF6_T4PQ,4607
|
|
5
|
+
ytdl/profiler.py,sha256=qekkm6SFob4e26pxpsG4SmLNqs7thTQNGRpfeK87IH4,2081
|
|
6
|
+
ytdl/utils.py,sha256=-2Y_89n_RhTWfACAVR-_Dtyjpdq-5Px5J5xii-rPGt4,1188
|
|
7
|
+
udown-0.0.2.dist-info/METADATA,sha256=yh7e48w7gFO2YWzuWVTynTd6DjVLpz5A5zG9O4zbKZY,6442
|
|
8
|
+
udown-0.0.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
+
udown-0.0.2.dist-info/entry_points.txt,sha256=JogR0ArrJoCDi1JC-U1SzjWmVX-8cAqHKfrRBfNxiAU,40
|
|
10
|
+
udown-0.0.2.dist-info/top_level.txt,sha256=S8I8RsIAG6qsXrOdAVauYxr2PGVaG-k_0tAnY6gEEVA,5
|
|
11
|
+
udown-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dipanjal Maitra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ytdl
|
ytdl/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTube Downloader with Parallel Processing
|
|
3
|
+
A fast YouTube video downloader that downloads audio and video streams in parallel.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .downloader import Downloader
|
|
7
|
+
from .profiler import Profiler
|
|
8
|
+
from .utils import Utils
|
|
9
|
+
|
|
10
|
+
__version__ = "1.0.0"
|
|
11
|
+
__all__ = ["Downloader", "Profiler", "Utils"]
|
ytdl/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTube Downloader CLI
|
|
3
|
+
A command-line interface for downloading YouTube videos with parallel processing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ytdl.downloader import Downloader
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Create and configure the argument parser."""
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description="Download YouTube videos with parallel processing",
|
|
17
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
18
|
+
epilog="""
|
|
19
|
+
Examples:
|
|
20
|
+
%(prog)s https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
21
|
+
%(prog)s -o /path/to/downloads https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
22
|
+
%(prog)s -c -d https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
23
|
+
""",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("url", help="YouTube video URL to download")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-o",
|
|
28
|
+
"--output",
|
|
29
|
+
dest="output_dir",
|
|
30
|
+
help="Output directory for downloaded files (default: ./downloads)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-c",
|
|
34
|
+
"--caption",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Download captions/subtitles if available",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"-d",
|
|
40
|
+
"--debug",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Enable debug mode with detailed timing information",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_url(url: str) -> bool:
|
|
49
|
+
"""Validate if the URL is a valid YouTube URL."""
|
|
50
|
+
youtube_domains = ["youtube.com", "www.youtube.com", "youtu.be", "m.youtube.com"]
|
|
51
|
+
|
|
52
|
+
url_lower = url.lower()
|
|
53
|
+
return any(domain in url_lower for domain in youtube_domains)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
"""Main CLI entry point."""
|
|
58
|
+
parser = create_parser()
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
# Validate URL
|
|
62
|
+
if not validate_url(args.url):
|
|
63
|
+
print("Error: Please provide a valid YouTube URL")
|
|
64
|
+
print("Supported formats:")
|
|
65
|
+
print(" - https://www.youtube.com/watch?v=VIDEO_ID")
|
|
66
|
+
print(" - https://youtu.be/VIDEO_ID")
|
|
67
|
+
print(" - https://m.youtube.com/watch?v=VIDEO_ID")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
# Set output directory
|
|
71
|
+
output_dir = args.output_dir if args.output_dir else "./downloads"
|
|
72
|
+
|
|
73
|
+
# Create output directory if it doesn't exist
|
|
74
|
+
output_path = Path(output_dir)
|
|
75
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# Initialize downloader
|
|
79
|
+
downloader = Downloader(
|
|
80
|
+
url=args.url,
|
|
81
|
+
out_dir=str(output_path),
|
|
82
|
+
caption=args.caption,
|
|
83
|
+
debug=args.debug,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Start download
|
|
87
|
+
downloader.start()
|
|
88
|
+
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
print("\nDownload cancelled by user")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Error: {str(e)}")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
ytdl/downloader.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pytubefix import YouTube # type: ignore
|
|
6
|
+
from pytubefix.cli import on_progress # type: ignore
|
|
7
|
+
|
|
8
|
+
from ytdl.profiler import Profiler
|
|
9
|
+
from ytdl.utils import Utils
|
|
10
|
+
|
|
11
|
+
ROOT_DIR = Path(__file__).parent.parent.resolve()
|
|
12
|
+
DOWNLOAD_DIR = ROOT_DIR / "downloads"
|
|
13
|
+
TEMP_DIR = ROOT_DIR / "temp"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Downloader:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
url: str,
|
|
20
|
+
out_dir: str = str(DOWNLOAD_DIR),
|
|
21
|
+
caption: bool = False,
|
|
22
|
+
debug: bool = False,
|
|
23
|
+
):
|
|
24
|
+
self.url = url
|
|
25
|
+
self.out_dir = out_dir
|
|
26
|
+
self.caption = caption
|
|
27
|
+
self.debug = debug
|
|
28
|
+
self._pre_config()
|
|
29
|
+
|
|
30
|
+
def _pre_config(self) -> None:
|
|
31
|
+
self.yt = YouTube(self.url, on_progress_callback=on_progress)
|
|
32
|
+
self.title = Utils.sanitize_filename(self.yt.title)
|
|
33
|
+
self.profiler = Profiler(self.debug)
|
|
34
|
+
|
|
35
|
+
# Setting up temp files
|
|
36
|
+
self.temp_video_file: Path = TEMP_DIR / "temp.mp4"
|
|
37
|
+
self.temp_audio_file: Path = TEMP_DIR / "temp.m4a"
|
|
38
|
+
|
|
39
|
+
# Create out directory if not exists
|
|
40
|
+
TEMP_DIR.mkdir(exist_ok=True)
|
|
41
|
+
DOWNLOAD_DIR.mkdir(exist_ok=True)
|
|
42
|
+
if self.out_dir != str(DOWNLOAD_DIR):
|
|
43
|
+
Path(self.out_dir).mkdir(exist_ok=True)
|
|
44
|
+
|
|
45
|
+
def _cleanup_temps(self) -> None:
|
|
46
|
+
# Clean up temporary files
|
|
47
|
+
Utils.delete_file(self.temp_video_file)
|
|
48
|
+
Utils.delete_file(self.temp_audio_file)
|
|
49
|
+
|
|
50
|
+
def _merge_with_ffmpeg(self) -> str:
|
|
51
|
+
self.profiler.start_timer("merging")
|
|
52
|
+
out_file_path: str = os.path.join(self.out_dir, f"{self.title}.mp4")
|
|
53
|
+
Utils.merge_with_ffmpeg(
|
|
54
|
+
video_file=str(self.temp_video_file),
|
|
55
|
+
audio_file=str(self.temp_audio_file),
|
|
56
|
+
out_file=out_file_path,
|
|
57
|
+
)
|
|
58
|
+
self.profiler.end_timer("merging")
|
|
59
|
+
return out_file_path
|
|
60
|
+
|
|
61
|
+
def _download_audio_file(self, file_path: Path) -> None:
|
|
62
|
+
"""Download the best audio stream."""
|
|
63
|
+
print("Downloading Audio File")
|
|
64
|
+
self.profiler.start_timer("audio")
|
|
65
|
+
self.yt.streams.filter(only_audio=True).order_by("abr").desc().first().download(
|
|
66
|
+
output_path=str(file_path.parent), filename=file_path.name
|
|
67
|
+
)
|
|
68
|
+
self.profiler.end_timer("audio")
|
|
69
|
+
|
|
70
|
+
def _download_video_file(self, file_path: Path) -> None:
|
|
71
|
+
"""Download the best video stream."""
|
|
72
|
+
print("Downloading Video File")
|
|
73
|
+
self.profiler.start_timer("video")
|
|
74
|
+
self.yt.streams.filter(only_video=True, file_extension="mp4").order_by("resolution").desc().first().download(
|
|
75
|
+
output_path=str(file_path.parent), filename=file_path.name
|
|
76
|
+
)
|
|
77
|
+
self.profiler.end_timer("video")
|
|
78
|
+
|
|
79
|
+
def _download_caption_file(self) -> None:
|
|
80
|
+
caption = self.yt.captions.get("a.en", None)
|
|
81
|
+
if not caption:
|
|
82
|
+
print("No caption found")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
out_file_path: str = os.path.join(self.out_dir, f"{self.title}.srt")
|
|
86
|
+
try:
|
|
87
|
+
self.profiler.start_timer("caption")
|
|
88
|
+
print("Downloading Caption File")
|
|
89
|
+
caption.save_captions(out_file_path)
|
|
90
|
+
self.profiler.end_timer("caption")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print("Unable to download caption file: ", str(e))
|
|
93
|
+
Utils.delete_file(out_file_path)
|
|
94
|
+
|
|
95
|
+
def _download(self) -> None:
|
|
96
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
97
|
+
# Submit video and audio download tasks
|
|
98
|
+
future_video = executor.submit(self._download_video_file, self.temp_video_file)
|
|
99
|
+
future_audio = executor.submit(self._download_audio_file, self.temp_audio_file)
|
|
100
|
+
|
|
101
|
+
# Submit caption download task if caption is requested
|
|
102
|
+
futures = [future_video, future_audio]
|
|
103
|
+
if self.caption:
|
|
104
|
+
future_caption = executor.submit(self._download_caption_file)
|
|
105
|
+
futures.append(future_caption)
|
|
106
|
+
|
|
107
|
+
# Wait for all downloads to complete
|
|
108
|
+
concurrent.futures.wait(futures)
|
|
109
|
+
|
|
110
|
+
def start(self) -> None:
|
|
111
|
+
"""Download video and audio, then merge them with FFmpeg."""
|
|
112
|
+
print(f"Downloading: {self.title}")
|
|
113
|
+
self.profiler.start_overall_timer()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
self._cleanup_temps()
|
|
117
|
+
self._download()
|
|
118
|
+
out_file_path: str = self._merge_with_ffmpeg()
|
|
119
|
+
print(f"Video downloaded successfully to: {out_file_path}")
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"Video downloaded failed due to unknown error: {str(e)}")
|
|
122
|
+
finally:
|
|
123
|
+
self._cleanup_temps()
|
|
124
|
+
|
|
125
|
+
self.profiler.end_overall_timer()
|
|
126
|
+
self.profiler.print_report()
|
ytdl/profiler.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Profiler:
|
|
6
|
+
def __init__(self, debug: bool = False):
|
|
7
|
+
self.timings: dict[str, dict[str, float]] = {}
|
|
8
|
+
self.start_time: Optional[float] = None
|
|
9
|
+
self.debug = debug
|
|
10
|
+
|
|
11
|
+
def start_timer(self, name: str) -> None:
|
|
12
|
+
"""Start timing a specific operation."""
|
|
13
|
+
self.timings[name] = {"start": time.time()}
|
|
14
|
+
|
|
15
|
+
def end_timer(self, name: str) -> None:
|
|
16
|
+
"""End timing a specific operation and calculate duration."""
|
|
17
|
+
if name in self.timings:
|
|
18
|
+
self.timings[name]["end"] = time.time()
|
|
19
|
+
self.timings[name]["duration"] = self.timings[name]["end"] - self.timings[name]["start"]
|
|
20
|
+
|
|
21
|
+
def start_overall_timer(self) -> None:
|
|
22
|
+
"""Start the overall process timer."""
|
|
23
|
+
self.start_time = time.time()
|
|
24
|
+
|
|
25
|
+
def end_overall_timer(self) -> None:
|
|
26
|
+
"""End the overall process timer and calculate total duration."""
|
|
27
|
+
if self.start_time:
|
|
28
|
+
total_duration = time.time() - self.start_time
|
|
29
|
+
self.timings["total"] = {"duration": total_duration}
|
|
30
|
+
|
|
31
|
+
def print_report(self) -> None:
|
|
32
|
+
"""Print a formatted timing report."""
|
|
33
|
+
sum_of_duration: float = 0.0
|
|
34
|
+
if self.debug:
|
|
35
|
+
print("\n" + "=" * 50)
|
|
36
|
+
print("DOWNLOAD PERFORMANCE REPORT")
|
|
37
|
+
print("=" * 50)
|
|
38
|
+
|
|
39
|
+
for name, timing in self.timings.items():
|
|
40
|
+
if name != "total":
|
|
41
|
+
duration = timing.get("duration", 0)
|
|
42
|
+
sum_of_duration += duration
|
|
43
|
+
print(f"{name.capitalize()} Took: {duration:.2f} seconds")
|
|
44
|
+
|
|
45
|
+
print("=" * 50)
|
|
46
|
+
|
|
47
|
+
if "total" in self.timings:
|
|
48
|
+
if self.debug:
|
|
49
|
+
print(f"Expected Duration: {sum_of_duration:.2f} seconds")
|
|
50
|
+
|
|
51
|
+
actual_total_duration = self.timings["total"]["duration"]
|
|
52
|
+
print(f"Total Process Time: {actual_total_duration:.2f} seconds")
|
|
53
|
+
|
|
54
|
+
if self.debug:
|
|
55
|
+
print("=" * 50)
|
|
56
|
+
print(f"Time Saved: {(sum_of_duration - actual_total_duration):.2f} seconds")
|
ytdl/utils.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Utils:
|
|
9
|
+
@staticmethod
|
|
10
|
+
def sanitize_filename(name: str) -> str:
|
|
11
|
+
"""Sanitize a string to be a valid filename."""
|
|
12
|
+
return re.sub(r'[\\/*?:"<>|]', "", name)
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def delete_file(file_path: Union[Path, str]) -> None:
|
|
16
|
+
if isinstance(file_path, str):
|
|
17
|
+
file_path = Path(file_path)
|
|
18
|
+
|
|
19
|
+
if file_path.exists():
|
|
20
|
+
os.remove(file_path)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def merge_with_ffmpeg(video_file: str, audio_file: str, out_file: str, debug: bool = False) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Merge video and audio using ffmpeg via subprocess.
|
|
26
|
+
"""
|
|
27
|
+
cmd = [
|
|
28
|
+
"ffmpeg",
|
|
29
|
+
"-y", # Overwrite output file if it exists
|
|
30
|
+
"-i",
|
|
31
|
+
video_file,
|
|
32
|
+
"-i",
|
|
33
|
+
audio_file,
|
|
34
|
+
"-c:v",
|
|
35
|
+
"copy",
|
|
36
|
+
"-c:a",
|
|
37
|
+
"aac",
|
|
38
|
+
"-map",
|
|
39
|
+
"0:v:0",
|
|
40
|
+
"-map",
|
|
41
|
+
"1:a:0",
|
|
42
|
+
out_file,
|
|
43
|
+
]
|
|
44
|
+
if debug:
|
|
45
|
+
print("Running ffmpeg command:", " ".join(cmd))
|
|
46
|
+
subprocess.run(cmd, check=True)
|