idm-cli 0.1.0__tar.gz → 0.2.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.
- {idm_cli-0.1.0 → idm_cli-0.2.0}/PKG-INFO +2 -1
- {idm_cli-0.1.0 → idm_cli-0.2.0}/README.md +27 -14
- {idm_cli-0.1.0 → idm_cli-0.2.0}/idm_cli.egg-info/PKG-INFO +2 -1
- {idm_cli-0.1.0 → idm_cli-0.2.0}/idm_cli.egg-info/requires.txt +1 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/setup.py +2 -1
- idm_cli-0.2.0/speedy_dl/__init__.py +2 -0
- idm_cli-0.2.0/speedy_dl/cli.py +424 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/speedy_dl/downloader.py +55 -16
- idm_cli-0.2.0/speedy_dl/extractor.py +193 -0
- idm_cli-0.1.0/speedy_dl/__init__.py +0 -2
- idm_cli-0.1.0/speedy_dl/cli.py +0 -199
- idm_cli-0.1.0/speedy_dl/extractor.py +0 -88
- {idm_cli-0.1.0 → idm_cli-0.2.0}/idm_cli.egg-info/SOURCES.txt +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/idm_cli.egg-info/dependency_links.txt +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/idm_cli.egg-info/entry_points.txt +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/idm_cli.egg-info/top_level.txt +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/setup.cfg +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/speedy_dl/state.py +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/speedy_dl/ui.py +0 -0
- {idm_cli-0.1.0 → idm_cli-0.2.0}/speedy_dl/updater.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: idm-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Requires-Dist: typer[all]>=0.9.0
|
|
5
5
|
Requires-Dist: httpx>=0.24.0
|
|
6
6
|
Requires-Dist: rich>=13.0.0
|
|
7
7
|
Requires-Dist: yt-dlp>=2023.0.0
|
|
8
|
+
Requires-Dist: imageio-ffmpeg>=0.4.0
|
|
8
9
|
Dynamic: requires-dist
|
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
|
|
9
9
|
1. **🚀 Segmented / Concurrent Downloads**: Splitting the file into up to 32 parallel streams (default 8) to download at the absolute maximum speed your bandwidth allows.
|
|
10
10
|
2. **💬 Interactive Shell Mode (REPL)**: Run the simple command `idm` anywhere, and it opens a persistent, visual download shell prompt where you can copy/paste links sequentially.
|
|
11
|
-
3.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
3. **🎭 Dynamic Quality Selection & Automated Merging (1080p, 4K)**:
|
|
12
|
+
- When downloading YouTube/streaming videos, you are prompted with a beautiful visual menu showing all available resolutions (144p, 360p, 720p, 1080p, 2K, 4K).
|
|
13
|
+
- High resolutions are downloaded concurrently (video + audio separately) and automatically merged in less than **1 second** (using copy remuxing)!
|
|
14
|
+
4. **🔌 Zero-Setup Plug & Play Concurrency**:
|
|
15
|
+
- Historically, merging high-resolution videos required the user to manually install `ffmpeg` and configure system paths.
|
|
16
|
+
- **IDM-CLI solves this!** We bundle **`imageio-ffmpeg`** which automatically downloads the correct static, lightweight FFmpeg binary for your specific OS (Windows, macOS, Linux) in the background during installation. Enjoy 1080p+ downloads out-of-the-box with **zero manual configuration**!
|
|
17
|
+
5. **⏯️ Robust Pause & Resume**: Supports pausing a download (using `Ctrl+C`) and resuming it exactly where it was left off. It automatically refreshes expired YouTube temporary signatures in the background on resume!
|
|
18
|
+
6. **🔔 Auto-Update Notification**: Clean, latency-free updates checks based on a 24-hour cache. Informs you when a new global release is available on PyPI.
|
|
19
|
+
7. **🔒 Atomic State Management**: To prevent corruption in case of unexpected crashes or power outages, states are written using an atomic two-step file operation (`.tmp` write followed by safe replacement).
|
|
20
|
+
8. **💾 RAM-Efficient Chunk Merging**: Uses $O(1)$ constant-memory buffered streaming (1MB blocks) to merge segments. Safely handles gigantic files (e.g., 10GB+) without lagging or crashing your system's RAM.
|
|
21
|
+
9. **🎨 Beautiful Terminal UI**: Multi-progress bar dashboards built with `rich`, showing connection progress, total speed, ETAs, percentages, and clean summary tables.
|
|
16
22
|
|
|
17
23
|
---
|
|
18
24
|
|
|
@@ -36,6 +42,7 @@ To install the tool in editable development mode:
|
|
|
36
42
|
```powershell
|
|
37
43
|
pip install -e .
|
|
38
44
|
```
|
|
45
|
+
*(Pip will automatically download the lightweight static FFmpeg binary for your OS in the background. Zero manual installation required!)*
|
|
39
46
|
|
|
40
47
|
---
|
|
41
48
|
|
|
@@ -50,15 +57,15 @@ idm
|
|
|
50
57
|
```
|
|
51
58
|
This opens the persistent prompt:
|
|
52
59
|
```text
|
|
53
|
-
|
|
54
|
-
|
|
|
55
|
-
| IDM-CLI Interactive Shell Mode
|
|
56
|
-
|
|
|
57
|
-
| • Paste any HTTP/HTTPS URL or YouTube URL to download instantly.
|
|
58
|
-
| • Type resume <metadata_file> to resume a paused download.
|
|
59
|
-
| • Type exit or press Ctrl+C to quit the application.
|
|
60
|
-
|
|
|
61
|
-
|
|
60
|
+
+-------------------------------- idm shell --------------------------------+
|
|
61
|
+
| |
|
|
62
|
+
| IDM-CLI Interactive Shell Mode |
|
|
63
|
+
| |
|
|
64
|
+
| • Paste any HTTP/HTTPS URL or YouTube URL to download instantly. |
|
|
65
|
+
| • Type resume <metadata_file> to resume a paused download. |
|
|
66
|
+
| • Type exit or press Ctrl+C to quit the application. |
|
|
67
|
+
| |
|
|
68
|
+
+---------------------------------------------------------------------------+
|
|
62
69
|
idm>
|
|
63
70
|
```
|
|
64
71
|
*Paste your links directly and hit Enter. Type `exit` or press `Ctrl+C` to quit.*
|
|
@@ -79,6 +86,12 @@ To use 16 threads and save it to a custom directory:
|
|
|
79
86
|
idm download https://example.com/largefile.zip -c 16 -o C:\path\to\downloads
|
|
80
87
|
```
|
|
81
88
|
|
|
89
|
+
#### Target a Specific Video Quality directly:
|
|
90
|
+
To download a YouTube video in 1080p directly:
|
|
91
|
+
```bash
|
|
92
|
+
idm download https://www.youtube.com/watch?v=... -q 1080
|
|
93
|
+
```
|
|
94
|
+
|
|
82
95
|
#### Resume a Download:
|
|
83
96
|
```bash
|
|
84
97
|
idm resume largefile.zip.dl.json
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: idm-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Requires-Dist: typer[all]>=0.9.0
|
|
5
5
|
Requires-Dist: httpx>=0.24.0
|
|
6
6
|
Requires-Dist: rich>=13.0.0
|
|
7
7
|
Requires-Dist: yt-dlp>=2023.0.0
|
|
8
|
+
Requires-Dist: imageio-ffmpeg>=0.4.0
|
|
8
9
|
Dynamic: requires-dist
|
|
@@ -2,13 +2,14 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="idm-cli",
|
|
5
|
-
version="0.
|
|
5
|
+
version="0.2.0",
|
|
6
6
|
packages=find_packages(),
|
|
7
7
|
install_requires=[
|
|
8
8
|
"typer[all]>=0.9.0",
|
|
9
9
|
"httpx>=0.24.0",
|
|
10
10
|
"rich>=13.0.0",
|
|
11
11
|
"yt-dlp>=2023.0.0",
|
|
12
|
+
"imageio-ffmpeg>=0.4.0",
|
|
12
13
|
],
|
|
13
14
|
entry_points={
|
|
14
15
|
"console_scripts": [
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import typer
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from speedy_dl.downloader import SpeedyDownloader
|
|
11
|
+
from speedy_dl.state import load_state
|
|
12
|
+
from speedy_dl.updater import check_for_updates
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="idm-cli",
|
|
16
|
+
help="IDM-CLI: A premium, blazing-fast multi-threaded CLI download manager (Internet Download Manager Clone) in Python.",
|
|
17
|
+
add_completion=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
# Configure basic logging to avoid spam but print crucial internal details if needed
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.ERROR,
|
|
25
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def run_async_downloader(downloader: SpeedyDownloader):
|
|
29
|
+
"""Runs the downloader asynchronously and handles Ctrl+C gracefully."""
|
|
30
|
+
loop = asyncio.new_event_loop()
|
|
31
|
+
asyncio.set_event_loop(loop)
|
|
32
|
+
|
|
33
|
+
download_task = loop.create_task(downloader.start_download())
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
loop.run_until_complete(download_task)
|
|
37
|
+
except KeyboardInterrupt:
|
|
38
|
+
# Cancel the task on Ctrl+C to trigger the downloader's clean pause block
|
|
39
|
+
download_task.cancel()
|
|
40
|
+
try:
|
|
41
|
+
loop.run_until_complete(download_task)
|
|
42
|
+
except asyncio.CancelledError:
|
|
43
|
+
pass
|
|
44
|
+
except Exception as e:
|
|
45
|
+
console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
finally:
|
|
48
|
+
loop.close()
|
|
49
|
+
|
|
50
|
+
import subprocess
|
|
51
|
+
from speedy_dl.extractor import is_streaming_url, is_ffmpeg_installed, extract_stream_info, get_ffmpeg_path
|
|
52
|
+
|
|
53
|
+
def merge_video_audio(video_path: str, audio_path: str, output_path: str) -> bool:
|
|
54
|
+
"""Invokes ffmpeg via subprocess to remux separate audio and video files without re-encoding."""
|
|
55
|
+
try:
|
|
56
|
+
ffmpeg_exe = get_ffmpeg_path()
|
|
57
|
+
cmd = [
|
|
58
|
+
ffmpeg_exe, "-y",
|
|
59
|
+
"-i", video_path,
|
|
60
|
+
"-i", audio_path,
|
|
61
|
+
"-c:v", "copy",
|
|
62
|
+
"-c:a", "aac",
|
|
63
|
+
"-map", "0:v:0",
|
|
64
|
+
"-map", "1:a:0",
|
|
65
|
+
output_path
|
|
66
|
+
]
|
|
67
|
+
# Run subprocess silently
|
|
68
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
69
|
+
return result.returncode == 0
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def prompt_for_quality(url: str) -> Optional[int]:
|
|
74
|
+
"""
|
|
75
|
+
Presents a beautiful menu of available resolutions for a streaming video
|
|
76
|
+
and returns the selected resolution height (e.g. 1080, 720, 360).
|
|
77
|
+
"""
|
|
78
|
+
console.print("[bold cyan]Fetching available video formats...[/bold cyan]")
|
|
79
|
+
info = extract_stream_info(url)
|
|
80
|
+
if not info:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
resolutions = info.get("resolutions", [])
|
|
84
|
+
if not resolutions:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
console.print("\n[bold green]Available Qualities:[/bold green]")
|
|
88
|
+
|
|
89
|
+
choices = {}
|
|
90
|
+
default_index = None
|
|
91
|
+
|
|
92
|
+
# We display them sorted descending (highest first)
|
|
93
|
+
sorted_res = sorted(list(resolutions), reverse=True)
|
|
94
|
+
|
|
95
|
+
for idx, res in enumerate(sorted_res, 1):
|
|
96
|
+
label = f"{res}p"
|
|
97
|
+
if res == 720:
|
|
98
|
+
label += " (Default)"
|
|
99
|
+
default_index = idx
|
|
100
|
+
elif res == 1080:
|
|
101
|
+
label += " (HD)"
|
|
102
|
+
elif res > 1080:
|
|
103
|
+
label += " (Ultra HD / 4K)"
|
|
104
|
+
|
|
105
|
+
console.print(f" [{idx}] {label}")
|
|
106
|
+
choices[str(idx)] = res
|
|
107
|
+
choices[f"{res}"] = res
|
|
108
|
+
choices[f"{res}p"] = res
|
|
109
|
+
|
|
110
|
+
# If 720p is not available, default index is the first one (highest quality)
|
|
111
|
+
if not default_index and resolutions:
|
|
112
|
+
default_res = sorted_res[0]
|
|
113
|
+
for idx, res in enumerate(sorted_res, 1):
|
|
114
|
+
if res == default_res:
|
|
115
|
+
default_index = idx
|
|
116
|
+
break
|
|
117
|
+
else:
|
|
118
|
+
default_res = 720
|
|
119
|
+
|
|
120
|
+
prompt_text = f"\nSelect quality index or resolution [bold yellow][default: {default_res}p][/bold yellow]: "
|
|
121
|
+
user_choice = console.input(prompt_text).strip()
|
|
122
|
+
|
|
123
|
+
if not user_choice:
|
|
124
|
+
return default_res
|
|
125
|
+
|
|
126
|
+
return choices.get(user_choice, choices.get(user_choice.lower(), default_res))
|
|
127
|
+
|
|
128
|
+
def download_streaming_video(url: str, output_path: Optional[str], connections: int, selected_res: Optional[int]):
|
|
129
|
+
"""Orchestrates streaming video downloads with full-resolution split merging and combined fallback options."""
|
|
130
|
+
# Check if user wants high-res but ffmpeg is missing
|
|
131
|
+
ffmpeg_available = is_ffmpeg_installed()
|
|
132
|
+
|
|
133
|
+
if selected_res and selected_res > 720 and not ffmpeg_available:
|
|
134
|
+
console.print(
|
|
135
|
+
f"\n[bold red]Warning: ffmpeg is not installed on this PC.[/bold red]\n"
|
|
136
|
+
f"[yellow]Resolutions higher than 720p require ffmpeg to merge video/audio streams.\n"
|
|
137
|
+
f"Falling back to the best available combined format...[/yellow]"
|
|
138
|
+
)
|
|
139
|
+
selected_res = None # Fallback to combined
|
|
140
|
+
|
|
141
|
+
info = extract_stream_info(url, selected_resolution=selected_res)
|
|
142
|
+
if not info:
|
|
143
|
+
console.print("[bold red]Error: Failed to extract streaming information.[/bold red]")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
is_split = info.get("is_split", False)
|
|
147
|
+
title = info.get("title")
|
|
148
|
+
filename = info.get("filename")
|
|
149
|
+
|
|
150
|
+
# Establish output path
|
|
151
|
+
downloads_dir = os.path.join(os.path.expanduser("~"), "Downloads")
|
|
152
|
+
if output_path:
|
|
153
|
+
if os.path.isdir(output_path):
|
|
154
|
+
final_filepath = os.path.join(output_path, filename)
|
|
155
|
+
else:
|
|
156
|
+
final_filepath = output_path
|
|
157
|
+
else:
|
|
158
|
+
if os.path.exists(downloads_dir) and os.path.isdir(downloads_dir):
|
|
159
|
+
final_filepath = os.path.join(downloads_dir, filename)
|
|
160
|
+
else:
|
|
161
|
+
final_filepath = os.path.abspath(filename)
|
|
162
|
+
|
|
163
|
+
if not is_split:
|
|
164
|
+
# Combined download
|
|
165
|
+
downloader = SpeedyDownloader(
|
|
166
|
+
url=info["stream_url"],
|
|
167
|
+
output_path=final_filepath,
|
|
168
|
+
connections=connections,
|
|
169
|
+
selected_resolution=selected_res,
|
|
170
|
+
stream_type="combined"
|
|
171
|
+
)
|
|
172
|
+
downloader.original_url = url
|
|
173
|
+
downloader.is_stream = True
|
|
174
|
+
run_async_downloader(downloader)
|
|
175
|
+
else:
|
|
176
|
+
# Separate Video + Audio download
|
|
177
|
+
console.print(Panel(
|
|
178
|
+
f"[bold yellow]Split Quality Mode Activated ({selected_res}p)[/bold yellow]\n"
|
|
179
|
+
f"Downloading separate Video and Audio streams concurrently...",
|
|
180
|
+
border_style="bold yellow"
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
video_temp = final_filepath + ".temp_video"
|
|
184
|
+
audio_temp = final_filepath + ".temp_audio"
|
|
185
|
+
|
|
186
|
+
# 1. Download Video
|
|
187
|
+
console.print("\n[bold cyan]1. Downloading Video Track...[/bold cyan]")
|
|
188
|
+
video_downloader = SpeedyDownloader(
|
|
189
|
+
url=info["video_url"],
|
|
190
|
+
output_path=video_temp,
|
|
191
|
+
connections=connections,
|
|
192
|
+
selected_resolution=selected_res,
|
|
193
|
+
stream_type="video"
|
|
194
|
+
)
|
|
195
|
+
video_downloader.original_url = url
|
|
196
|
+
video_downloader.is_stream = True
|
|
197
|
+
run_async_downloader(video_downloader)
|
|
198
|
+
|
|
199
|
+
# 2. Download Audio
|
|
200
|
+
console.print("\n[bold cyan]2. Downloading Audio Track...[/bold cyan]")
|
|
201
|
+
audio_downloader = SpeedyDownloader(
|
|
202
|
+
url=info["audio_url"],
|
|
203
|
+
output_path=audio_temp,
|
|
204
|
+
connections=min(connections, 4),
|
|
205
|
+
selected_resolution=selected_res,
|
|
206
|
+
stream_type="audio"
|
|
207
|
+
)
|
|
208
|
+
audio_downloader.original_url = url
|
|
209
|
+
audio_downloader.is_stream = True
|
|
210
|
+
run_async_downloader(audio_downloader)
|
|
211
|
+
|
|
212
|
+
# 3. Merge streams using FFmpeg
|
|
213
|
+
console.print("\n[bold green]3. Merging Video and Audio tracks into final MP4...[/bold green]")
|
|
214
|
+
success = merge_video_audio(video_temp, audio_temp, final_filepath)
|
|
215
|
+
if success:
|
|
216
|
+
# Clean up temp files
|
|
217
|
+
for p in (video_temp, audio_temp):
|
|
218
|
+
if os.path.exists(p):
|
|
219
|
+
try:
|
|
220
|
+
os.remove(p)
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
console.print(f"[bold gold1]* File successfully saved to: {final_filepath}[/bold gold1]")
|
|
224
|
+
else:
|
|
225
|
+
console.print(
|
|
226
|
+
f"[bold red]✖ Error: FFmpeg merging failed. Raw streams are saved at:[/bold red]\n"
|
|
227
|
+
f" - Video: {video_temp}\n"
|
|
228
|
+
f" - Audio: {audio_temp}\n"
|
|
229
|
+
f"Please merge them manually or verify your FFmpeg installation."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def download(
|
|
234
|
+
url: str = typer.Argument(..., help="The HTTP/HTTPS URL of the file to download."),
|
|
235
|
+
output_path: Optional[str] = typer.Option(
|
|
236
|
+
None, "--output-path", "-o",
|
|
237
|
+
help="Custom destination directory or full filename path."
|
|
238
|
+
),
|
|
239
|
+
connections: int = typer.Option(
|
|
240
|
+
8, "--connections", "-c", min=1, max=32,
|
|
241
|
+
help="Number of concurrent connection threads (1-32)."
|
|
242
|
+
),
|
|
243
|
+
quality: Optional[str] = typer.Option(
|
|
244
|
+
None, "--quality", "-q",
|
|
245
|
+
help="Target video quality for streaming links (e.g. 360, 720, 1080)."
|
|
246
|
+
)
|
|
247
|
+
):
|
|
248
|
+
"""
|
|
249
|
+
Downloads a file or video globally.
|
|
250
|
+
If it's a streaming link, prompts visually or uses quality parameter.
|
|
251
|
+
"""
|
|
252
|
+
if not url.startswith(("http://", "https://")):
|
|
253
|
+
console.print("[bold red]Error: Invalid URL protocol. Only http:// and https:// are supported.[/bold red]")
|
|
254
|
+
raise typer.Exit(code=1)
|
|
255
|
+
|
|
256
|
+
is_stream = is_streaming_url(url)
|
|
257
|
+
|
|
258
|
+
if is_stream:
|
|
259
|
+
# Resolve quality parameter or prompt
|
|
260
|
+
if quality:
|
|
261
|
+
try:
|
|
262
|
+
selected_res = int(quality.lower().replace("p", ""))
|
|
263
|
+
except ValueError:
|
|
264
|
+
selected_res = 720
|
|
265
|
+
else:
|
|
266
|
+
selected_res = prompt_for_quality(url)
|
|
267
|
+
|
|
268
|
+
download_streaming_video(url, output_path, connections, selected_res)
|
|
269
|
+
else:
|
|
270
|
+
# Standard file download
|
|
271
|
+
downloader = SpeedyDownloader(
|
|
272
|
+
url=url,
|
|
273
|
+
output_path=output_path,
|
|
274
|
+
connections=connections
|
|
275
|
+
)
|
|
276
|
+
run_async_downloader(downloader)
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def resume(
|
|
280
|
+
state_file: str = typer.Argument(
|
|
281
|
+
...,
|
|
282
|
+
help="Path to the '.dl.json' status file generated by a paused download."
|
|
283
|
+
)
|
|
284
|
+
):
|
|
285
|
+
"""
|
|
286
|
+
Resumes a paused download using the saved metadata file.
|
|
287
|
+
"""
|
|
288
|
+
if not os.path.exists(state_file):
|
|
289
|
+
console.print(f"[bold red]Error: The state file '{state_file}' does not exist.[/bold red]")
|
|
290
|
+
raise typer.Exit(code=1)
|
|
291
|
+
|
|
292
|
+
# Load state first to extract original URL
|
|
293
|
+
metadata = load_state(state_file)
|
|
294
|
+
if not metadata:
|
|
295
|
+
console.print(f"[bold red]Error: Could not parse or load state from '{state_file}'. It may be corrupted.[/bold red]")
|
|
296
|
+
raise typer.Exit(code=1)
|
|
297
|
+
|
|
298
|
+
url = metadata.get("url")
|
|
299
|
+
connections = metadata.get("connections", 8)
|
|
300
|
+
filepath = metadata.get("filepath")
|
|
301
|
+
|
|
302
|
+
if not url:
|
|
303
|
+
console.print("[bold red]Error: Invalid state file format. Missing original URL.[/bold red]")
|
|
304
|
+
raise typer.Exit(code=1)
|
|
305
|
+
|
|
306
|
+
console.print(f"[bold yellow]Found pause state for:[/bold yellow] [cyan]{os.path.basename(filepath)}[/cyan]")
|
|
307
|
+
|
|
308
|
+
downloader = SpeedyDownloader(
|
|
309
|
+
url=url,
|
|
310
|
+
output_path=filepath,
|
|
311
|
+
connections=connections,
|
|
312
|
+
state_file=state_file
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
run_async_downloader(downloader)
|
|
316
|
+
|
|
317
|
+
def interactive_shell():
|
|
318
|
+
# 1. Run quick asynchronous/cached check for updates
|
|
319
|
+
try:
|
|
320
|
+
new_version = check_for_updates()
|
|
321
|
+
if new_version:
|
|
322
|
+
import speedy_dl
|
|
323
|
+
console.print(Panel(
|
|
324
|
+
f"[bold gold1]★ A new version of idm-cli is available![/bold gold1]\n"
|
|
325
|
+
f"[bold white]Local Version:[/bold white] [red]{speedy_dl.__version__}[/red] ➜ "
|
|
326
|
+
f"[bold white]Latest Version:[/bold white] [green]{new_version}[/green]\n"
|
|
327
|
+
f"Run [bold cyan]pip install --upgrade idm-cli[/bold cyan] to update globally!",
|
|
328
|
+
border_style="bold gold1",
|
|
329
|
+
title="[bold gold1]Update Available[/bold gold1]",
|
|
330
|
+
padding=(1, 2)
|
|
331
|
+
))
|
|
332
|
+
console.print() # Spacer
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
console.print(Panel(
|
|
337
|
+
"[bold green]IDM-CLI Interactive Shell Mode[/bold green]\n"
|
|
338
|
+
"[bold white]Developed by:[/bold white] [cyan]Rehan Jamil[/cyan]\n"
|
|
339
|
+
"[bold white]GitHub Profile:[/bold white] [link=https://github.com/rj41-w2][cyan]github.com/rj41-w2[/cyan][/link] [dim](Ctrl + Click to Open)[/dim]\n\n"
|
|
340
|
+
"• Paste any HTTP/HTTPS URL or YouTube URL to download instantly.\n"
|
|
341
|
+
"• Type [bold yellow]resume <metadata_file>[/bold yellow] to resume a paused download.\n"
|
|
342
|
+
"• Type [bold yellow]help[/bold yellow] to see all available commands.\n"
|
|
343
|
+
"• Type [bold red]exit[/bold red] or press [bold red]Ctrl+C[/bold red] to quit the application.",
|
|
344
|
+
title="[bold yellow]idm-cli[/bold yellow]",
|
|
345
|
+
border_style="bold green",
|
|
346
|
+
padding=(1, 2)
|
|
347
|
+
))
|
|
348
|
+
|
|
349
|
+
# Import Table inside for clean scoping
|
|
350
|
+
from rich.table import Table
|
|
351
|
+
|
|
352
|
+
while True:
|
|
353
|
+
try:
|
|
354
|
+
url_input = console.input("[bold cyan]idm>[/bold cyan] ").strip()
|
|
355
|
+
if not url_input:
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
if url_input.lower() in ("help", "?"):
|
|
359
|
+
# Render a beautiful help commands table
|
|
360
|
+
help_table = Table(title="[bold yellow]IDM-CLI Shell Commands[/bold yellow]", border_style="bold cyan")
|
|
361
|
+
help_table.add_column("Command", style="bold green", justify="left")
|
|
362
|
+
help_table.add_column("Description", style="white", justify="left")
|
|
363
|
+
help_table.add_row("<URL>", "Paste a direct download link or YouTube/streaming video URL to download.")
|
|
364
|
+
help_table.add_row("resume <state_file>", "Resumes an interrupted or paused download using its JSON state file.")
|
|
365
|
+
help_table.add_row("help / ?", "Displays this help menu showing all available commands.")
|
|
366
|
+
help_table.add_row("exit / quit", "Safely quits the interactive IDM-CLI shell.")
|
|
367
|
+
console.print()
|
|
368
|
+
console.print(help_table)
|
|
369
|
+
console.print()
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
if url_input.lower() in ("exit", "quit"):
|
|
373
|
+
console.print("[bold yellow]Goodbye![/bold yellow]")
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
if url_input.startswith("resume "):
|
|
377
|
+
state_file = url_input[7:].strip()
|
|
378
|
+
if not os.path.exists(state_file):
|
|
379
|
+
console.print(f"[bold red]Error: State file '{state_file}' does not exist.[/bold red]")
|
|
380
|
+
continue
|
|
381
|
+
metadata = load_state(state_file)
|
|
382
|
+
if not metadata:
|
|
383
|
+
console.print(f"[bold red]Error: Could not load state from '{state_file}'.[/bold red]")
|
|
384
|
+
continue
|
|
385
|
+
url = metadata.get("url")
|
|
386
|
+
connections = metadata.get("connections", 8)
|
|
387
|
+
filepath = metadata.get("filepath")
|
|
388
|
+
downloader = SpeedyDownloader(
|
|
389
|
+
url=url,
|
|
390
|
+
output_path=filepath,
|
|
391
|
+
connections=connections,
|
|
392
|
+
state_file=state_file
|
|
393
|
+
)
|
|
394
|
+
run_async_downloader(downloader)
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
if not url_input.startswith(("http://", "https://")):
|
|
398
|
+
console.print("[bold red]Error: Please enter a valid HTTP/HTTPS URL or 'resume <state_file>'.[/bold red]")
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
if is_streaming_url(url_input):
|
|
402
|
+
selected_res = prompt_for_quality(url_input)
|
|
403
|
+
if selected_res:
|
|
404
|
+
download_streaming_video(url_input, None, 8, selected_res)
|
|
405
|
+
else:
|
|
406
|
+
downloader = SpeedyDownloader(url=url_input)
|
|
407
|
+
run_async_downloader(downloader)
|
|
408
|
+
|
|
409
|
+
except KeyboardInterrupt:
|
|
410
|
+
console.print("\n[bold yellow]Exiting Interactive Shell. Goodbye![/bold yellow]")
|
|
411
|
+
break
|
|
412
|
+
except Exception as e:
|
|
413
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
414
|
+
|
|
415
|
+
@app.callback(invoke_without_command=True)
|
|
416
|
+
def main(ctx: typer.Context):
|
|
417
|
+
"""
|
|
418
|
+
Main entry point. If no subcommand is specified, launches the interactive shell.
|
|
419
|
+
"""
|
|
420
|
+
if ctx.invoked_subcommand is None:
|
|
421
|
+
interactive_shell()
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
app()
|
|
@@ -20,9 +20,12 @@ class SpeedyDownloader:
|
|
|
20
20
|
url: str,
|
|
21
21
|
output_path: Optional[str] = None,
|
|
22
22
|
connections: int = 8,
|
|
23
|
-
state_file: Optional[str] = None
|
|
23
|
+
state_file: Optional[str] = None,
|
|
24
|
+
selected_resolution: Optional[int] = None,
|
|
25
|
+
stream_type: Optional[str] = None
|
|
24
26
|
):
|
|
25
27
|
self.connections = connections
|
|
28
|
+
self.custom_filepath = output_path is not None and not os.path.isdir(output_path)
|
|
26
29
|
|
|
27
30
|
# Recover original url / stream status if resuming from a saved state file
|
|
28
31
|
loaded = None
|
|
@@ -33,10 +36,14 @@ class SpeedyDownloader:
|
|
|
33
36
|
self.url = loaded.get("url", url)
|
|
34
37
|
self.original_url = loaded.get("original_url", self.url)
|
|
35
38
|
self.is_stream = loaded.get("is_stream", is_streaming_url(self.original_url))
|
|
39
|
+
self.selected_resolution = loaded.get("selected_resolution", selected_resolution)
|
|
40
|
+
self.stream_type = loaded.get("stream_type", stream_type)
|
|
36
41
|
else:
|
|
37
42
|
self.url = url
|
|
38
43
|
self.original_url = url
|
|
39
44
|
self.is_stream = is_streaming_url(url)
|
|
45
|
+
self.selected_resolution = selected_resolution
|
|
46
|
+
self.stream_type = stream_type
|
|
40
47
|
|
|
41
48
|
# We will determine these later
|
|
42
49
|
self.total_size = 0
|
|
@@ -97,18 +104,33 @@ class SpeedyDownloader:
|
|
|
97
104
|
if self.is_stream:
|
|
98
105
|
console.print("[bold cyan]Extracting streaming video link...[/bold cyan]")
|
|
99
106
|
loop = asyncio.get_running_loop()
|
|
100
|
-
info = await loop.run_in_executor(
|
|
107
|
+
info = await loop.run_in_executor(
|
|
108
|
+
None,
|
|
109
|
+
lambda: extract_stream_info(self.original_url, self.selected_resolution)
|
|
110
|
+
)
|
|
101
111
|
if not info:
|
|
102
112
|
raise ValueError("Failed to extract streaming details from URL. The video may be private or restricted.")
|
|
103
113
|
|
|
104
|
-
|
|
105
|
-
self.
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
# Select correct stream URL and size based on stream_type
|
|
115
|
+
if self.stream_type == "video" and "video_url" in info:
|
|
116
|
+
self.url = info["video_url"]
|
|
117
|
+
self.total_size = info["video_size"]
|
|
118
|
+
elif self.stream_type == "audio" and "audio_url" in info:
|
|
119
|
+
self.url = info["audio_url"]
|
|
120
|
+
self.total_size = info["audio_size"]
|
|
121
|
+
else:
|
|
122
|
+
self.url = info.get("stream_url", self.url)
|
|
123
|
+
self.total_size = info.get("size", self.total_size)
|
|
108
124
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
# Preserve user's custom file path (such as temp split track names)
|
|
126
|
+
if not self.custom_filepath:
|
|
127
|
+
self.filename = info["filename"]
|
|
128
|
+
dir_name = os.path.dirname(self.filepath) if self.filepath else os.getcwd()
|
|
129
|
+
self.filepath = os.path.join(dir_name, self.filename)
|
|
130
|
+
else:
|
|
131
|
+
self.filename = os.path.basename(self.filepath)
|
|
132
|
+
|
|
133
|
+
self.resumable = True
|
|
112
134
|
self.state_filepath = f"{self.filepath}.dl.json"
|
|
113
135
|
|
|
114
136
|
return {
|
|
@@ -118,16 +140,19 @@ class SpeedyDownloader:
|
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client:
|
|
143
|
+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
|
|
121
144
|
try:
|
|
122
145
|
# 1. Try HEAD request
|
|
123
|
-
response = await client.head(self.url)
|
|
146
|
+
response = await client.head(self.url, headers=headers)
|
|
124
147
|
if response.status_code >= 400:
|
|
125
148
|
# Fallback to GET for servers that block HEAD requests
|
|
126
|
-
|
|
149
|
+
headers["Range"] = "bytes=0-0"
|
|
150
|
+
response = await client.get(self.url, headers=headers)
|
|
127
151
|
except Exception as e:
|
|
128
152
|
# Direct GET fallback
|
|
129
153
|
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client2:
|
|
130
|
-
|
|
154
|
+
headers["Range"] = "bytes=0-0"
|
|
155
|
+
response = await client2.get(self.url, headers=headers)
|
|
131
156
|
|
|
132
157
|
# Handle response headers
|
|
133
158
|
headers = response.headers
|
|
@@ -213,7 +238,9 @@ class SpeedyDownloader:
|
|
|
213
238
|
"total_size": self.total_size,
|
|
214
239
|
"resumable": self.resumable,
|
|
215
240
|
"connections": self.connections,
|
|
216
|
-
"segments": self.segments
|
|
241
|
+
"segments": self.segments,
|
|
242
|
+
"selected_resolution": self.selected_resolution,
|
|
243
|
+
"stream_type": self.stream_type
|
|
217
244
|
}
|
|
218
245
|
save_state_atomic(self.state_filepath, self.state)
|
|
219
246
|
|
|
@@ -254,7 +281,9 @@ class SpeedyDownloader:
|
|
|
254
281
|
segment["downloaded"]
|
|
255
282
|
)
|
|
256
283
|
|
|
257
|
-
headers = {
|
|
284
|
+
headers = {
|
|
285
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
286
|
+
}
|
|
258
287
|
if self.resumable:
|
|
259
288
|
start_range = segment["start"] + segment["downloaded"]
|
|
260
289
|
headers["Range"] = f"bytes={start_range}-{segment['end']}"
|
|
@@ -305,14 +334,24 @@ class SpeedyDownloader:
|
|
|
305
334
|
self.segments = loaded["segments"]
|
|
306
335
|
self.connections = loaded["connections"]
|
|
307
336
|
self.is_stream = loaded.get("is_stream", False)
|
|
337
|
+
self.selected_resolution = loaded.get("selected_resolution", self.selected_resolution)
|
|
338
|
+
self.stream_type = loaded.get("stream_type", self.stream_type)
|
|
308
339
|
|
|
309
340
|
# If it's a stream URL, refresh the potentially expired signature link!
|
|
310
341
|
if self.is_stream:
|
|
311
342
|
console.print("[bold yellow]Refreshing expired streaming signatures...[/bold yellow]")
|
|
312
343
|
loop = asyncio.get_running_loop()
|
|
313
|
-
info = await loop.run_in_executor(
|
|
344
|
+
info = await loop.run_in_executor(
|
|
345
|
+
None,
|
|
346
|
+
lambda: extract_stream_info(self.original_url, self.selected_resolution)
|
|
347
|
+
)
|
|
314
348
|
if info:
|
|
315
|
-
self.
|
|
349
|
+
if self.stream_type == "video" and "video_url" in info:
|
|
350
|
+
self.url = info["video_url"]
|
|
351
|
+
elif self.stream_type == "audio" and "audio_url" in info:
|
|
352
|
+
self.url = info["audio_url"]
|
|
353
|
+
else:
|
|
354
|
+
self.url = info.get("stream_url", self.url)
|
|
316
355
|
else:
|
|
317
356
|
console.print("[bold red]Warning: Failed to refresh streaming link. Trying with old URL...[/bold red]")
|
|
318
357
|
else:
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any, Optional, List
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
import yt_dlp
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("speedy_dl.extractor")
|
|
9
|
+
|
|
10
|
+
def is_streaming_url(url: str) -> bool:
|
|
11
|
+
"""
|
|
12
|
+
Checks if a URL belongs to a supported streaming site (YouTube, Vimeo, etc.).
|
|
13
|
+
This is a fast local check based on domains to avoid network delay.
|
|
14
|
+
"""
|
|
15
|
+
streaming_domains = [
|
|
16
|
+
"youtube.com", "youtu.be", "vimeo.com",
|
|
17
|
+
"dailymotion.com", "twitch.tv", "facebook.com",
|
|
18
|
+
"instagram.com", "tiktok.com"
|
|
19
|
+
]
|
|
20
|
+
try:
|
|
21
|
+
parsed = urlparse(url)
|
|
22
|
+
netloc = parsed.netloc.lower()
|
|
23
|
+
return any(domain in netloc for domain in streaming_domains)
|
|
24
|
+
except Exception:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def is_ffmpeg_installed() -> bool:
|
|
28
|
+
"""Checks if ffmpeg is installed globally in PATH or available via imageio-ffmpeg."""
|
|
29
|
+
if shutil.which("ffmpeg") is not None:
|
|
30
|
+
return True
|
|
31
|
+
try:
|
|
32
|
+
import imageio_ffmpeg
|
|
33
|
+
return imageio_ffmpeg.get_ffmpeg_exe() is not None
|
|
34
|
+
except ImportError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def get_ffmpeg_path() -> str:
|
|
38
|
+
"""Returns the path to the active ffmpeg executable (global or bundled)."""
|
|
39
|
+
if shutil.which("ffmpeg") is not None:
|
|
40
|
+
return "ffmpeg"
|
|
41
|
+
try:
|
|
42
|
+
import imageio_ffmpeg
|
|
43
|
+
bundled = imageio_ffmpeg.get_ffmpeg_exe()
|
|
44
|
+
if bundled:
|
|
45
|
+
return bundled
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
return "ffmpeg"
|
|
49
|
+
|
|
50
|
+
def extract_stream_info(url: str, selected_resolution: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
|
51
|
+
"""
|
|
52
|
+
Extracts streaming video details.
|
|
53
|
+
If the selected resolution has a combined (audio+video) stream, it uses it directly (is_split=False).
|
|
54
|
+
Otherwise, if ffmpeg is installed, downloads separate video and audio streams concurrently.
|
|
55
|
+
Falls back to the best available combined stream if separate streams are not available or ffmpeg is missing.
|
|
56
|
+
"""
|
|
57
|
+
ydl_opts = {
|
|
58
|
+
"quiet": True,
|
|
59
|
+
"no_warnings": True,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
64
|
+
info = ydl.extract_info(url, download=False)
|
|
65
|
+
if not info:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
title = info.get("title", "streaming_video")
|
|
69
|
+
safe_title = "".join(c for c in title if c.isalnum() or c in "._- ").strip()
|
|
70
|
+
if not safe_title:
|
|
71
|
+
safe_title = "streaming_video"
|
|
72
|
+
|
|
73
|
+
formats = info.get("formats", [])
|
|
74
|
+
|
|
75
|
+
# Extract unique available video heights, converting safely to integers
|
|
76
|
+
available_heights = set()
|
|
77
|
+
for f in formats:
|
|
78
|
+
height = f.get("height")
|
|
79
|
+
if height is not None and f.get("vcodec") != "none":
|
|
80
|
+
try:
|
|
81
|
+
available_heights.add(int(height))
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
pass
|
|
84
|
+
sorted_heights = sorted(list(available_heights), reverse=True)
|
|
85
|
+
|
|
86
|
+
# Filter all combined formats containing both video and audio
|
|
87
|
+
combined_formats = [
|
|
88
|
+
f for f in formats
|
|
89
|
+
if f.get("vcodec") != "none"
|
|
90
|
+
and f.get("acodec") != "none"
|
|
91
|
+
and f.get("url")
|
|
92
|
+
]
|
|
93
|
+
combined_formats.sort(
|
|
94
|
+
key=lambda x: (x.get("height", 0) or 0, x.get("tbr", 0) or 0),
|
|
95
|
+
reverse=True
|
|
96
|
+
)
|
|
97
|
+
best_combined = combined_formats[0] if combined_formats else None
|
|
98
|
+
|
|
99
|
+
ffmpeg_available = is_ffmpeg_installed()
|
|
100
|
+
|
|
101
|
+
# 1. Check if there is a direct combined format matching the selected resolution
|
|
102
|
+
# (e.g. if user chose 360p or 720p, and a combined 360p/720p format exists)
|
|
103
|
+
combined_match = None
|
|
104
|
+
if selected_resolution and combined_formats:
|
|
105
|
+
try:
|
|
106
|
+
target_res = int(selected_resolution)
|
|
107
|
+
matches = [
|
|
108
|
+
f for f in combined_formats
|
|
109
|
+
if f.get("height") is not None and int(f.get("height")) == target_res
|
|
110
|
+
]
|
|
111
|
+
if matches:
|
|
112
|
+
combined_match = matches[0]
|
|
113
|
+
except (ValueError, TypeError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
if combined_match:
|
|
117
|
+
size = combined_match.get("filesize") or combined_match.get("filesize_approx") or 0
|
|
118
|
+
ext = combined_match.get("ext", "mp4")
|
|
119
|
+
return {
|
|
120
|
+
"title": title,
|
|
121
|
+
"filename": f"{safe_title}.{ext}",
|
|
122
|
+
"is_split": False,
|
|
123
|
+
"stream_url": combined_match.get("url"),
|
|
124
|
+
"size": size,
|
|
125
|
+
"resolutions": sorted_heights,
|
|
126
|
+
"selected_resolution": selected_resolution
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# 2. Otherwise, if we have a chosen resolution and ffmpeg is installed, try splitting
|
|
130
|
+
if selected_resolution and ffmpeg_available:
|
|
131
|
+
try:
|
|
132
|
+
target_res = int(selected_resolution)
|
|
133
|
+
|
|
134
|
+
# Find separate video-only stream matching target resolution
|
|
135
|
+
video_candidates = [
|
|
136
|
+
f for f in formats
|
|
137
|
+
if f.get("height") is not None
|
|
138
|
+
and int(f.get("height")) == target_res
|
|
139
|
+
and f.get("vcodec") != "none"
|
|
140
|
+
and f.get("url")
|
|
141
|
+
]
|
|
142
|
+
video_candidates.sort(key=lambda x: x.get("tbr", 0) or 0, reverse=True)
|
|
143
|
+
|
|
144
|
+
# Find the best separate audio-only stream
|
|
145
|
+
audio_candidates = [
|
|
146
|
+
f for f in formats
|
|
147
|
+
if f.get("acodec") != "none"
|
|
148
|
+
and f.get("vcodec") == "none"
|
|
149
|
+
and f.get("url")
|
|
150
|
+
]
|
|
151
|
+
audio_candidates.sort(key=lambda x: x.get("tbr", 0) or 0, reverse=True)
|
|
152
|
+
|
|
153
|
+
if video_candidates and audio_candidates:
|
|
154
|
+
best_video = video_candidates[0]
|
|
155
|
+
best_audio = audio_candidates[0]
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"title": title,
|
|
159
|
+
"filename": f"{safe_title}.mp4",
|
|
160
|
+
"is_split": True,
|
|
161
|
+
"video_url": best_video.get("url"),
|
|
162
|
+
"video_size": best_video.get("filesize") or best_video.get("filesize_approx") or 0,
|
|
163
|
+
"audio_url": best_audio.get("url"),
|
|
164
|
+
"audio_size": best_audio.get("filesize") or best_audio.get("filesize_approx") or 0,
|
|
165
|
+
"resolutions": sorted_heights,
|
|
166
|
+
"selected_resolution": selected_resolution
|
|
167
|
+
}
|
|
168
|
+
except (ValueError, TypeError):
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
# 3. Fallback to best available combined format (usually 360p or 720p)
|
|
172
|
+
if best_combined:
|
|
173
|
+
stream_url = best_combined.get("url")
|
|
174
|
+
size = best_combined.get("filesize") or best_combined.get("filesize_approx") or 0
|
|
175
|
+
ext = best_combined.get("ext", "mp4")
|
|
176
|
+
else:
|
|
177
|
+
stream_url = info.get("url")
|
|
178
|
+
size = info.get("filesize") or info.get("filesize_approx") or 0
|
|
179
|
+
ext = info.get("ext", "mp4")
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"title": title,
|
|
183
|
+
"filename": f"{safe_title}.{ext}",
|
|
184
|
+
"is_split": False,
|
|
185
|
+
"stream_url": stream_url,
|
|
186
|
+
"size": size,
|
|
187
|
+
"resolutions": sorted_heights,
|
|
188
|
+
"selected_resolution": best_combined.get("height") if best_combined else None
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Failed to extract stream info: {e}")
|
|
193
|
+
return None
|
idm_cli-0.1.0/speedy_dl/cli.py
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import sys
|
|
3
|
-
import asyncio
|
|
4
|
-
import logging
|
|
5
|
-
import typer
|
|
6
|
-
from typing import Optional
|
|
7
|
-
from rich.console import Console
|
|
8
|
-
from rich.panel import Panel
|
|
9
|
-
|
|
10
|
-
from speedy_dl.downloader import SpeedyDownloader
|
|
11
|
-
from speedy_dl.state import load_state
|
|
12
|
-
from speedy_dl.updater import check_for_updates
|
|
13
|
-
|
|
14
|
-
app = typer.Typer(
|
|
15
|
-
name="idm-cli",
|
|
16
|
-
help="IDM-CLI: A premium, blazing-fast multi-threaded CLI download manager (Internet Download Manager Clone) in Python.",
|
|
17
|
-
add_completion=True
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
console = Console()
|
|
21
|
-
|
|
22
|
-
# Configure basic logging to avoid spam but print crucial internal details if needed
|
|
23
|
-
logging.basicConfig(
|
|
24
|
-
level=logging.ERROR,
|
|
25
|
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
def run_async_downloader(downloader: SpeedyDownloader):
|
|
29
|
-
"""Runs the downloader asynchronously and handles Ctrl+C gracefully."""
|
|
30
|
-
loop = asyncio.new_event_loop()
|
|
31
|
-
asyncio.set_event_loop(loop)
|
|
32
|
-
|
|
33
|
-
download_task = loop.create_task(downloader.start_download())
|
|
34
|
-
|
|
35
|
-
try:
|
|
36
|
-
loop.run_until_complete(download_task)
|
|
37
|
-
except KeyboardInterrupt:
|
|
38
|
-
# Cancel the task on Ctrl+C to trigger the downloader's clean pause block
|
|
39
|
-
download_task.cancel()
|
|
40
|
-
try:
|
|
41
|
-
loop.run_until_complete(download_task)
|
|
42
|
-
except asyncio.CancelledError:
|
|
43
|
-
pass
|
|
44
|
-
except Exception as e:
|
|
45
|
-
console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]")
|
|
46
|
-
sys.exit(1)
|
|
47
|
-
finally:
|
|
48
|
-
loop.close()
|
|
49
|
-
|
|
50
|
-
@app.command()
|
|
51
|
-
def download(
|
|
52
|
-
url: str = typer.Argument(..., help="The HTTP/HTTPS URL of the file to download."),
|
|
53
|
-
output_path: Optional[str] = typer.Option(
|
|
54
|
-
None, "--output-path", "-o",
|
|
55
|
-
help="Custom destination directory or full filename path."
|
|
56
|
-
),
|
|
57
|
-
connections: int = typer.Option(
|
|
58
|
-
8, "--connections", "-c", min=1, max=32,
|
|
59
|
-
help="Number of concurrent connection threads (1-32)."
|
|
60
|
-
)
|
|
61
|
-
):
|
|
62
|
-
"""
|
|
63
|
-
Downloads a file using multi-threaded segmented requests.
|
|
64
|
-
Automatically checks if server supports resuming and generates status checkpoints.
|
|
65
|
-
"""
|
|
66
|
-
if not url.startswith(("http://", "https://")):
|
|
67
|
-
console.print("[bold red]Error: Invalid URL protocol. Only http:// and https:// are supported.[/bold red]")
|
|
68
|
-
raise typer.Exit(code=1)
|
|
69
|
-
|
|
70
|
-
downloader = SpeedyDownloader(
|
|
71
|
-
url=url,
|
|
72
|
-
output_path=output_path,
|
|
73
|
-
connections=connections
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
run_async_downloader(downloader)
|
|
77
|
-
|
|
78
|
-
@app.command()
|
|
79
|
-
def resume(
|
|
80
|
-
state_file: str = typer.Argument(
|
|
81
|
-
...,
|
|
82
|
-
help="Path to the '.dl.json' status file generated by a paused download."
|
|
83
|
-
)
|
|
84
|
-
):
|
|
85
|
-
"""
|
|
86
|
-
Resumes a paused download using the saved metadata file.
|
|
87
|
-
"""
|
|
88
|
-
if not os.path.exists(state_file):
|
|
89
|
-
console.print(f"[bold red]Error: The state file '{state_file}' does not exist.[/bold red]")
|
|
90
|
-
raise typer.Exit(code=1)
|
|
91
|
-
|
|
92
|
-
# Load state first to extract original URL
|
|
93
|
-
metadata = load_state(state_file)
|
|
94
|
-
if not metadata:
|
|
95
|
-
console.print(f"[bold red]Error: Could not parse or load state from '{state_file}'. It may be corrupted.[/bold red]")
|
|
96
|
-
raise typer.Exit(code=1)
|
|
97
|
-
|
|
98
|
-
url = metadata.get("url")
|
|
99
|
-
connections = metadata.get("connections", 8)
|
|
100
|
-
filepath = metadata.get("filepath")
|
|
101
|
-
|
|
102
|
-
if not url:
|
|
103
|
-
console.print("[bold red]Error: Invalid state file format. Missing original URL.[/bold red]")
|
|
104
|
-
raise typer.Exit(code=1)
|
|
105
|
-
|
|
106
|
-
console.print(f"[bold yellow]Found pause state for:[/bold yellow] [cyan]{os.path.basename(filepath)}[/cyan]")
|
|
107
|
-
|
|
108
|
-
downloader = SpeedyDownloader(
|
|
109
|
-
url=url,
|
|
110
|
-
output_path=filepath,
|
|
111
|
-
connections=connections,
|
|
112
|
-
state_file=state_file
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
run_async_downloader(downloader)
|
|
116
|
-
|
|
117
|
-
def interactive_shell():
|
|
118
|
-
# 1. Run quick asynchronous/cached check for updates
|
|
119
|
-
try:
|
|
120
|
-
new_version = check_for_updates()
|
|
121
|
-
if new_version:
|
|
122
|
-
import speedy_dl
|
|
123
|
-
console.print(Panel(
|
|
124
|
-
f"[bold gold1]★ A new version of idm-cli is available![/bold gold1]\n"
|
|
125
|
-
f"[bold white]Local Version:[/bold white] [red]{speedy_dl.__version__}[/red] ➜ "
|
|
126
|
-
f"[bold white]Latest Version:[/bold white] [green]{new_version}[/green]\n"
|
|
127
|
-
f"Run [bold cyan]pip install --upgrade idm-cli[/bold cyan] to update globally!",
|
|
128
|
-
border_style="bold gold1",
|
|
129
|
-
title="[bold gold1]Update Available[/bold gold1]",
|
|
130
|
-
padding=(1, 2)
|
|
131
|
-
))
|
|
132
|
-
console.print() # Spacer
|
|
133
|
-
except Exception:
|
|
134
|
-
pass
|
|
135
|
-
|
|
136
|
-
console.print(Panel(
|
|
137
|
-
"[bold green]IDM-CLI Interactive Shell Mode[/bold green]\n\n"
|
|
138
|
-
"• Paste any HTTP/HTTPS URL or YouTube URL to download instantly.\n"
|
|
139
|
-
"• Type [bold yellow]resume <metadata_file>[/bold yellow] to resume a paused download.\n"
|
|
140
|
-
"• Type [bold red]exit[/bold bold red] or press [bold red]Ctrl+C[/bold red] to quit the application.",
|
|
141
|
-
title="[bold yellow]idm shell[/bold yellow]",
|
|
142
|
-
border_style="bold green",
|
|
143
|
-
padding=(1, 2)
|
|
144
|
-
))
|
|
145
|
-
|
|
146
|
-
while True:
|
|
147
|
-
try:
|
|
148
|
-
url_input = console.input("[bold cyan]idm>[/bold cyan] ").strip()
|
|
149
|
-
if not url_input:
|
|
150
|
-
continue
|
|
151
|
-
|
|
152
|
-
if url_input.lower() in ("exit", "quit"):
|
|
153
|
-
console.print("[bold yellow]Goodbye![/bold yellow]")
|
|
154
|
-
break
|
|
155
|
-
|
|
156
|
-
if url_input.startswith("resume "):
|
|
157
|
-
state_file = url_input[7:].strip()
|
|
158
|
-
if not os.path.exists(state_file):
|
|
159
|
-
console.print(f"[bold red]Error: State file '{state_file}' does not exist.[/bold red]")
|
|
160
|
-
continue
|
|
161
|
-
metadata = load_state(state_file)
|
|
162
|
-
if not metadata:
|
|
163
|
-
console.print(f"[bold red]Error: Could not load state from '{state_file}'.[/bold red]")
|
|
164
|
-
continue
|
|
165
|
-
url = metadata.get("url")
|
|
166
|
-
connections = metadata.get("connections", 8)
|
|
167
|
-
filepath = metadata.get("filepath")
|
|
168
|
-
downloader = SpeedyDownloader(
|
|
169
|
-
url=url,
|
|
170
|
-
output_path=filepath,
|
|
171
|
-
connections=connections,
|
|
172
|
-
state_file=state_file
|
|
173
|
-
)
|
|
174
|
-
run_async_downloader(downloader)
|
|
175
|
-
continue
|
|
176
|
-
|
|
177
|
-
if not url_input.startswith(("http://", "https://")):
|
|
178
|
-
console.print("[bold red]Error: Please enter a valid HTTP/HTTPS URL or 'resume <state_file>'.[/bold red]")
|
|
179
|
-
continue
|
|
180
|
-
|
|
181
|
-
downloader = SpeedyDownloader(url=url_input)
|
|
182
|
-
run_async_downloader(downloader)
|
|
183
|
-
|
|
184
|
-
except KeyboardInterrupt:
|
|
185
|
-
console.print("\n[bold yellow]Exiting Interactive Shell. Goodbye![/bold yellow]")
|
|
186
|
-
break
|
|
187
|
-
except Exception as e:
|
|
188
|
-
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
189
|
-
|
|
190
|
-
@app.callback(invoke_without_command=True)
|
|
191
|
-
def main(ctx: typer.Context):
|
|
192
|
-
"""
|
|
193
|
-
Main entry point. If no subcommand is specified, launches the interactive shell.
|
|
194
|
-
"""
|
|
195
|
-
if ctx.invoked_subcommand is None:
|
|
196
|
-
interactive_shell()
|
|
197
|
-
|
|
198
|
-
if __name__ == "__main__":
|
|
199
|
-
app()
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from typing import Dict, Any, Optional
|
|
3
|
-
from urllib.parse import urlparse
|
|
4
|
-
import yt_dlp
|
|
5
|
-
|
|
6
|
-
logger = logging.getLogger("speedy_dl.extractor")
|
|
7
|
-
|
|
8
|
-
def is_streaming_url(url: str) -> bool:
|
|
9
|
-
"""
|
|
10
|
-
Checks if a URL belongs to a supported streaming site (YouTube, Vimeo, etc.).
|
|
11
|
-
This is a fast local check based on domains to avoid network delay.
|
|
12
|
-
"""
|
|
13
|
-
streaming_domains = [
|
|
14
|
-
"youtube.com", "youtu.be", "vimeo.com",
|
|
15
|
-
"dailymotion.com", "twitch.tv", "facebook.com",
|
|
16
|
-
"instagram.com", "tiktok.com"
|
|
17
|
-
]
|
|
18
|
-
try:
|
|
19
|
-
parsed = urlparse(url)
|
|
20
|
-
netloc = parsed.netloc.lower()
|
|
21
|
-
return any(domain in netloc for domain in streaming_domains)
|
|
22
|
-
except Exception:
|
|
23
|
-
return False
|
|
24
|
-
|
|
25
|
-
def extract_stream_info(url: str) -> Optional[Dict[str, Any]]:
|
|
26
|
-
"""
|
|
27
|
-
Programmatically invokes yt-dlp to extract the video title and the direct
|
|
28
|
-
HTTPS streaming URL for the best available format that contains both video and audio.
|
|
29
|
-
"""
|
|
30
|
-
ydl_opts = {
|
|
31
|
-
"quiet": True,
|
|
32
|
-
"no_warnings": True,
|
|
33
|
-
"format": "best", # yt-dlp default fallback
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
38
|
-
# Extract info without downloading the media
|
|
39
|
-
info = ydl.extract_info(url, download=False)
|
|
40
|
-
if not info:
|
|
41
|
-
return None
|
|
42
|
-
|
|
43
|
-
title = info.get("title", "streaming_video")
|
|
44
|
-
# Sanitize title to make it a safe filename
|
|
45
|
-
safe_title = "".join(c for c in title if c.isalnum() or c in "._- ").strip()
|
|
46
|
-
if not safe_title:
|
|
47
|
-
safe_title = "streaming_video"
|
|
48
|
-
|
|
49
|
-
formats = info.get("formats", [])
|
|
50
|
-
# Filter for formats containing BOTH video and audio so it plays out-of-the-box
|
|
51
|
-
combined_formats = [
|
|
52
|
-
f for f in formats
|
|
53
|
-
if f.get("vcodec") != "none"
|
|
54
|
-
and f.get("acodec") != "none"
|
|
55
|
-
and f.get("url")
|
|
56
|
-
]
|
|
57
|
-
|
|
58
|
-
# Sort by resolution (height) then bitrate (tbr) descending
|
|
59
|
-
combined_formats.sort(
|
|
60
|
-
key=lambda x: (x.get("height", 0) or 0, x.get("tbr", 0) or 0),
|
|
61
|
-
reverse=True
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
if not combined_formats:
|
|
65
|
-
# Fallback to standard best format if no explicit combined filter matches
|
|
66
|
-
best_format = info
|
|
67
|
-
ext = info.get("ext", "mp4")
|
|
68
|
-
else:
|
|
69
|
-
best_format = combined_formats[0]
|
|
70
|
-
ext = best_format.get("ext", "mp4")
|
|
71
|
-
|
|
72
|
-
stream_url = best_format.get("url")
|
|
73
|
-
size = best_format.get("filesize") or best_format.get("filesize_approx") or 0
|
|
74
|
-
|
|
75
|
-
# Form clean filename
|
|
76
|
-
filename = f"{safe_title}.{ext}"
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
"title": title,
|
|
80
|
-
"filename": filename,
|
|
81
|
-
"stream_url": stream_url,
|
|
82
|
-
"size": size,
|
|
83
|
-
"ext": ext
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
except Exception as e:
|
|
87
|
-
logger.error(f"Failed to extract stream info: {e}")
|
|
88
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|