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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ idm = idm_cli.cli:app
@@ -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