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.
Files changed (38) hide show
  1. idm_cli-1.3.1/MANIFEST.in +1 -0
  2. {idm_cli-1.3.0/idm_cli.egg-info → idm_cli-1.3.1}/PKG-INFO +28 -88
  3. {idm_cli-1.3.0 → idm_cli-1.3.1}/README.md +21 -78
  4. idm_cli-1.3.1/idm_cli/__init__.py +3 -0
  5. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/config.py +17 -7
  6. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/core.py +28 -19
  7. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/downloader.py +60 -23
  8. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/handlers.py +58 -15
  9. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/muxer.py +12 -17
  10. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/state.py +5 -3
  11. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/__init__.py +4 -4
  12. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/winget.py +5 -1
  13. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/ui/cli.py +6 -17
  14. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/ui/utils.py +6 -4
  15. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/update_checker.py +5 -2
  16. {idm_cli-1.3.0 → idm_cli-1.3.1/idm_cli.egg-info}/PKG-INFO +28 -88
  17. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/SOURCES.txt +4 -5
  18. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/requires.txt +3 -0
  19. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/top_level.txt +1 -0
  20. idm_cli-1.3.1/pyproject.toml +45 -0
  21. {idm_cli-1.3.0 → idm_cli-1.3.1}/requirements.txt +0 -2
  22. {idm_cli-1.3.0 → idm_cli-1.3.1}/setup.py +1 -3
  23. idm_cli-1.3.1/tests/test_core.py +76 -0
  24. idm_cli-1.3.0/MANIFEST.in +0 -2
  25. idm_cli-1.3.0/idm_cli/__init__.py +0 -3
  26. idm_cli-1.3.0/idm_cli/extension/daemon.py +0 -44
  27. idm_cli-1.3.0/idm_cli/extension/extension.py +0 -161
  28. idm_cli-1.3.0/idm_cli/extension/native_host.py +0 -124
  29. {idm_cli-1.3.0 → idm_cli-1.3.1}/LICENSE +0 -0
  30. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/__main__.py +0 -0
  31. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/downloader/__init__.py +0 -0
  32. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/direct.py +0 -0
  33. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli/extractors/ytdlp.py +0 -0
  34. {idm_cli-1.3.0/idm_cli/extension → idm_cli-1.3.1/idm_cli/ui}/__init__.py +0 -0
  35. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/dependency_links.txt +0 -0
  36. {idm_cli-1.3.0 → idm_cli-1.3.1}/idm_cli.egg-info/entry_points.txt +0 -0
  37. {idm_cli-1.3.0 → idm_cli-1.3.1}/setup.cfg +0 -0
  38. {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.0
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
- Requires-Dist: pytest
26
- Requires-Dist: pytest-asyncio
27
- Dynamic: author
28
- Dynamic: classifier
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
- # IDM-CLI (Internet Download Manager CLI)
34
+ <div align="center">
38
35
 
39
- *Inspired by the official [Internet Download Manager (IDM)](https://www.internetdownloadmanager.com/)*
36
+ <h1>IDM-CLI</h1>
40
37
 
41
- A lightning-fast, cross-platform command-line download manager written in Python. 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.).
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
+ [![PyPI version](https://img.shields.io/pypi/v/idm-cli?color=blue&label=pypi%20version)](https://pypi.org/project/idm-cli/)
43
+ [![Downloads](https://static.pepy.tech/badge/idm-cli)](https://pepy.tech/project/idm-cli)
44
+ [![License](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/rj41-w2/idm-cli/blob/main/LICENSE)
45
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
46
+ [![Install](https://img.shields.io/badge/install-pip%20install%20idm--cli-brightgreen)](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 direct URL. A built-in HTTP `HEAD` detector automatically recognizes file types and routes them to the parallel engine.
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 and browser registration.
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 — whether it's a bug fix, a new feature, or just improving the docs.
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
- - Keep PRs focused on a single change. Don't mix unrelated fixes.
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
- # IDM-CLI (Internet Download Manager CLI)
1
+ <div align="center">
2
2
 
3
- *Inspired by the official [Internet Download Manager (IDM)](https://www.internetdownloadmanager.com/)*
3
+ <h1>IDM-CLI</h1>
4
4
 
5
- A lightning-fast, cross-platform command-line download manager written in Python. 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.).
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
+ [![PyPI version](https://img.shields.io/pypi/v/idm-cli?color=blue&label=pypi%20version)](https://pypi.org/project/idm-cli/)
10
+ [![Downloads](https://static.pepy.tech/badge/idm-cli)](https://pepy.tech/project/idm-cli)
11
+ [![License](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/rj41-w2/idm-cli/blob/main/LICENSE)
12
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
13
+ [![Install](https://img.shields.io/badge/install-pip%20install%20idm--cli-brightgreen)](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 direct URL. A built-in HTTP `HEAD` detector automatically recognizes file types and routes them to the parallel engine.
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 and browser registration.
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 — whether it's a bug fix, a new feature, or just improving the docs.
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
- - Keep PRs focused on a single change. Don't mix unrelated fixes.
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
 
@@ -0,0 +1,3 @@
1
+ # IDM-CLI Package
2
+
3
+ __version__ = "1.3.1"
@@ -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
- handler.setFormatter(formatter)
46
- logger.addHandler(handler)
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
- info = extractor.fetch_all_info(url_to_extract)
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
- asyncio.run(download_media(video_url, audio_url, headers, loop_chunks, video_dest, audio_dest, pause_event, warning_state, media_type))
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
- chunk_progress[str(chunk_index)] = chunk_progress.get(str(chunk_index), 0) + len(chunk)
63
- try:
64
- async with aiofiles.open(progress_file, 'w') as pf:
65
- await pf.write(json.dumps(chunk_progress))
66
- except OSError as e:
67
- logger.debug(f"Failed to write progress: {e}")
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.client_exceptions.ClientPayloadError, asyncio.TimeoutError, aiohttp.client_exceptions.ClientError) as e:
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
- file_size = os.path.getsize(dest_path)
93
- if progress_queue is not None and task_id is not None:
94
- if isinstance(task_id, list):
95
- chunk_size_est = file_size // num_chunks
96
- for i in range(num_chunks):
97
- end = file_size - 1 if i == num_chunks - 1 else ((i * chunk_size_est) + chunk_size_est - 1)
98
- c_size = end - (i * chunk_size_est) + 1
99
- await progress_queue.put({'task_id': task_id[i], 'total_size': c_size})
100
- await progress_queue.put({'task_id': task_id[i], 'bytes_downloaded': c_size})
101
- else:
102
- await progress_queue.put({'task_id': task_id, 'total_size': file_size})
103
- await progress_queue.put({'task_id': task_id, 'bytes_downloaded': file_size})
104
- return
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, 8)
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