Bl4ckSpaces commited on
Commit
c89d9d1
Β·
verified Β·
1 Parent(s): 8a75b5b

Release v16.0.3: Batch, Smart Skip, Auto-Extract

Browse files
Files changed (2) hide show
  1. setup.py +3 -3
  2. speedster/__init__.py +96 -44
setup.py CHANGED
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
 
3
  setup(
4
  name="speedster",
5
- version="16.0.2", # Major Update: CLI & Windows Support
6
  packages=find_packages(),
7
  install_requires=[
8
  "aiohttp>=3.9.0",
@@ -12,11 +12,11 @@ setup(
12
  ],
13
  entry_points={
14
  'console_scripts': [
15
- 'speedster=speedster:cli_main', # Mendaftarkan command 'speedster'
16
  ],
17
  },
18
  author="Bl4ckSpaces",
19
- description="Universal cross-platform downloader (Windows/Linux/Colab) with connection retry logic.",
20
  keywords="downloader, aiohttp, async, civitai, huggingface, speedster, cli",
21
  python_requires=">=3.8",
22
  )
 
2
 
3
  setup(
4
  name="speedster",
5
+ version="16.0.3", # Juggernaut Update
6
  packages=find_packages(),
7
  install_requires=[
8
  "aiohttp>=3.9.0",
 
12
  ],
13
  entry_points={
14
  'console_scripts': [
15
+ 'speedster=speedster:cli_main',
16
  ],
17
  },
18
  author="Bl4ckSpaces",
19
+ description="Universal downloader with Batch processing, Smart Skip, and Auto-Extract.",
20
  keywords="downloader, aiohttp, async, civitai, huggingface, speedster, cli",
21
  python_requires=">=3.8",
22
  )
speedster/__init__.py CHANGED
@@ -4,40 +4,59 @@ import asyncio
4
  import aiohttp
5
  import urllib.parse
6
  import argparse
 
 
 
7
  from tqdm.asyncio import tqdm
8
  import nest_asyncio
9
 
10
  # Apply nest_asyncio globally
11
  nest_asyncio.apply()
12
 
 
 
 
 
 
 
 
 
13
  class Speedster:
14
  def __init__(self, num_threads=16, chunk_size_mb=2, max_retries=5):
15
  self.num_threads = num_threads
16
  self.chunk_size = chunk_size_mb * 1024 * 1024
17
  self.max_retries = max_retries
18
- self.is_windows = os.name == 'nt' # Deteksi Windows
19
-
20
- self.default_headers = {
21
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
 
 
22
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
23
  "Accept-Language": "en-US,en;q=0.5",
24
  }
 
 
 
 
 
25
 
26
- # --- CROSS-PLATFORM WRITER ---
27
  def _write_data(self, fd, data, offset):
28
  if self.is_windows:
29
- # Windows Fallback: Lseek + Write (Blocking but safe)
30
  os.lseek(fd, offset, 0)
31
  os.write(fd, data)
32
  else:
33
- # Linux/Mac/Android: Pwrite (Atomic & Fast)
34
  os.pwrite(fd, data, offset)
35
 
 
 
 
 
 
36
  async def _fetch_chunk(self, session, url, start, end, fd, headers, pbar):
37
  chunk_headers = headers.copy()
38
  chunk_headers['Range'] = f'bytes={start}-{end}'
39
 
40
- # --- RETRY LOGIC (NYAWA CADANGAN) ---
41
  for attempt in range(self.max_retries):
42
  try:
43
  async with session.get(url, headers=chunk_headers, timeout=60) as response:
@@ -48,25 +67,24 @@ class Speedster:
48
  self._write_data(fd, chunk, offset)
49
  offset += len(chunk)
50
  pbar.update(len(chunk))
51
- return # Sukses, keluar dari fungsi
52
  except Exception as e:
53
  if attempt < self.max_retries - 1:
54
- # Tunggu sebentar sebelum coba lagi (Exponential Backoff)
55
  await asyncio.sleep(1 * (attempt + 1))
56
  else:
57
- # Nyawa habis
58
- tqdm.write(f"❌ Gagal pada chunk {start}-{end} setelah {self.max_retries} kali coba. Error: {e}")
59
  raise e
60
 
61
- async def _download_logic(self, url, dest_path, headers):
 
 
62
  async with aiohttp.ClientSession() as session:
63
- # 1. Get Metadata
64
  try:
65
  async with session.get(url, headers=headers, allow_redirects=True) as response:
66
  final_url = response.url
67
  total_size = int(response.headers.get('Content-Length', 0))
68
 
69
- # Smart Filename
70
  filename = "downloaded_file.bin"
71
  if os.path.isdir(dest_path):
72
  if 'Content-Disposition' in response.headers:
@@ -79,23 +97,29 @@ class Speedster:
79
  path_filename = os.path.basename(urllib.parse.urlparse(str(final_url)).path)
80
  if path_filename: filename = path_filename
81
 
82
- # Clean filename from weird URL params
83
  filename = filename.split('?')[0]
84
- dest_path = os.path.join(dest_path, filename)
 
 
 
 
 
 
 
 
 
85
 
86
- if total_size == 0:
87
- print(f"❌ Server tidak memberikan ukuran file. Tidak bisa multi-thread.")
88
- return
89
 
90
- print(f"πŸš€ [Speedster Universal] Target: {dest_path}")
91
- print(f"πŸ“¦ Size: {total_size / (1024**3):.2f} GB | Threads: {self.num_threads} | OS: {'Windows' if self.is_windows else 'Linux/Unix'}")
92
 
93
- # 2. Prepare Disk
94
- # Mode wb+ untuk binary write update
95
- fd = os.open(dest_path, os.O_RDWR | os.O_CREAT | os.O_BINARY if self.is_windows else os.O_RDWR | os.O_CREAT)
96
  os.ftruncate(fd, total_size)
97
 
98
- # 3. Create Tasks
99
  chunk_size = total_size // self.num_threads
100
  chunks = []
101
  for i in range(self.num_threads):
@@ -103,42 +127,70 @@ class Speedster:
103
  end = total_size - 1 if i == self.num_threads - 1 else (start + chunk_size - 1)
104
  chunks.append((start, end))
105
 
106
- pbar = tqdm(total=total_size, unit='iB', unit_scale=True, unit_divisor=1024, desc="⚑ DOWNLOADING")
107
  tasks = [asyncio.create_task(self._fetch_chunk(session, final_url, s, e, fd, headers, pbar)) for s, e in chunks]
108
 
109
  await asyncio.gather(*tasks)
110
 
111
  pbar.close()
112
  os.close(fd)
113
- print("βœ… Download Selesai!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  except Exception as e:
116
- print(f"❌ Critical Error: {e}")
 
 
 
117
 
118
- def download(self, url, dest_path=".", token=None):
119
- headers = self.default_headers.copy()
120
- if token:
121
- headers["Authorization"] = f"Bearer {token}"
122
-
123
  loop = asyncio.get_event_loop()
124
  if loop.is_running():
125
  nest_asyncio.apply()
126
- loop.run_until_complete(self._download_logic(url, dest_path, headers))
127
  else:
128
- asyncio.run(self._download_logic(url, dest_path, headers))
129
 
130
- # --- CLI ENTRY POINT ---
131
  def cli_main():
132
- parser = argparse.ArgumentParser(description="Speedster v16.0.2 - Universal Downloader")
133
- parser.add_argument("url", help="URL file/model yang ingin didownload")
134
- parser.add_argument("--out", "-o", default=".", help="Folder tujuan (Default: folder saat ini)")
135
- parser.add_argument("--token", "-t", help="Bearer token (Khusus Civitai/HuggingFace Private)")
136
- parser.add_argument("--threads", type=int, default=16, help="Jumlah koneksi simultan (Default: 16)")
 
 
137
 
138
  args = parser.parse_args()
139
-
140
  engine = Speedster(num_threads=args.threads)
141
- engine.download(args.url, args.out, args.token)
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
  if __name__ == "__main__":
144
  cli_main()
 
4
  import aiohttp
5
  import urllib.parse
6
  import argparse
7
+ import shutil
8
+ import random
9
+ import hashlib
10
  from tqdm.asyncio import tqdm
11
  import nest_asyncio
12
 
13
  # Apply nest_asyncio globally
14
  nest_asyncio.apply()
15
 
16
+ # --- STEALTH AGENTS ---
17
+ USER_AGENTS = [
18
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
19
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
20
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
21
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
22
+ ]
23
+
24
  class Speedster:
25
  def __init__(self, num_threads=16, chunk_size_mb=2, max_retries=5):
26
  self.num_threads = num_threads
27
  self.chunk_size = chunk_size_mb * 1024 * 1024
28
  self.max_retries = max_retries
29
+ self.is_windows = os.name == 'nt'
30
+
31
+ def _get_headers(self, token=None):
32
+ # Stealth Mode: Pick random agent
33
+ headers = {
34
+ "User-Agent": random.choice(USER_AGENTS),
35
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
36
  "Accept-Language": "en-US,en;q=0.5",
37
  }
38
+ # Priority: Function Arg > Env Var
39
+ final_token = token or os.environ.get("CIVITAI_TOKEN") or os.environ.get("HF_TOKEN")
40
+ if final_token:
41
+ headers["Authorization"] = f"Bearer {final_token}"
42
+ return headers
43
 
 
44
  def _write_data(self, fd, data, offset):
45
  if self.is_windows:
 
46
  os.lseek(fd, offset, 0)
47
  os.write(fd, data)
48
  else:
 
49
  os.pwrite(fd, data, offset)
50
 
51
+ def _check_disk_space(self, path, required_bytes):
52
+ total, used, free = shutil.disk_usage(os.path.dirname(os.path.abspath(path)) or '.')
53
+ if free < required_bytes:
54
+ raise OSError(f"❌ Disk Full! Required: {required_bytes/1024**3:.2f}GB, Free: {free/1024**3:.2f}GB")
55
+
56
  async def _fetch_chunk(self, session, url, start, end, fd, headers, pbar):
57
  chunk_headers = headers.copy()
58
  chunk_headers['Range'] = f'bytes={start}-{end}'
59
 
 
60
  for attempt in range(self.max_retries):
61
  try:
62
  async with session.get(url, headers=chunk_headers, timeout=60) as response:
 
67
  self._write_data(fd, chunk, offset)
68
  offset += len(chunk)
69
  pbar.update(len(chunk))
70
+ return
71
  except Exception as e:
72
  if attempt < self.max_retries - 1:
 
73
  await asyncio.sleep(1 * (attempt + 1))
74
  else:
75
+ # tqdm.write(f"❌ Chunk failed: {e}") # Silent fail to reduce spam
 
76
  raise e
77
 
78
+ async def _download_logic(self, url, dest_path, token=None, extract=False, verify=False):
79
+ headers = self._get_headers(token)
80
+
81
  async with aiohttp.ClientSession() as session:
82
+ # 1. Metadata & Smart Filename
83
  try:
84
  async with session.get(url, headers=headers, allow_redirects=True) as response:
85
  final_url = response.url
86
  total_size = int(response.headers.get('Content-Length', 0))
87
 
 
88
  filename = "downloaded_file.bin"
89
  if os.path.isdir(dest_path):
90
  if 'Content-Disposition' in response.headers:
 
97
  path_filename = os.path.basename(urllib.parse.urlparse(str(final_url)).path)
98
  if path_filename: filename = path_filename
99
 
 
100
  filename = filename.split('?')[0]
101
+ final_path = os.path.join(dest_path, filename)
102
+ else:
103
+ final_path = dest_path
104
+
105
+ # 2. Smart Skip (File Exists & Size Match)
106
+ if os.path.exists(final_path):
107
+ local_size = os.path.getsize(final_path)
108
+ if local_size == total_size and total_size > 0:
109
+ print(f"⏩ [Skip] {os.path.basename(final_path)} exists & valid.")
110
+ return final_path
111
 
112
+ # 3. Disk Check
113
+ self._check_disk_space(dest_path, total_size)
 
114
 
115
+ print(f"πŸš€ [Speedster] {os.path.basename(final_path)}")
116
+ print(f"πŸ“¦ Size: {total_size / (1024**3):.2f} GB | Threads: {self.num_threads}")
117
 
118
+ # 4. Atomic Download (.part file)
119
+ part_path = final_path + ".part"
120
+ fd = os.open(part_path, os.O_RDWR | os.O_CREAT | os.O_BINARY if self.is_windows else os.O_RDWR | os.O_CREAT)
121
  os.ftruncate(fd, total_size)
122
 
 
123
  chunk_size = total_size // self.num_threads
124
  chunks = []
125
  for i in range(self.num_threads):
 
127
  end = total_size - 1 if i == self.num_threads - 1 else (start + chunk_size - 1)
128
  chunks.append((start, end))
129
 
130
+ pbar = tqdm(total=total_size, unit='iB', unit_scale=True, unit_divisor=1024, desc="⚑ DL")
131
  tasks = [asyncio.create_task(self._fetch_chunk(session, final_url, s, e, fd, headers, pbar)) for s, e in chunks]
132
 
133
  await asyncio.gather(*tasks)
134
 
135
  pbar.close()
136
  os.close(fd)
137
+
138
+ # 5. Finalize (Rename .part -> .safetensors)
139
+ if os.path.exists(final_path):
140
+ os.remove(final_path)
141
+ os.rename(part_path, final_path)
142
+ print("βœ… Download Complete!")
143
+
144
+ # 6. Auto-Extract
145
+ if extract:
146
+ print(f"πŸ“‚ Extracting {filename}...")
147
+ try:
148
+ shutil.unpack_archive(final_path, dest_path)
149
+ print("βœ… Extracted successfully.")
150
+ except Exception as e:
151
+ print(f"⚠️ Extraction failed: {e}")
152
+
153
+ return final_path
154
 
155
  except Exception as e:
156
+ print(f"❌ Error: {e}")
157
+ if 'part_path' in locals() and os.path.exists(part_path):
158
+ os.remove(part_path) # Cleanup corrupt partial
159
+ return None
160
 
161
+ def download(self, url, dest_path=".", token=None, extract=False, verify=False):
 
 
 
 
162
  loop = asyncio.get_event_loop()
163
  if loop.is_running():
164
  nest_asyncio.apply()
165
+ return loop.run_until_complete(self._download_logic(url, dest_path, token, extract, verify))
166
  else:
167
+ return asyncio.run(self._download_logic(url, dest_path, token, extract, verify))
168
 
169
+ # --- CLI HANDLER ---
170
  def cli_main():
171
+ parser = argparse.ArgumentParser(description="Speedster v16.0.3 - Juggernaut Update")
172
+ parser.add_argument("input", help="URL file OR path to .txt file for batch download")
173
+ parser.add_argument("--out", "-o", default=".", help="Output folder")
174
+ parser.add_argument("--token", "-t", help="Auth Token (Optional if env var set)")
175
+ parser.add_argument("--threads", type=int, default=16, help="Thread count")
176
+ parser.add_argument("--extract", "-x", action="store_true", help="Auto extract archives")
177
+ parser.add_argument("--batch", "-b", action="store_true", help="Force treat input as batch file")
178
 
179
  args = parser.parse_args()
 
180
  engine = Speedster(num_threads=args.threads)
181
+
182
+ # BATCH MODE LOGIC
183
+ urls = []
184
+ if args.batch or (os.path.isfile(args.input) and args.input.endswith(".txt")):
185
+ print(f"πŸ“œ Batch Mode: Reading {args.input}")
186
+ with open(args.input, 'r') as f:
187
+ urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
188
+ else:
189
+ urls = [args.input]
190
+
191
+ for i, url in enumerate(urls):
192
+ if len(urls) > 1: print(f"\n--- Processing {i+1}/{len(urls)} ---")
193
+ engine.download(url, args.out, args.token, extract=args.extract)
194
 
195
  if __name__ == "__main__":
196
  cli_main()