From ab13fba0a1c0711841b9f7710c935112e144ff30 Mon Sep 17 00:00:00 2001 From: arousi Date: Sat, 22 Aug 2026 04:48:36 +0200 Subject: [PATCH 1/6] fix(updater): download updates through the queue with pause/resume The old modal UpdateDownloaderDialog streamed the whole release zip while flooding the GUI thread with per-chunk cross-thread progress calls; on a large (205MB) release, concurrent with the live-graph update_ui, it could crash the app mid-download. Route updates through the existing, proven download engine instead: - an available update is added to the normal queue as a DownloadTask (is_update); the user starts it and can pause/resume (Range-resume) it like any other download - update tasks bypass the Cloudflare/CAPTCHA direct-link step and are never auto-extracted - on completion the app offers Install now (restart) or Install on next open; a deferred update is applied via _check_pending_update on launch - execute_update -> _apply_downloaded_update(zip_path): the robust extract -> robocopy swap -> marker-verify -> rollback flow is unchanged, it just consumes the already-downloaded zip Removes the crash-prone modal download path entirely. --- CHANGELOG.md | 5 ++ pyqt_downloader.py | 142 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 132 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a26f53..a92b4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Fixes & Improvements +* **Updates download in the queue (no more crash):** The auto-updater no longer uses a separate modal downloader (which could crash mid-download on large releases). A new version is now added to your normal download queue — start it when you like, and pause/resume it like any other download. When it finishes, choose **Install now** (restart) or **Install on next open**; a deferred update is offered again the next time you launch. Update downloads skip the Cloudflare/CAPTCHA step and are never auto-extracted. + ## [v1.5.0] - 2026-08-21 ### New Features diff --git a/pyqt_downloader.py b/pyqt_downloader.py index 4c5bdb4..bcbba0c 100644 --- a/pyqt_downloader.py +++ b/pyqt_downloader.py @@ -426,7 +426,7 @@ def paint(self, painter: QPainter, option, index): from curl_cffi import requests as curl_requests from cf_turnstile import TurnstileSolver from PyQt6.QtCore import QMetaObject, Q_ARG -from update_logic import UpdateCheckerThread, UpdateDownloaderDialog +from update_logic import UpdateCheckerThread import datetime as _dt import scheduler as offpeak from ui_style import button_style @@ -927,6 +927,11 @@ def __init__(self, link, base_save_dir, folder_name=None): self.tree_item = None self.is_selected = False + # App-update tasks download a plain URL (no CAPTCHA/direct-link step) + # and, once complete, offer to install instead of being extracted. + self.is_update = False + self.update_version = None + def to_dict(self): return { "uid": self.uid, @@ -937,7 +942,9 @@ def to_dict(self): "error_message": self.error_message, "downloaded_bytes": self.downloaded_bytes, "total_bytes": self.total_bytes, - "progress": self.progress + "progress": self.progress, + "is_update": self.is_update, + "update_version": self.update_version } @classmethod @@ -958,6 +965,8 @@ def from_dict(cls, data): task.total_bytes = data.get("total_bytes", 0) task.progress = data.get("progress", 0.0) task.error_message = data.get("error_message", "") + task.is_update = data.get("is_update", False) + task.update_version = data.get("update_version") return task def get_history_path(): @@ -1048,6 +1057,9 @@ def __init__(self): self._refresh_schedule_indicator() + # Offer to finish installing an update that was deferred to "next open". + QTimer.singleShot(1200, self._check_pending_update) + def closeEvent(self, event): save_history(self.tasks) col_widths = {} @@ -1653,31 +1665,54 @@ def prompt_update(self, version, changelog, download_url): except Exception: pass + # Don't queue the same version twice. + for t in self.tasks: + if getattr(t, "is_update", False) and t.update_version == version: + return + + # Add the update to the normal download queue so it downloads through the + # proven engine (pause/resume/Range-resume) instead of a fragile modal + # downloader. The user starts it like any other task; on completion it + # offers to install now or on next launch. + task = self._make_update_task(version, download_url) + self.add_task_to_ui(task) + dialog = QDialog(self) dialog.setWindowTitle(f"Update Available: {version}") dialog.setMinimumWidth(500) - + layout = QVBoxLayout(dialog) layout.addWidget(QLabel(f"A new version ({version}) is available!")) - + layout.addWidget(QLabel( + f'Added to your download queue as "{task.folder_name}". ' + "Start it now or later — you can pause and resume it like any " + "other download.")) + text_edit = QTextEdit() text_edit.setReadOnly(True) text_edit.setMarkdown(changelog) layout.addWidget(text_edit) - + btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No) - btn_box.button(QDialogButtonBox.StandardButton.Yes).setText("Download and Restart") + btn_box.button(QDialogButtonBox.StandardButton.Yes).setText("Start download now") + btn_box.button(QDialogButtonBox.StandardButton.No).setText("I'll start it later") btn_box.accepted.connect(dialog.accept) btn_box.rejected.connect(dialog.reject) layout.addWidget(btn_box) - + if dialog.exec() == QDialog.DialogCode.Accepted: - self.execute_update(download_url) + task.status = "Pending" + task.error_message = "" + task.cancel_flag = False + task.pause_flag = False - def execute_update(self, download_url): - dl_dialog = UpdateDownloaderDialog(download_url, self) - if dl_dialog.exec() == QDialog.DialogCode.Accepted: - zip_path = dl_dialog.temp_zip + def _apply_downloaded_update(self, zip_path, version=None): + # Apply an already-downloaded release zip: extract it, then hand off to a + # batch script that swaps the app directory and restarts (same robust + # move -> robocopy -> marker-verify -> rollback flow as before). + self.settings.pop("pending_update", None) + save_settings(self.settings) + if zip_path and os.path.exists(zip_path): extract_dir = os.path.join(tempfile.gettempdir(), f"silverspoon_extract_{int(time.time())}") try: @@ -1787,6 +1822,71 @@ def execute_update(self, download_url): except Exception as e: QMessageBox.critical(self, "Update Failed", f"Failed to apply the update:\n{str(e)}") + else: + QMessageBox.warning( + self, "Update", + "The downloaded update file is missing. Please download it again.") + + def _make_update_task(self, version, download_url): + updates_dir = os.path.expanduser("~/.silverspoon_updates") + task = DownloadTask(download_url, updates_dir, f"SilverSpoon Update {version}") + task.is_update = True + task.update_version = version + task.is_selected = True + return task + + def _check_update_task_done(self): + """When a queued update download finishes, offer to install it. Skips a + version already deferred to next-open so it isn't prompted twice.""" + pending_v = (self.settings.get("pending_update") or {}).get("version") + for t in self.tasks: + if (getattr(t, "is_update", False) and t.status == "Completed" + and not getattr(t, "_install_handled", False) + and t.update_version != pending_v): + t._install_handled = True + self._prompt_install(t) + + def _prompt_install(self, task): + box = QMessageBox(self) + box.setWindowTitle("Update Downloaded") + box.setText( + f"SilverSpoon {task.update_version} has finished downloading.\n\n" + "Install it now (the app will restart), or on the next launch?") + now_btn = box.addButton("Install now", QMessageBox.ButtonRole.AcceptRole) + later_btn = box.addButton("Install on next open", QMessageBox.ButtonRole.ActionRole) + box.addButton("Not yet", QMessageBox.ButtonRole.RejectRole) + box.setDefaultButton(now_btn) + box.exec() + clicked = box.clickedButton() + if clicked == now_btn: + self._apply_downloaded_update(task.filepath, task.update_version) + elif clicked == later_btn: + self.settings["pending_update"] = { + "zip": task.filepath, "version": task.update_version} + save_settings(self.settings) + QMessageBox.information( + self, "Update Scheduled", + f"SilverSpoon {task.update_version} will be installed the next " + "time you open the app.") + + def _check_pending_update(self): + """On startup, offer to install an update that was deferred earlier.""" + pending = self.settings.get("pending_update") + if not pending: + return + zip_path, version = pending.get("zip"), pending.get("version") + if not zip_path or not os.path.exists(zip_path): + self.settings.pop("pending_update", None) + save_settings(self.settings) + return + reply = QMessageBox.question( + self, "Install Update", + f"SilverSpoon {version} was downloaded earlier and is ready to " + "install.\n\nInstall it now? The app will restart.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + if reply == QMessageBox.StandardButton.Yes: + self._apply_downloaded_update(zip_path, version) + # If No, keep pending_update so it asks again next open. def open_settings_dialog(self): dialog = SettingsDialog(self.settings, self) @@ -2475,7 +2575,9 @@ def update_ui(self): # Store the progress and status in the item's data for the custom delegate to paint batch_item.setData(0, Qt.ItemDataRole.UserRole, prog) batch_item.setData(1, Qt.ItemDataRole.UserRole, batch_status) - + + self._check_update_task_done() + def download_manager(self): while True: # CAPTCHA resolution belongs to the same worker slot as the actual @@ -2536,7 +2638,11 @@ def check_extraction(self): for folder_name, tasks_in_folder in folders.items(): if folder_name in self.extracted_folders: continue - + + # Never auto-extract an app-update download. + if any(getattr(t, "is_update", False) for t in tasks_in_folder): + continue + valid_extraction_statuses = {"Completed", "Extracted", "Extracting..."} if tasks_in_folder and all(t.status in valid_extraction_statuses for t in tasks_in_folder): if all(t.status == "Extracted" for t in tasks_in_folder): @@ -2688,7 +2794,13 @@ def get_direct_link(self, task): return None def download_worker(self, task): - dl_url = self.get_direct_link(task) + if getattr(task, "is_update", False): + # App updates are plain, direct URLs — no Cloudflare/CAPTCHA step. + dl_url = task.link + task._dl_cookies = {} + task._dl_user_agent = "" + else: + dl_url = self.get_direct_link(task) if not dl_url: if not task.cancel_flag and not task.pause_flag: if self.settings.get("auto_retry_errors", False) and task.retry_count < 3: From 20ec92f743256bd882ece5856366d5f8fab83d35 Mon Sep 17 00:00:00 2001 From: arousi Date: Sat, 22 Aug 2026 05:24:26 +0200 Subject: [PATCH 2/6] fix(updater): download update via urllib, not the shared curl session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 'added but never progresses / connection timed out': the queued update task ran through download_worker on a manager-spawned thread and reused the shared curl_cffi self.dl_session. That session (built on the main thread) hangs with curl (28) for a plain request that never went through the nodriver/get_direct_link path first — normal downloads work only because get_direct_link runs before the transfer. Route update tasks through a dedicated _download_update_file() using urllib (stdlib): supports Range-resume, pause/cancel, progress, and a 416 already-complete short-circuit. Confirmed progressing via the real manager path where curl deterministically hung. Also stop a previously failed update task from blocking a fresh re-offer. --- pyqt_downloader.py | 117 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/pyqt_downloader.py b/pyqt_downloader.py index bcbba0c..4bc0650 100644 --- a/pyqt_downloader.py +++ b/pyqt_downloader.py @@ -11,6 +11,8 @@ import zipfile import shutil import uuid +import urllib.request +import urllib.error from collections import deque logging.basicConfig( @@ -1665,9 +1667,11 @@ def prompt_update(self, version, changelog, download_url): except Exception: pass - # Don't queue the same version twice. + # Don't queue the same version twice — but a previously failed/cancelled + # update shouldn't block re-offering it. for t in self.tasks: - if getattr(t, "is_update", False) and t.update_version == version: + if (getattr(t, "is_update", False) and t.update_version == version + and t.status not in ("Error", "Cancelled")): return # Add the update to the normal download queue so it downloads through the @@ -2793,14 +2797,111 @@ def get_direct_link(self, task): task.error_message = "Could not get the direct download link. The link may be expired or blocked." return None + def _download_update_file(self, task): + """Download an app-update file via urllib (stdlib), with resume, + pause/cancel and progress. Kept off the shared curl_cffi session, which + hangs (curl 28) for a manager-spawned worker that skips get_direct_link.""" + try: + os.makedirs(task.save_dir, exist_ok=True) + except Exception as e: + task.status = "Error" + task.error_message = ( + f"Failed to create update folder '{task.save_dir}'. " + f"{format_error_message(e)}") + self.trigger_history_save() + return + + initial_size = os.path.getsize(task.filepath) if os.path.exists(task.filepath) else 0 + req = urllib.request.Request( + task.link, headers={"User-Agent": f"SilverSpoon-Updater/{CURRENT_VERSION}"}) + if initial_size > 0: + req.add_header("Range", f"bytes={initial_size}-") + + task.status = "Downloading" + task.error_message = "" + try: + with contextlib.closing(urllib.request.urlopen(req, timeout=30)) as r: + status = getattr(r, "status", 200) or 200 + content_range = r.headers.get("Content-Range") + content_length = r.headers.get("Content-Length") + if status == 206 and content_range: + m = re.search(r'/([0-9]+)$', content_range) + task.total_bytes = int(m.group(1)) if m else 0 + mode = "ab" + else: + # Server ignored Range (or none asked) -> full body, restart. + task.total_bytes = int(content_length) if content_length else 0 + initial_size = 0 + mode = "wb" + task.downloaded_bytes = initial_size + + now = time.time() + samples = deque([(now, task.downloaded_bytes)]) + with open(task.filepath, mode) as f: + while True: + if task.pause_flag: + task.status = "Paused"; task.speed = 0; return + if task.cancel_flag: + task.status = "Cancelled"; task.speed = 0; return + chunk = r.read(65536) + if not chunk: + break + f.write(chunk) + task.downloaded_bytes += len(chunk) + now = time.time() + samples.append((now, task.downloaded_bytes)) + while len(samples) > 1 and now - samples[0][0] > 3: + samples.popleft() + w0t, w0b = samples[0] + dur = now - w0t + if dur > 0: + task.speed = ((task.downloaded_bytes - w0b) / dur) / (1024 * 1024) + if task.total_bytes > 0: + task.progress = (task.downloaded_bytes / task.total_bytes) * 100 + + task.progress = 100 + task.speed = 0 + task.status = "Completed" + task.error_message = "" + self.trigger_history_save() + except urllib.error.HTTPError as he: + if he.code == 416 and initial_size > 0: + # Range not satisfiable -> the file is already fully downloaded. + task.total_bytes = initial_size + task.downloaded_bytes = initial_size + task.progress = 100 + task.speed = 0 + task.status = "Completed" + task.error_message = "" + self.trigger_history_save() + return + self._update_download_failed(task, he) + except Exception as e: + self._update_download_failed(task, e) + + def _update_download_failed(self, task, exc): + logging.error("Update download error for %s: %s", task.link, exc, exc_info=True) + if task.cancel_flag or task.pause_flag: + return + if self.settings.get("auto_retry_errors", False) and task.retry_count < 3: + task.retry_count += 1 + task.status = "Pending" + task.error_message = "" + else: + task.status = "Error" + task.error_message = f"Update download failed. {format_error_message(exc)}" + self.trigger_history_save() + def download_worker(self, task): if getattr(task, "is_update", False): - # App updates are plain, direct URLs — no Cloudflare/CAPTCHA step. - dl_url = task.link - task._dl_cookies = {} - task._dl_user_agent = "" - else: - dl_url = self.get_direct_link(task) + # App updates are plain, direct URLs downloaded via urllib (stdlib). + # The shared curl_cffi session hangs (curl 28) when a manager-spawned + # worker uses it for a request that never went through the + # nodriver/get_direct_link path first; urllib is unaffected. + self._download_update_file(task) + return + + dl_url = self.get_direct_link(task) if not dl_url: if not task.cancel_flag and not task.pause_flag: if self.settings.get("auto_retry_errors", False) and task.retry_count < 3: From 9c6475ecd19eaad1c216d5c07f3f036b8dbd03f2 Mon Sep 17 00:00:00 2001 From: arousi Date: Sat, 22 Aug 2026 05:31:09 +0200 Subject: [PATCH 3/6] fix(updater): save update download to the visible Save-To directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update downloads were written to a hidden ~/.silverspoon_updates folder, so users couldn't find the completed file. Download to the same Save-To directory as every other download (…/SilverSpoon Update /), so it's visible, consistent, and persists for an install-on-next-open. --- pyqt_downloader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyqt_downloader.py b/pyqt_downloader.py index 4bc0650..74b268c 100644 --- a/pyqt_downloader.py +++ b/pyqt_downloader.py @@ -1832,8 +1832,10 @@ def _apply_downloaded_update(self, zip_path, version=None): "The downloaded update file is missing. Please download it again.") def _make_update_task(self, version, download_url): - updates_dir = os.path.expanduser("~/.silverspoon_updates") - task = DownloadTask(download_url, updates_dir, f"SilverSpoon Update {version}") + # Download to the visible Save-To directory, like every other download, + # so the user can find it (and it persists for an install-on-next-open). + base_dir = os.path.abspath(self.dir_input.text()) + task = DownloadTask(download_url, base_dir, f"SilverSpoon Update {version}") task.is_update = True task.update_version = version task.is_selected = True From e9f520ad468d4fd592a89df561972019203dcb73 Mon Sep 17 00:00:00 2001 From: arousi Date: Sat, 22 Aug 2026 05:32:43 +0200 Subject: [PATCH 4/6] fix(updater): ignore an update task whose downloaded file is gone Guard the completion prompt and the re-offer dedup on os.path.exists(filepath) so a stale Completed update task (file deleted/moved) neither fires a phantom install prompt nor blocks re-downloading. --- pyqt_downloader.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pyqt_downloader.py b/pyqt_downloader.py index 74b268c..14bf2af 100644 --- a/pyqt_downloader.py +++ b/pyqt_downloader.py @@ -1667,11 +1667,12 @@ def prompt_update(self, version, changelog, download_url): except Exception: pass - # Don't queue the same version twice — but a previously failed/cancelled - # update shouldn't block re-offering it. + # Don't queue the same version twice — but re-offer if the previous try + # failed/was cancelled, or its downloaded file is gone. for t in self.tasks: if (getattr(t, "is_update", False) and t.update_version == version - and t.status not in ("Error", "Cancelled")): + and t.status not in ("Error", "Cancelled") + and os.path.exists(t.filepath)): return # Add the update to the normal download queue so it downloads through the @@ -1848,7 +1849,8 @@ def _check_update_task_done(self): for t in self.tasks: if (getattr(t, "is_update", False) and t.status == "Completed" and not getattr(t, "_install_handled", False) - and t.update_version != pending_v): + and t.update_version != pending_v + and os.path.exists(t.filepath)): t._install_handled = True self._prompt_install(t) From c1c310fdb81df04b81f8fb958830022d08cbeb6a Mon Sep 17 00:00:00 2001 From: arousi Date: Sat, 22 Aug 2026 05:44:45 +0200 Subject: [PATCH 5/6] feat(downloads): handle general direct URLs, keep resolver for FF/DataNodes Normalize the download path: any link whose host isn't a Turnstile/CAPTCHA host now downloads straight over HTTP via urllib (the same robust path used for app updates), with resume/pause/progress. FuckingFast and DataNodes links still go through get_direct_link (the nodriver Turnstile solver) + the curl transport exactly as before. - add needs_resolution(link) + RESOLVER_HOSTS = (fuckingfast.co, datanodes.to) - download_worker routes non-resolver hosts (and updates) to the renamed, now host-agnostic _download_direct_file() - broaden the paste-box hint to mention direct URLs Verified: general URL downloads without invoking the solver; a fuckingfast link still invokes get_direct_link exactly once. --- pyqt_downloader.py | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/pyqt_downloader.py b/pyqt_downloader.py index 14bf2af..447e913 100644 --- a/pyqt_downloader.py +++ b/pyqt_downloader.py @@ -13,6 +13,7 @@ import uuid import urllib.request import urllib.error +import urllib.parse from collections import deque logging.basicConfig( @@ -436,6 +437,22 @@ def paint(self, painter: QPainter, option, index): CURRENT_VERSION = "v1.5.0" GITHUB_REPO = "billysams21/SilverSpoon" +# Hosts that hide the file behind a Cloudflare/Turnstile challenge and need the +# solver to extract a direct link. Every other host is treated as a plain, +# direct download (fetched straight over HTTP). Keep FuckingFast + DataNodes. +RESOLVER_HOSTS = ("fuckingfast.co", "datanodes.to") + + +def needs_resolution(link): + """True if the link's host needs the Turnstile/CAPTCHA solver.""" + try: + host = urllib.parse.urlparse(link).netloc.lower() + except Exception: + return False + if host.startswith("www."): + host = host[4:] + return any(host == h or host.endswith("." + h) for h in RESOLVER_HOSTS) + def get_settings_path(): return os.path.expanduser("~/.silverspoon_settings.json") @@ -1177,7 +1194,7 @@ def setup_ui(self): left_layout.addLayout(left_header) self.text_links = QTextEdit() - self.text_links.setPlaceholderText("Paste one or multiple FuckingFast URLs here...") + self.text_links.setPlaceholderText("Paste FuckingFast / DataNodes links or any direct download URLs here...") self.text_links.setAcceptRichText(False) self.text_links.setFixedHeight(77) self.text_links.installEventFilter(self) @@ -2801,10 +2818,11 @@ def get_direct_link(self, task): task.error_message = "Could not get the direct download link. The link may be expired or blocked." return None - def _download_update_file(self, task): - """Download an app-update file via urllib (stdlib), with resume, - pause/cancel and progress. Kept off the shared curl_cffi session, which - hangs (curl 28) for a manager-spawned worker that skips get_direct_link.""" + def _download_direct_file(self, task): + """Download a plain, direct URL via urllib (stdlib), with resume, + pause/cancel and progress. Used for app updates and any non-resolver + host. Kept off the shared curl_cffi session, which hangs (curl 28) for a + manager-spawned worker that skips the get_direct_link/nodriver path.""" try: os.makedirs(task.save_dir, exist_ok=True) except Exception as e: @@ -2817,7 +2835,7 @@ def _download_update_file(self, task): initial_size = os.path.getsize(task.filepath) if os.path.exists(task.filepath) else 0 req = urllib.request.Request( - task.link, headers={"User-Agent": f"SilverSpoon-Updater/{CURRENT_VERSION}"}) + task.link, headers={"User-Agent": f"SilverSpoon/{CURRENT_VERSION}"}) if initial_size > 0: req.add_header("Range", f"bytes={initial_size}-") @@ -2897,12 +2915,11 @@ def _update_download_failed(self, task, exc): self.trigger_history_save() def download_worker(self, task): - if getattr(task, "is_update", False): - # App updates are plain, direct URLs downloaded via urllib (stdlib). - # The shared curl_cffi session hangs (curl 28) when a manager-spawned - # worker uses it for a request that never went through the - # nodriver/get_direct_link path first; urllib is unaffected. - self._download_update_file(task) + # App updates and any non-resolver (general direct) URL download straight + # over HTTP via urllib. Only FuckingFast/DataNodes-style links go through + # the Turnstile/CAPTCHA solver + curl transport, exactly as before. + if getattr(task, "is_update", False) or not needs_resolution(task.link): + self._download_direct_file(task) return dl_url = self.get_direct_link(task) From 0c2d09c86690e026af02ae8d76faa7f9ecb4fe2a Mon Sep 17 00:00:00 2001 From: arousi Date: Sat, 22 Aug 2026 06:21:19 +0200 Subject: [PATCH 6/6] refactor+test: extract host routing to providers.py with unit tests --- providers.py | 22 ++++++++++++++++++++++ pyqt_downloader.py | 17 +---------------- test_providers.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 providers.py create mode 100644 test_providers.py diff --git a/providers.py b/providers.py new file mode 100644 index 0000000..1e61267 --- /dev/null +++ b/providers.py @@ -0,0 +1,22 @@ +"""Provider/host classification for the downloader. + +Kept Qt-free (stdlib only) so it can be unit-tested headlessly. Hosts that hide +the file behind a Cloudflare/Turnstile challenge need the solver to extract a +direct link; every other host is treated as a plain, direct HTTP download. +""" +import urllib.parse + +# Hosts handled by the Turnstile/CAPTCHA solver (cf_turnstile). Keep in sync +# with the providers cf_turnstile.py actually supports. +RESOLVER_HOSTS = ("fuckingfast.co", "datanodes.to") + + +def needs_resolution(link): + """True if the link's host must go through the Turnstile/CAPTCHA solver.""" + try: + host = urllib.parse.urlparse(link).netloc.lower() + except Exception: + return False + if host.startswith("www."): + host = host[4:] + return any(host == h or host.endswith("." + h) for h in RESOLVER_HOSTS) diff --git a/pyqt_downloader.py b/pyqt_downloader.py index 447e913..9ca5696 100644 --- a/pyqt_downloader.py +++ b/pyqt_downloader.py @@ -433,26 +433,11 @@ def paint(self, painter: QPainter, option, index): import datetime as _dt import scheduler as offpeak from ui_style import button_style +from providers import needs_resolution, RESOLVER_HOSTS CURRENT_VERSION = "v1.5.0" GITHUB_REPO = "billysams21/SilverSpoon" -# Hosts that hide the file behind a Cloudflare/Turnstile challenge and need the -# solver to extract a direct link. Every other host is treated as a plain, -# direct download (fetched straight over HTTP). Keep FuckingFast + DataNodes. -RESOLVER_HOSTS = ("fuckingfast.co", "datanodes.to") - - -def needs_resolution(link): - """True if the link's host needs the Turnstile/CAPTCHA solver.""" - try: - host = urllib.parse.urlparse(link).netloc.lower() - except Exception: - return False - if host.startswith("www."): - host = host[4:] - return any(host == h or host.endswith("." + h) for h in RESOLVER_HOSTS) - def get_settings_path(): return os.path.expanduser("~/.silverspoon_settings.json") diff --git a/test_providers.py b/test_providers.py new file mode 100644 index 0000000..3e19c1d --- /dev/null +++ b/test_providers.py @@ -0,0 +1,37 @@ +"""Headless unit checks for provider/host routing. Run: python test_providers.py""" +import providers as P + + +def test_resolver_hosts_need_resolution(): + for link in ( + "https://fuckingfast.co/abc123", + "https://www.fuckingfast.co/abc123#file.rar", + "https://datanodes.to/xyz", + "https://cdn.datanodes.to/d/xyz", + "http://fuckingfast.co/x", + ): + assert P.needs_resolution(link) is True, link + + +def test_general_hosts_are_direct(): + for link in ( + "https://example.com/file.zip", + "https://github.com/o/r/releases/download/v1/x.zip", + "https://objects.githubusercontent.com/x/SilverSpoon.zip", + "https://notfuckingfast.co.evil.com/x", + "https://mydatanodes.to.example.com/x", + ): + assert P.needs_resolution(link) is False, link + + +def test_malformed_never_crashes(): + for bad in ("", "not a url", "ftp://", "://///", "javascript:void"): + assert P.needs_resolution(bad) is False, bad + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for t in tests: + t() + print(f"ok {t.__name__}") + print(f"\n{len(tests)} passed")