diff --git a/.github/workflows/HostnameRedaction.yml b/.github/workflows/HostnameRedaction.yml index c8895856..a84afded 100644 --- a/.github/workflows/HostnameRedaction.yml +++ b/.github/workflows/HostnameRedaction.yml @@ -26,6 +26,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} REPO: ${{ github.repository }} SENDER: ${{ github.event.sender.login }} + GITHUB_ACTOR: ${{ github.actor }} # Issue fields ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_TITLE: ${{ github.event.issue.title }} @@ -43,6 +44,7 @@ jobs: import os import re import subprocess + import time import urllib.request # Load environment @@ -50,6 +52,7 @@ jobs: EVENT_NAME = os.environ.get("EVENT_NAME", "") REPO = os.environ.get("REPO", "") SENDER = os.environ.get("SENDER", "") + GITHUB_ACTOR = os.environ.get("GITHUB_ACTOR", "") ISSUE_BODY = os.environ.get("ISSUE_BODY") or "" ISSUE_TITLE = os.environ.get("ISSUE_TITLE") or "" @@ -75,9 +78,16 @@ jobs: else: NUMBER = ISSUE_NUMBER - # Prevent infinite loop when the action itself edits - if SENDER.endswith("[bot]"): - print(f"Edit by bot ({SENDER}), skipping") + # Prevent self-trigger loops when actions edit/comment. + sender_lower = (SENDER or "").lower().strip() + actor_lower = (GITHUB_ACTOR or "").lower().strip() + if ( + sender_lower.endswith("[bot]") + or sender_lower == "github-actions" + or actor_lower.endswith("[bot]") + or actor_lower == "github-actions" + ): + print(f"Event by bot (sender={SENDER}, actor={GITHUB_ACTOR}), skipping") exit(0) if not HOSTNAMES_URL: @@ -88,14 +98,25 @@ jobs: print("Could not determine issue/PR number, skipping") exit(0) - # Fetch hostname list - try: - req = urllib.request.Request(HOSTNAMES_URL, headers={"User-Agent": "Mozilla/5.0"}) - with urllib.request.urlopen(req, timeout=10) as resp: - hostnames_data = resp.read().decode("utf-8") - except Exception as e: - print(f"Failed to fetch hostnames: {e}") - exit(1) + # Fetch hostname list (retry to avoid transient network timeouts) + hostnames_data = "" + fetch_error = None + for attempt in range(1, 4): + try: + req = urllib.request.Request(HOSTNAMES_URL, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + hostnames_data = resp.read().decode("utf-8") + fetch_error = None + break + except Exception as e: + fetch_error = e + print(f"Failed to fetch hostnames (attempt {attempt}/3): {e}") + if attempt < 3: + time.sleep(attempt * 2) + + if not hostnames_data: + print(f"Failed to fetch hostnames after retries, skipping: {fetch_error}") + exit(0) # Parse hostname list into domain_base -> alias mapping domain_to_alias = {} @@ -133,6 +154,31 @@ jobs: def post_warning(count, target_type): """Post a warning comment about redacted hostnames.""" + warning_marker = f"automatically redacted from this {target_type}" + + # Avoid duplicate warning comments if this workflow is retriggered. + try: + existing_comments_raw = subprocess.check_output( + ["gh", "api", f"/repos/{REPO}/issues/{NUMBER}/comments"], + text=True, + ) + existing_comments = json.loads(existing_comments_raw) + except Exception as e: + print(f"Could not load existing comments for deduplication: {e}") + existing_comments = [] + + for comment in existing_comments: + comment_body = (comment.get("body") or "").lower() + user_login = ((comment.get("user") or {}).get("login") or "").lower() + if warning_marker in comment_body and ( + user_login == "github-actions[bot]" + or user_login == "github-actions" + ): + print( + f"Warning already posted for this {target_type}; skipping duplicate comment" + ) + return + if count == 1: msg = f"⚠️ **1 hostname was automatically redacted from this {target_type}.**" else: diff --git a/.github/workflows/PullRequests.yml b/.github/workflows/PullRequests.yml index dd26baa5..5220a34f 100644 --- a/.github/workflows/PullRequests.yml +++ b/.github/workflows/PullRequests.yml @@ -54,7 +54,7 @@ jobs: version: needs: [ quality-check ] - if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev')) + if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.event_name == 'pull_request' && github.head_ref == 'dev' && github.base_ref == 'main') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'push') && github.ref_name == 'dev')) runs-on: ubuntu-latest timeout-minutes: 1 outputs: @@ -78,7 +78,7 @@ jobs: build-wheel: needs: [ quality-check, version ] - if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev')) + if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.event_name == 'pull_request' && github.head_ref == 'dev' && github.base_ref == 'main') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'push') && github.ref_name == 'dev')) runs-on: ubuntu-latest timeout-minutes: 3 outputs: @@ -109,7 +109,7 @@ jobs: build-exe: needs: [ quality-check, version ] - if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev')) + if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.event_name == 'pull_request' && github.head_ref == 'dev' && github.base_ref == 'main') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'push') && github.ref_name == 'dev')) runs-on: windows-latest timeout-minutes: 3 env: @@ -208,7 +208,7 @@ jobs: build-docker-amd64: needs: [ quality-check, version, build-wheel ] - if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev')) + if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.event_name == 'pull_request' && github.head_ref == 'dev' && github.base_ref == 'main') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'push') && github.ref_name == 'dev')) runs-on: ubuntu-latest timeout-minutes: 3 steps: @@ -241,7 +241,7 @@ jobs: build-docker-arm64: needs: [ quality-check, version, build-wheel ] - if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev')) + if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.event_name == 'pull_request' && github.head_ref == 'dev' && github.base_ref == 'main') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'push') && github.ref_name == 'dev')) runs-on: ubuntu-24.04-arm timeout-minutes: 3 steps: @@ -300,7 +300,7 @@ jobs: ${{ env.GHCR_ENDPOINT }}:${TAG_ARM64} notify: - name: Notify Discord & PR + name: Notify PR needs: [ quality-check, version, beta-release, merge-docker-manifest ] if: needs.quality-check.outputs.changes_pushed != 'true' && needs.beta-release.result == 'success' runs-on: ubuntu-latest @@ -313,17 +313,12 @@ jobs: - name: Send Notifications env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} VERSION: ${{ needs.version.outputs.version }} EPOCH: ${{ needs.version.outputs.epoch }} REPO: ${{ github.repository }} run: | echo "📢 Notifying stakeholders..." RELEASE_BODY=$(gh release view beta --json body --jq .body) - if [ -n "$DISCORD_WEBHOOK" ]; then - jq -n --arg title "🚀 New Beta Build: $VERSION ($EPOCH)" --arg url "https://github.com/$REPO/releases/tag/beta" '{content: null, flags: 4096, embeds: [{title: $title, url: $url, color: 5814783}]}' > discord_payload.json - curl -H "Content-Type: application/json" -d @discord_payload.json "$DISCORD_WEBHOOK" - fi PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number') if [ -n "$PR_NUMBER" ]; then echo "## 🚀 Beta Release $VERSION is Live!" > pr_comment.md diff --git a/README.md b/README.md index 6547472f..cc148188 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,6 @@ docker run -d \ -e 'QUASARR_API_KEY'='your_quasarr_api_key_here' \ -e 'FLARESOLVERR_URL'='http://10.10.0.1:8191/v1' \ -e 'APIKEY_2CAPTCHA'='your_2captcha_api_key_here' \ - -e 'DEATHBYCAPTCHA_TOKEN'='your_deathbycaptcha_token_here' \ -e 'TZ'='Europe/Berlin' \ ghcr.io/rix1337/sponsors-helper:latest ``` @@ -327,13 +326,4 @@ docker run -d \ | `QUASARR_API_KEY` | Your Quasarr API key (found in Quasarr web UI under "API Settings") | | `FLARESOLVERR_URL` | Local URL of [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) | | `APIKEY_2CAPTCHA` | [2Captcha](https://2captcha.com/?from=27506687) account API key | -| `DEATHBYCAPTCHA_TOKEN` | [DeathByCaptcha](https://deathbycaptcha.com/register?refid=6184288242b) account token | | `TZ` | Optional. Timezone for SponsorsHelper (e.g., `Europe/Berlin`) | - -| Volume | Purpose | -|--------|---------| -| `/config` | Persistent SponsorsHelper state (including cached GitHub auth token) | - -> - [2Captcha](https://2captcha.com/?from=27506687) is the recommended CAPTCHA solving service. -> - [DeathByCaptcha](https://deathbycaptcha.com/register?refid=6184288242b) can serve as a fallback or work on its own. -> - If you set both `APIKEY_2CAPTCHA` and `DEATHBYCAPTCHA_TOKEN` both services will be used alternately. diff --git a/docker/dev-services-compose.yml b/docker/dev-services-compose.yml index ac0efca6..34d9ce45 100644 --- a/docker/dev-services-compose.yml +++ b/docker/dev-services-compose.yml @@ -47,7 +47,6 @@ services: # - QUASARR_URL=http://192.168.0.1:8080 # - QUASARR_API_KEY=your_quasarr_api_key_here # - APIKEY_2CAPTCHA=your_2captcha_api_key_here -# - DEATHBYCAPTCHA_TOKEN=your_deathbycaptcha_token_here # - FLARESOLVERR_URL=http://10.10.0.1:8191/v1 # volumes: # - ${CONFIG_VOLUMES}/sponsorshelper:/config diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 450ade76..1cb643fb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -28,5 +28,4 @@ services: - 'QUASARR_API_KEY=your_quasarr_api_key_here' - 'FLARESOLVERR_URL=http://10.10.0.1:8191/v1' - 'APIKEY_2CAPTCHA=your_2captcha_api_key_here' - - 'DEATHBYCAPTCHA_TOKEN=your_deathbycaptcha_token_here' - 'TZ=Europe/Berlin' diff --git a/quasarr/api/__init__.py b/quasarr/api/__init__.py index fbffc298..bfe5a2a8 100644 --- a/quasarr/api/__init__.py +++ b/quasarr/api/__init__.py @@ -327,7 +327,7 @@ def render_notification_toggle_rows(provider):
By default, Quasarr uses strict request timeouts so manual searches stay within a reasonable timeframe. Enable slow mode only if you are willing to wait longer on slow sites. @@ -336,7 +336,7 @@ def render_notification_toggle_rows(provider): {timeout_slow_mode_rows}
{render_button("Save Slow Mode Settings", "primary", {"onclick": "saveTimeoutSlowModeSettings()", "type": "button", "id": "timeoutSlowModeSaveBtn"})}
+{render_button("Save Timeout Settings", "primary", {"onclick": "saveTimeoutSlowModeSettings()", "type": "button", "id": "timeoutSlowModeSaveBtn"})}
@@ -619,9 +619,15 @@ def render_notification_toggle_rows(provider): border-top: 1px solid var(--card-border, #dee2e6); text-align: left; }} - .timeout-slow-mode-section h4 {{ - margin: 0 0 8px 0; - font-size: 1em; + .timeout-slow-mode-heading {{ + display: block; + font-weight: 500; + margin-bottom: 6px; + font-size: 0.95em; + text-align: left; + }} + .timeout-slow-mode-actions {{ + text-align: center; }} .timeout-slow-mode-value {{ margin-top: 2px; @@ -1148,7 +1154,7 @@ def render_notification_toggle_rows(provider): async function saveTimeoutSlowModeSettings() {{ var saveButton = document.getElementById('timeoutSlowModeSaveBtn'); - setNotificationStatus('timeout-slow-mode-status', 'Saving slow mode settings...', true); + setNotificationStatus('timeout-slow-mode-status', 'Saving timeout settings...', true); if (saveButton) {{ saveButton.disabled = true; @@ -1163,7 +1169,7 @@ def render_notification_toggle_rows(provider): }}); var data = await response.json(); if (!response.ok || !data.success) {{ - throw new Error(data.message || 'Failed to save slow mode settings'); + throw new Error(data.message || 'Failed to save timeout settings'); }} if (data.settings && typeof data.settings === 'object') {{ @@ -1184,7 +1190,7 @@ def render_notification_toggle_rows(provider): }} finally {{ if (saveButton) {{ saveButton.disabled = false; - saveButton.textContent = 'Save Slow Mode Settings'; + saveButton.textContent = 'Save Timeout Settings'; }} }} }} diff --git a/quasarr/downloads/sources/by.py b/quasarr/downloads/sources/by.py index 21be274e..97fc53ef 100644 --- a/quasarr/downloads/sources/by.py +++ b/quasarr/downloads/sources/by.py @@ -5,7 +5,7 @@ import concurrent.futures import re import time -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse import requests from bs4 import BeautifulSoup @@ -48,7 +48,7 @@ def get_download_links(self, shared_state, url, mirrors, title, password): frame_urls = [src for src in frames if f"https://{by}" in src] if not frame_urls: debug(f"No iframe hosts found on {url} for {title}.") - return [] + return {"links": []} async_results = [] @@ -104,43 +104,93 @@ def fetch(url): url_hosters.append((href, link_hostname)) + def _is_protected_or_auto_link(candidate_url): + lowered = (candidate_url or "").lower() + return ( + "filecrypt." in lowered + or "hide." in lowered + or "tolink." in lowered + or "keeplinks." in lowered + ) + def resolve_redirect(href_hostname): href, _hostname = href_hostname - try: - rq = requests.get( - href, - headers=headers, - timeout=DOWNLOAD_REQUEST_TIMEOUT_SECONDS, - allow_redirects=True, - ) - rq.raise_for_status() - if "/404.html" in rq.url: - info(f"Link leads to 404 page: {r.url}") + current_url = href + visited = set() + + # If iframe already gives protected/auto URL, return directly. + if _is_protected_or_auto_link(current_url): + return current_url + + for _hop in range(8): + if current_url in visited: + debug(f"BY redirect loop detected for {current_url}") + return None + visited.add(current_url) + + if _is_protected_or_auto_link(current_url): + return current_url + + try: + # Resolve redirect chain manually so we can capture final + # FileCrypt URL without requesting FileCrypt itself. + rq = requests.get( + current_url, + headers=headers, + timeout=DOWNLOAD_REQUEST_TIMEOUT_SECONDS, + allow_redirects=False, + ) + except Exception as e: + debug(f"Error resolving BY redirect for {current_url}: {e}") + return None + + location = (rq.headers.get("Location") or "").strip() + if location: + next_url = urljoin(current_url, location) + if "/404.html" in next_url: + debug(f"BY redirect led to 404 page: {next_url}") + return None + current_url = next_url + continue + + final_url = (rq.url or current_url).strip() + if _is_protected_or_auto_link(final_url): + return final_url + + if "/404.html" in final_url: + debug(f"BY redirect led to 404 page: {final_url}") + return None + + if rq.status_code >= 400: + debug( + f"Error resolving BY redirect: HTTP {rq.status_code} at {final_url}" + ) return None + time.sleep(1) - return rq.url - except Exception as e: - info(f"Error resolving link: {e}") - mark_hostname_issue( - Source.initials, - "download", - str(e) if "e" in dir() else "Download error", - ) - return None + return final_url + + debug(f"BY redirect hop limit exceeded for {href}") + return None for pair in url_hosters: resolved_url = resolve_redirect(pair) link_hostname = pair[1] - if not link_hostname: + if not resolved_url: + continue + + # Protected/auto links must be returned as-is so downstream + # classification stores them in CAPTCHA flow instead of direct. + if _is_protected_or_auto_link(resolved_url): + links.append([resolved_url, "filecrypt"]) + continue + + if not link_hostname and resolved_url: link_hostname = urlparse(resolved_url).hostname - if ( - resolved_url - and link_hostname - and link_hostname.startswith( - ("ddownload", "rapidgator", "turbobit", "filecrypt") - ) + if link_hostname and link_hostname.startswith( + ("ddownload", "rapidgator", "turbobit", "filecrypt") ): if "rapidgator" in link_hostname: links.insert(0, [resolved_url, link_hostname]) diff --git a/quasarr/downloads/sources/nk.py b/quasarr/downloads/sources/nk.py index 63a3d0f0..a2b95057 100644 --- a/quasarr/downloads/sources/nk.py +++ b/quasarr/downloads/sources/nk.py @@ -2,7 +2,7 @@ # Quasarr # Project by https://github.com/rix1337 -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse import requests from bs4 import BeautifulSoup @@ -10,7 +10,7 @@ from quasarr.constants import DOWNLOAD_REQUEST_TIMEOUT_SECONDS from quasarr.downloads.sources.helpers.abstract_source import AbstractDownloadSource from quasarr.providers.hostname_issues import mark_hostname_issue -from quasarr.providers.log import info +from quasarr.providers.log import debug, info class Source(AbstractDownloadSource): @@ -63,23 +63,14 @@ def get_download_links(self, shared_state, url, mirrors, title, password): if not href.lower().startswith(("http://", "https://")): href = "https://" + host + href - try: - r = requests.head( - href, - headers=headers, - allow_redirects=True, - timeout=DOWNLOAD_REQUEST_TIMEOUT_SECONDS, - ) - r.raise_for_status() - href = r.url - except Exception as e: - info(f"Could not resolve download link for {title}: {e}") - mark_hostname_issue( - Source.initials, - "download", - str(e) if "e" in dir() else "Download error", - ) - continue + if _is_nk_redirect_link(href, host): + resolved_href = _resolve_nk_redirect(session, href, headers, title) + if resolved_href: + href = resolved_href + else: + # Never yield unresolved NK "/go" links. They bypass protected-link + # classification and end up as broken direct links in JD. + continue candidates.append([href, mirror]) @@ -92,6 +83,86 @@ def get_download_links(self, shared_state, url, mirrors, title, password): _SUPPORTED_MIRRORS = {"rapidgator", "ddownload"} +def _normalize_host(host): + normalized = (host or "").lower().strip() + if normalized.startswith("www."): + normalized = normalized[4:] + return normalized + + +def _is_nk_redirect_link(url, host): + try: + parsed = urlparse(url) + except Exception: + return False + + if _normalize_host(parsed.netloc) != _normalize_host(host): + return False + + return parsed.path.startswith("/go/") + + +def _is_filecrypt_link(url): + return "filecrypt." in (url or "").lower() + + +def _resolve_nk_redirect(session, url, headers, title): + current_url = url + visited = set() + + # Resolve redirect chain manually. This avoids fetching FileCrypt itself, + # which can return 403 for automated traffic even when URL is valid. + for _hop in range(8): + if current_url in visited: + debug(f"Could not resolve NK redirect for {title}: redirect loop detected") + return None + visited.add(current_url) + + try: + response = session.get( + current_url, + headers=headers, + allow_redirects=False, + timeout=DOWNLOAD_REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as e: + debug(f"Could not resolve NK redirect for {title}: {e}") + return None + except Exception as e: + debug(f"Could not resolve NK redirect for {title}: {e}") + return None + + location = (response.headers.get("Location") or "").strip() + if location: + next_url = urljoin(current_url, location) + if _is_filecrypt_link(next_url): + return next_url + current_url = next_url + continue + + final_url = (response.url or current_url).strip() + if _is_filecrypt_link(final_url): + return final_url + + if "/404.html" in final_url: + debug(f"NK redirect resolved to 404 for {title}: {final_url}") + return None + + if response.status_code >= 400: + debug( + f"Could not resolve NK redirect for {title}: HTTP {response.status_code} at {final_url}" + ) + return None + + debug( + f"Could not resolve NK redirect for {title}: no redirect target from {final_url}" + ) + return None + + debug(f"Could not resolve NK redirect for {title}: exceeded redirect hop limit") + return None + + def _normalize_mirror_name(mirror_name): normalized = mirror_name.lower().strip() diff --git a/quasarr/providers/version.py b/quasarr/providers/version.py index e366e62a..0cfb9f04 100644 --- a/quasarr/providers/version.py +++ b/quasarr/providers/version.py @@ -5,7 +5,7 @@ import re import sys -__version__ = "4.3.2" +__version__ = "4.3.3" def get_version(): diff --git a/uv.lock b/uv.lock index 0b71c5a0..22f22d48 100644 --- a/uv.lock +++ b/uv.lock @@ -346,11 +346,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.2" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]]