idm-cli 1.3.0__tar.gz → 1.3.1__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-1.3.1/MANIFEST.in +1 -0
- {idm_cli-1.3.0/idm_cli.egg-info → idm_cli-1.3.1}/PKG-INFO +28 -88
- {idm_cli-1.3.0 → idm_cli-1.3.1}/README.md +21 -78
- idm_cli-1.3.1/idm_cli/__init__.py +3 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/config.py +17 -7
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/core.py +28 -19
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/downloader.py +60 -23
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/handlers.py +58 -15
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/muxer.py +12 -17
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/state.py +5 -3
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/__init__.py +4 -4
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/winget.py +5 -1
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/ui/cli.py +6 -17
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/ui/utils.py +6 -4
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/update_checker.py +5 -2
- {idm_cli-1.3.0 → idm_cli-1.3.1/idm_cli.egg-info}/PKG-INFO +28 -88
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/SOURCES.txt +4 -5
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/requires.txt +3 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/top_level.txt +1 -0
- idm_cli-1.3.1/pyproject.toml +45 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/requirements.txt +0 -2
- {idm_cli-1.3.0 → idm_cli-1.3.1}/setup.py +1 -3
- idm_cli-1.3.1/tests/test_core.py +76 -0
- idm_cli-1.3.0/MANIFEST.in +0 -2
- idm_cli-1.3.0/idm_cli/__init__.py +0 -3
- idm_cli-1.3.0/idm_cli/extension/daemon.py +0 -44
- idm_cli-1.3.0/idm_cli/extension/extension.py +0 -161
- idm_cli-1.3.0/idm_cli/extension/native_host.py +0 -124
- {idm_cli-1.3.0 → idm_cli-1.3.1}/LICENSE +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/__main__.py +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/__init__.py +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/direct.py +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/ytdlp.py +0 -0
- {idm_cli-1.3.0/idm_cli/extension → idm_cli-1.3.1/idm_cli/ui}/__init__.py +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/dependency_links.txt +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/entry_points.txt +0 -0
- {idm_cli-1.3.0 → idm_cli-1.3.1}/setup.cfg +0 -0
- {idm_cli-1.3.0/idm_cli/ui → idm_cli-1.3.1/tests}/__init__.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include requirements.txt
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: idm-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: A lightning-fast, universal command-line download manager
|
|
5
5
|
Home-page: https://github.com/rj41-w2/idm-cli
|
|
6
6
|
Author: Rehan
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/rj41-w2/idm-cli
|
|
7
9
|
Classifier: Programming Language :: Python :: 3
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
10
|
Classifier: Operating System :: OS Independent
|
|
10
11
|
Classifier: Environment :: Console
|
|
11
12
|
Requires-Python: >=3.8
|
|
@@ -22,35 +23,43 @@ Requires-Dist: aiodns
|
|
|
22
23
|
Requires-Dist: Brotli
|
|
23
24
|
Requires-Dist: psutil
|
|
24
25
|
Requires-Dist: platformdirs
|
|
25
|
-
|
|
26
|
-
Requires-Dist: pytest
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
Dynamic: description
|
|
30
|
-
Dynamic: description-content-type
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
29
|
+
Requires-Dist: packaging; extra == "dev"
|
|
31
30
|
Dynamic: home-page
|
|
32
31
|
Dynamic: license-file
|
|
33
|
-
Dynamic: requires-dist
|
|
34
32
|
Dynamic: requires-python
|
|
35
|
-
Dynamic: summary
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
<div align="center">
|
|
38
35
|
|
|
39
|
-
|
|
36
|
+
<h1>IDM-CLI</h1>
|
|
40
37
|
|
|
41
|
-
A lightning-fast, cross-platform command-line download manager written in Python
|
|
38
|
+
**A lightning-fast, cross-platform command-line download manager written in Python.**
|
|
39
|
+
|
|
40
|
+
*The official IDM asks you to pay after 30 days — IDM-CLI is 100% free, open-source, and yours to modify!*
|
|
41
|
+
|
|
42
|
+
[](https://pypi.org/project/idm-cli/)
|
|
43
|
+
[](https://pepy.tech/project/idm-cli)
|
|
44
|
+
[](https://github.com/rj41-w2/idm-cli/blob/main/LICENSE)
|
|
45
|
+

|
|
46
|
+
[](https://pypi.org/project/idm-cli/)
|
|
47
|
+
|
|
48
|
+
[Download via PyPI](https://pypi.org/project/idm-cli/) · [Report a Bug](https://github.com/rj41-w2/idm-cli/issues)
|
|
49
|
+
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
IDM-CLI splits files into multiple parallel chunks (default 8, up to 32) to maximize your internet speed. It supports downloading from **YouTube**, **Facebook**, **Instagram**, and any **Direct File URL** (`.exe`, `.zip`, `.pdf`, etc.).
|
|
42
53
|
|
|
43
54
|
## Features
|
|
44
55
|
|
|
45
56
|
- **Parallel Chunk Engine** — Splits downloads into multiple concurrent chunks using `aiohttp` async sessions to fully saturate your bandwidth.
|
|
46
|
-
- **Universal Downloader** — Paste any
|
|
47
|
-
- **Social Media Support** — Natively supports YouTube, Facebook, and Instagram via a modular extractor architecture with yt-dlp.
|
|
57
|
+
- **Universal Downloader** — Paste any URL. yt-dlp handles 1000+ sites (YouTube, Facebook, Instagram, etc.), with automatic fallback to direct HTTP download for unsupported URLs.
|
|
48
58
|
- **Audio Only / MP3 Conversion** — Download videos as audio. IDM-CLI automatically grabs the best audio stream and uses FFmpeg to convert it to `.mp3`.
|
|
49
59
|
- **Smart Auto-Resume** — Internet dropped? Pressed `Ctrl+C`? No problem. IDM-CLI remembers the exact byte positions of all incomplete chunks. Paste the same link again to resume instantly.
|
|
50
60
|
- **Persistent Queue System** — Add multiple files to a queue with `-Q` and type `start queue` to download them all sequentially.
|
|
51
|
-
- **Browser Extension Integration** — Chrome/Edge extension intercepts downloads and routes them directly into IDM-CLI via Native Messaging.
|
|
52
61
|
- **Interactive UI** — Modern terminal interface powered by `rich` and `questionary` with real-time speed, ETA, and progress bars. Press `p` to pause, `r` to resume.
|
|
53
|
-
- **Cross-Platform** — Works on Windows, macOS, and Linux with platform-specific FFmpeg handling
|
|
62
|
+
- **Cross-Platform** — Works on Windows, macOS, and Linux with platform-specific FFmpeg handling.
|
|
54
63
|
- **Auto-Update Check** — Checks PyPI once a day and notifies you when a new version is available.
|
|
55
64
|
|
|
56
65
|
## Prerequisites
|
|
@@ -118,22 +127,9 @@ idm "https://facebook.com/..." -Q
|
|
|
118
127
|
| `<URL>` | Download a video or file |
|
|
119
128
|
| `resume` | Resume or delete incomplete downloads |
|
|
120
129
|
| `start queue` | Process all queued downloads |
|
|
121
|
-
| `install extension` | Guide to install the browser extension |
|
|
122
130
|
| `help` | Show available commands and flags |
|
|
123
131
|
| `exit` | Exit the application |
|
|
124
132
|
|
|
125
|
-
### Browser Extension
|
|
126
|
-
|
|
127
|
-
IDM-CLI includes a Chrome/Edge extension that intercepts file downloads automatically. To install it:
|
|
128
|
-
|
|
129
|
-
```bash
|
|
130
|
-
idm
|
|
131
|
-
# then type:
|
|
132
|
-
install extension
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Follow the on-screen guide to load the unpacked extension and link it to IDM-CLI.
|
|
136
|
-
|
|
137
133
|
### Troubleshooting: Windows Device Guard
|
|
138
134
|
|
|
139
135
|
If you get `"idm.exe was blocked by your organization's Device Guard policy"`, run the Python module directly:
|
|
@@ -161,69 +157,13 @@ idm_cli/
|
|
|
161
157
|
│ ├── ytdlp.py # YouTube/social media extractor
|
|
162
158
|
│ ├── direct.py # Direct HTTP file downloader
|
|
163
159
|
│ └── winget.py # Windows Package Manager extractor
|
|
164
|
-
└── extension/
|
|
165
|
-
├── extension.py # Browser extension installer
|
|
166
|
-
├── native_host.py # Native Messaging Host
|
|
167
|
-
├── daemon.py # Queue daemon with PID lock
|
|
168
|
-
└── browser_extension/ # Chrome/Edge extension source
|
|
169
160
|
```
|
|
170
161
|
|
|
171
162
|
## Contributing
|
|
172
163
|
|
|
173
|
-
Contributions are welcome
|
|
174
|
-
|
|
175
|
-
### Reporting Bugs
|
|
176
|
-
|
|
177
|
-
If you find a bug, [open an issue](https://github.com/rj41-w2/idm-cli/issues/new) with:
|
|
178
|
-
|
|
179
|
-
- A clear title and description of the problem
|
|
180
|
-
- Steps to reproduce the issue
|
|
181
|
-
- Your OS and Python version (`python --version`)
|
|
182
|
-
- The full error output if there is one
|
|
183
|
-
|
|
184
|
-
### Suggesting Features
|
|
185
|
-
|
|
186
|
-
Got an idea? [Open an issue](https://github.com/rj41-w2/idm-cli/issues/new) with the tag `enhancement` and describe:
|
|
187
|
-
|
|
188
|
-
- What you want to happen and why
|
|
189
|
-
- How it would work from the user's perspective
|
|
190
|
-
- Any alternatives you considered
|
|
191
|
-
|
|
192
|
-
### Submitting a Pull Request
|
|
193
|
-
|
|
194
|
-
1. **Fork** the repository from [github.com/rj41-w2/idm-cli](https://github.com/rj41-w2/idm-cli)
|
|
195
|
-
|
|
196
|
-
2. **Clone your fork** and set it up:
|
|
197
|
-
```bash
|
|
198
|
-
git clone https://github.com/<your-username>/idm-cli.git
|
|
199
|
-
cd IDM-CLI
|
|
200
|
-
pip install -r requirements.txt
|
|
201
|
-
pip install -e .
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
3. **Create a branch** for your change:
|
|
205
|
-
```bash
|
|
206
|
-
git checkout -b fix/short-description
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
4. **Make your changes**, test them, and commit with a clear message:
|
|
210
|
-
```bash
|
|
211
|
-
git commit -m "fix: describe the bug you fixed"
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
5. **Push** to your fork and open a Pull Request against `main`:
|
|
215
|
-
```bash
|
|
216
|
-
git push origin fix/short-description
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
6. In your PR description, explain **what changed** and **why**. Link the issue if there is one.
|
|
220
|
-
|
|
221
|
-
### Guidelines
|
|
164
|
+
Contributions are welcome. Fork the repo, make your changes on a feature branch, and open a PR against `main`. Keep changes focused and follow the existing code style.
|
|
222
165
|
|
|
223
|
-
|
|
224
|
-
- Follow the existing code style — no comments unless absolutely necessary.
|
|
225
|
-
- Test your changes on at least one platform before submitting.
|
|
226
|
-
- If you're adding a feature, update the README and spec if needed.
|
|
166
|
+
For bug reports and feature requests, [open an issue](https://github.com/rj41-w2/idm-cli/issues).
|
|
227
167
|
|
|
228
168
|
## License
|
|
229
169
|
|
|
@@ -1,20 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<h1>IDM-CLI</h1>
|
|
4
4
|
|
|
5
|
-
A lightning-fast, cross-platform command-line download manager written in Python
|
|
5
|
+
**A lightning-fast, cross-platform command-line download manager written in Python.**
|
|
6
|
+
|
|
7
|
+
*The official IDM asks you to pay after 30 days — IDM-CLI is 100% free, open-source, and yours to modify!*
|
|
8
|
+
|
|
9
|
+
[](https://pypi.org/project/idm-cli/)
|
|
10
|
+
[](https://pepy.tech/project/idm-cli)
|
|
11
|
+
[](https://github.com/rj41-w2/idm-cli/blob/main/LICENSE)
|
|
12
|
+

|
|
13
|
+
[](https://pypi.org/project/idm-cli/)
|
|
14
|
+
|
|
15
|
+
[Download via PyPI](https://pypi.org/project/idm-cli/) · [Report a Bug](https://github.com/rj41-w2/idm-cli/issues)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
IDM-CLI splits files into multiple parallel chunks (default 8, up to 32) to maximize your internet speed. It supports downloading from **YouTube**, **Facebook**, **Instagram**, and any **Direct File URL** (`.exe`, `.zip`, `.pdf`, etc.).
|
|
6
20
|
|
|
7
21
|
## Features
|
|
8
22
|
|
|
9
23
|
- **Parallel Chunk Engine** — Splits downloads into multiple concurrent chunks using `aiohttp` async sessions to fully saturate your bandwidth.
|
|
10
|
-
- **Universal Downloader** — Paste any
|
|
11
|
-
- **Social Media Support** — Natively supports YouTube, Facebook, and Instagram via a modular extractor architecture with yt-dlp.
|
|
24
|
+
- **Universal Downloader** — Paste any URL. yt-dlp handles 1000+ sites (YouTube, Facebook, Instagram, etc.), with automatic fallback to direct HTTP download for unsupported URLs.
|
|
12
25
|
- **Audio Only / MP3 Conversion** — Download videos as audio. IDM-CLI automatically grabs the best audio stream and uses FFmpeg to convert it to `.mp3`.
|
|
13
26
|
- **Smart Auto-Resume** — Internet dropped? Pressed `Ctrl+C`? No problem. IDM-CLI remembers the exact byte positions of all incomplete chunks. Paste the same link again to resume instantly.
|
|
14
27
|
- **Persistent Queue System** — Add multiple files to a queue with `-Q` and type `start queue` to download them all sequentially.
|
|
15
|
-
- **Browser Extension Integration** — Chrome/Edge extension intercepts downloads and routes them directly into IDM-CLI via Native Messaging.
|
|
16
28
|
- **Interactive UI** — Modern terminal interface powered by `rich` and `questionary` with real-time speed, ETA, and progress bars. Press `p` to pause, `r` to resume.
|
|
17
|
-
- **Cross-Platform** — Works on Windows, macOS, and Linux with platform-specific FFmpeg handling
|
|
29
|
+
- **Cross-Platform** — Works on Windows, macOS, and Linux with platform-specific FFmpeg handling.
|
|
18
30
|
- **Auto-Update Check** — Checks PyPI once a day and notifies you when a new version is available.
|
|
19
31
|
|
|
20
32
|
## Prerequisites
|
|
@@ -82,22 +94,9 @@ idm "https://facebook.com/..." -Q
|
|
|
82
94
|
| `<URL>` | Download a video or file |
|
|
83
95
|
| `resume` | Resume or delete incomplete downloads |
|
|
84
96
|
| `start queue` | Process all queued downloads |
|
|
85
|
-
| `install extension` | Guide to install the browser extension |
|
|
86
97
|
| `help` | Show available commands and flags |
|
|
87
98
|
| `exit` | Exit the application |
|
|
88
99
|
|
|
89
|
-
### Browser Extension
|
|
90
|
-
|
|
91
|
-
IDM-CLI includes a Chrome/Edge extension that intercepts file downloads automatically. To install it:
|
|
92
|
-
|
|
93
|
-
```bash
|
|
94
|
-
idm
|
|
95
|
-
# then type:
|
|
96
|
-
install extension
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Follow the on-screen guide to load the unpacked extension and link it to IDM-CLI.
|
|
100
|
-
|
|
101
100
|
### Troubleshooting: Windows Device Guard
|
|
102
101
|
|
|
103
102
|
If you get `"idm.exe was blocked by your organization's Device Guard policy"`, run the Python module directly:
|
|
@@ -125,69 +124,13 @@ idm_cli/
|
|
|
125
124
|
│ ├── ytdlp.py # YouTube/social media extractor
|
|
126
125
|
│ ├── direct.py # Direct HTTP file downloader
|
|
127
126
|
│ └── winget.py # Windows Package Manager extractor
|
|
128
|
-
└── extension/
|
|
129
|
-
├── extension.py # Browser extension installer
|
|
130
|
-
├── native_host.py # Native Messaging Host
|
|
131
|
-
├── daemon.py # Queue daemon with PID lock
|
|
132
|
-
└── browser_extension/ # Chrome/Edge extension source
|
|
133
127
|
```
|
|
134
128
|
|
|
135
129
|
## Contributing
|
|
136
130
|
|
|
137
|
-
Contributions are welcome
|
|
138
|
-
|
|
139
|
-
### Reporting Bugs
|
|
140
|
-
|
|
141
|
-
If you find a bug, [open an issue](https://github.com/rj41-w2/idm-cli/issues/new) with:
|
|
142
|
-
|
|
143
|
-
- A clear title and description of the problem
|
|
144
|
-
- Steps to reproduce the issue
|
|
145
|
-
- Your OS and Python version (`python --version`)
|
|
146
|
-
- The full error output if there is one
|
|
147
|
-
|
|
148
|
-
### Suggesting Features
|
|
149
|
-
|
|
150
|
-
Got an idea? [Open an issue](https://github.com/rj41-w2/idm-cli/issues/new) with the tag `enhancement` and describe:
|
|
151
|
-
|
|
152
|
-
- What you want to happen and why
|
|
153
|
-
- How it would work from the user's perspective
|
|
154
|
-
- Any alternatives you considered
|
|
155
|
-
|
|
156
|
-
### Submitting a Pull Request
|
|
157
|
-
|
|
158
|
-
1. **Fork** the repository from [github.com/rj41-w2/idm-cli](https://github.com/rj41-w2/idm-cli)
|
|
159
|
-
|
|
160
|
-
2. **Clone your fork** and set it up:
|
|
161
|
-
```bash
|
|
162
|
-
git clone https://github.com/<your-username>/idm-cli.git
|
|
163
|
-
cd IDM-CLI
|
|
164
|
-
pip install -r requirements.txt
|
|
165
|
-
pip install -e .
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
3. **Create a branch** for your change:
|
|
169
|
-
```bash
|
|
170
|
-
git checkout -b fix/short-description
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
4. **Make your changes**, test them, and commit with a clear message:
|
|
174
|
-
```bash
|
|
175
|
-
git commit -m "fix: describe the bug you fixed"
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
5. **Push** to your fork and open a Pull Request against `main`:
|
|
179
|
-
```bash
|
|
180
|
-
git push origin fix/short-description
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
6. In your PR description, explain **what changed** and **why**. Link the issue if there is one.
|
|
184
|
-
|
|
185
|
-
### Guidelines
|
|
131
|
+
Contributions are welcome. Fork the repo, make your changes on a feature branch, and open a PR against `main`. Keep changes focused and follow the existing code style.
|
|
186
132
|
|
|
187
|
-
|
|
188
|
-
- Follow the existing code style — no comments unless absolutely necessary.
|
|
189
|
-
- Test your changes on at least one platform before submitting.
|
|
190
|
-
- If you're adding a feature, update the README and spec if needed.
|
|
133
|
+
For bug reports and feature requests, [open an issue](https://github.com/rj41-w2/idm-cli/issues).
|
|
191
134
|
|
|
192
135
|
## License
|
|
193
136
|
|
|
@@ -6,13 +6,15 @@ import logging
|
|
|
6
6
|
from logging.handlers import RotatingFileHandler
|
|
7
7
|
from platformdirs import user_data_dir, user_downloads_dir
|
|
8
8
|
|
|
9
|
+
__all__ = ["CONFIG_DIR", "CONFIG_FILE", "logger", "load_config", "save_config", "setup_logging"]
|
|
10
|
+
|
|
9
11
|
APP_NAME = "idm-cli"
|
|
10
12
|
APP_AUTHOR = "idm-cli"
|
|
11
13
|
CONFIG_DIR = user_data_dir(APP_NAME, APP_AUTHOR)
|
|
12
14
|
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
13
15
|
LOG_FILE = os.path.join(CONFIG_DIR, "idm.log")
|
|
14
16
|
|
|
15
|
-
def _migrate_old_config():
|
|
17
|
+
def _migrate_old_config() -> None:
|
|
16
18
|
old_dir = os.path.expanduser("~/.idm_cli")
|
|
17
19
|
if old_dir == CONFIG_DIR:
|
|
18
20
|
return
|
|
@@ -20,6 +22,8 @@ def _migrate_old_config():
|
|
|
20
22
|
return
|
|
21
23
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
22
24
|
for item in os.listdir(old_dir):
|
|
25
|
+
if item == "__pycache__":
|
|
26
|
+
continue
|
|
23
27
|
src = os.path.join(old_dir, item)
|
|
24
28
|
dst = os.path.join(CONFIG_DIR, item)
|
|
25
29
|
if not os.path.exists(dst):
|
|
@@ -34,16 +38,22 @@ def _migrate_old_config():
|
|
|
34
38
|
def _get_default_download_dir() -> str:
|
|
35
39
|
return user_downloads_dir()
|
|
36
40
|
|
|
37
|
-
def setup_logging():
|
|
41
|
+
def setup_logging() -> logging.Logger:
|
|
38
42
|
_migrate_old_config()
|
|
39
43
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
40
44
|
logger = logging.getLogger("idm_cli")
|
|
41
45
|
logger.setLevel(logging.DEBUG)
|
|
42
46
|
if not logger.handlers:
|
|
43
|
-
handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=2)
|
|
44
47
|
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
|
|
49
|
+
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=2, encoding="utf-8")
|
|
50
|
+
file_handler.setFormatter(formatter)
|
|
51
|
+
logger.addHandler(file_handler)
|
|
52
|
+
|
|
53
|
+
stream_handler = logging.StreamHandler()
|
|
54
|
+
stream_handler.setFormatter(formatter)
|
|
55
|
+
stream_handler.stream.reconfigure(errors="backslashreplace")
|
|
56
|
+
logger.addHandler(stream_handler)
|
|
47
57
|
return logger
|
|
48
58
|
|
|
49
59
|
logger = setup_logging()
|
|
@@ -55,7 +65,7 @@ DEFAULT_CONFIG = {
|
|
|
55
65
|
"last_version": "0.0.0"
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
def load_config():
|
|
68
|
+
def load_config() -> dict:
|
|
59
69
|
config = DEFAULT_CONFIG.copy()
|
|
60
70
|
if os.path.exists(CONFIG_FILE):
|
|
61
71
|
try:
|
|
@@ -66,7 +76,7 @@ def load_config():
|
|
|
66
76
|
logger.warning(f"Failed to load config: {e}")
|
|
67
77
|
return config
|
|
68
78
|
|
|
69
|
-
def save_config(config_data):
|
|
79
|
+
def save_config(config_data: dict) -> None:
|
|
70
80
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
71
81
|
try:
|
|
72
82
|
with open(CONFIG_FILE, "w") as f:
|
|
@@ -13,6 +13,16 @@ from idm_cli.downloader.muxer import mux_audio_video, convert_to_mp3
|
|
|
13
13
|
from idm_cli.ui.utils import console, custom_style, sanitize_filename
|
|
14
14
|
from idm_cli.config import logger
|
|
15
15
|
|
|
16
|
+
def run_download_and_mux(video_url, audio_url, headers, chunks, video_dest, audio_dest, final_dest, media_type, pause_event, warning_state):
|
|
17
|
+
asyncio.run(download_media(video_url, audio_url, headers, chunks, video_dest, audio_dest, pause_event, warning_state, media_type))
|
|
18
|
+
if video_dest and audio_dest:
|
|
19
|
+
mux_audio_video(video_dest, audio_dest, final_dest)
|
|
20
|
+
elif audio_dest and not video_dest:
|
|
21
|
+
convert_to_mp3(audio_dest, final_dest)
|
|
22
|
+
elif video_dest and not audio_dest:
|
|
23
|
+
shutil.move(video_dest, final_dest)
|
|
24
|
+
return True
|
|
25
|
+
|
|
16
26
|
def process_download(
|
|
17
27
|
current_url: str,
|
|
18
28
|
is_interactive: bool,
|
|
@@ -55,11 +65,20 @@ def process_download(
|
|
|
55
65
|
with console.status("[bold cyan]Fetching metadata...", spinner="dots"):
|
|
56
66
|
try:
|
|
57
67
|
extractor = get_extractor(url_to_extract)
|
|
58
|
-
|
|
68
|
+
try:
|
|
69
|
+
info = extractor.fetch_all_info(url_to_extract)
|
|
70
|
+
except Exception:
|
|
71
|
+
if extractor.__name__ == "idm_cli.extractors.ytdlp":
|
|
72
|
+
import importlib
|
|
73
|
+
extractor = importlib.import_module("idm_cli.extractors.direct")
|
|
74
|
+
info = extractor.fetch_all_info(url_to_extract)
|
|
75
|
+
else:
|
|
76
|
+
raise
|
|
77
|
+
|
|
59
78
|
title = info.get("title", "download") if not found_task_id else title
|
|
60
79
|
if loop_filename and not found_task_id:
|
|
61
80
|
title = os.path.basename(loop_filename)
|
|
62
|
-
|
|
81
|
+
|
|
63
82
|
if info.get("_type") == "playlist":
|
|
64
83
|
console.print("\n[bold yellow]Currently, the feature to download album/playlist photos or videos is not added.[/]")
|
|
65
84
|
if not is_interactive:
|
|
@@ -75,6 +94,12 @@ def process_download(
|
|
|
75
94
|
if not is_interactive:
|
|
76
95
|
raise typer.Exit(code=1)
|
|
77
96
|
return False
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.error(f"Error fetching info: {e}")
|
|
99
|
+
console.print(f"[bold red]Error:[/] {e}")
|
|
100
|
+
if not is_interactive:
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
return False
|
|
78
103
|
|
|
79
104
|
if not found_task_id:
|
|
80
105
|
if format_id != "audio_only":
|
|
@@ -264,7 +289,7 @@ def process_download(
|
|
|
264
289
|
signal.signal(signal.SIGINT, custom_handler)
|
|
265
290
|
|
|
266
291
|
try:
|
|
267
|
-
|
|
292
|
+
run_download_and_mux(video_url, audio_url, headers, loop_chunks, video_dest, audio_dest, final_dest, media_type, pause_event, warning_state)
|
|
268
293
|
except KeyboardInterrupt:
|
|
269
294
|
console.print("\n[bold red]Download cancelled by user. Progress saved to resume later.[/bold red]")
|
|
270
295
|
return False
|
|
@@ -283,22 +308,6 @@ def process_download(
|
|
|
283
308
|
signal.signal(signal.SIGINT, original_sigint)
|
|
284
309
|
|
|
285
310
|
console.print("\n[bold green]✓[/] Downloads completed.")
|
|
286
|
-
console.print("[bold cyan]Muxing audio and video streams...[/]")
|
|
287
|
-
|
|
288
|
-
with console.status("[bold magenta]Running FFmpeg...", spinner="bouncingBar"):
|
|
289
|
-
try:
|
|
290
|
-
if video_dest and audio_dest:
|
|
291
|
-
mux_audio_video(video_dest, audio_dest, final_dest)
|
|
292
|
-
elif audio_dest and not video_dest:
|
|
293
|
-
convert_to_mp3(audio_dest, final_dest)
|
|
294
|
-
elif video_dest and not audio_dest:
|
|
295
|
-
shutil.move(video_dest, final_dest)
|
|
296
|
-
except (ValueError, TypeError, OSError) as e:
|
|
297
|
-
logger.error(f"Muxing failed: {e}")
|
|
298
|
-
console.print(f"[bold red]Muxing failed:[/] {e}")
|
|
299
|
-
if not is_interactive:
|
|
300
|
-
raise typer.Exit(code=1)
|
|
301
|
-
return False
|
|
302
311
|
|
|
303
312
|
remove_download(task_id)
|
|
304
313
|
console.print(f"\n[bold green] Success! {media_type} saved as:[/] [bold white]{final_dest}[/]")
|
|
@@ -15,7 +15,7 @@ from rich.progress import (
|
|
|
15
15
|
from idm_cli.ui.utils import console, progress_listener
|
|
16
16
|
from idm_cli.config import logger
|
|
17
17
|
|
|
18
|
-
async def _download_chunk(session: aiohttp.ClientSession, url: str, start: int, end: int, chunk_index: int, dest_path: str, headers: dict, progress_queue: asyncio.Queue, task_id, pause_event: asyncio.Event = None, chunk_progress: dict = None):
|
|
18
|
+
async def _download_chunk(session: aiohttp.ClientSession, url: str, start: int, end: int, chunk_index: int, dest_path: str, headers: dict, progress_queue: asyncio.Queue, task_id, pause_event: asyncio.Event = None, chunk_progress: dict = None, chunk_lock: asyncio.Lock = None):
|
|
19
19
|
max_retries = 10
|
|
20
20
|
retry_count = 0
|
|
21
21
|
progress_file = f"{dest_path}.progress.json"
|
|
@@ -33,6 +33,7 @@ async def _download_chunk(session: aiohttp.ClientSession, url: str, start: int,
|
|
|
33
33
|
chunk_headers['Range'] = f'bytes={current_start}-'
|
|
34
34
|
else:
|
|
35
35
|
chunk_headers['Range'] = f'bytes={current_start}-{end}'
|
|
36
|
+
chunk_headers['Accept-Encoding'] = 'identity'
|
|
36
37
|
|
|
37
38
|
async with session.get(url, headers=chunk_headers) as response:
|
|
38
39
|
response.raise_for_status()
|
|
@@ -59,12 +60,21 @@ async def _download_chunk(session: aiohttp.ClientSession, url: str, start: int,
|
|
|
59
60
|
await f.write(chunk)
|
|
60
61
|
|
|
61
62
|
if chunk_progress is not None:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
if chunk_lock is not None:
|
|
64
|
+
async with chunk_lock:
|
|
65
|
+
chunk_progress[str(chunk_index)] = chunk_progress.get(str(chunk_index), 0) + len(chunk)
|
|
66
|
+
try:
|
|
67
|
+
async with aiofiles.open(progress_file, 'w') as pf:
|
|
68
|
+
await pf.write(json.dumps(chunk_progress))
|
|
69
|
+
except OSError as e:
|
|
70
|
+
logger.debug(f"Failed to write progress: {e}")
|
|
71
|
+
else:
|
|
72
|
+
chunk_progress[str(chunk_index)] = chunk_progress.get(str(chunk_index), 0) + len(chunk)
|
|
73
|
+
try:
|
|
74
|
+
async with aiofiles.open(progress_file, 'w') as pf:
|
|
75
|
+
await pf.write(json.dumps(chunk_progress))
|
|
76
|
+
except OSError as e:
|
|
77
|
+
logger.debug(f"Failed to write progress: {e}")
|
|
68
78
|
|
|
69
79
|
if progress_queue is not None and task_id is not None:
|
|
70
80
|
current_task_id = task_id[chunk_index] if isinstance(task_id, list) else task_id
|
|
@@ -74,12 +84,21 @@ async def _download_chunk(session: aiohttp.ClientSession, url: str, start: int,
|
|
|
74
84
|
'bytes_downloaded': len(chunk)
|
|
75
85
|
})
|
|
76
86
|
return dest_path
|
|
77
|
-
except (aiohttp.
|
|
87
|
+
except (aiohttp.ClientPayloadError, asyncio.TimeoutError, aiohttp.ClientConnectionError, aiohttp.ClientConnectorError) as e:
|
|
78
88
|
retry_count += 1
|
|
79
89
|
if retry_count >= max_retries:
|
|
80
90
|
raise ConnectionError("Your poor internet connection. Try again.")
|
|
81
91
|
logger.warning(f"Chunk {chunk_index} retry {retry_count}/{max_retries}: {e}")
|
|
82
92
|
await asyncio.sleep(min(2 ** retry_count, 30))
|
|
93
|
+
except aiohttp.ClientResponseError as e:
|
|
94
|
+
if e.status >= 500:
|
|
95
|
+
retry_count += 1
|
|
96
|
+
if retry_count >= max_retries:
|
|
97
|
+
raise ConnectionError(f"Server error {e.status}. Try again.")
|
|
98
|
+
logger.warning(f"Chunk {chunk_index} retry {retry_count}/{max_retries}: HTTP {e.status}")
|
|
99
|
+
await asyncio.sleep(min(2 ** retry_count, 30))
|
|
100
|
+
else:
|
|
101
|
+
raise
|
|
83
102
|
|
|
84
103
|
async def download_file(session: aiohttp.ClientSession, url: str, dest_path: str, headers: dict, num_chunks: int = 8, progress_queue: asyncio.Queue = None, task_id = None, pause_event: asyncio.Event = None):
|
|
85
104
|
"""
|
|
@@ -89,19 +108,35 @@ async def download_file(session: aiohttp.ClientSession, url: str, dest_path: str
|
|
|
89
108
|
chunk_progress = {}
|
|
90
109
|
|
|
91
110
|
if os.path.exists(dest_path) and not os.path.exists(progress_file):
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
local_size = os.path.getsize(dest_path)
|
|
112
|
+
server_size = None
|
|
113
|
+
try:
|
|
114
|
+
async with session.head(url, headers=headers or {}, allow_redirects=True) as response:
|
|
115
|
+
response.raise_for_status()
|
|
116
|
+
cl = response.headers.get('Content-Length')
|
|
117
|
+
if cl:
|
|
118
|
+
server_size = int(cl)
|
|
119
|
+
except aiohttp.ClientError:
|
|
120
|
+
pass
|
|
121
|
+
if server_size is not None and local_size != server_size:
|
|
122
|
+
logger.debug(f"File exists but size mismatch (local={local_size}, server={server_size}), restarting download")
|
|
123
|
+
try:
|
|
124
|
+
os.remove(dest_path)
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
else:
|
|
128
|
+
if progress_queue is not None and task_id is not None:
|
|
129
|
+
if isinstance(task_id, list):
|
|
130
|
+
chunk_size_est = local_size // num_chunks
|
|
131
|
+
for i in range(num_chunks):
|
|
132
|
+
end = local_size - 1 if i == num_chunks - 1 else ((i * chunk_size_est) + chunk_size_est - 1)
|
|
133
|
+
c_size = end - (i * chunk_size_est) + 1
|
|
134
|
+
await progress_queue.put({'task_id': task_id[i], 'total_size': c_size})
|
|
135
|
+
await progress_queue.put({'task_id': task_id[i], 'bytes_downloaded': c_size})
|
|
136
|
+
else:
|
|
137
|
+
await progress_queue.put({'task_id': task_id, 'total_size': local_size})
|
|
138
|
+
await progress_queue.put({'task_id': task_id, 'bytes_downloaded': local_size})
|
|
139
|
+
return
|
|
105
140
|
|
|
106
141
|
if os.path.exists(progress_file):
|
|
107
142
|
try:
|
|
@@ -121,6 +156,7 @@ async def download_file(session: aiohttp.ClientSession, url: str, dest_path: str
|
|
|
121
156
|
try:
|
|
122
157
|
range_headers = headers.copy()
|
|
123
158
|
range_headers['Range'] = 'bytes=0-0'
|
|
159
|
+
range_headers['Accept-Encoding'] = 'identity'
|
|
124
160
|
async with session.get(url, headers=range_headers, allow_redirects=True) as response:
|
|
125
161
|
response.raise_for_status()
|
|
126
162
|
content_range = response.headers.get('Content-Range')
|
|
@@ -204,12 +240,13 @@ async def download_file(session: aiohttp.ClientSession, url: str, dest_path: str
|
|
|
204
240
|
})
|
|
205
241
|
|
|
206
242
|
chunk_size = file_size // num_chunks
|
|
243
|
+
chunk_lock = asyncio.Lock()
|
|
207
244
|
tasks = []
|
|
208
245
|
for i in range(num_chunks):
|
|
209
246
|
start = i * chunk_size
|
|
210
247
|
end = file_size - 1 if i == num_chunks - 1 else (start + chunk_size - 1)
|
|
211
248
|
tasks.append(
|
|
212
|
-
_download_chunk(session, url, start, end, i, dest_path, headers, progress_queue, task_id, pause_event, chunk_progress)
|
|
249
|
+
_download_chunk(session, url, start, end, i, dest_path, headers, progress_queue, task_id, pause_event, chunk_progress, chunk_lock)
|
|
213
250
|
)
|
|
214
251
|
|
|
215
252
|
await asyncio.gather(*tasks)
|
|
@@ -239,7 +276,7 @@ async def download_media(video_url: str, audio_url: str, headers: dict, chunks:
|
|
|
239
276
|
|
|
240
277
|
listener = asyncio.create_task(progress_listener(queue, progress, pause_event, warning_state))
|
|
241
278
|
|
|
242
|
-
conn_limit = min(chunks,
|
|
279
|
+
conn_limit = min(chunks * 2, 16)
|
|
243
280
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None, sock_read=10), connector=aiohttp.TCPConnector(limit=conn_limit, limit_per_host=conn_limit)) as session:
|
|
244
281
|
v_task = asyncio.create_task(download_file(session, video_url, video_dest, headers, chunks, queue, video_task_ids, pause_event)) if video_url else None
|
|
245
282
|
a_task = asyncio.create_task(download_file(session, audio_url, audio_dest, headers, chunks, queue, audio_task_id, pause_event)) if audio_url else None
|