idm-cli 0.0.1__py3-none-any.whl
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/__init__.py +1 -0
- idm_cli/cli.py +522 -0
- idm_cli/downloader.py +145 -0
- idm_cli/extractors/__init__.py +18 -0
- idm_cli/extractors/direct.py +19 -0
- idm_cli/extractors/facebook.py +105 -0
- idm_cli/extractors/youtube.py +112 -0
- idm_cli/muxer.py +71 -0
- idm_cli/state.py +59 -0
- idm_cli-0.0.1.dist-info/METADATA +109 -0
- idm_cli-0.0.1.dist-info/RECORD +15 -0
- idm_cli-0.0.1.dist-info/WHEEL +5 -0
- idm_cli-0.0.1.dist-info/entry_points.txt +2 -0
- idm_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- idm_cli-0.0.1.dist-info/top_level.txt +1 -0
idm_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# IDM-CLI Package
|
idm_cli/cli.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import typer
|
|
7
|
+
import signal
|
|
8
|
+
import shlex
|
|
9
|
+
import argparse
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import (
|
|
13
|
+
Progress,
|
|
14
|
+
SpinnerColumn,
|
|
15
|
+
BarColumn,
|
|
16
|
+
TextColumn,
|
|
17
|
+
TimeElapsedColumn,
|
|
18
|
+
TimeRemainingColumn,
|
|
19
|
+
DownloadColumn,
|
|
20
|
+
TransferSpeedColumn
|
|
21
|
+
)
|
|
22
|
+
import pyfiglet
|
|
23
|
+
import questionary
|
|
24
|
+
from prompt_toolkit.lexers import Lexer
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import msvcrt
|
|
28
|
+
except ImportError:
|
|
29
|
+
msvcrt = None
|
|
30
|
+
|
|
31
|
+
from idm_cli.extractors import get_extractor
|
|
32
|
+
from idm_cli.downloader import download_file
|
|
33
|
+
from idm_cli.muxer import mux_audio_video, convert_to_mp3
|
|
34
|
+
from idm_cli.state import save_download, remove_download, get_incomplete_downloads
|
|
35
|
+
|
|
36
|
+
custom_style = questionary.Style([
|
|
37
|
+
('qmark', 'fg:cyan bold'),
|
|
38
|
+
('question', 'bold'),
|
|
39
|
+
('answer', 'fg:cyan bold'),
|
|
40
|
+
('pointer', 'fg:cyan bold'),
|
|
41
|
+
('highlighted', 'fg:cyan bold'),
|
|
42
|
+
('flags', 'fg:white'),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
class IDMLexer(Lexer):
|
|
46
|
+
def lex_document(self, document):
|
|
47
|
+
def get_line(lineno):
|
|
48
|
+
line = document.lines[lineno]
|
|
49
|
+
idx = line.find(" -")
|
|
50
|
+
if idx != -1:
|
|
51
|
+
return [('class:answer', line[:idx]), ('class:flags', line[idx:])]
|
|
52
|
+
return [('class:answer', line)]
|
|
53
|
+
return get_line
|
|
54
|
+
|
|
55
|
+
app = typer.Typer(help="IDM-CLI: A lightning-fast YouTube downloader.")
|
|
56
|
+
console = Console()
|
|
57
|
+
|
|
58
|
+
async def progress_listener(queue: asyncio.Queue, progress: Progress, pause_event: asyncio.Event = None, warning_state: dict = None):
|
|
59
|
+
"""Listens for progress updates from the downloader and updates the Rich progress bar."""
|
|
60
|
+
while True:
|
|
61
|
+
if warning_state and warning_state.get("show"):
|
|
62
|
+
for task in progress.tasks:
|
|
63
|
+
if "[WARNING: Press Ctrl+C again to cancel]" not in task.description:
|
|
64
|
+
progress.update(task.id, description=f"[bold yellow][WARNING: Press Ctrl+C again to cancel][/] {task.description}")
|
|
65
|
+
warning_state["show"] = False
|
|
66
|
+
|
|
67
|
+
if pause_event and msvcrt:
|
|
68
|
+
if msvcrt.kbhit():
|
|
69
|
+
key = msvcrt.getch().decode('utf-8', 'ignore').lower()
|
|
70
|
+
if key == 'p' and pause_event.is_set():
|
|
71
|
+
pause_event.clear()
|
|
72
|
+
for task in progress.tasks:
|
|
73
|
+
if "Paused" not in task.description:
|
|
74
|
+
progress.update(task.id, description=f"[bold yellow]Paused[/] {task.description}")
|
|
75
|
+
elif key == 'r' and not pause_event.is_set():
|
|
76
|
+
pause_event.set()
|
|
77
|
+
for task in progress.tasks:
|
|
78
|
+
if "Paused" in task.description:
|
|
79
|
+
new_desc = task.description.replace("[bold yellow]Paused[/] ", "")
|
|
80
|
+
progress.update(task.id, description=new_desc)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
update = await asyncio.wait_for(queue.get(), timeout=0.1)
|
|
84
|
+
except asyncio.TimeoutError:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if update is None:
|
|
88
|
+
break # Signal to stop
|
|
89
|
+
|
|
90
|
+
task_id = update.get('task_id')
|
|
91
|
+
if 'total_size' in update:
|
|
92
|
+
progress.update(task_id, total=update['total_size'])
|
|
93
|
+
elif 'bytes_downloaded' in update:
|
|
94
|
+
progress.advance(task_id, advance=update['bytes_downloaded'])
|
|
95
|
+
|
|
96
|
+
queue.task_done()
|
|
97
|
+
|
|
98
|
+
async def download_media(video_url: str, audio_url: str, headers: dict, chunks: int, video_dest: str, audio_dest: str, pause_event: asyncio.Event, warning_state: dict = None):
|
|
99
|
+
queue = asyncio.Queue()
|
|
100
|
+
|
|
101
|
+
with Progress(
|
|
102
|
+
SpinnerColumn(),
|
|
103
|
+
TextColumn("[bold blue]{task.description}", justify="right"),
|
|
104
|
+
BarColumn(bar_width=40),
|
|
105
|
+
"[progress.percentage]{task.percentage:>3.1f}%",
|
|
106
|
+
DownloadColumn(),
|
|
107
|
+
TransferSpeedColumn(),
|
|
108
|
+
TimeRemainingColumn(),
|
|
109
|
+
console=console
|
|
110
|
+
) as progress:
|
|
111
|
+
video_task_id = progress.add_task("[cyan]Video", total=None) if video_url else None
|
|
112
|
+
audio_task_id = progress.add_task("[magenta]Audio", total=None) if audio_url else None
|
|
113
|
+
|
|
114
|
+
listener = asyncio.create_task(progress_listener(queue, progress, pause_event, warning_state))
|
|
115
|
+
|
|
116
|
+
v_task = asyncio.create_task(download_file(video_url, video_dest, headers, chunks, queue, video_task_id, pause_event)) if video_url else None
|
|
117
|
+
a_task = asyncio.create_task(download_file(audio_url, audio_dest, headers, chunks, queue, audio_task_id, pause_event)) if audio_url else None
|
|
118
|
+
|
|
119
|
+
tasks_to_gather = []
|
|
120
|
+
if a_task:
|
|
121
|
+
tasks_to_gather.append(a_task)
|
|
122
|
+
if v_task:
|
|
123
|
+
tasks_to_gather.append(v_task)
|
|
124
|
+
|
|
125
|
+
await asyncio.gather(*tasks_to_gather)
|
|
126
|
+
|
|
127
|
+
await queue.put(None)
|
|
128
|
+
await listener
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def download(
|
|
132
|
+
url: Optional[str] = typer.Argument(None, help="The Video URL to download."),
|
|
133
|
+
chunks: int = typer.Option(8, "--chunks", "-c", help="Number of concurrent chunks per file."),
|
|
134
|
+
quality: Optional[str] = typer.Option(None, "--quality", "-q", help="Video quality (e.g., 720p, 1080p)."),
|
|
135
|
+
audio_only: bool = typer.Option(False, "--audio-only", "-a", help="Download audio only."),
|
|
136
|
+
video_only: bool = typer.Option(False, "--video", "-v", help="Download video + audio (bypasses prompt)."),
|
|
137
|
+
queue: bool = typer.Option(False, "--queue", "-Q", help="Add to queue instead of downloading immediately.")
|
|
138
|
+
):
|
|
139
|
+
"""
|
|
140
|
+
Download a YouTube video at maximum speed using parallel chunks.
|
|
141
|
+
"""
|
|
142
|
+
banner = pyfiglet.figlet_format("IDM CLI")
|
|
143
|
+
console.print(f"[bold green]{banner}[/bold green]")
|
|
144
|
+
console.print("[bold cyan]--- The Ultimate High-Speed CLI Downloader ---[/bold cyan]")
|
|
145
|
+
console.print("Type 'help' for available commands\n", style="white")
|
|
146
|
+
|
|
147
|
+
is_interactive = (url is None)
|
|
148
|
+
last_ctrl_c_time = 0
|
|
149
|
+
show_warning = False
|
|
150
|
+
|
|
151
|
+
while True:
|
|
152
|
+
loop_quality = quality
|
|
153
|
+
loop_audio_only = audio_only
|
|
154
|
+
loop_video_only = video_only
|
|
155
|
+
loop_queue = queue
|
|
156
|
+
loop_chunks = chunks
|
|
157
|
+
|
|
158
|
+
current_url = url
|
|
159
|
+
if not current_url:
|
|
160
|
+
prompt_str = "idm (Press again ctrl+c to exit) " if show_warning else "idm "
|
|
161
|
+
current_url = questionary.text(prompt_str, style=custom_style, lexer=IDMLexer()).ask(kbi_msg="")
|
|
162
|
+
if current_url is None:
|
|
163
|
+
if time.time() - last_ctrl_c_time <= 5:
|
|
164
|
+
console.print("[bold red]Cancelled by user[/bold red]")
|
|
165
|
+
raise typer.Exit()
|
|
166
|
+
else:
|
|
167
|
+
last_ctrl_c_time = time.time()
|
|
168
|
+
show_warning = True
|
|
169
|
+
continue
|
|
170
|
+
elif not current_url.strip():
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
last_ctrl_c_time = 0
|
|
174
|
+
show_warning = False
|
|
175
|
+
found_task_id = None
|
|
176
|
+
|
|
177
|
+
if is_interactive and current_url and current_url.strip().lower() not in ["help", "exit", "start queue", "queue start", "resume"]:
|
|
178
|
+
try:
|
|
179
|
+
parts = shlex.split(current_url)
|
|
180
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
181
|
+
parser.add_argument('-q', '--quality')
|
|
182
|
+
parser.add_argument('-a', '--audio-only', action='store_true')
|
|
183
|
+
parser.add_argument('-v', '--video', action='store_true')
|
|
184
|
+
parser.add_argument('-Q', '--queue', action='store_true')
|
|
185
|
+
parser.add_argument('-c', '--chunks', type=int)
|
|
186
|
+
|
|
187
|
+
parsed_args, unknown = parser.parse_known_args(parts)
|
|
188
|
+
loop_quality = parsed_args.quality or loop_quality
|
|
189
|
+
loop_audio_only = parsed_args.audio_only or loop_audio_only
|
|
190
|
+
loop_video_only = parsed_args.video or loop_video_only
|
|
191
|
+
loop_queue = parsed_args.queue or loop_queue
|
|
192
|
+
loop_chunks = parsed_args.chunks or loop_chunks
|
|
193
|
+
|
|
194
|
+
urls = [u for u in unknown if not u.startswith('-')]
|
|
195
|
+
if urls:
|
|
196
|
+
current_url = urls[0]
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
fast_mode = not is_interactive or loop_quality or loop_audio_only or loop_video_only or loop_queue
|
|
201
|
+
if fast_mode and not loop_quality and not loop_audio_only:
|
|
202
|
+
loop_quality = "720p"
|
|
203
|
+
|
|
204
|
+
if current_url.strip().lower() == "help":
|
|
205
|
+
console.print("\n[bold cyan]Available Commands:[/bold cyan]")
|
|
206
|
+
console.print(" [bold green]<URL>[/bold green] - Paste a YouTube URL to download")
|
|
207
|
+
console.print(" [bold green]resume[/bold green] - Resume or delete an incomplete download")
|
|
208
|
+
console.print(" [bold green]start queue[/bold green] - Start downloading queued videos")
|
|
209
|
+
console.print(" [bold green]help[/bold green] - Show this help menu")
|
|
210
|
+
console.print(" [bold green]exit[/bold green] - Exit the application\n")
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if current_url.strip().lower() == "exit":
|
|
214
|
+
console.print("[bold green]Goodbye![/bold green]")
|
|
215
|
+
raise typer.Exit()
|
|
216
|
+
|
|
217
|
+
if current_url.strip().lower() in ["start queue", "queue start"]:
|
|
218
|
+
incomplete = get_incomplete_downloads()
|
|
219
|
+
queued = {tid: data for tid, data in incomplete.items() if data.get("status") == "queued"}
|
|
220
|
+
if not queued:
|
|
221
|
+
console.print("[bold green]No videos in queue![/]")
|
|
222
|
+
if not is_interactive:
|
|
223
|
+
raise typer.Exit(code=0)
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
for tid, data in queued.items():
|
|
227
|
+
console.print(f"[bold yellow]Starting queued download:[/] {data['title']}\n")
|
|
228
|
+
url_to_extract = data['url']
|
|
229
|
+
format_id = data['format_id']
|
|
230
|
+
video_dest = data['video_dest']
|
|
231
|
+
audio_dest = data['audio_dest']
|
|
232
|
+
final_dest = data['final_dest']
|
|
233
|
+
title = data['title']
|
|
234
|
+
|
|
235
|
+
with console.status("[bold cyan]Fetching metadata...", spinner="dots"):
|
|
236
|
+
try:
|
|
237
|
+
extractor = get_extractor(url_to_extract)
|
|
238
|
+
info = extractor.fetch_all_info(url_to_extract)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
console.print(f"[bold red]Error fetching info:[/] {e}")
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
with console.status(f"[bold cyan]Extracting URLs...", spinner="dots"):
|
|
244
|
+
try:
|
|
245
|
+
extractor = get_extractor(url_to_extract)
|
|
246
|
+
extracted = extractor.extract_urls(info, format_id)
|
|
247
|
+
video_url = extracted.get("video_url")
|
|
248
|
+
audio_url = extracted.get("audio_url")
|
|
249
|
+
headers = extracted.get("headers", {})
|
|
250
|
+
if not video_url: video_dest = ""
|
|
251
|
+
if not audio_url: audio_dest = ""
|
|
252
|
+
except Exception as e:
|
|
253
|
+
console.print(f"[bold red]Error extracting URLs:[/] {e}")
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
pause_event = asyncio.Event()
|
|
257
|
+
pause_event.set()
|
|
258
|
+
warning_state = {"show": False}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
asyncio.run(download_media(video_url, audio_url, headers, loop_chunks, video_dest, audio_dest, pause_event, warning_state))
|
|
262
|
+
with console.status("[bold magenta]Running FFmpeg...", spinner="bouncingBar"):
|
|
263
|
+
if video_dest and audio_dest:
|
|
264
|
+
mux_audio_video(video_dest, audio_dest, final_dest)
|
|
265
|
+
elif audio_dest and not video_dest:
|
|
266
|
+
convert_to_mp3(audio_dest, final_dest)
|
|
267
|
+
elif video_dest and not audio_dest:
|
|
268
|
+
import shutil
|
|
269
|
+
shutil.move(video_dest, final_dest)
|
|
270
|
+
remove_download(tid)
|
|
271
|
+
console.print(f"\n[bold green]🎉 Success! Video saved as:[/] [bold white]{final_dest}[/]")
|
|
272
|
+
except KeyboardInterrupt:
|
|
273
|
+
break
|
|
274
|
+
except Exception as e:
|
|
275
|
+
console.print(f"[bold red]Download failed:[/] {e}")
|
|
276
|
+
if not is_interactive:
|
|
277
|
+
raise typer.Exit(code=0)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if current_url.strip().lower() == "resume":
|
|
281
|
+
incomplete = get_incomplete_downloads()
|
|
282
|
+
if not incomplete:
|
|
283
|
+
console.print("[bold green]No incomplete downloads found![/]")
|
|
284
|
+
if not is_interactive:
|
|
285
|
+
raise typer.Exit(code=1)
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
choices = []
|
|
289
|
+
for tid, data in incomplete.items():
|
|
290
|
+
choices.append(f"[Resume] {data['title']}")
|
|
291
|
+
choices.append(f"[Delete] {data['title']}")
|
|
292
|
+
choices.append("[Back to Main]")
|
|
293
|
+
|
|
294
|
+
selected = questionary.select("Select an action:", choices=choices, style=custom_style).ask(kbi_msg="")
|
|
295
|
+
if not selected:
|
|
296
|
+
console.print("[bold red]Cancelled by user[/bold red]")
|
|
297
|
+
if not is_interactive:
|
|
298
|
+
raise typer.Exit(code=1)
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
if selected == "[Back to Main]":
|
|
302
|
+
console.print("[bold cyan]Returning to main menu...[/bold cyan]")
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
action = "Resume" if selected.startswith("[Resume]") else "Delete"
|
|
306
|
+
title_selected = selected.split("] ", 1)[1]
|
|
307
|
+
|
|
308
|
+
task_id = None
|
|
309
|
+
task_data = None
|
|
310
|
+
for tid, data in incomplete.items():
|
|
311
|
+
if data['title'] == title_selected:
|
|
312
|
+
task_id = tid
|
|
313
|
+
task_data = data
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
if action == "Delete":
|
|
317
|
+
remove_download(task_id)
|
|
318
|
+
# Remove any partial files if they exist
|
|
319
|
+
if os.path.exists(task_data['video_dest']): os.remove(task_data['video_dest'])
|
|
320
|
+
if os.path.exists(task_data['audio_dest']): os.remove(task_data['audio_dest'])
|
|
321
|
+
for i in range(32): # Clean parts up to 32 chunks
|
|
322
|
+
v_part = f"{task_data['video_dest']}.part{i}"
|
|
323
|
+
a_part = f"{task_data['audio_dest']}.part{i}"
|
|
324
|
+
if os.path.exists(v_part): os.remove(v_part)
|
|
325
|
+
if os.path.exists(a_part): os.remove(a_part)
|
|
326
|
+
console.print(f"[bold red]Deleted:[/] {title_selected}")
|
|
327
|
+
if not is_interactive:
|
|
328
|
+
raise typer.Exit(code=0)
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
console.print(f"[bold yellow]Resuming download for:[/] {title_selected}\n")
|
|
332
|
+
url_to_extract = task_data['url']
|
|
333
|
+
format_id = task_data['format_id']
|
|
334
|
+
video_dest = task_data['video_dest']
|
|
335
|
+
audio_dest = task_data['audio_dest']
|
|
336
|
+
final_dest = task_data['final_dest']
|
|
337
|
+
title = task_data['title']
|
|
338
|
+
found_task_id = task_id
|
|
339
|
+
else:
|
|
340
|
+
url_to_extract = current_url
|
|
341
|
+
incomplete = get_incomplete_downloads()
|
|
342
|
+
|
|
343
|
+
found_task_data = None
|
|
344
|
+
|
|
345
|
+
for tid, data in incomplete.items():
|
|
346
|
+
if data.get('url') == url_to_extract:
|
|
347
|
+
found_task_id = tid
|
|
348
|
+
found_task_data = data
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
if found_task_id:
|
|
352
|
+
console.print("[bold yellow]Found in resume list! Auto-resuming...[/]")
|
|
353
|
+
task_id = found_task_id
|
|
354
|
+
format_id = found_task_data['format_id']
|
|
355
|
+
video_dest = found_task_data['video_dest']
|
|
356
|
+
audio_dest = found_task_data['audio_dest']
|
|
357
|
+
final_dest = found_task_data['final_dest']
|
|
358
|
+
title = found_task_data['title']
|
|
359
|
+
else:
|
|
360
|
+
format_id = None
|
|
361
|
+
task_id = str(uuid.uuid4())
|
|
362
|
+
console.print(f"[bold yellow]Initializing download for:[/] {current_url}\n")
|
|
363
|
+
|
|
364
|
+
if loop_audio_only:
|
|
365
|
+
format_id = "audio_only"
|
|
366
|
+
|
|
367
|
+
with console.status("[bold cyan]Fetching metadata...", spinner="dots"):
|
|
368
|
+
try:
|
|
369
|
+
extractor = get_extractor(url_to_extract)
|
|
370
|
+
info = extractor.fetch_all_info(url_to_extract)
|
|
371
|
+
title = info.get("title", "download") if not found_task_id else title
|
|
372
|
+
if not found_task_id and format_id != "audio_only":
|
|
373
|
+
resolutions = extractor.get_video_resolutions(info)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
console.print(f"[bold red]Error fetching info:[/] {e}")
|
|
376
|
+
if not is_interactive:
|
|
377
|
+
raise typer.Exit(code=1)
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
if not found_task_id:
|
|
381
|
+
if format_id != "audio_only":
|
|
382
|
+
if not resolutions:
|
|
383
|
+
console.print("[bold red]No video resolutions found.[/]")
|
|
384
|
+
if not is_interactive:
|
|
385
|
+
raise typer.Exit(code=1)
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
console.print(f"[bold green]✓[/] Fetched info for: [bold white]{title}[/]")
|
|
389
|
+
|
|
390
|
+
if loop_quality:
|
|
391
|
+
matched = next((r for r in resolutions if r['resolution'] == loop_quality), None)
|
|
392
|
+
if matched:
|
|
393
|
+
selected_res = matched['resolution']
|
|
394
|
+
else:
|
|
395
|
+
selected_res = resolutions[0]['resolution']
|
|
396
|
+
else:
|
|
397
|
+
choices = [r['resolution'] for r in resolutions]
|
|
398
|
+
selected_res = questionary.select("Choose video quality:", choices=choices, style=custom_style).ask(kbi_msg="")
|
|
399
|
+
if not selected_res:
|
|
400
|
+
console.print("[bold red]Cancelled by user[/bold red]")
|
|
401
|
+
if not is_interactive:
|
|
402
|
+
raise typer.Exit(code=1)
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
selected_format = next(r for r in resolutions if r['resolution'] == selected_res)
|
|
406
|
+
format_id = selected_format['format_id']
|
|
407
|
+
|
|
408
|
+
safe_title = "".join([c for c in title if c.isalpha() or c.isdigit() or c in ' -_.']).rstrip()[:60].strip()
|
|
409
|
+
downloads_dir = os.path.join(os.path.expanduser("~"), "Downloads")
|
|
410
|
+
os.makedirs(downloads_dir, exist_ok=True)
|
|
411
|
+
|
|
412
|
+
tmp_dir = os.path.expanduser("~/.idm_cli/tmp")
|
|
413
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
414
|
+
|
|
415
|
+
if format_id == "direct_file":
|
|
416
|
+
video_dest = os.path.join(tmp_dir, safe_title)
|
|
417
|
+
audio_dest = ""
|
|
418
|
+
final_dest = os.path.join(downloads_dir, safe_title)
|
|
419
|
+
elif format_id == "audio_only":
|
|
420
|
+
video_dest = ""
|
|
421
|
+
audio_dest = os.path.join(tmp_dir, f"{safe_title}_audio.m4a")
|
|
422
|
+
final_dest = os.path.join(downloads_dir, f"{safe_title}.mp3")
|
|
423
|
+
else:
|
|
424
|
+
video_dest = os.path.join(tmp_dir, f"{safe_title}_video.mp4")
|
|
425
|
+
audio_dest = os.path.join(tmp_dir, f"{safe_title}_audio.m4a")
|
|
426
|
+
final_dest = os.path.join(downloads_dir, f"{safe_title}.mp4")
|
|
427
|
+
|
|
428
|
+
if loop_queue:
|
|
429
|
+
action = "Add to Queue"
|
|
430
|
+
elif fast_mode:
|
|
431
|
+
action = "Download Now"
|
|
432
|
+
else:
|
|
433
|
+
action = questionary.select("Action:", choices=["Download Now", "Add to Queue"], style=custom_style).ask(kbi_msg="")
|
|
434
|
+
if not action:
|
|
435
|
+
console.print("[bold red]Cancelled by user[/bold red]")
|
|
436
|
+
continue
|
|
437
|
+
if action == "Add to Queue":
|
|
438
|
+
save_download(task_id, url_to_extract, format_id, title, video_dest, audio_dest, final_dest, status="queued")
|
|
439
|
+
console.print("[bold green]Added to queue![/]")
|
|
440
|
+
if not is_interactive:
|
|
441
|
+
raise typer.Exit(code=0)
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
with console.status(f"[bold cyan]Extracting URLs...", spinner="dots"):
|
|
445
|
+
try:
|
|
446
|
+
extractor = get_extractor(url_to_extract)
|
|
447
|
+
extracted = extractor.extract_urls(info, format_id)
|
|
448
|
+
video_url = extracted.get("video_url")
|
|
449
|
+
audio_url = extracted.get("audio_url")
|
|
450
|
+
headers = extracted.get("headers", {})
|
|
451
|
+
if not video_url: video_dest = ""
|
|
452
|
+
if not audio_url: audio_dest = ""
|
|
453
|
+
except Exception as e:
|
|
454
|
+
console.print(f"[bold red]Error extracting URLs:[/] {e}")
|
|
455
|
+
if not is_interactive:
|
|
456
|
+
raise typer.Exit(code=1)
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# Save state before downloading
|
|
460
|
+
save_download(task_id, url_to_extract, format_id, title, video_dest, audio_dest, final_dest, status="interrupted")
|
|
461
|
+
|
|
462
|
+
console.print(f"[bold green]✓[/] Using {loop_chunks} chunks per file.")
|
|
463
|
+
console.print("[bold cyan]Starting parallel downloads... (Press 'p' to pause, 'r' to resume)[/]\n")
|
|
464
|
+
|
|
465
|
+
pause_event = asyncio.Event()
|
|
466
|
+
pause_event.set()
|
|
467
|
+
|
|
468
|
+
last_dl_ctrl_c = 0.0
|
|
469
|
+
original_sigint = signal.getsignal(signal.SIGINT)
|
|
470
|
+
warning_state = {"show": False}
|
|
471
|
+
|
|
472
|
+
def custom_handler(signum, frame):
|
|
473
|
+
nonlocal last_dl_ctrl_c
|
|
474
|
+
if time.time() - last_dl_ctrl_c <= 5:
|
|
475
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
476
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
477
|
+
else:
|
|
478
|
+
warning_state["show"] = True
|
|
479
|
+
last_dl_ctrl_c = time.time()
|
|
480
|
+
|
|
481
|
+
signal.signal(signal.SIGINT, custom_handler)
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
asyncio.run(download_media(video_url, audio_url, headers, loop_chunks, video_dest, audio_dest, pause_event, warning_state))
|
|
485
|
+
except KeyboardInterrupt:
|
|
486
|
+
console.print("\n[bold red]Download cancelled by user. Progress saved to resume later.[/bold red]")
|
|
487
|
+
continue
|
|
488
|
+
except Exception as e:
|
|
489
|
+
console.print(f"[bold red]Download failed:[/] {e}")
|
|
490
|
+
if not is_interactive:
|
|
491
|
+
raise typer.Exit(code=1)
|
|
492
|
+
continue
|
|
493
|
+
finally:
|
|
494
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
495
|
+
|
|
496
|
+
console.print("\n[bold green]✓[/] Downloads completed.")
|
|
497
|
+
console.print("[bold cyan]Muxing audio and video streams...[/]")
|
|
498
|
+
|
|
499
|
+
with console.status("[bold magenta]Running FFmpeg...", spinner="bouncingBar"):
|
|
500
|
+
try:
|
|
501
|
+
if video_dest and audio_dest:
|
|
502
|
+
mux_audio_video(video_dest, audio_dest, final_dest)
|
|
503
|
+
elif audio_dest and not video_dest:
|
|
504
|
+
convert_to_mp3(audio_dest, final_dest)
|
|
505
|
+
elif video_dest and not audio_dest:
|
|
506
|
+
import shutil
|
|
507
|
+
shutil.move(video_dest, final_dest)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
console.print(f"[bold red]Muxing failed:[/] {e}")
|
|
510
|
+
if not is_interactive:
|
|
511
|
+
raise typer.Exit(code=1)
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
remove_download(task_id)
|
|
515
|
+
console.print(f"\n[bold green]🎉 Success! Video saved as:[/] [bold white]{final_dest}[/]")
|
|
516
|
+
|
|
517
|
+
if not is_interactive:
|
|
518
|
+
raise typer.Exit(code=0)
|
|
519
|
+
url = None
|
|
520
|
+
|
|
521
|
+
if __name__ == "__main__":
|
|
522
|
+
app()
|
idm_cli/downloader.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import aiohttp
|
|
4
|
+
import aiofiles
|
|
5
|
+
|
|
6
|
+
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):
|
|
7
|
+
chunk_path = f"{dest_path}.part{chunk_index}"
|
|
8
|
+
max_retries = 5
|
|
9
|
+
retry_count = 0
|
|
10
|
+
|
|
11
|
+
while retry_count < max_retries:
|
|
12
|
+
try:
|
|
13
|
+
# Determine how much of this chunk has already been downloaded (from prior failed attempts)
|
|
14
|
+
existing_size = 0
|
|
15
|
+
if os.path.exists(chunk_path):
|
|
16
|
+
existing_size = os.path.getsize(chunk_path)
|
|
17
|
+
|
|
18
|
+
current_start = start + existing_size
|
|
19
|
+
|
|
20
|
+
# If we've already downloaded this entire chunk, return immediately
|
|
21
|
+
if end is not None and current_start > end:
|
|
22
|
+
return chunk_path
|
|
23
|
+
|
|
24
|
+
chunk_headers = headers.copy() if headers else {}
|
|
25
|
+
if end is None:
|
|
26
|
+
chunk_headers['Range'] = f'bytes={current_start}-'
|
|
27
|
+
else:
|
|
28
|
+
chunk_headers['Range'] = f'bytes={current_start}-{end}'
|
|
29
|
+
|
|
30
|
+
async with session.get(url, headers=chunk_headers) as response:
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
# Use append mode ('ab') to resume writing if we already have data, avoiding overwrite
|
|
33
|
+
mode = 'ab' if existing_size > 0 else 'wb'
|
|
34
|
+
async with aiofiles.open(chunk_path, mode) as f:
|
|
35
|
+
async for chunk in response.content.iter_chunked(1024 * 1024):
|
|
36
|
+
if pause_event is not None:
|
|
37
|
+
await pause_event.wait()
|
|
38
|
+
if not chunk:
|
|
39
|
+
break
|
|
40
|
+
await f.write(chunk)
|
|
41
|
+
if progress_queue is not None and task_id is not None:
|
|
42
|
+
# IMPORTANT BUG PREVENTION:
|
|
43
|
+
# We ONLY report the newly downloaded bytes for this specific iteration.
|
|
44
|
+
# We DO NOT report `existing_size` because those bytes were already sent
|
|
45
|
+
# to the progress queue during a previous attempt.
|
|
46
|
+
# Using `progress.advance()` in main.py accumulates these safely,
|
|
47
|
+
# preventing the progress bar from jumping, resetting, or duplicating.
|
|
48
|
+
await progress_queue.put({
|
|
49
|
+
'task_id': task_id,
|
|
50
|
+
'chunk_index': chunk_index,
|
|
51
|
+
'bytes_downloaded': len(chunk)
|
|
52
|
+
})
|
|
53
|
+
return chunk_path
|
|
54
|
+
except (aiohttp.client_exceptions.ClientPayloadError, asyncio.TimeoutError, aiohttp.client_exceptions.ClientError) as e:
|
|
55
|
+
retry_count += 1
|
|
56
|
+
if retry_count >= max_retries:
|
|
57
|
+
raise Exception(f"Chunk {chunk_index} failed after {max_retries} retries: {e}")
|
|
58
|
+
await asyncio.sleep(2 ** retry_count)
|
|
59
|
+
|
|
60
|
+
async def download_file(url: str, dest_path: str, headers: dict, num_chunks: int = 8, progress_queue: asyncio.Queue = None, task_id = None, pause_event: asyncio.Event = None):
|
|
61
|
+
"""
|
|
62
|
+
Downloads a file in multiple chunks concurrently using aiohttp and aiofiles.
|
|
63
|
+
"""
|
|
64
|
+
if os.path.exists(dest_path):
|
|
65
|
+
file_size = os.path.getsize(dest_path)
|
|
66
|
+
if progress_queue is not None and task_id is not None:
|
|
67
|
+
await progress_queue.put({'task_id': task_id, 'total_size': file_size})
|
|
68
|
+
await progress_queue.put({'task_id': task_id, 'bytes_downloaded': file_size})
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
if headers is None:
|
|
72
|
+
headers = {}
|
|
73
|
+
|
|
74
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None)) as session:
|
|
75
|
+
file_size = None
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
async with session.head(url, headers=headers, allow_redirects=True) as response:
|
|
79
|
+
content_length = response.headers.get('Content-Length')
|
|
80
|
+
if content_length:
|
|
81
|
+
file_size = int(content_length)
|
|
82
|
+
except aiohttp.ClientError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
if not file_size:
|
|
86
|
+
try:
|
|
87
|
+
# Use GET with Range bytes=0-0 to get total length from Content-Range
|
|
88
|
+
range_headers = headers.copy()
|
|
89
|
+
range_headers['Range'] = 'bytes=0-0'
|
|
90
|
+
async with session.get(url, headers=range_headers, allow_redirects=True) as response:
|
|
91
|
+
content_range = response.headers.get('Content-Range')
|
|
92
|
+
if content_range and '/' in content_range:
|
|
93
|
+
file_size = int(content_range.split('/')[-1])
|
|
94
|
+
else:
|
|
95
|
+
content_length = response.headers.get('Content-Length')
|
|
96
|
+
if content_length:
|
|
97
|
+
file_size = int(content_length)
|
|
98
|
+
except aiohttp.ClientError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
if not file_size:
|
|
102
|
+
# Fallback to single chunk download
|
|
103
|
+
chunk_path = await _download_chunk(session, url, 0, None, 0, dest_path, headers, progress_queue, task_id, pause_event)
|
|
104
|
+
os.rename(chunk_path, dest_path)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if progress_queue is not None and task_id is not None:
|
|
108
|
+
await progress_queue.put({
|
|
109
|
+
'task_id': task_id,
|
|
110
|
+
'total_size': file_size
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
total_existing_size = 0
|
|
114
|
+
for i in range(num_chunks):
|
|
115
|
+
chunk_path = f"{dest_path}.part{i}"
|
|
116
|
+
if os.path.exists(chunk_path):
|
|
117
|
+
total_existing_size += os.path.getsize(chunk_path)
|
|
118
|
+
|
|
119
|
+
if total_existing_size > 0:
|
|
120
|
+
await progress_queue.put({
|
|
121
|
+
'task_id': task_id,
|
|
122
|
+
'bytes_downloaded': total_existing_size
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
chunk_size = file_size // num_chunks
|
|
126
|
+
tasks = []
|
|
127
|
+
for i in range(num_chunks):
|
|
128
|
+
start = i * chunk_size
|
|
129
|
+
end = file_size - 1 if i == num_chunks - 1 else (start + chunk_size - 1)
|
|
130
|
+
tasks.append(
|
|
131
|
+
_download_chunk(session, url, start, end, i, dest_path, headers, progress_queue, task_id, pause_event)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
chunk_paths = await asyncio.gather(*tasks)
|
|
135
|
+
|
|
136
|
+
# Merge chunks
|
|
137
|
+
async with aiofiles.open(dest_path, 'wb') as out_file:
|
|
138
|
+
for chunk_path in chunk_paths:
|
|
139
|
+
async with aiofiles.open(chunk_path, 'rb') as in_file:
|
|
140
|
+
while True:
|
|
141
|
+
data = await in_file.read(1024 * 1024)
|
|
142
|
+
if not data:
|
|
143
|
+
break
|
|
144
|
+
await out_file.write(data)
|
|
145
|
+
os.remove(chunk_path)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import urllib.request
|
|
3
|
+
|
|
4
|
+
def get_extractor(url: str):
|
|
5
|
+
if "facebook.com" in url or "fb.watch" in url:
|
|
6
|
+
return importlib.import_module("idm_cli.extractors.facebook")
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
req = urllib.request.Request(url, method='HEAD', headers={'User-Agent': 'Mozilla/5.0'})
|
|
10
|
+
with urllib.request.urlopen(req, timeout=3) as response:
|
|
11
|
+
content_type = response.headers.get('Content-Type', '')
|
|
12
|
+
if content_type and 'text/html' not in content_type:
|
|
13
|
+
return importlib.import_module("idm_cli.extractors.direct")
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
# Default to youtube (handles youtube, twitter, and generic sites mostly)
|
|
18
|
+
return importlib.import_module("idm_cli.extractors.youtube")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
def fetch_all_info(url: str) -> dict:
|
|
2
|
+
"""Extracts a filename from the URL and returns info."""
|
|
3
|
+
filename = url.split('/')[-1].split('?')[0]
|
|
4
|
+
if not filename:
|
|
5
|
+
filename = "file_download"
|
|
6
|
+
return {"title": filename, "url": url}
|
|
7
|
+
|
|
8
|
+
def get_video_resolutions(info: dict) -> list[dict]:
|
|
9
|
+
"""Returns a dummy resolution for direct files."""
|
|
10
|
+
return [{"resolution": "Direct File", "format_id": "direct_file"}]
|
|
11
|
+
|
|
12
|
+
def extract_urls(info: dict, video_format_id: str) -> dict:
|
|
13
|
+
"""Extracts download URLs."""
|
|
14
|
+
return {
|
|
15
|
+
"video_url": info.get("url"),
|
|
16
|
+
"audio_url": None,
|
|
17
|
+
"headers": {},
|
|
18
|
+
"title": info.get("title", "download")
|
|
19
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import yt_dlp
|
|
2
|
+
|
|
3
|
+
def fetch_all_info(url: str) -> dict:
|
|
4
|
+
"""
|
|
5
|
+
Fetches all video info from a Facebook URL using yt-dlp.
|
|
6
|
+
"""
|
|
7
|
+
ydl_opts = {
|
|
8
|
+
'quiet': True,
|
|
9
|
+
'no_warnings': True,
|
|
10
|
+
}
|
|
11
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
12
|
+
info = ydl.extract_info(url, download=False)
|
|
13
|
+
return info
|
|
14
|
+
|
|
15
|
+
def get_video_resolutions(info: dict) -> list[dict]:
|
|
16
|
+
"""
|
|
17
|
+
Parses the formats from the info dict and returns a list of available resolutions.
|
|
18
|
+
"""
|
|
19
|
+
resolutions_dict = {}
|
|
20
|
+
formats = info.get('formats', [])
|
|
21
|
+
|
|
22
|
+
for fmt in formats:
|
|
23
|
+
if fmt.get('vcodec') == 'none':
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
format_id = str(fmt.get('format_id', ''))
|
|
27
|
+
height = fmt.get('height')
|
|
28
|
+
|
|
29
|
+
if height:
|
|
30
|
+
resolution = f"{height}p"
|
|
31
|
+
else:
|
|
32
|
+
resolution = format_id.upper() if format_id else 'UNKNOWN'
|
|
33
|
+
|
|
34
|
+
if resolution not in resolutions_dict:
|
|
35
|
+
resolutions_dict[resolution] = {
|
|
36
|
+
'resolution': resolution,
|
|
37
|
+
'format_id': format_id
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def parse_res(r):
|
|
41
|
+
res = r['resolution']
|
|
42
|
+
if res.endswith('p') and res[:-1].isdigit():
|
|
43
|
+
return int(res[:-1])
|
|
44
|
+
if res == 'HD': return 720
|
|
45
|
+
if res == 'SD': return 480
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
return sorted(list(resolutions_dict.values()), key=parse_res, reverse=True)
|
|
49
|
+
|
|
50
|
+
def extract_urls(info: dict, video_format_id: str) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Given the info dict and a selected video format ID, returns the video URL,
|
|
53
|
+
audio URL (if not pre-muxed), headers, and title.
|
|
54
|
+
"""
|
|
55
|
+
formats = info.get('formats', [])
|
|
56
|
+
|
|
57
|
+
video_url = None
|
|
58
|
+
audio_url = None
|
|
59
|
+
headers = {}
|
|
60
|
+
|
|
61
|
+
if video_format_id != "audio_only":
|
|
62
|
+
for fmt in formats:
|
|
63
|
+
if str(fmt.get('format_id')) == str(video_format_id):
|
|
64
|
+
video_url = fmt.get('url')
|
|
65
|
+
headers = fmt.get('http_headers', {})
|
|
66
|
+
|
|
67
|
+
is_premuxed = (fmt.get('acodec') != 'none')
|
|
68
|
+
if is_premuxed:
|
|
69
|
+
audio_url = None
|
|
70
|
+
else:
|
|
71
|
+
# Find the best audio-only stream
|
|
72
|
+
best_audio = None
|
|
73
|
+
for a_fmt in formats:
|
|
74
|
+
if a_fmt.get('vcodec') == 'none' and a_fmt.get('acodec') != 'none':
|
|
75
|
+
if not best_audio or (a_fmt.get('abr') or 0) > (best_audio.get('abr') or 0):
|
|
76
|
+
best_audio = a_fmt
|
|
77
|
+
if best_audio:
|
|
78
|
+
audio_url = best_audio.get('url')
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if video_format_id == "audio_only":
|
|
82
|
+
best_audio = None
|
|
83
|
+
# First try to find pure audio stream
|
|
84
|
+
for a_fmt in formats:
|
|
85
|
+
if a_fmt.get('vcodec') == 'none' and a_fmt.get('acodec') != 'none':
|
|
86
|
+
if not best_audio or (a_fmt.get('abr') or 0) > (best_audio.get('abr') or 0):
|
|
87
|
+
best_audio = a_fmt
|
|
88
|
+
|
|
89
|
+
# If no pure audio stream exists, grab the smallest pre-muxed video to extract audio from
|
|
90
|
+
if not best_audio:
|
|
91
|
+
premuxed = [f for f in formats if f.get('acodec') != 'none' and f.get('vcodec') != 'none']
|
|
92
|
+
if premuxed:
|
|
93
|
+
premuxed.sort(key=lambda x: (x.get('height') or 9999, x.get('tbr') or 9999))
|
|
94
|
+
best_audio = premuxed[0]
|
|
95
|
+
|
|
96
|
+
if best_audio:
|
|
97
|
+
audio_url = best_audio.get('url')
|
|
98
|
+
headers = best_audio.get('http_headers', {})
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
'video_url': video_url,
|
|
102
|
+
'audio_url': audio_url,
|
|
103
|
+
'headers': headers,
|
|
104
|
+
'title': info.get('title', 'Facebook Video')
|
|
105
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
def fetch_all_info(url: str) -> dict:
|
|
2
|
+
"""
|
|
3
|
+
Extracts all info without downloading, without strict format filtering.
|
|
4
|
+
"""
|
|
5
|
+
import yt_dlp
|
|
6
|
+
ydl_opts = {
|
|
7
|
+
'noplaylist': True,
|
|
8
|
+
'quiet': True,
|
|
9
|
+
'extractor_args': {'youtube': ['player_client=android,ios']},
|
|
10
|
+
}
|
|
11
|
+
try:
|
|
12
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
13
|
+
return ydl.extract_info(url, download=False)
|
|
14
|
+
except Exception as e:
|
|
15
|
+
error_msg = str(e).lower()
|
|
16
|
+
if 'bot' in error_msg or 'cookie' in error_msg:
|
|
17
|
+
# Bypass 1: Use alternative player clients (Android, iOS, TV)
|
|
18
|
+
clients_to_try = ['android', 'ios', 'tv']
|
|
19
|
+
for client in clients_to_try:
|
|
20
|
+
ydl_opts['extractor_args'] = {'youtube': [f'player_client={client}']}
|
|
21
|
+
try:
|
|
22
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
23
|
+
return ydl.extract_info(url, download=False)
|
|
24
|
+
except Exception:
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
# Bypass 2: Local Browser Cookies
|
|
28
|
+
if 'extractor_args' in ydl_opts:
|
|
29
|
+
del ydl_opts['extractor_args']
|
|
30
|
+
|
|
31
|
+
for browser in ['chrome', 'edge', 'firefox', 'brave', 'opera']:
|
|
32
|
+
ydl_opts['cookiesfrombrowser'] = (browser,)
|
|
33
|
+
try:
|
|
34
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
35
|
+
return ydl.extract_info(url, download=False)
|
|
36
|
+
except Exception:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
raise Exception("YouTube bot protection blocked the request and bypass failed. Ensure your browser (Chrome/Edge) is FULLY CLOSED so cookies can be read, then try again.") from e
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
def get_video_resolutions(info: dict) -> list[dict]:
|
|
43
|
+
"""
|
|
44
|
+
Parse formats from info, filter out audio-only streams,
|
|
45
|
+
group/deduplicate by resolution (e.g. '1080p', '720p'),
|
|
46
|
+
and return list of dicts with 'resolution' and 'format_id', sorted highest to lowest.
|
|
47
|
+
"""
|
|
48
|
+
formats = info.get('formats', [])
|
|
49
|
+
resolutions = {}
|
|
50
|
+
|
|
51
|
+
for fmt in formats:
|
|
52
|
+
# filter out audio-only streams
|
|
53
|
+
if fmt.get('vcodec') == 'none':
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
height = fmt.get('height')
|
|
57
|
+
if not height:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
res_str = f"{height}p"
|
|
61
|
+
fmt_id = fmt.get('format_id')
|
|
62
|
+
|
|
63
|
+
# Keep track of resolutions we've seen.
|
|
64
|
+
if height not in resolutions:
|
|
65
|
+
resolutions[height] = {
|
|
66
|
+
'resolution': res_str,
|
|
67
|
+
'format_id': fmt_id
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Sort by height descending
|
|
71
|
+
sorted_heights = sorted(resolutions.keys(), reverse=True)
|
|
72
|
+
return [resolutions[h] for h in sorted_heights]
|
|
73
|
+
|
|
74
|
+
def extract_urls(info: dict, video_format_id: str) -> dict:
|
|
75
|
+
"""
|
|
76
|
+
Find specific video format URL using video_format_id.
|
|
77
|
+
Find best audio format URL (vcodec == 'none' and acodec != 'none').
|
|
78
|
+
Extract http_headers and title.
|
|
79
|
+
"""
|
|
80
|
+
title = info.get('title', 'Unknown Title')
|
|
81
|
+
headers = info.get('http_headers', {}).copy()
|
|
82
|
+
|
|
83
|
+
formats = info.get('formats', [])
|
|
84
|
+
video_url = None
|
|
85
|
+
audio_url = None
|
|
86
|
+
|
|
87
|
+
# Find video
|
|
88
|
+
if video_format_id != "audio_only":
|
|
89
|
+
for fmt in formats:
|
|
90
|
+
if str(fmt.get('format_id')) == str(video_format_id):
|
|
91
|
+
video_url = fmt.get('url')
|
|
92
|
+
if fmt.get('http_headers'):
|
|
93
|
+
headers.update(fmt.get('http_headers'))
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# Find best audio (yt-dlp normally sorts formats from worst to best overall,
|
|
97
|
+
# but let's just find the last one that is audio-only, or sort by abr)
|
|
98
|
+
audio_formats = [f for f in formats if f.get('vcodec') == 'none' and f.get('acodec') != 'none']
|
|
99
|
+
if audio_formats:
|
|
100
|
+
# Sort by audio bitrate if available
|
|
101
|
+
audio_formats.sort(key=lambda x: x.get('abr', 0) or 0)
|
|
102
|
+
best_audio = audio_formats[-1]
|
|
103
|
+
audio_url = best_audio.get('url')
|
|
104
|
+
if best_audio.get('http_headers'):
|
|
105
|
+
headers.update(best_audio.get('http_headers'))
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
'video_url': video_url,
|
|
109
|
+
'audio_url': audio_url,
|
|
110
|
+
'headers': headers,
|
|
111
|
+
'title': title
|
|
112
|
+
}
|
idm_cli/muxer.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
def mux_audio_video(video_path: str, audio_path: str, output_path: str) -> None:
|
|
8
|
+
"""
|
|
9
|
+
Muxes a video and audio file together into a single output file using ffmpeg.
|
|
10
|
+
Copies the streams without re-encoding.
|
|
11
|
+
Deletes the original video and audio files upon successful muxing.
|
|
12
|
+
"""
|
|
13
|
+
if not os.path.exists(video_path):
|
|
14
|
+
raise FileNotFoundError(f"Video file not found: {video_path}")
|
|
15
|
+
if not os.path.exists(audio_path):
|
|
16
|
+
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
|
17
|
+
|
|
18
|
+
cmd = [
|
|
19
|
+
"ffmpeg",
|
|
20
|
+
"-y", # Overwrite output file if it exists
|
|
21
|
+
"-i", video_path,
|
|
22
|
+
"-i", audio_path,
|
|
23
|
+
"-c", "copy",
|
|
24
|
+
output_path
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
logger.info(f"Muxing {video_path} and {audio_path} into {output_path}")
|
|
29
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
30
|
+
|
|
31
|
+
logger.info("Muxing successful. Deleting original files.")
|
|
32
|
+
try:
|
|
33
|
+
os.remove(video_path)
|
|
34
|
+
os.remove(audio_path)
|
|
35
|
+
except OSError as e:
|
|
36
|
+
logger.warning(f"Failed to delete original files: {e}")
|
|
37
|
+
|
|
38
|
+
except subprocess.CalledProcessError as e:
|
|
39
|
+
logger.error(f"FFmpeg muxing failed: {e.stderr.decode('utf-8', errors='replace')}")
|
|
40
|
+
raise RuntimeError(f"FFmpeg muxing failed: {e}")
|
|
41
|
+
|
|
42
|
+
def convert_to_mp3(audio_path: str, output_path: str) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Converts an audio file to mp3 using ffmpeg.
|
|
45
|
+
Deletes the original audio file upon success.
|
|
46
|
+
"""
|
|
47
|
+
if not os.path.exists(audio_path):
|
|
48
|
+
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
|
49
|
+
|
|
50
|
+
cmd = [
|
|
51
|
+
"ffmpeg",
|
|
52
|
+
"-y",
|
|
53
|
+
"-i", audio_path,
|
|
54
|
+
"-q:a", "0",
|
|
55
|
+
"-map", "a",
|
|
56
|
+
output_path
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
logger.info(f"Converting {audio_path} into {output_path}")
|
|
61
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
62
|
+
|
|
63
|
+
logger.info("Conversion successful. Deleting original file.")
|
|
64
|
+
try:
|
|
65
|
+
os.remove(audio_path)
|
|
66
|
+
except OSError as e:
|
|
67
|
+
logger.warning(f"Failed to delete original file: {e}")
|
|
68
|
+
|
|
69
|
+
except subprocess.CalledProcessError as e:
|
|
70
|
+
logger.error(f"FFmpeg conversion failed: {e.stderr.decode('utf-8', errors='replace')}")
|
|
71
|
+
raise RuntimeError(f"FFmpeg conversion failed: {e}")
|
idm_cli/state.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
STATE_DIR = os.path.expanduser("~/.idm_cli")
|
|
6
|
+
STATE_FILE = os.path.join(STATE_DIR, "state.json")
|
|
7
|
+
|
|
8
|
+
def _ensure_dir():
|
|
9
|
+
if not os.path.exists(STATE_DIR):
|
|
10
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
11
|
+
|
|
12
|
+
def get_incomplete_downloads() -> dict:
|
|
13
|
+
"""Returns the parsed JSON of incomplete downloads."""
|
|
14
|
+
if not os.path.exists(STATE_FILE):
|
|
15
|
+
return {}
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
|
19
|
+
return json.load(f)
|
|
20
|
+
except (json.JSONDecodeError, IOError):
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
def _write_state(state: dict):
|
|
24
|
+
"""Writes the state to the JSON file atomically."""
|
|
25
|
+
_ensure_dir()
|
|
26
|
+
# Write atomically using a temporary file in the same directory
|
|
27
|
+
fd, temp_path = tempfile.mkstemp(dir=STATE_DIR, prefix="state_", suffix=".json")
|
|
28
|
+
try:
|
|
29
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
30
|
+
json.dump(state, f, indent=4)
|
|
31
|
+
|
|
32
|
+
# Atomically replace the target file
|
|
33
|
+
os.replace(temp_path, STATE_FILE)
|
|
34
|
+
except Exception:
|
|
35
|
+
# Clean up temp file on error
|
|
36
|
+
if os.path.exists(temp_path):
|
|
37
|
+
os.remove(temp_path)
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
def save_download(task_id: str, url: str, format_id: str, title: str, video_dest: str, audio_dest: str, final_dest: str, status: str = "interrupted"):
|
|
41
|
+
"""Adds or updates a download in the JSON."""
|
|
42
|
+
state = get_incomplete_downloads()
|
|
43
|
+
state[task_id] = {
|
|
44
|
+
"url": url,
|
|
45
|
+
"format_id": format_id,
|
|
46
|
+
"title": title,
|
|
47
|
+
"video_dest": video_dest,
|
|
48
|
+
"audio_dest": audio_dest,
|
|
49
|
+
"final_dest": final_dest,
|
|
50
|
+
"status": status
|
|
51
|
+
}
|
|
52
|
+
_write_state(state)
|
|
53
|
+
|
|
54
|
+
def remove_download(task_id: str):
|
|
55
|
+
"""Removes the entry from JSON upon completion."""
|
|
56
|
+
state = get_incomplete_downloads()
|
|
57
|
+
if task_id in state:
|
|
58
|
+
del state[task_id]
|
|
59
|
+
_write_state(state)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idm-cli
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A lightning-fast, universal command-line download manager
|
|
5
|
+
Home-page: https://github.com/rj41-w2/idm-cli
|
|
6
|
+
Author: Rehan
|
|
7
|
+
Author-email: rehanjamilwattoo@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: typer
|
|
16
|
+
Requires-Dist: rich
|
|
17
|
+
Requires-Dist: aiohttp
|
|
18
|
+
Requires-Dist: aiofiles
|
|
19
|
+
Requires-Dist: yt-dlp
|
|
20
|
+
Requires-Dist: pyfiglet
|
|
21
|
+
Requires-Dist: questionary
|
|
22
|
+
Dynamic: author
|
|
23
|
+
Dynamic: author-email
|
|
24
|
+
Dynamic: classifier
|
|
25
|
+
Dynamic: description
|
|
26
|
+
Dynamic: description-content-type
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
Dynamic: requires-dist
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
# IDM-CLI (Internet Download Manager CLI)
|
|
34
|
+
|
|
35
|
+
A lightning-fast, powerful, and universal 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 seamlessly supports downloading from **YouTube**, **Facebook**, and any **Direct File URL** (`.exe`, `.zip`, `.pdf`, etc.).
|
|
36
|
+
|
|
37
|
+
## 🚀 Features
|
|
38
|
+
|
|
39
|
+
- **Blazing Fast Speeds:** Splits downloads into multiple parallel chunks (like traditional IDM) to fully saturate your bandwidth.
|
|
40
|
+
- **Universal Downloader:** Paste any direct URL (e.g., a `.pdf` or `.exe`). The built-in smart HTTP `HEAD` detector automatically recognizes file types and routes them to the parallel engine.
|
|
41
|
+
- **Social Media Support:** Natively supports downloading from **YouTube** and **Facebook** via a modular extractor architecture. Auto-detects pre-muxed (SD/HD) vs separated streams.
|
|
42
|
+
- **Audio Only / MP3 Conversion:** Easily download videos as audio. IDM-CLI automatically grabs the best audio stream and uses `ffmpeg` to perfectly convert it to `.mp3`.
|
|
43
|
+
- **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.
|
|
44
|
+
- **Persistent Queue System (`-Q`):** Add multiple files or videos to a queue and type `start queue` to automatically download all of them sequentially in the background.
|
|
45
|
+
- **Pristine Downloads Folder:** All temporary chunks and raw media files are kept hidden in `~/.idm_cli/tmp/`. Only the fully assembled, 100% complete files are moved to your `~/Downloads` folder.
|
|
46
|
+
- **Interactive UI:** A beautiful, responsive terminal interface powered by `rich` and `questionary` with real-time speed, ETA, and progress bars.
|
|
47
|
+
- **Pause & Play:** Press `p` to pause the active downloads without losing progress, and `r` to resume them.
|
|
48
|
+
|
|
49
|
+
## 🛠️ Prerequisites
|
|
50
|
+
|
|
51
|
+
- **Python 3.8+**
|
|
52
|
+
- **FFmpeg:** Required for muxing video and audio streams, and converting media to `.mp3`. Ensure `ffmpeg` is installed and added to your system's PATH.
|
|
53
|
+
|
|
54
|
+
## 📦 Installation
|
|
55
|
+
|
|
56
|
+
Clone the repository and install the required dependencies:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/rj41-w2/idm-cli.git
|
|
60
|
+
cd IDM-CLI
|
|
61
|
+
pip install -r requirements.txt
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
*(Note: If you plan to publish this to PyPI, users will simply be able to run `pip install idm-cli`)*
|
|
65
|
+
|
|
66
|
+
## 💻 Usage
|
|
67
|
+
|
|
68
|
+
Run the app interactively by simply typing:
|
|
69
|
+
```bash
|
|
70
|
+
idm
|
|
71
|
+
```
|
|
72
|
+
This will open a prompt where you can paste your link, select video resolutions, or choose to queue the download.
|
|
73
|
+
|
|
74
|
+
### CLI Flags (Fast Mode)
|
|
75
|
+
|
|
76
|
+
Skip the interactive menus by passing arguments directly!
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Download a video with auto-selected 1080p quality
|
|
80
|
+
idm "https://youtube.com/watch?v=..." -q 1080p -v
|
|
81
|
+
|
|
82
|
+
# Download Audio Only (converts to MP3)
|
|
83
|
+
idm "https://youtube.com/watch?v=..." -a
|
|
84
|
+
|
|
85
|
+
# Use 16 parallel chunks for maximum speed (default is 8)
|
|
86
|
+
idm "https://example.com/largefile.zip" -c 16
|
|
87
|
+
|
|
88
|
+
# Add a video to the Queue without downloading it right now
|
|
89
|
+
idm "https://facebook.com/..." -Q
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Queue Management
|
|
93
|
+
|
|
94
|
+
To start downloading all items currently in your queue, simply type:
|
|
95
|
+
```bash
|
|
96
|
+
idm start queue
|
|
97
|
+
```
|
|
98
|
+
*(You can also just type `start queue` into the interactive `idm` prompt!)*
|
|
99
|
+
|
|
100
|
+
## ⚙️ Architecture Highlights
|
|
101
|
+
|
|
102
|
+
- **`downloader.py`:** The core asynchronous engine handling `aiohttp` range requests, chunk merging, and resume states.
|
|
103
|
+
- **`cli.py`:** The interactive orchestration layer utilizing `typer`, `rich`, and `questionary`.
|
|
104
|
+
- **`muxer.py`:** Safe abstraction over `subprocess` calls to `ffmpeg` for media stream merging.
|
|
105
|
+
- **`extractors/`:** Modular plugins (`youtube.py`, `facebook.py`, `direct.py`) for handling specialized metadata retrieval.
|
|
106
|
+
|
|
107
|
+
## 📄 License
|
|
108
|
+
|
|
109
|
+
This project is open-source and available under the MIT License.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
idm_cli/__init__.py,sha256=txzQmfaWzjuImDpT4p2m93DgAMHVlKPiqbPYudUY5Bs,18
|
|
2
|
+
idm_cli/cli.py,sha256=dC6sWDIMwU1jCI8oms8ZvooQDulI6SHsRyHHe0dpg2o,23497
|
|
3
|
+
idm_cli/downloader.py,sha256=yt2qRv2FGx2Cgytlh9KIq6YluT6c4724vVjSwRm3gyw,6873
|
|
4
|
+
idm_cli/muxer.py,sha256=W7AvKfT_ptx_49J64KZ5fZMu7o0jWhq1x6e08776Asg,2451
|
|
5
|
+
idm_cli/state.py,sha256=DFRI90q7xu_uC0j5d-SQLfmbWYGiC2jdUXuG1Xed12o,1877
|
|
6
|
+
idm_cli/extractors/__init__.py,sha256=p6sj75HD2Ygj2mv-1_vlZmToYSlOVb_O3nylXZH7dYI,753
|
|
7
|
+
idm_cli/extractors/direct.py,sha256=f0IoxuwdQutHAvDV7F8_7kHE4ZSVNrVefAQ13musI8k,675
|
|
8
|
+
idm_cli/extractors/facebook.py,sha256=RXW0OrXenkGX0nxD6kEIlUwkeUONLRCwGxT_Z-gzFtA,3693
|
|
9
|
+
idm_cli/extractors/youtube.py,sha256=vZOh-4nmxTO55Y0JXrHNoMtkl3xWxEI5ZbIAm7DhqB0,4245
|
|
10
|
+
idm_cli-0.0.1.dist-info/licenses/LICENSE,sha256=ISSxF6YLBU6mA1fme1HHStx1cXyv8MnhqLIhe1DMZCQ,1062
|
|
11
|
+
idm_cli-0.0.1.dist-info/METADATA,sha256=qNeEa-U2XWhF4A5lq93K8zEwvpS4GFmT2YYk_mQE-KI,4771
|
|
12
|
+
idm_cli-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
idm_cli-0.0.1.dist-info/entry_points.txt,sha256=R-b5b67D3EmeIj_EIfEltdaZCdt0gie6SZaFblz4jnE,40
|
|
14
|
+
idm_cli-0.0.1.dist-info/top_level.txt,sha256=yAjcrNJHpInrKlhJXbAM1ORfW3_9CEEOqdvYRohhJoI,8
|
|
15
|
+
idm_cli-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rehan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
idm_cli
|