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.
- {idm_cli-0.2.3 → idm_cli-0.2.4}/PKG-INFO +1 -1
- idm_cli-0.2.4/idm_cli/__init__.py +2 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli/cli.py +76 -23
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli/downloader.py +55 -39
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli/extractor.py +17 -0
- idm_cli-0.2.4/idm_cli/server.py +189 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli.egg-info/PKG-INFO +1 -1
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli.egg-info/SOURCES.txt +1 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/setup.py +1 -1
- idm_cli-0.2.3/idm_cli/__init__.py +0 -2
- {idm_cli-0.2.3 → idm_cli-0.2.4}/README.md +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli/state.py +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli/ui.py +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli/updater.py +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli.egg-info/dependency_links.txt +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli.egg-info/entry_points.txt +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli.egg-info/requires.txt +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/idm_cli.egg-info/top_level.txt +0 -0
- {idm_cli-0.2.3 → idm_cli-0.2.4}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|