idm-cli 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.
idm_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: idm-cli
3
+ Version: 0.1.0
4
+ Requires-Dist: typer[all]>=0.9.0
5
+ Requires-Dist: httpx>=0.24.0
6
+ Requires-Dist: rich>=13.0.0
7
+ Requires-Dist: yt-dlp>=2023.0.0
8
+ Dynamic: requires-dist
@@ -0,0 +1,106 @@
1
+ # IDM-CLI (CLI Internet Download Manager Clone)
2
+
3
+ **IDM-CLI** is a blazing-fast, multi-threaded CLI download manager built in Python. Inspired by Internet Download Manager (IDM), it splits files into multiple segments and downloads them concurrently to maximize bandwidth, complete with a modern interactive prompt shell, auto-update checks, atomic state tracking, and ultra-safe chunk merging.
4
+
5
+ ---
6
+
7
+ ## ✨ Key Features
8
+
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
+ 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. **⏯️ Robust Pause & Resume**: Supports pausing a download (using `Ctrl+C`) and resuming it exactly where it was left off. It even automatically refreshes expired YouTube temporary signatures in the background on resume!
12
+ 4. **🔔 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.
13
+ 5. **🔒 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).
14
+ 6. **💾 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.
15
+ 7. **🎨 Beautiful Terminal UI**: Multi-progress bar dashboards built with `rich`, showing connection progress, total speed, ETAs, percentages, and clean summary tables.
16
+
17
+ ---
18
+
19
+ ## 📦 Installation & Setup
20
+
21
+ Ensure you have **Python 3.8+** installed.
22
+
23
+ ### 1. Clone the repository & Navigate
24
+ ```bash
25
+ cd IDM-Clone-CLI
26
+ ```
27
+
28
+ ### 2. Create and Activate Virtual Environment
29
+ ```powershell
30
+ python -m venv .venv
31
+ .venv\Scripts\Activate.ps1
32
+ ```
33
+
34
+ ### 3. Install Globally (registers the `idm` command)
35
+ To install the tool in editable development mode:
36
+ ```powershell
37
+ pip install -e .
38
+ ```
39
+
40
+ ---
41
+
42
+ ## ⚡ CLI Usage & Commands
43
+
44
+ Once installed globally, you can type **`idm`** from any terminal folder!
45
+
46
+ ### 1. Open the Interactive Shell (Recommended)
47
+ Simply run:
48
+ ```bash
49
+ idm
50
+ ```
51
+ This opens the persistent prompt:
52
+ ```text
53
+ +------------------------------ idm shell ------------------------------+
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
+ +-----------------------------------------------------------------------+
62
+ idm>
63
+ ```
64
+ *Paste your links directly and hit Enter. Type `exit` or press `Ctrl+C` to quit.*
65
+
66
+ ---
67
+
68
+ ### 2. Standard One-Off Commands
69
+ If you prefer single-line shell commands:
70
+
71
+ #### Download a File:
72
+ ```bash
73
+ idm download https://example.com/largefile.zip
74
+ ```
75
+
76
+ #### Custom Connections & Output Path:
77
+ To use 16 threads and save it to a custom directory:
78
+ ```bash
79
+ idm download https://example.com/largefile.zip -c 16 -o C:\path\to\downloads
80
+ ```
81
+
82
+ #### Resume a Download:
83
+ ```bash
84
+ idm resume largefile.zip.dl.json
85
+ ```
86
+
87
+ ---
88
+
89
+ ## 🖥️ Standalone Bundling (Compile to `idm.exe`)
90
+
91
+ If you want to package IDM-CLI as a single standalone executable that runs on any Windows machine (even if they don't have Python installed):
92
+
93
+ 1. **Install PyInstaller** inside your activated environment:
94
+ ```bash
95
+ pip install pyinstaller
96
+ ```
97
+
98
+ 2. **Bundle the application**:
99
+ ```bash
100
+ pyinstaller --onefile --name idm main.py
101
+ ```
102
+
103
+ 3. **Find the `.exe`**:
104
+ The standalone executable will be generated inside the `dist/` folder:
105
+ - File location: `dist/idm.exe`
106
+ - You can copy this `idm.exe` and distribute it to any other Windows PC. Just add it to your Windows PATH to run it globally!
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: idm-cli
3
+ Version: 0.1.0
4
+ Requires-Dist: typer[all]>=0.9.0
5
+ Requires-Dist: httpx>=0.24.0
6
+ Requires-Dist: rich>=13.0.0
7
+ Requires-Dist: yt-dlp>=2023.0.0
8
+ Dynamic: requires-dist
@@ -0,0 +1,15 @@
1
+ README.md
2
+ setup.py
3
+ idm_cli.egg-info/PKG-INFO
4
+ idm_cli.egg-info/SOURCES.txt
5
+ idm_cli.egg-info/dependency_links.txt
6
+ idm_cli.egg-info/entry_points.txt
7
+ idm_cli.egg-info/requires.txt
8
+ idm_cli.egg-info/top_level.txt
9
+ speedy_dl/__init__.py
10
+ speedy_dl/cli.py
11
+ speedy_dl/downloader.py
12
+ speedy_dl/extractor.py
13
+ speedy_dl/state.py
14
+ speedy_dl/ui.py
15
+ speedy_dl/updater.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ idm = speedy_dl.cli:app
3
+ idm-cli = speedy_dl.cli:app
@@ -0,0 +1,4 @@
1
+ typer[all]>=0.9.0
2
+ httpx>=0.24.0
3
+ rich>=13.0.0
4
+ yt-dlp>=2023.0.0
@@ -0,0 +1 @@
1
+ speedy_dl
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
idm_cli-0.1.0/setup.py ADDED
@@ -0,0 +1,19 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="idm-cli",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ install_requires=[
8
+ "typer[all]>=0.9.0",
9
+ "httpx>=0.24.0",
10
+ "rich>=13.0.0",
11
+ "yt-dlp>=2023.0.0",
12
+ ],
13
+ entry_points={
14
+ "console_scripts": [
15
+ "idm=speedy_dl.cli:app", # Typing 'idm' starts the CLI globally!
16
+ "idm-cli=speedy_dl.cli:app", # Typing 'idm-cli' also works!
17
+ ],
18
+ },
19
+ )
@@ -0,0 +1,2 @@
1
+ # speedy_dl Package
2
+ __version__ = "0.1.0"
@@ -0,0 +1,199 @@
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()
@@ -0,0 +1,408 @@
1
+ import os
2
+ import time
3
+ import asyncio
4
+ import logging
5
+ from typing import Dict, Any, List, Optional
6
+ from urllib.parse import urlparse, unquote
7
+ import httpx
8
+ from rich.console import Console
9
+
10
+ from speedy_dl.state import save_state_atomic, load_state
11
+ from speedy_dl.ui import SpeedyProgress
12
+ from speedy_dl.extractor import is_streaming_url, extract_stream_info
13
+
14
+ logger = logging.getLogger("speedy_dl.downloader")
15
+ console = Console()
16
+
17
+ class SpeedyDownloader:
18
+ def __init__(
19
+ self,
20
+ url: str,
21
+ output_path: Optional[str] = None,
22
+ connections: int = 8,
23
+ state_file: Optional[str] = None
24
+ ):
25
+ self.connections = connections
26
+
27
+ # Recover original url / stream status if resuming from a saved state file
28
+ loaded = None
29
+ if state_file and os.path.exists(state_file):
30
+ loaded = load_state(state_file)
31
+
32
+ if loaded:
33
+ self.url = loaded.get("url", url)
34
+ self.original_url = loaded.get("original_url", self.url)
35
+ self.is_stream = loaded.get("is_stream", is_streaming_url(self.original_url))
36
+ else:
37
+ self.url = url
38
+ self.original_url = url
39
+ self.is_stream = is_streaming_url(url)
40
+
41
+ # We will determine these later
42
+ self.total_size = 0
43
+ self.resumable = False
44
+ self.filename = ""
45
+ self.filepath = ""
46
+ self.state_filepath = ""
47
+ self.segments: List[Dict[str, Any]] = []
48
+ self.state: Dict[str, Any] = {}
49
+
50
+ # Downloader active flags
51
+ self.is_paused = False
52
+ self.is_completed = False
53
+ self._state_save_task: Optional[asyncio.Task] = None
54
+ self._download_tasks: List[asyncio.Task] = []
55
+ self._write_lock = asyncio.Lock() # Ensure atomic memory updates
56
+
57
+ # Check details first to establish name/size
58
+ self._initialize_paths(output_path, state_file)
59
+
60
+ def _initialize_paths(self, output_path: Optional[str], state_file: Optional[str]):
61
+ """Resolves output filename, directory, and the metadata state path."""
62
+ # Parse URL for default filename
63
+ parsed_url = urlparse(self.url)
64
+ url_filename = os.path.basename(unquote(parsed_url.path))
65
+
66
+ if self.is_stream:
67
+ self.filename = "streaming_video.mp4"
68
+ elif not url_filename or "." not in url_filename:
69
+ self.filename = "download_file"
70
+ else:
71
+ self.filename = url_filename
72
+
73
+ # Establish working output path
74
+ if output_path:
75
+ if os.path.isdir(output_path):
76
+ self.filepath = os.path.join(output_path, self.filename)
77
+ else:
78
+ self.filepath = output_path
79
+ self.filename = os.path.basename(self.filepath)
80
+ else:
81
+ # Dynamically resolve user's system "Downloads" directory (Cross-Platform)
82
+ downloads_dir = os.path.join(os.path.expanduser("~"), "Downloads")
83
+ if os.path.exists(downloads_dir) and os.path.isdir(downloads_dir):
84
+ self.filepath = os.path.join(downloads_dir, self.filename)
85
+ else:
86
+ # Fallback to current working directory if Downloads folder doesn't exist
87
+ self.filepath = os.path.abspath(self.filename)
88
+
89
+ # Establish state file path
90
+ if state_file:
91
+ self.state_filepath = state_file
92
+ else:
93
+ self.state_filepath = f"{self.filepath}.dl.json"
94
+
95
+ async def probe_server(self) -> Dict[str, Any]:
96
+ """Probes the server with HEAD/GET requests to determine size and Range support."""
97
+ if self.is_stream:
98
+ console.print("[bold cyan]Extracting streaming video link...[/bold cyan]")
99
+ loop = asyncio.get_running_loop()
100
+ info = await loop.run_in_executor(None, extract_stream_info, self.original_url)
101
+ if not info:
102
+ raise ValueError("Failed to extract streaming details from URL. The video may be private or restricted.")
103
+
104
+ self.url = info["stream_url"]
105
+ self.filename = info["filename"]
106
+ self.total_size = info["size"]
107
+ self.resumable = True
108
+
109
+ # Re-evaluate filepath and state paths based on fetched streaming name
110
+ dir_name = os.path.dirname(self.filepath) if self.filepath else os.getcwd()
111
+ self.filepath = os.path.join(dir_name, self.filename)
112
+ self.state_filepath = f"{self.filepath}.dl.json"
113
+
114
+ return {
115
+ "filename": self.filename,
116
+ "size": self.total_size,
117
+ "resumable": self.resumable
118
+ }
119
+
120
+ async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client:
121
+ try:
122
+ # 1. Try HEAD request
123
+ response = await client.head(self.url)
124
+ if response.status_code >= 400:
125
+ # Fallback to GET for servers that block HEAD requests
126
+ response = await client.get(self.url, headers={"Range": "bytes=0-0"})
127
+ except Exception as e:
128
+ # Direct GET fallback
129
+ async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client2:
130
+ response = await client2.get(self.url, headers={"Range": "bytes=0-0"})
131
+
132
+ # Handle response headers
133
+ headers = response.headers
134
+
135
+ # Content-Disposition header filename extraction
136
+ cd_header = headers.get("content-disposition", "")
137
+ if "filename=" in cd_header:
138
+ parts = cd_header.split("filename=")
139
+ if len(parts) > 1:
140
+ fname = parts[1].strip('"\'')
141
+ if fname:
142
+ self.filename = fname
143
+ # Re-evaluate filepath based on extracted filename
144
+ self.filepath = os.path.join(os.path.dirname(self.filepath), self.filename)
145
+ if not self.state_filepath.endswith(".dl.json"):
146
+ self.state_filepath = f"{self.filepath}.dl.json"
147
+
148
+ # Parse size
149
+ content_length = headers.get("content-length")
150
+ # If we requested range 0-0, content-range will have the total size
151
+ content_range = headers.get("content-range")
152
+
153
+ if content_range and "bytes" in content_range:
154
+ # format: bytes 0-0/FILE_SIZE
155
+ try:
156
+ self.total_size = int(content_range.split("/")[-1])
157
+ except (ValueError, IndexError):
158
+ self.total_size = int(content_length) if content_length else 0
159
+ else:
160
+ self.total_size = int(content_length) if content_length else 0
161
+
162
+ # Check Range support (Resumable)
163
+ accept_ranges = headers.get("accept-ranges", "").lower()
164
+ self.resumable = (
165
+ accept_ranges == "bytes" or
166
+ "content-range" in headers or
167
+ response.status_code == 206
168
+ ) and self.total_size > 0
169
+
170
+ return {
171
+ "filename": self.filename,
172
+ "size": self.total_size,
173
+ "resumable": self.resumable
174
+ }
175
+
176
+ def prepare_segments(self):
177
+ """Calculates ranges and sets up temporary segment tracking structures."""
178
+ if not self.resumable or self.connections <= 1:
179
+ # Single connection segment
180
+ self.segments = [{
181
+ "id": 0,
182
+ "start": 0,
183
+ "end": self.total_size - 1 if self.total_size > 0 else -1,
184
+ "downloaded": 0,
185
+ "completed": False,
186
+ "part_path": f"{self.filepath}.part0"
187
+ }]
188
+ return
189
+
190
+ self.segments = []
191
+ chunk_size = self.total_size // self.connections
192
+
193
+ for i in range(self.connections):
194
+ start = i * chunk_size
195
+ # The last segment consumes any remaining remainder bytes
196
+ end = self.total_size - 1 if i == self.connections - 1 else (start + chunk_size - 1)
197
+ self.segments.append({
198
+ "id": i,
199
+ "start": start,
200
+ "end": end,
201
+ "downloaded": 0,
202
+ "completed": False,
203
+ "part_path": f"{self.filepath}.part{i}"
204
+ })
205
+
206
+ def save_state(self):
207
+ """Saves current state snapshot to file atomically."""
208
+ self.state = {
209
+ "url": self.url,
210
+ "original_url": self.original_url,
211
+ "is_stream": self.is_stream,
212
+ "filepath": self.filepath,
213
+ "total_size": self.total_size,
214
+ "resumable": self.resumable,
215
+ "connections": self.connections,
216
+ "segments": self.segments
217
+ }
218
+ save_state_atomic(self.state_filepath, self.state)
219
+
220
+ async def _periodic_save(self):
221
+ """Asynchronous background task that periodically updates state on disk."""
222
+ while not self.is_paused and not self.is_completed:
223
+ await asyncio.sleep(1.5)
224
+ async with self._write_lock:
225
+ self.save_state()
226
+
227
+ async def download_segment(
228
+ self,
229
+ client: httpx.AsyncClient,
230
+ segment: Dict[str, Any],
231
+ ui: SpeedyProgress
232
+ ):
233
+ """Downloads a single file byte segment concurrently."""
234
+ seg_id = segment["id"]
235
+ part_path = segment["part_path"]
236
+
237
+ # Check current progress on disk (for safety and resuming)
238
+ current_disk_bytes = 0
239
+ if os.path.exists(part_path):
240
+ current_disk_bytes = os.path.getsize(part_path)
241
+
242
+ async with self._write_lock:
243
+ segment["downloaded"] = current_disk_bytes
244
+ if segment["downloaded"] >= (segment["end"] - segment["start"] + 1) and segment["end"] > 0:
245
+ segment["completed"] = True
246
+ ui.set_segment_total(seg_id, segment["end"] - segment["start"] + 1, segment["downloaded"])
247
+ ui.mark_segment_completed(seg_id)
248
+ return
249
+
250
+ # Initialize progress bar for this segment
251
+ ui.set_segment_total(
252
+ seg_id,
253
+ segment["end"] - segment["start"] + 1,
254
+ segment["downloaded"]
255
+ )
256
+
257
+ headers = {}
258
+ if self.resumable:
259
+ start_range = segment["start"] + segment["downloaded"]
260
+ headers["Range"] = f"bytes={start_range}-{segment['end']}"
261
+
262
+ mode = "ab" if segment["downloaded"] > 0 else "wb"
263
+
264
+ try:
265
+ async with client.stream("GET", self.url, headers=headers, timeout=30.0) as response:
266
+ if response.status_code not in (200, 206):
267
+ raise httpx.HTTPStatusError(
268
+ f"Bad status code {response.status_code}",
269
+ request=response.request,
270
+ response=response
271
+ )
272
+
273
+ # Dynamic write buffer matching
274
+ with open(part_path, mode) as f:
275
+ async for chunk in response.aiter_bytes(chunk_size=16384):
276
+ f.write(chunk)
277
+ chunk_len = len(chunk)
278
+
279
+ async with self._write_lock:
280
+ segment["downloaded"] += chunk_len
281
+
282
+ ui.update_segment(seg_id, chunk_len)
283
+
284
+ async with self._write_lock:
285
+ segment["completed"] = True
286
+ ui.mark_segment_completed(seg_id)
287
+
288
+ except asyncio.CancelledError:
289
+ # Handle graceful cancellation state updates
290
+ raise
291
+ except Exception as e:
292
+ logger.error(f"Error in connection {seg_id + 1}: {e}")
293
+ raise e
294
+
295
+ async def start_download(self):
296
+ """Orchestrates the entire multi-threaded download process."""
297
+ # 1. Server validation check
298
+ console.print("[bold cyan]Probing remote server...[/bold cyan]")
299
+ await self.probe_server()
300
+
301
+ # 2. Check if a valid state already exists (Resume verification)
302
+ loaded = load_state(self.state_filepath)
303
+ if loaded and loaded.get("original_url") == self.original_url:
304
+ console.print("[bold yellow]Resuming existing download from saved checkpoints...[/bold yellow]")
305
+ self.segments = loaded["segments"]
306
+ self.connections = loaded["connections"]
307
+ self.is_stream = loaded.get("is_stream", False)
308
+
309
+ # If it's a stream URL, refresh the potentially expired signature link!
310
+ if self.is_stream:
311
+ console.print("[bold yellow]Refreshing expired streaming signatures...[/bold yellow]")
312
+ loop = asyncio.get_running_loop()
313
+ info = await loop.run_in_executor(None, extract_stream_info, self.original_url)
314
+ if info:
315
+ self.url = info["stream_url"]
316
+ else:
317
+ console.print("[bold red]Warning: Failed to refresh streaming link. Trying with old URL...[/bold red]")
318
+ else:
319
+ self.prepare_segments()
320
+ self.save_state()
321
+
322
+ # 3. Create active workspace directory
323
+ os.makedirs(os.path.dirname(os.path.abspath(self.filepath)), exist_ok=True)
324
+
325
+ start_time = time.time()
326
+
327
+ # 4. Start visual engine
328
+ with SpeedyProgress(len(self.segments), self.total_size, self.filename) as ui:
329
+ # Periodic state saver activation
330
+ self._state_save_task = asyncio.create_task(self._periodic_save())
331
+
332
+ limits = httpx.Limits(max_keepalive_connections=self.connections, max_connections=self.connections)
333
+ async with httpx.AsyncClient(limits=limits, follow_redirects=True) as client:
334
+ self._download_tasks = []
335
+ for seg in self.segments:
336
+ self._download_tasks.append(
337
+ asyncio.create_task(self.download_segment(client, seg, ui))
338
+ )
339
+
340
+ try:
341
+ await asyncio.gather(*self._download_tasks)
342
+ self.is_completed = True
343
+ except asyncio.CancelledError:
344
+ self.is_paused = True
345
+ # Cancel all workers cleanly
346
+ for t in self._download_tasks:
347
+ if not t.done():
348
+ t.cancel()
349
+ await asyncio.gather(*self._download_tasks, return_exceptions=True)
350
+ # Force write current progress state
351
+ self.save_state()
352
+ console.print("\n[bold yellow]⚠ Download paused. Your progress has been saved atomically.[/bold yellow]")
353
+ raise
354
+ except Exception as e:
355
+ self.is_paused = True
356
+ self.save_state()
357
+ console.print(f"\n[bold red]✖ Download interrupted due to error: {e}[/bold red]")
358
+ raise e
359
+ finally:
360
+ # Clean up periodic saver task safely
361
+ if self._state_save_task and not self._state_save_task.done():
362
+ self._state_save_task.cancel()
363
+ try:
364
+ await self._state_save_task
365
+ except asyncio.CancelledError:
366
+ pass
367
+
368
+ if self.is_completed:
369
+ elapsed = time.time() - start_time
370
+ # 5. Cleanly assemble file segments with memory-safe streaming
371
+ console.print("[bold green]Merging segments into final output file...[/bold green]")
372
+ await self.merge_segments()
373
+
374
+ # Clean state JSON files
375
+ if os.path.exists(self.state_filepath):
376
+ try:
377
+ os.remove(self.state_filepath)
378
+ except OSError:
379
+ pass
380
+
381
+ # Display final success details
382
+ ui.print_summary(elapsed, self.total_size, self.filepath)
383
+
384
+ async def merge_segments(self):
385
+ """
386
+ Memory-safe O(1) chunk-by-chunk file merging.
387
+ Reads parts sequentially using small buffers to prevent high RAM overhead.
388
+ """
389
+ # Create output file
390
+ with open(self.filepath, "wb") as outfile:
391
+ for seg in self.segments:
392
+ part_path = seg["part_path"]
393
+ if not os.path.exists(part_path):
394
+ continue
395
+
396
+ # Buffered streaming merge (1MB blocks)
397
+ with open(part_path, "rb") as infile:
398
+ while True:
399
+ chunk = infile.read(1024 * 1024) # 1MB buffer
400
+ if not chunk:
401
+ break
402
+ outfile.write(chunk)
403
+
404
+ # Safely delete temporary part file once merged
405
+ try:
406
+ os.remove(part_path)
407
+ except OSError:
408
+ pass
@@ -0,0 +1,88 @@
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
@@ -0,0 +1,55 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ from typing import Any, Dict, Optional
5
+
6
+ logger = logging.getLogger("speedy_dl.state")
7
+
8
+ def save_state_atomic(filepath: str, data: Dict[str, Any]) -> bool:
9
+ """
10
+ Saves metadata to a file atomically by first writing to a temporary file,
11
+ flushing to disk, and then replacing the target file.
12
+ This prevents corruption if the download is interrupted or the power cuts out.
13
+ """
14
+ temp_filepath = f"{filepath}.tmp"
15
+ try:
16
+ # Create directory if it doesn't exist
17
+ os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
18
+
19
+ # Write to temp file
20
+ with open(temp_filepath, "w", encoding="utf-8") as f:
21
+ json.dump(data, f, indent=4, ensure_ascii=False)
22
+ f.flush()
23
+ # Force write to physical disk storage
24
+ try:
25
+ os.fsync(f.fileno())
26
+ except OSError:
27
+ # Some filesystems or OS layers don't support fsync on all descriptors
28
+ pass
29
+
30
+ # Atomically replace target file
31
+ os.replace(temp_filepath, filepath)
32
+ return True
33
+ except Exception as e:
34
+ logger.error(f"Failed to save state atomically: {e}")
35
+ # Clean up temp file if it exists
36
+ if os.path.exists(temp_filepath):
37
+ try:
38
+ os.remove(temp_filepath)
39
+ except OSError:
40
+ pass
41
+ return False
42
+
43
+ def load_state(filepath: str) -> Optional[Dict[str, Any]]:
44
+ """
45
+ Loads state metadata from the JSON file. Returns None if file does not exist or is corrupt.
46
+ """
47
+ if not os.path.exists(filepath):
48
+ return None
49
+
50
+ try:
51
+ with open(filepath, "r", encoding="utf-8") as f:
52
+ return json.load(f)
53
+ except (json.JSONDecodeError, OSError) as e:
54
+ logger.error(f"Failed to load state from {filepath}: {e}")
55
+ return None
@@ -0,0 +1,119 @@
1
+ from rich.progress import (
2
+ Progress,
3
+ TextColumn,
4
+ BarColumn,
5
+ DownloadColumn,
6
+ TransferSpeedColumn,
7
+ TimeRemainingColumn,
8
+ SpinnerColumn,
9
+ TaskID,
10
+ )
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from typing import Dict
15
+
16
+ console = Console()
17
+
18
+ class SpeedyProgress:
19
+ def __init__(self, num_segments: int, total_size: int, filename: str):
20
+ self.num_segments = num_segments
21
+ self.total_size = total_size
22
+ self.filename = filename
23
+ self.segment_tasks: Dict[int, TaskID] = {}
24
+
25
+ # Configure columns for a clean, premium dashboard
26
+ self.progress = Progress(
27
+ SpinnerColumn(spinner_name="dots", style="bold cyan"),
28
+ TextColumn("[bold blue]{task.description:<20}"),
29
+ BarColumn(bar_width=40, style="grey23", complete_style="bold green", finished_style="bold gold1"),
30
+ "[progress.percentage]{task.percentage:>3.0f}%",
31
+ DownloadColumn(),
32
+ TransferSpeedColumn(),
33
+ TimeRemainingColumn(),
34
+ console=console,
35
+ transient=True # Clear progress display once finished to keep terminal clean
36
+ )
37
+
38
+ self.master_task: TaskID = None
39
+
40
+ def __enter__(self):
41
+ self.progress.start()
42
+
43
+ # Header banner
44
+ console.print(Panel(
45
+ f"[bold green]IDM-CLI Engine Activated[/bold green]\n"
46
+ f"[bold white]Downloading:[/bold white] [cyan]{self.filename}[/cyan]\n"
47
+ f"[bold white]Total Size:[/bold white] [yellow]{self._format_size(self.total_size)}[/yellow] | "
48
+ f"[bold white]Connections:[/bold white] [yellow]{self.num_segments}[/yellow]",
49
+ border_style="bold blue",
50
+ title="[bold yellow]idm-cli[/bold yellow]"
51
+ ))
52
+
53
+ # Add master overall task
54
+ self.master_task = self.progress.add_task(
55
+ "[bold gold1]Overall Progress[/bold gold1]",
56
+ total=self.total_size
57
+ )
58
+
59
+ # Add segment tasks if they aren't too crowded (max 16 tasks for screen space)
60
+ if self.num_segments <= 16:
61
+ for i in range(self.num_segments):
62
+ self.segment_tasks[i] = self.progress.add_task(
63
+ f" ├── Connection {i+1}",
64
+ total=0, # Will be set when segment size is known
65
+ visible=True
66
+ )
67
+
68
+ return self
69
+
70
+ def __exit__(self, exc_type, exc_val, exc_tb):
71
+ self.progress.stop()
72
+
73
+ def set_segment_total(self, segment_id: int, total: int, completed: int = 0):
74
+ """Sets the total size for a segment's progress bar (and how much is already completed)."""
75
+ if segment_id in self.segment_tasks:
76
+ self.progress.update(self.segment_tasks[segment_id], total=total, completed=completed)
77
+ # Advance overall progress by the pre-completed amount
78
+ if completed > 0:
79
+ self.progress.advance(self.master_task, completed)
80
+
81
+ def update_segment(self, segment_id: int, bytes_downloaded: int):
82
+ """Updates both the segment progress and the overall master progress."""
83
+ if segment_id in self.segment_tasks:
84
+ self.progress.advance(self.segment_tasks[segment_id], bytes_downloaded)
85
+ self.progress.advance(self.master_task, bytes_downloaded)
86
+
87
+ def mark_segment_completed(self, segment_id: int):
88
+ """Marks a segment as fully finished and updates visual cues."""
89
+ if segment_id in self.segment_tasks:
90
+ task_id = self.segment_tasks[segment_id]
91
+ # Ensure it reaches 100%
92
+ self.progress.update(
93
+ task_id,
94
+ completed=self.progress.tasks[task_id].total,
95
+ description=f" ├── [green]Connection {segment_id+1} [OK][/green]"
96
+ )
97
+
98
+ def print_summary(self, elapsed_time: float, final_size: int, saved_path: str):
99
+ """Prints a beautiful summary table upon successful download completion."""
100
+ avg_speed = final_size / elapsed_time if elapsed_time > 0 else 0
101
+
102
+ summary_table = Table(title="[bold green]Download Summary[/bold green]", show_header=False, border_style="bold green")
103
+ summary_table.add_row("[bold white]File Saved To[/bold white]", f"[cyan]{saved_path}[/cyan]")
104
+ summary_table.add_row("[bold white]Downloaded Size[/bold white]", f"[yellow]{self._format_size(final_size)}[/yellow]")
105
+ summary_table.add_row("[bold white]Time Elapsed[/bold white]", f"{elapsed_time:.2f} seconds")
106
+ summary_table.add_row("[bold white]Average Speed[/bold white]", f"[bold yellow]{self._format_size(avg_speed)}/s[/bold yellow]")
107
+
108
+ console.print()
109
+ console.print(Panel(summary_table, border_style="bold green"))
110
+ console.print("[bold gold1]* Download complete! Enjoy your speedy delivery.[/bold gold1]")
111
+
112
+ @staticmethod
113
+ def _format_size(size: float) -> str:
114
+ """Helper to format bytes into readable KB/MB/GB strings."""
115
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
116
+ if size < 1024.0:
117
+ return f"{size:.2f} {unit}"
118
+ size /= 1024.0
119
+ return f"{size:.2f} PB"
@@ -0,0 +1,71 @@
1
+ import os
2
+ import time
3
+ import httpx
4
+ import logging
5
+ from typing import Optional
6
+
7
+ import speedy_dl
8
+ from speedy_dl.state import load_state, save_state_atomic
9
+
10
+ logger = logging.getLogger("speedy_dl.updater")
11
+
12
+ # Store cache in user's home directory so it persists across updates and system-wide calls
13
+ CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".speedy_dl_config.json")
14
+
15
+ def get_latest_version_cached(package_name: str = "idm-cli", cache_expiry: int = 86400) -> Optional[str]:
16
+ """
17
+ Retrieves the latest published package version from the PyPI JSON API.
18
+ Caches results locally for 24 hours to ensure 0ms network latency on shell startup.
19
+ Fails silently in under 1 second if offline.
20
+ """
21
+ current_time = time.time()
22
+ config = load_state(CONFIG_PATH) or {}
23
+
24
+ last_check = config.get("last_update_check", 0)
25
+ cached_version = config.get("latest_version")
26
+
27
+ # If cached results exist and cache has not expired yet, use it immediately
28
+ if cached_version and (current_time - last_check < cache_expiry):
29
+ return cached_version
30
+
31
+ # Cache is either missing or expired. Let's perform a fresh PyPI API check
32
+ try:
33
+ url = f"https://pypi.org/pypi/{package_name}/json"
34
+ # Strict 1.0 second timeout to prevent hanging on slow/offline connections
35
+ with httpx.Client(timeout=1.0) as client:
36
+ response = client.get(url)
37
+ if response.status_code == 200:
38
+ data = response.json()
39
+ latest_ver = data.get("info", {}).get("version")
40
+ if latest_ver:
41
+ # Update local cache atomically
42
+ config["last_update_check"] = current_time
43
+ config["latest_version"] = latest_ver
44
+ save_state_atomic(CONFIG_PATH, config)
45
+ return latest_ver
46
+ except Exception:
47
+ # Network unreachable or timeout occurred - silently ignore and fallback to cached
48
+ pass
49
+
50
+ return cached_version
51
+
52
+ def check_for_updates() -> Optional[str]:
53
+ """
54
+ Compares the local version of speedy-dl with the latest version published on PyPI.
55
+ Returns the latest version string if a newer update is available, otherwise returns None.
56
+ """
57
+ local_ver = speedy_dl.__version__
58
+ latest_ver = get_latest_version_cached()
59
+
60
+ if not latest_ver:
61
+ return None
62
+
63
+ try:
64
+ # Convert semantic strings "0.1.0" into numeric tuples (0, 1, 0) for safe comparison
65
+ local_parts = tuple(map(int, local_ver.split(".")))
66
+ latest_parts = tuple(map(int, latest_ver.split(".")))
67
+ if latest_parts > local_parts:
68
+ return latest_ver
69
+ except Exception:
70
+ pass
71
+ return None