idm-cli 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: idm-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Requires-Dist: typer[all]>=0.9.0
5
5
  Requires-Dist: httpx>=0.24.0
6
6
  Requires-Dist: rich>=13.0.0
@@ -0,0 +1,2 @@
1
+ # idm_cli Package
2
+ __version__ = "0.2.4"
@@ -3,7 +3,7 @@ import sys
3
3
  import asyncio
4
4
  import logging
5
5
  import typer
6
- from typing import Optional
6
+ from typing import Optional, Any
7
7
  from rich.console import Console
8
8
  from rich.panel import Panel
9
9
 
@@ -42,8 +42,8 @@ def run_async_downloader(downloader: IDMDownloader):
42
42
  except asyncio.CancelledError:
43
43
  pass
44
44
  except Exception as e:
45
- console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]")
46
- sys.exit(1)
45
+ # Re-raise the exception instead of calling sys.exit(1) to avoid killing the shell/server process.
46
+ raise e
47
47
  finally:
48
48
  loop.close()
49
49
 
@@ -255,25 +255,28 @@ def download(
255
255
 
256
256
  is_stream = is_streaming_url(url)
257
257
 
258
- if is_stream:
259
- # Resolve quality parameter or prompt
260
- if quality:
261
- try:
262
- selected_res = int(quality.lower().replace("p", ""))
263
- except ValueError:
264
- selected_res = 720
258
+ try:
259
+ if is_stream:
260
+ # Resolve quality parameter or prompt
261
+ if quality:
262
+ try:
263
+ selected_res = int(quality.lower().replace("p", ""))
264
+ except ValueError:
265
+ selected_res = 720
266
+ else:
267
+ selected_res = prompt_for_quality(url)
268
+
269
+ download_streaming_video(url, output_path, connections, selected_res)
265
270
  else:
266
- selected_res = prompt_for_quality(url)
267
-
268
- download_streaming_video(url, output_path, connections, selected_res)
269
- else:
270
- # Standard file download
271
- downloader = IDMDownloader(
272
- url=url,
273
- output_path=output_path,
274
- connections=connections
275
- )
276
- run_async_downloader(downloader)
271
+ # Standard file download
272
+ downloader = IDMDownloader(
273
+ url=url,
274
+ output_path=output_path,
275
+ connections=connections
276
+ )
277
+ run_async_downloader(downloader)
278
+ except Exception:
279
+ raise typer.Exit(code=1)
277
280
 
278
281
  @app.command()
279
282
  def resume(
@@ -312,7 +315,44 @@ def resume(
312
315
  state_file=state_file
313
316
  )
314
317
 
315
- run_async_downloader(downloader)
318
+ try:
319
+ run_async_downloader(downloader)
320
+ except Exception:
321
+ raise typer.Exit(code=1)
322
+
323
+ def handle_incoming_url(url: str, quality: Optional[Any] = None):
324
+ """Callback triggered sequentially by the queue worker when it's this URL's turn to download."""
325
+ console.print(f"\n[bold green]▶ [Queue Download Started]:[/bold green] [cyan]{url}[/cyan]")
326
+ try:
327
+ if is_streaming_url(url):
328
+ target_quality = quality if quality is not None else 720
329
+ try:
330
+ if isinstance(target_quality, str):
331
+ target_quality = int(target_quality.lower().replace("p", ""))
332
+ else:
333
+ target_quality = int(target_quality)
334
+ except (ValueError, TypeError):
335
+ target_quality = 720
336
+ console.print(f"[bold yellow]Routing stream download to {target_quality}p...[/bold yellow]")
337
+ download_streaming_video(url, None, 8, target_quality)
338
+ else:
339
+ downloader = IDMDownloader(url=url)
340
+ run_async_downloader(downloader)
341
+
342
+ console.print(f"[bold green]✓ Download completed successfully.[/bold green]")
343
+
344
+ # Check if more items are waiting in the queue
345
+ from idm_cli.server import _queue_lock, _queue_items
346
+ with _queue_lock:
347
+ waiting = sum(1 for qi in _queue_items if qi["status"] == "waiting")
348
+ if waiting > 0:
349
+ console.print(f"[bold cyan]⏳ {waiting} more download(s) waiting in queue. Starting next...[/bold cyan]")
350
+
351
+ # Reprint the prompt cleanly
352
+ console.print("\n[bold cyan]idm>[/bold cyan] ", end="")
353
+ except Exception as e:
354
+ console.print(f"[bold red]✖ Download failed: {e}[/bold red]")
355
+ console.print("\n[bold cyan]idm>[/bold cyan] ", end="")
316
356
 
317
357
  def interactive_shell():
318
358
  # 1. Run quick asynchronous/cached check for updates
@@ -333,8 +373,9 @@ def interactive_shell():
333
373
  except Exception:
334
374
  pass
335
375
 
376
+ import idm_cli
336
377
  console.print(Panel(
337
- "[bold green]IDM-CLI Interactive Shell Mode[/bold green]\n"
378
+ f"[bold green]IDM-CLI Interactive Shell Mode[/bold green] [yellow]v{idm_cli.__version__}[/yellow]\n"
338
379
  "[bold white]Developed by:[/bold white] [cyan]Rehan Jamil[/cyan]\n"
339
380
  "[bold white]GitHub Profile:[/bold white] [link=https://github.com/rj41-w2][cyan]github.com/rj41-w2[/cyan][/link] [dim](Ctrl + Click to Open)[/dim]\n\n"
340
381
  "• Paste any HTTP/HTTPS URL or YouTube URL to download instantly.\n"
@@ -348,6 +389,18 @@ def interactive_shell():
348
389
  padding=(1, 2)
349
390
  ))
350
391
 
392
+ # Start the local HTTP background server for browser extension integration
393
+ import threading
394
+ from idm_cli.server import start_server
395
+
396
+ server_thread = threading.Thread(
397
+ target=start_server,
398
+ args=(18944, handle_incoming_url),
399
+ daemon=True
400
+ )
401
+ server_thread.start()
402
+ console.print("[bold green]✓ Browser Integration Server listening on http://localhost:18944[/bold green]\n")
403
+
351
404
  # Import Table inside for clean scoping
352
405
  from rich.table import Table
353
406
 
@@ -257,7 +257,7 @@ class IDMDownloader:
257
257
  segment: Dict[str, Any],
258
258
  ui: IDMProgress
259
259
  ):
260
- """Downloads a single file byte segment concurrently."""
260
+ """Downloads a single file byte segment concurrently with automatic retries."""
261
261
  seg_id = segment["id"]
262
262
  part_path = segment["part_path"]
263
263
 
@@ -268,7 +268,7 @@ class IDMDownloader:
268
268
 
269
269
  async with self._write_lock:
270
270
  segment["downloaded"] = current_disk_bytes
271
- if segment["downloaded"] >= (segment["end"] - segment["start"] + 1) and segment["end"] > 0:
271
+ if segment["end"] > 0 and segment["downloaded"] >= (segment["end"] - segment["start"] + 1):
272
272
  segment["completed"] = True
273
273
  ui.set_segment_total(seg_id, segment["end"] - segment["start"] + 1, segment["downloaded"])
274
274
  ui.mark_segment_completed(seg_id)
@@ -281,45 +281,61 @@ class IDMDownloader:
281
281
  segment["downloaded"]
282
282
  )
283
283
 
284
- headers = {
285
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
286
- }
287
- if self.resumable:
288
- start_range = segment["start"] + segment["downloaded"]
289
- headers["Range"] = f"bytes={start_range}-{segment['end']}"
284
+ max_retries = 5
285
+ last_error = None
290
286
 
291
- mode = "ab" if segment["downloaded"] > 0 else "wb"
292
-
293
- try:
294
- async with client.stream("GET", self.url, headers=headers, timeout=30.0) as response:
295
- if response.status_code not in (200, 206):
296
- raise httpx.HTTPStatusError(
297
- f"Bad status code {response.status_code}",
298
- request=response.request,
299
- response=response
300
- )
301
-
302
- # Dynamic write buffer matching
303
- with open(part_path, mode) as f:
304
- async for chunk in response.aiter_bytes(chunk_size=16384):
305
- f.write(chunk)
306
- chunk_len = len(chunk)
307
-
308
- async with self._write_lock:
309
- segment["downloaded"] += chunk_len
310
-
311
- ui.update_segment(seg_id, chunk_len)
287
+ for attempt in range(max_retries):
288
+ # Calculate range dynamically based on current downloaded bytes
289
+ headers = {
290
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
291
+ }
292
+ if self.resumable:
293
+ start_range = segment["start"] + segment["downloaded"]
294
+ if start_range > segment["end"]:
295
+ break
296
+ headers["Range"] = f"bytes={start_range}-{segment['end']}"
312
297
 
313
- async with self._write_lock:
314
- segment["completed"] = True
315
- ui.mark_segment_completed(seg_id)
316
-
317
- except asyncio.CancelledError:
318
- # Handle graceful cancellation state updates
319
- raise
320
- except Exception as e:
321
- logger.error(f"Error in connection {seg_id + 1}: {e}")
322
- raise e
298
+ # Append mode if we already have some bytes written to disk
299
+ mode = "ab" if segment["downloaded"] > 0 else "wb"
300
+
301
+ try:
302
+ async with client.stream("GET", self.url, headers=headers, timeout=30.0) as response:
303
+ if response.status_code not in (200, 206):
304
+ raise httpx.HTTPStatusError(
305
+ f"Bad status code {response.status_code}",
306
+ request=response.request,
307
+ response=response
308
+ )
309
+
310
+ # Dynamic write buffer matching
311
+ with open(part_path, mode) as f:
312
+ async for chunk in response.aiter_bytes(chunk_size=16384):
313
+ f.write(chunk)
314
+ chunk_len = len(chunk)
315
+
316
+ async with self._write_lock:
317
+ segment["downloaded"] += chunk_len
318
+
319
+ ui.update_segment(seg_id, chunk_len)
320
+
321
+ # Successfully downloaded this segment
322
+ async with self._write_lock:
323
+ segment["completed"] = True
324
+ ui.mark_segment_completed(seg_id)
325
+ return
326
+
327
+ except asyncio.CancelledError:
328
+ # Handle graceful cancellation state updates
329
+ raise
330
+ except Exception as e:
331
+ last_error = e
332
+ logger.warning(f"Connection {seg_id + 1} failed (attempt {attempt + 1}/{max_retries}): {e}")
333
+ # Wait before retrying (exponential backoff)
334
+ await asyncio.sleep(1.5 * (attempt + 1))
335
+
336
+ # If we exhausted all retries, raise the error
337
+ logger.error(f"Error in connection {seg_id + 1} after {max_retries} attempts: {last_error}", exc_info=True)
338
+ raise last_error
323
339
 
324
340
  async def start_download(self):
325
341
  """Orchestrates the entire multi-threaded download process."""
@@ -54,9 +54,26 @@ def extract_stream_info(url: str, selected_resolution: Optional[int] = None) ->
54
54
  Otherwise, if ffmpeg is installed, downloads separate video and audio streams concurrently.
55
55
  Falls back to the best available combined stream if separate streams are not available or ffmpeg is missing.
56
56
  """
57
+ # Clean YouTube URL to strip playlist and other non-essential query parameters
58
+ if "youtube.com" in url or "youtu.be" in url:
59
+ from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
60
+ try:
61
+ parsed = urlparse(url)
62
+ query = parse_qs(parsed.query)
63
+ if "v" in query:
64
+ new_query = {"v": query["v"]}
65
+ new_query_str = urlencode(new_query, doseq=True)
66
+ parsed = parsed._replace(query=new_query_str)
67
+ url = urlunparse(parsed)
68
+ except Exception:
69
+ pass
70
+
57
71
  ydl_opts = {
58
72
  "quiet": True,
59
73
  "no_warnings": True,
74
+ "noplaylist": True,
75
+ "youtube_include_dash_manifest": False,
76
+ "youtube_include_hls_manifest": False,
60
77
  }
61
78
 
62
79
  try:
@@ -0,0 +1,189 @@
1
+ import json
2
+ import logging
3
+ import queue
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ import threading
6
+ import urllib.parse
7
+
8
+ logger = logging.getLogger("idm_cli.server")
9
+
10
+ # Global reference to trigger downloads in the main CLI thread
11
+ _download_callback = None
12
+
13
+ # Thread-safe download queue (max 10 items)
14
+ MAX_QUEUE_SIZE = 10
15
+ _download_queue = queue.Queue(maxsize=MAX_QUEUE_SIZE)
16
+
17
+ # Tracking for display: list of dicts with url, quality, status
18
+ _queue_lock = threading.Lock()
19
+ _queue_items = [] # [{url, quality, status: "waiting"/"downloading"/"done"}]
20
+ _current_download = None # url string of item currently downloading
21
+
22
+
23
+ def _queue_worker():
24
+ """Background worker that processes the download queue sequentially."""
25
+ global _current_download
26
+ while True:
27
+ # Block until an item is available
28
+ item = _download_queue.get()
29
+ if item is None:
30
+ break # Poison pill to stop worker
31
+
32
+ url = item["url"]
33
+ quality = item["quality"]
34
+
35
+ # Mark as downloading
36
+ with _queue_lock:
37
+ _current_download = url
38
+ for qi in _queue_items:
39
+ if qi["url"] == url and qi["status"] == "waiting":
40
+ qi["status"] = "downloading"
41
+ break
42
+
43
+ # Execute the download callback
44
+ try:
45
+ if _download_callback:
46
+ _download_callback(url, quality)
47
+ except Exception as e:
48
+ logger.error(f"Queue download failed for {url}: {e}")
49
+
50
+ # Mark as done and remove from tracking list
51
+ with _queue_lock:
52
+ _current_download = None
53
+ _queue_items[:] = [qi for qi in _queue_items if not (qi["url"] == url and qi["status"] == "downloading")]
54
+
55
+ _download_queue.task_done()
56
+
57
+
58
+ class IDMRequestHandler(BaseHTTPRequestHandler):
59
+ def log_message(self, format, *args):
60
+ # Prevent default logging to keep terminal UI clean
61
+ pass
62
+
63
+ def _send_json(self, status_code, data):
64
+ """Helper to send a JSON response with CORS headers."""
65
+ self.send_response(status_code)
66
+ self.send_header("Content-Type", "application/json")
67
+ self.send_header("Access-Control-Allow-Origin", "*")
68
+ self.end_headers()
69
+ self.wfile.write(json.dumps(data).encode("utf-8"))
70
+
71
+ def do_GET(self):
72
+ parsed_url = urllib.parse.urlparse(self.path)
73
+ if parsed_url.path == "/ping":
74
+ self._send_json(200, {"status": "alive"})
75
+ elif parsed_url.path == "/info":
76
+ query_params = urllib.parse.parse_qs(parsed_url.query)
77
+ url_list = query_params.get("url")
78
+ url = url_list[0] if url_list else None
79
+
80
+ if not url:
81
+ self._send_json(400, {"error": "Missing URL"})
82
+ return
83
+
84
+ from idm_cli.extractor import extract_stream_info
85
+ try:
86
+ info = extract_stream_info(url)
87
+ if info:
88
+ response_data = {
89
+ "title": info.get("title", ""),
90
+ "resolutions": info.get("resolutions", [])
91
+ }
92
+ else:
93
+ response_data = {"error": "Failed to extract info"}
94
+ except Exception as e:
95
+ response_data = {"error": str(e)}
96
+
97
+ self._send_json(200, response_data)
98
+ elif parsed_url.path == "/queue":
99
+ # Return current queue status
100
+ with _queue_lock:
101
+ items_info = []
102
+ for qi in _queue_items:
103
+ items_info.append({
104
+ "url": qi["url"],
105
+ "quality": qi["quality"],
106
+ "status": qi["status"]
107
+ })
108
+ self._send_json(200, {
109
+ "queue_size": len(items_info),
110
+ "max_size": MAX_QUEUE_SIZE,
111
+ "items": items_info
112
+ })
113
+ else:
114
+ self.send_response(404)
115
+ self.end_headers()
116
+
117
+ def do_POST(self):
118
+ parsed_url = urllib.parse.urlparse(self.path)
119
+ if parsed_url.path == "/download":
120
+ content_length = int(self.headers.get('Content-Length', 0))
121
+ post_data = self.rfile.read(content_length)
122
+
123
+ try:
124
+ data = json.loads(post_data.decode('utf-8'))
125
+ url = data.get("url")
126
+ quality = data.get("quality")
127
+ except Exception:
128
+ url = None
129
+ quality = None
130
+
131
+ if not url:
132
+ self._send_json(400, {"error": "Missing URL"})
133
+ return
134
+
135
+ # Check if queue is full
136
+ with _queue_lock:
137
+ waiting_count = sum(1 for qi in _queue_items if qi["status"] in ("waiting", "downloading"))
138
+ if waiting_count >= MAX_QUEUE_SIZE:
139
+ self._send_json(429, {
140
+ "error": "Queue is full",
141
+ "message": f"Maximum {MAX_QUEUE_SIZE} downloads allowed. Please wait for current downloads to finish.",
142
+ "queue_size": waiting_count,
143
+ "max_size": MAX_QUEUE_SIZE
144
+ })
145
+ return
146
+
147
+ # Add to queue
148
+ item = {"url": url, "quality": quality}
149
+ try:
150
+ _download_queue.put_nowait(item)
151
+ with _queue_lock:
152
+ _queue_items.append({"url": url, "quality": quality, "status": "waiting"})
153
+ position = sum(1 for qi in _queue_items if qi["status"] in ("waiting", "downloading"))
154
+
155
+ self._send_json(200, {
156
+ "status": "queued",
157
+ "position": position,
158
+ "queue_size": position,
159
+ "max_size": MAX_QUEUE_SIZE
160
+ })
161
+ except queue.Full:
162
+ self._send_json(429, {
163
+ "error": "Queue is full",
164
+ "message": f"Maximum {MAX_QUEUE_SIZE} downloads allowed.",
165
+ "max_size": MAX_QUEUE_SIZE
166
+ })
167
+ else:
168
+ self.send_response(404)
169
+ self.end_headers()
170
+
171
+ def do_OPTIONS(self):
172
+ # Handle CORS preflight options request from browser extension
173
+ self.send_response(200)
174
+ self.send_header("Access-Control-Allow-Origin", "*")
175
+ self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
176
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
177
+ self.end_headers()
178
+
179
+ def start_server(port=18944, download_callback=None):
180
+ """Starts the local HTTP server in a blocking loop with a sequential download queue worker."""
181
+ global _download_callback
182
+ _download_callback = download_callback
183
+
184
+ # Start the sequential queue worker thread
185
+ worker = threading.Thread(target=_queue_worker, daemon=True)
186
+ worker.start()
187
+
188
+ server = HTTPServer(("localhost", port), IDMRequestHandler)
189
+ server.serve_forever()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: idm-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Requires-Dist: typer[all]>=0.9.0
5
5
  Requires-Dist: httpx>=0.24.0
6
6
  Requires-Dist: rich>=13.0.0
@@ -4,6 +4,7 @@ idm_cli/__init__.py
4
4
  idm_cli/cli.py
5
5
  idm_cli/downloader.py
6
6
  idm_cli/extractor.py
7
+ idm_cli/server.py
7
8
  idm_cli/state.py
8
9
  idm_cli/ui.py
9
10
  idm_cli/updater.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="idm-cli",
5
- version="0.2.3",
5
+ version="0.2.4",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "typer[all]>=0.9.0",
@@ -1,2 +0,0 @@
1
- # idm_cli Package
2
- __version__ = "0.2.3"
File without changes
File without changes
File without changes
File without changes
File without changes