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 +8 -0
- idm_cli-0.1.0/README.md +106 -0
- idm_cli-0.1.0/idm_cli.egg-info/PKG-INFO +8 -0
- idm_cli-0.1.0/idm_cli.egg-info/SOURCES.txt +15 -0
- idm_cli-0.1.0/idm_cli.egg-info/dependency_links.txt +1 -0
- idm_cli-0.1.0/idm_cli.egg-info/entry_points.txt +3 -0
- idm_cli-0.1.0/idm_cli.egg-info/requires.txt +4 -0
- idm_cli-0.1.0/idm_cli.egg-info/top_level.txt +1 -0
- idm_cli-0.1.0/setup.cfg +4 -0
- idm_cli-0.1.0/setup.py +19 -0
- idm_cli-0.1.0/speedy_dl/__init__.py +2 -0
- idm_cli-0.1.0/speedy_dl/cli.py +199 -0
- idm_cli-0.1.0/speedy_dl/downloader.py +408 -0
- idm_cli-0.1.0/speedy_dl/extractor.py +88 -0
- idm_cli-0.1.0/speedy_dl/state.py +55 -0
- idm_cli-0.1.0/speedy_dl/ui.py +119 -0
- idm_cli-0.1.0/speedy_dl/updater.py +71 -0
idm_cli-0.1.0/PKG-INFO
ADDED
idm_cli-0.1.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
speedy_dl
|
idm_cli-0.1.0/setup.cfg
ADDED
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,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
|