|
4 | 4 | import logging |
5 | 5 | import json |
6 | 6 | import os |
| 7 | +import time |
7 | 8 | import xml.etree.ElementTree as ET |
8 | 9 | from collections import defaultdict |
9 | 10 | import re |
10 | 11 | import zipfile |
11 | 12 |
|
12 | | -# Configure logging |
13 | | -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
| 13 | +# NOTE: Do NOT call logging.basicConfig() here. This module is imported by |
| 14 | +# api_server.py BEFORE api_server configures the root logger, and basicConfig is |
| 15 | +# a no-op once the root logger already has handlers. Configuring the root logger |
| 16 | +# here would prevent api_server's FileHandler from ever being installed (logs |
| 17 | +# would silently go to stderr instead of backend_debug.log). Let api_server own |
| 18 | +# the root logger configuration; module-level logging.* calls propagate to it. |
14 | 19 |
|
15 | 20 | def get_server_versions(server_type): |
16 | 21 | """Fetches available server versions for a given type from mcutils.com API.""" |
@@ -186,48 +191,84 @@ def fetch_username_from_uuid(uuid_str): |
186 | 191 |
|
187 | 192 | def download_server_jar(server_type, server_version, save_path, progress_callback): |
188 | 193 | """Downloads the server.jar file for a given type and version with progress.""" |
| 194 | + # CRITICAL: mcutils.com API is case-sensitive and returns HTTP 500 for |
| 195 | + # capitalized server types (e.g. "Paper"). Always normalize to lowercase. |
| 196 | + server_type = (server_type or "").lower() |
189 | 197 | download_url = f"https://mcutils.com/api/server-jars/{server_type}/{server_version}/download" |
190 | 198 | return download_file_from_url(download_url, save_path, progress_callback) |
191 | 199 |
|
192 | | -def download_file_from_url(download_url, save_path, progress_callback): |
193 | | - """Downloads a file from a specific URL with progress.""" |
| 200 | +# A browser-like User-Agent avoids bot-blocking by Cloudflare-fronted CDNs |
| 201 | +# (mcutils.com, fill-data.papermc.io, piston-data.mojang.com). |
| 202 | +_DOWNLOAD_HEADERS = { |
| 203 | + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " |
| 204 | + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" |
| 205 | +} |
| 206 | + |
| 207 | +def download_file_from_url(download_url, save_path, progress_callback, retries=3): |
| 208 | + """Downloads a file from a specific URL with progress. |
| 209 | +
|
| 210 | + Retries on transient failures (Cloudflare 500/429, connection resets) and |
| 211 | + returns False on failure. The last error is logged with the URL + status so |
| 212 | + callers can surface a useful message instead of a generic one. |
| 213 | + """ |
194 | 214 | os.makedirs(os.path.dirname(save_path), exist_ok=True) |
195 | | - try: |
196 | | - with requests.get(download_url, stream=True, timeout=30) as r: |
197 | | - r.raise_for_status() |
198 | | - total_size_raw = r.headers.get('content-length') |
199 | | - total_size = int(total_size_raw) if total_size_raw else 0 |
200 | | - bytes_downloaded = 0 |
201 | | - last_reported_mb = 0 |
202 | | - |
203 | | - logging.info(f"Downloading {download_url} - Total size: {total_size if total_size > 0 else 'unknown'}") |
204 | | - |
205 | | - with open(save_path, 'wb') as f: |
206 | | - for chunk in r.iter_content(chunk_size=65536): # 64KB chunks for better throughput |
207 | | - if not chunk: |
208 | | - continue |
209 | | - f.write(chunk) |
210 | | - bytes_downloaded += len(chunk) |
211 | | - |
212 | | - if total_size > 0: |
213 | | - progress = (bytes_downloaded / total_size) * 100 |
214 | | - progress_callback(progress) |
215 | | - else: |
216 | | - # Fallback: Report "activity" every 1MB |
217 | | - current_mb = bytes_downloaded // (1024 * 1024) |
218 | | - if current_mb > last_reported_mb: |
219 | | - last_reported_mb = current_mb |
220 | | - # Send a small incremental progress or just trigger the callback |
221 | | - # to keep the UI alive. We'll send a "mock" slow progress |
222 | | - mock_progress = min(current_mb * 5, 95) # Caps at 95 until real finish |
223 | | - progress_callback(mock_progress) |
224 | | - logging.debug(f"Downloaded {current_mb}MB (unknown total size)") |
225 | | - |
226 | | - progress_callback(100) |
227 | | - return True |
228 | | - except Exception as e: |
229 | | - logging.error(f"Failed to download file: {e}") |
230 | | - return False |
| 215 | + last_error = None |
| 216 | + for attempt in range(1, retries + 1): |
| 217 | + try: |
| 218 | + with requests.get( |
| 219 | + download_url, stream=True, timeout=30, headers=_DOWNLOAD_HEADERS |
| 220 | + ) as r: |
| 221 | + if r.status_code >= 500: |
| 222 | + # Transient server/CDN error — retry with backoff |
| 223 | + last_error = f"HTTP {r.status_code} from {download_url}" |
| 224 | + logging.warning( |
| 225 | + f"Download attempt {attempt}/{retries} failed: {last_error}" |
| 226 | + ) |
| 227 | + if attempt < retries: |
| 228 | + time.sleep(2 * attempt) |
| 229 | + continue |
| 230 | + r.raise_for_status() |
| 231 | + total_size_raw = r.headers.get('content-length') |
| 232 | + total_size = int(total_size_raw) if total_size_raw else 0 |
| 233 | + bytes_downloaded = 0 |
| 234 | + last_reported_mb = 0 |
| 235 | + |
| 236 | + logging.info(f"Downloading {download_url} - Total size: {total_size if total_size > 0 else 'unknown'}") |
| 237 | + |
| 238 | + with open(save_path, 'wb') as f: |
| 239 | + for chunk in r.iter_content(chunk_size=65536): # 64KB chunks |
| 240 | + if not chunk: |
| 241 | + continue |
| 242 | + f.write(chunk) |
| 243 | + bytes_downloaded += len(chunk) |
| 244 | + |
| 245 | + if total_size > 0: |
| 246 | + progress = (bytes_downloaded / total_size) * 100 |
| 247 | + progress_callback(progress) |
| 248 | + else: |
| 249 | + # Fallback: Report "activity" every 1MB |
| 250 | + current_mb = bytes_downloaded // (1024 * 1024) |
| 251 | + if current_mb > last_reported_mb: |
| 252 | + last_reported_mb = current_mb |
| 253 | + mock_progress = min(current_mb * 5, 95) |
| 254 | + progress_callback(mock_progress) |
| 255 | + logging.debug(f"Downloaded {current_mb}MB (unknown total size)") |
| 256 | + |
| 257 | + progress_callback(100) |
| 258 | + return True |
| 259 | + except requests.RequestException as e: |
| 260 | + last_error = f"{e} ({download_url})" |
| 261 | + logging.warning(f"Download attempt {attempt}/{retries} failed: {last_error}") |
| 262 | + if attempt < retries: |
| 263 | + time.sleep(2 * attempt) |
| 264 | + except Exception as e: |
| 265 | + last_error = f"{e} ({download_url})" |
| 266 | + logging.error(f"Failed to download file: {last_error}") |
| 267 | + return False |
| 268 | + logging.error(f"Download failed after {retries} attempts: {last_error}") |
| 269 | + # Stash the last error on the function so callers can surface a useful message |
| 270 | + download_file_from_url.last_error = last_error |
| 271 | + return False |
231 | 272 |
|
232 | 273 | def download_and_extract_zip(url, extract_to_dir, progress_callback, contains_single_folder=True): |
233 | 274 | """ |
|
0 commit comments