mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-25 12:50:24 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9452ebc70d | ||
|
|
d3f4ccd79a | ||
|
|
cb690b45b8 | ||
|
|
3d7ea40088 | ||
|
|
633004ecf0 | ||
|
|
c06b8ce8ef | ||
|
|
69ff0d6a78 | ||
|
|
3937ae119b | ||
|
|
d7fe28595c | ||
|
|
68c0e83330 | ||
|
|
faaa119884 | ||
|
|
be41a92436 | ||
|
|
7de9319c7a |
@@ -25,14 +25,14 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3
|
||||
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ RUN npm run build
|
||||
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14.7-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base
|
||||
FROM python:3.14.7-slim@sha256:cae66f2ef0ec51a9891263eeee7f987dacf0a9879e8aa9353d5606e0530619a5 AS base
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
|
||||
@@ -2344,6 +2344,7 @@ Override destination based on content type metadata.
|
||||
| `EXT_BYPASSER_URL` | URL of the external bypasser service (e.g., FlareSolverr). | string | `http://flaresolverr:8191` |
|
||||
| `EXT_BYPASSER_PATH` | API path for the external bypasser. | string | `/v1` |
|
||||
| `EXT_BYPASSER_TIMEOUT` | Timeout for external bypasser requests in milliseconds. | number | `60000` |
|
||||
| `BYPASS_PAGE_SOURCE_TIMEOUT` | How long to wait for a solved page to produce its content before the bypass is retried. Raise it if solves succeed but searches still fail. | number | `20` |
|
||||
| `BYPASS_BROWSER_IDLE_TIMEOUT` | How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner. | number | `180` |
|
||||
|
||||
<details>
|
||||
@@ -2400,6 +2401,16 @@ Timeout for external bypasser requests in milliseconds.
|
||||
- **Requires restart:** Yes
|
||||
- **Constraints:** min: 10000, max: 300000
|
||||
|
||||
#### `BYPASS_PAGE_SOURCE_TIMEOUT`
|
||||
|
||||
**Page Read Timeout (seconds)**
|
||||
|
||||
How long to wait for a solved page to produce its content before the bypass is retried. Raise it if solves succeed but searches still fail.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `20`
|
||||
- **Constraints:** min: 1, max: 120
|
||||
|
||||
#### `BYPASS_BROWSER_IDLE_TIMEOUT`
|
||||
|
||||
**Bypasser Idle Timeout (seconds)**
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ dependencies = [
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.52.2",
|
||||
"seleniumbase==4.53.5",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
@@ -43,7 +43,7 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.16.4",
|
||||
"ruff==0.16.5",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -3,3 +3,14 @@
|
||||
|
||||
class BypassCancelledError(Exception):
|
||||
"""Raised when a bypass operation is cancelled."""
|
||||
|
||||
|
||||
class ChallengeNotSolvedError(Exception):
|
||||
"""Raised when a bypasser ran but the site still answered with a challenge.
|
||||
|
||||
Distinct from a bypasser that is broken or unreachable, which is what every
|
||||
"the bypass failed" message used to say. A solver can do its job perfectly and
|
||||
still be handed something it cannot clear - DDoS-Guard's manual CAPTCHA page is
|
||||
the case from #1292 - and telling the user to go check that FlareSolverr is
|
||||
reachable sends them to fix a service that is working.
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError
|
||||
from shelfmark.bypass.challenge import challenge_marker
|
||||
from shelfmark.bypass.cookie_store import store_extracted_cookies
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -91,7 +92,13 @@ def _store_solution_clearance(target_url: str, solution: Mapping[str, Any]) -> N
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None.
|
||||
|
||||
Raises:
|
||||
ChallengeNotSolvedError: the service answered with a page that is still a
|
||||
challenge, whatever verdict it reported on itself.
|
||||
|
||||
"""
|
||||
raw_bypasser_url = _coerce_config_str(
|
||||
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
|
||||
"http://flaresolverr:8191",
|
||||
@@ -143,6 +150,32 @@ def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
logger.warning("External bypasser returned empty response for '%s'", target_url)
|
||||
return None
|
||||
|
||||
# "Challenge solved!" is the solver's verdict on its own work, and #1289 showed
|
||||
# it can be reported alongside a page the caller then rejects. Say what actually
|
||||
# came back, so a later report does not have to infer it from downstream errors.
|
||||
marker = challenge_marker(html)
|
||||
logger.debug(
|
||||
"External bypasser page for '%s': %d bytes, challenge_marker=%r",
|
||||
target_url,
|
||||
len(html),
|
||||
marker,
|
||||
)
|
||||
if marker:
|
||||
# The solver's verdict is not evidence; the page is. Returning this one as a
|
||||
# success is what made #1292 unrecoverable: the retry-and-rotate loop that
|
||||
# could still have saved the search - the next mirror is a different
|
||||
# DDoS-Guard host, in its own state - was never entered, and the challenge
|
||||
# page's own __ddg cookies were filed as this host's clearance and replayed
|
||||
# on every later request.
|
||||
logger.warning(
|
||||
"External bypasser reported success but returned a challenge page for "
|
||||
"'%s' (%d bytes, marker=%r) - the solve did not clear the protection",
|
||||
target_url,
|
||||
len(html),
|
||||
marker,
|
||||
)
|
||||
raise ChallengeNotSolvedError(marker)
|
||||
|
||||
try:
|
||||
_store_solution_clearance(target_url, solution)
|
||||
except AttributeError, KeyError, TypeError, ValueError:
|
||||
@@ -192,16 +225,33 @@ def get_bypassed_page(
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
) -> str | None:
|
||||
"""Fetch HTML via external bypasser with retries and mirror rotation."""
|
||||
"""Fetch HTML via external bypasser with retries and mirror rotation.
|
||||
|
||||
Raises:
|
||||
ChallengeNotSolvedError: every attempt came back still carrying a challenge.
|
||||
Reported apart from returning None because the two ask the user for
|
||||
opposite things: None means go and check the bypasser, this means the
|
||||
bypasser is fine and the host is the one refusing.
|
||||
BypassCancelledError: the caller's cancel flag was set.
|
||||
|
||||
"""
|
||||
from shelfmark.download import network as network_module
|
||||
|
||||
sel = selector or network_module.AAMirrorSelector()
|
||||
unsolved_marker: str | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRY + 1):
|
||||
_check_cancelled(cancel_flag, "by user")
|
||||
|
||||
attempt_url = sel.rewrite(url)
|
||||
result = _fetch_via_bypasser(attempt_url)
|
||||
try:
|
||||
result = _fetch_via_bypasser(attempt_url)
|
||||
except ChallengeNotSolvedError as e:
|
||||
# Worth the remaining attempts rather than an immediate give-up: the retry
|
||||
# rotates onto the next mirror, and that is a different DDoS-Guard host with
|
||||
# its own idea of whether this caller needs a CAPTCHA.
|
||||
unsolved_marker = str(e) or unsolved_marker
|
||||
result = None
|
||||
if result:
|
||||
return result
|
||||
|
||||
@@ -222,4 +272,11 @@ def get_bypassed_page(
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
logger.info("Rotated %s for retry", action)
|
||||
|
||||
if unsolved_marker:
|
||||
msg = (
|
||||
"The bypasser ran, but the site kept answering with a protection challenge "
|
||||
f"(marker={unsolved_marker!r}). That is usually a manual CAPTCHA, which no "
|
||||
"bypasser can answer - the bypasser itself is working. Try again shortly."
|
||||
)
|
||||
raise ChallengeNotSolvedError(msg)
|
||||
return None
|
||||
|
||||
@@ -82,6 +82,9 @@ _HELPER_RESULT_POLL_SECONDS = 0.05
|
||||
# what it is doing and exit before its session is killed instead.
|
||||
_HELPER_SHUTDOWN_GRACE_SECONDS = 15.0
|
||||
_HELPER_IDLE_TIMEOUT_DEFAULT = 180.0
|
||||
# How long to wait for a solved page to produce its document before the attempt is
|
||||
# abandoned. SeleniumBase's own get_page_source() allows one second; see _read_page_source.
|
||||
_PAGE_SOURCE_TIMEOUT_DEFAULT = 20.0
|
||||
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
|
||||
# How much of ffmpeg's stderr to quote when reporting that it died.
|
||||
_FFMPEG_ERROR_TAIL_CHARS = 500
|
||||
@@ -522,18 +525,6 @@ async def _bypass_method_humanlike(page: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _bypass_method_cdp_solve(page: Any) -> bool:
|
||||
"""CDP Mode with solve_captcha() - auto-detects challenge type."""
|
||||
try:
|
||||
logger.debug("Attempting bypass: CDP solve_captcha")
|
||||
await page.solve_captcha()
|
||||
await asyncio.sleep(_RNG.uniform(3, 5))
|
||||
return await _is_bypassed(page)
|
||||
except _CDP_OPERATION_ERRORS as e:
|
||||
logger.debug("CDP solve_captcha failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
CDP_CLICK_SELECTORS = [
|
||||
"#turnstile-widget div", # Cloudflare Turnstile
|
||||
"#cf-turnstile div", # Alternative CF Turnstile
|
||||
@@ -613,8 +604,13 @@ async def _bypass_method_cdp_gui_click(page: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Ordered cheapest-first, and deliberately without a bare `solve_captcha()` entry:
|
||||
# _bypass_method_cdp_gui_click opens by doing exactly that and returns the moment it
|
||||
# works, so a separate method ahead of it could only ever repeat the half that had
|
||||
# already failed - one wasted round trip plus the backoff before the next attempt, on
|
||||
# every solve that gets this far. Measured at ~5.5s of the ~26s each solve cost, and
|
||||
# 0/19 successes for the standalone method against DDoS-Guard. See issue #1285.
|
||||
BYPASS_METHODS = [
|
||||
_bypass_method_cdp_solve,
|
||||
_bypass_method_cdp_gui_click,
|
||||
_bypass_method_cdp_click,
|
||||
_bypass_method_humanlike,
|
||||
@@ -819,6 +815,22 @@ def _build_host_resolver_rules() -> list[str]:
|
||||
DRIVER_RESET_ERRORS = {"ProtocolException", "RuntimeError", "TimeoutError"}
|
||||
|
||||
|
||||
async def _read_page_source(page: Any) -> str:
|
||||
"""Read a solved page's HTML, waiting for the document to arrive.
|
||||
|
||||
`get_page_source()` waits one second for the `html` element. A page released from a
|
||||
challenge is often still navigating to the real content, so the read times out even
|
||||
though the solve succeeded: the whole attempt is retried, and the repeated requests
|
||||
are what earn a 429 from a host that was about to serve us.
|
||||
"""
|
||||
timeout = _coerce_non_negative_float(
|
||||
app_config.get("BYPASS_PAGE_SOURCE_TIMEOUT", _PAGE_SOURCE_TIMEOUT_DEFAULT),
|
||||
_PAGE_SOURCE_TIMEOUT_DEFAULT,
|
||||
)
|
||||
element = await page.find("html", timeout=timeout)
|
||||
return await element.get_html_async()
|
||||
|
||||
|
||||
async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
|
||||
"""Fetch URL with Cloudflare bypass using a CDP browser."""
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled before starting")
|
||||
@@ -842,7 +854,7 @@ async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
|
||||
logger.debug("Starting bypass process...")
|
||||
if await _bypass(page, cancel_flag=cancel_flag):
|
||||
await _extract_cookies_from_cdp(driver, page, url)
|
||||
return await page.get_page_source()
|
||||
return await _read_page_source(page)
|
||||
|
||||
logger.warning("Bypass completed but page still shows protection")
|
||||
try:
|
||||
|
||||
@@ -1683,6 +1683,18 @@ def cloudflare_bypass_settings() -> list[SettingsField]:
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="BYPASS_PAGE_SOURCE_TIMEOUT",
|
||||
label="Page Read Timeout (seconds)",
|
||||
description=(
|
||||
"How long to wait for a solved page to produce its content before the "
|
||||
"bypass is retried. Raise it if solves succeed but searches still fail."
|
||||
),
|
||||
default=20,
|
||||
min_value=1,
|
||||
max_value=120,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": False},
|
||||
),
|
||||
NumberField(
|
||||
key="BYPASS_BROWSER_IDLE_TIMEOUT",
|
||||
label="Bypasser Idle Timeout (seconds)",
|
||||
|
||||
@@ -112,18 +112,53 @@ def _normalize_languages(languages: list[str] | None, user_id: int | None) -> li
|
||||
return _to_language_codes(languages, source="the search request")
|
||||
|
||||
|
||||
def _pick_search_author(book: BookMetadata) -> str:
|
||||
def first_author(value: str) -> str:
|
||||
"""The first name in a possibly comma-joined author string.
|
||||
|
||||
Both ends of the app hand us every contributor in one string. The frontend joins
|
||||
`authors` with ", " for display (`bookTransformers.ts`) and that display string comes
|
||||
straight back as the `author` request parameter, while several providers set
|
||||
`search_author` from the same joined text. Searching a release source for
|
||||
"Blindness Jose Saramago, Giovanni Pontiero, ..." - the author plus two translators -
|
||||
matches nothing, and the user is told the book has no releases at all.
|
||||
|
||||
A "Last, First" author collapses to the surname, which is still a usable search term
|
||||
and is what the authors[] fallback has always done with the same input. See #1252.
|
||||
"""
|
||||
first, _, _ = value.partition(",")
|
||||
return first.strip()
|
||||
|
||||
|
||||
def pick_search_author(book: BookMetadata) -> str:
|
||||
"""The one author a release query should carry, from whichever field holds one.
|
||||
|
||||
Every release source that builds its own query wants exactly this, so it lives here
|
||||
rather than being re-derived per source - the two branches below drifted apart once
|
||||
already (#1252) and the IRC source carried a third copy of the same preference.
|
||||
|
||||
#1290 fixed the same report by merging the two branches and trimming whichever one
|
||||
won; this keeps that outcome ("Blindness Jose Saramago" from either field, measured
|
||||
there at 0 releases before and 49 after) and adds the empty-narrowing fallback, so a
|
||||
credit list that merely starts with a blank entry does not fall out to title-only.
|
||||
"""
|
||||
# Narrowing can come back empty - the joined string starts with a comma because the
|
||||
# first contributor was blank, and `authors.join(', ')` does not drop the empty entry.
|
||||
# Falling through to authors[] then still finds a usable name; returning "" would
|
||||
# search by title alone and lose the author we were holding all along.
|
||||
if book.search_author:
|
||||
return book.search_author
|
||||
narrowed = first_author(book.search_author)
|
||||
if narrowed:
|
||||
return narrowed
|
||||
|
||||
if not book.authors:
|
||||
return ""
|
||||
# A bare string here would otherwise be iterated one character at a time; the IRC
|
||||
# source guarded against exactly that before it shared this helper.
|
||||
authors = book.authors if isinstance(book.authors, list) else [book.authors or ""]
|
||||
for author in authors:
|
||||
narrowed = first_author(author or "")
|
||||
if narrowed:
|
||||
return narrowed
|
||||
|
||||
first = book.authors[0]
|
||||
if "," in first:
|
||||
first = first.split(",")[0].strip()
|
||||
|
||||
return first
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_search_title(book: BookMetadata) -> str:
|
||||
@@ -150,7 +185,7 @@ def build_release_search_plan(
|
||||
if manual_query:
|
||||
resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None
|
||||
|
||||
author = _pick_search_author(book)
|
||||
author = pick_search_author(book)
|
||||
base_title = _pick_search_title(book)
|
||||
|
||||
if resolved_manual_query:
|
||||
|
||||
@@ -5,12 +5,12 @@ import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError, cookie_store
|
||||
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store
|
||||
from shelfmark.bypass.challenge import challenge_marker
|
||||
from shelfmark.core import search_deadline
|
||||
from shelfmark.core.config import config as app_config
|
||||
@@ -29,6 +29,10 @@ logger = setup_logger(__name__)
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
# DDoS-Guard's re-check probe. Its 302 to `?check=1` is one hop of a handshake rather
|
||||
# than a page: the parameter asserts the caller already holds the cookies that hop
|
||||
# issued.
|
||||
_DDG_CHECK_PARAM = "check"
|
||||
# Z-Library answers the first hit with a 503 whose only real payload is a Set-Cookie; echoing
|
||||
# that cookie back returns the 302 to the real page. Two attempts cover the handshake without
|
||||
# letting a server that keeps re-issuing cookies hold us in the loop.
|
||||
@@ -48,6 +52,7 @@ _BYPASS_GRACE_SLACK_SECONDS = 30.0
|
||||
_BYPASSER_ERRORS = (
|
||||
AttributeError,
|
||||
BypassCancelledError,
|
||||
ChallengeNotSolvedError,
|
||||
KeyError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
@@ -252,6 +257,33 @@ def _response_challenge_marker(response: requests.Response) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _solvable_url(url: str) -> str:
|
||||
"""The URL a solver should open, given one we may be mid-handshake on.
|
||||
|
||||
The manual AA redirect follower in `html_get_page` walks DDoS-Guard's handshake by
|
||||
reassigning `current_url`, so by the time a 403, a 503 challenge or a redirect loop
|
||||
hands that URL to a bypasser it is often the `?check=1` probe rather than the page
|
||||
we actually wanted. A solver opens it in a fresh browser holding none of the cookies
|
||||
the probe exists to collect, so DDoS-Guard cannot verify it automatically and answers
|
||||
with the manual CAPTCHA page that nothing can solve - the failure in #1292, where
|
||||
FlareSolverr reported "Challenge solved!" over a 4.7 KB DDOS-GUARD interstitial.
|
||||
|
||||
Handing over the pre-probe URL instead lets the solver's browser run the whole
|
||||
handshake itself, which is what a real browser does and what the solver is for.
|
||||
|
||||
Scoped to the hosts whose redirects we follow manually: everywhere else `check` is
|
||||
an ordinary query parameter and none of our business.
|
||||
"""
|
||||
if not network.should_rotate_dns_for_url(url):
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
params = parse_qsl(parsed.query, keep_blank_values=True)
|
||||
kept = [(key, value) for key, value in params if key != _DDG_CHECK_PARAM]
|
||||
if len(kept) == len(params):
|
||||
return url
|
||||
return urlunparse(parsed._replace(query=urlencode(kept)))
|
||||
|
||||
|
||||
def _fatal_mirror_reason(e: Exception) -> str | None:
|
||||
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
|
||||
|
||||
@@ -371,6 +403,9 @@ def html_get_page(
|
||||
retry-loop branch above with `continue`, and with MAX_RETRY=1 there is no
|
||||
later attempt for that branch to run on either.
|
||||
"""
|
||||
# Every handoff reaches the solver through here, so this is the one place the
|
||||
# mid-handshake `?check=1` URL has to be unwound. See _solvable_url.
|
||||
bypass_url = _solvable_url(bypass_url)
|
||||
# Never start a minutes-long browser solve on a budget that has already run out:
|
||||
# nothing downstream would get to report the real reason before the caller's
|
||||
# deadline (or its reverse proxy) cut the request off.
|
||||
@@ -405,6 +440,18 @@ def html_get_page(
|
||||
except _STATUS_CALLBACK_ERRORS:
|
||||
logger.debug("Rate-limit status callback failed", exc_info=True)
|
||||
return _fail(str(e), bypass_url)
|
||||
except ChallengeNotSolvedError as e:
|
||||
# Not a bypasser malfunction: it ran, and the host answered with something it
|
||||
# cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the
|
||||
# generic handler below, whose "the protection bypasser failed" is what sent
|
||||
# #1292 off to fix a FlareSolverr that was working perfectly.
|
||||
logger.info("Bypass ran but did not clear the protection: %s", e)
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("error", str(e))
|
||||
except _STATUS_CALLBACK_ERRORS:
|
||||
logger.debug("Unsolved-challenge status callback failed", exc_info=True)
|
||||
return _fail(str(e), bypass_url)
|
||||
except _BYPASSER_ERRORS as e:
|
||||
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
|
||||
# Surface the real reason. Without this the caller only sees an empty
|
||||
|
||||
+11
-1
@@ -1180,6 +1180,12 @@ def api_config() -> Response | tuple[Response, int]:
|
||||
[],
|
||||
user_id=db_user_id,
|
||||
),
|
||||
# The client must not give up before this budget does. `/api/releases`
|
||||
# answers a spent budget with a message naming the real cause (a protection
|
||||
# challenge nobody could solve); a browser that aborted first replaces it
|
||||
# with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a
|
||||
# setting the user can raise with no visible effect. See issue #1285.
|
||||
"release_search_timeout": search_deadline.budget_seconds(),
|
||||
"settings_enabled": _is_config_dir_writable(),
|
||||
"onboarding_complete": _get_onboarding_complete(),
|
||||
# Default sort orders
|
||||
@@ -2947,6 +2953,10 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
elif provider == "manual":
|
||||
resolved_title = title_param or manual_query or "Manual Search"
|
||||
resolved_author = author_param or ""
|
||||
# The release modal sends `authors.join(', ')` as `author`, so the commas here
|
||||
# are joins between contributors, not part of one name. This split is the only
|
||||
# place that knows that, so `search_author` comes from it rather than from the
|
||||
# joined text - see issue #1252.
|
||||
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
|
||||
|
||||
book = BookMetadata(
|
||||
@@ -2955,7 +2965,7 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
provider_display_name="Manual Search",
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
search_author=authors[0] if authors else None,
|
||||
authors=authors,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -519,6 +519,10 @@ def browse_record_to_book_metadata(
|
||||
"""Convert a source-native browse record into generic book metadata."""
|
||||
resolved_title = title_override or str(record.title or "").strip() or "Unknown title"
|
||||
resolved_author = author_override or str(record.author or "").strip()
|
||||
# `author_override` is the frontend's display string, `authors.join(', ')` - every
|
||||
# contributor, translators included. The split below is the only place that knows the
|
||||
# commas were joins rather than part of a name, so `search_author` is taken from it
|
||||
# rather than from the joined text. See issue #1252.
|
||||
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
|
||||
publish_year = None
|
||||
|
||||
@@ -535,7 +539,7 @@ def browse_record_to_book_metadata(
|
||||
provider_display_name=get_source_display_name(record.source),
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
search_author=authors[0] if authors else None,
|
||||
authors=authors,
|
||||
cover_url=record.preview,
|
||||
description=record.description,
|
||||
|
||||
@@ -6,6 +6,8 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import replace
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
@@ -16,6 +18,7 @@ import requests
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from bs4.element import NavigableString
|
||||
|
||||
from shelfmark.bypass.challenge import MAX_CHALLENGE_HTML_CHARS, challenge_marker
|
||||
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
|
||||
from shelfmark.core import search_deadline
|
||||
from shelfmark.core.config import config
|
||||
@@ -43,7 +46,7 @@ from shelfmark.release_sources import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
@@ -112,6 +115,16 @@ def _html_response_text(response: str | tuple[str, str]) -> str:
|
||||
return response
|
||||
|
||||
|
||||
def _html_response_url(response: str | tuple[str, str]) -> str | None:
|
||||
"""The URL that actually answered, when the downloader was asked to report it.
|
||||
|
||||
None for the plain-string shape, so a caller can fall back to what it requested.
|
||||
"""
|
||||
if isinstance(response, tuple):
|
||||
return response[1] or None
|
||||
return None
|
||||
|
||||
|
||||
def _attr_to_str(value: object) -> str | None:
|
||||
"""Convert a BeautifulSoup attribute value to a plain string."""
|
||||
if isinstance(value, str):
|
||||
@@ -556,13 +569,6 @@ _AA_PAGE_MARKERS = (
|
||||
"/fast_download",
|
||||
"/slow_download",
|
||||
)
|
||||
_CHALLENGE_MARKERS = (
|
||||
"ddos-guard",
|
||||
"just a moment",
|
||||
"cloudflare",
|
||||
"checking your browser",
|
||||
"cf-browser-verification",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_aa_page(html: str) -> bool:
|
||||
@@ -572,12 +578,120 @@ def _looks_like_aa_page(html: str) -> bool:
|
||||
|
||||
|
||||
def _looks_like_challenge_page(html: str) -> bool:
|
||||
"""Whether ``html`` is a protection interstitial rather than the site behind it."""
|
||||
lowered = html.lower()
|
||||
return any(marker in lowered for marker in _CHALLENGE_MARKERS)
|
||||
"""Whether ``html`` is a protection interstitial rather than the site behind it.
|
||||
|
||||
Delegates to the shared detector rather than substring-matching here. A bare
|
||||
"ddos-guard"/"cloudflare" scan flags the protected site's *own* pages: DDoS-Guard
|
||||
links its endpoints on everything it fronts, and AA ships a `DDOS-GUARD` comment in
|
||||
the inline JS on every page it serves. That misread every real AA response that was
|
||||
not a results table as an unsolved challenge, and sent users off to fix a bypasser
|
||||
that had just succeeded - see #1289/#1292. `challenge_marker` caps its scan at
|
||||
64 KB, which is what separates a few-KB interstitial from the page behind it.
|
||||
"""
|
||||
return challenge_marker(html) is not None
|
||||
|
||||
|
||||
# Pages already fetched during the search in flight, keyed by URL. Scoped to one
|
||||
# DirectDownload.search() so nothing is carried between requests.
|
||||
_search_page_cache: ContextVar[dict[str, tuple[str, Tag | None]] | None] = ContextVar(
|
||||
"aa_search_page_cache", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _search_page_reuse() -> Iterator[None]:
|
||||
"""Fetch each distinct AA search URL at most once per search.
|
||||
|
||||
One search asks AA for the same URL more than once. The language-filter retry in
|
||||
`search()` re-runs every title variant, and when DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH
|
||||
is on the requested language is applied locally instead of as `&lang=`, so both
|
||||
passes build a byte-identical URL - the retry differs only in the filtering it does
|
||||
to the response it already had. A repeat is not a cheap round trip either: AA is
|
||||
behind DDoS-Guard, so each one is a fresh browser solve, tens of seconds that buy
|
||||
nothing. See issue #1285.
|
||||
"""
|
||||
token = _search_page_cache.set({})
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_search_page_cache.reset(token)
|
||||
|
||||
|
||||
def _is_reusable_answer(result: tuple[str, Tag | None]) -> bool:
|
||||
"""Whether a fetched page is an answer, rather than a giving-up worth retrying.
|
||||
|
||||
`_fetch_search_table_uncached` exists to rotate past mirrors that are not actually AA,
|
||||
and when it runs out of them it *returns* instead of raising: a page with no results
|
||||
table and no marker. Storing that would hand the language-filter retry - the pass this
|
||||
cache exists for - a mirror set that may have recovered in between (DNS rotation, a
|
||||
mirror coming back), turning a transient outage into "this book has no releases". A
|
||||
real "No files found." is an answer and is worth keeping.
|
||||
"""
|
||||
html, tbody = result
|
||||
return tbody is not None or "No files found." in html or _looks_like_aa_page(html)
|
||||
|
||||
|
||||
# How much of an unreadable search page to quote in the debug log. Enough to carry the
|
||||
# <head> - title, injected challenge scripts - without pasting a 180 KB page into a log
|
||||
# file that ships inside the debug bundle.
|
||||
_PAGE_FINGERPRINT_CHARS = 700
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _log_untabled_search_page(url: str, html: str) -> None:
|
||||
"""Record why a search page with no results table is about to be classified.
|
||||
|
||||
#1289 cost a full investigation because the log said only "unsolved protection
|
||||
challenge" while FlareSolverr said "Challenge solved!", and the debug bundle carries
|
||||
no response bodies - there was no way to tell a real AA page from an interstitial
|
||||
after the fact. These are the facts that would have settled it in one line: the size
|
||||
(the 64 KB cap is what separates the two), which markers matched, and the head of
|
||||
the document.
|
||||
|
||||
Diagnostics must never be the reason a search fails, so this swallows its own errors.
|
||||
"""
|
||||
try:
|
||||
title_match = _TITLE_RE.search(html[: _PAGE_FINGERPRINT_CHARS * 4])
|
||||
title = " ".join(title_match.group(1).split())[:120] if title_match else "<none>"
|
||||
lowered = html.lower()
|
||||
aa_markers = [marker for marker in _AA_PAGE_MARKERS if marker in lowered]
|
||||
logger.info(
|
||||
"Search page has no results table: %s (bytes=%d, title=%r, aa_markers=%s, "
|
||||
"challenge_marker=%r, over_challenge_size_cap=%s)",
|
||||
url,
|
||||
len(html),
|
||||
title,
|
||||
aa_markers or "none",
|
||||
challenge_marker(html),
|
||||
len(html) > MAX_CHALLENGE_HTML_CHARS,
|
||||
)
|
||||
logger.debug(
|
||||
"Untabled search page head (%d of %d bytes): %s",
|
||||
min(len(html), _PAGE_FINGERPRINT_CHARS),
|
||||
len(html),
|
||||
html[:_PAGE_FINGERPRINT_CHARS],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not fingerprint the untabled search page", exc_info=True)
|
||||
|
||||
|
||||
def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[str, Tag | None]:
|
||||
"""Fetch the AA search page, reusing one already fetched during this search."""
|
||||
cache = _search_page_cache.get()
|
||||
if cache is not None and url in cache:
|
||||
logger.debug("Reusing search page already fetched for this search: %s", url)
|
||||
return cache[url]
|
||||
|
||||
result = _fetch_search_table_uncached(url, selector)
|
||||
|
||||
if cache is not None and _is_reusable_answer(result):
|
||||
cache[url] = result
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_search_table_uncached(
|
||||
url: str, selector: network.AAMirrorSelector
|
||||
) -> tuple[str, Tag | None]:
|
||||
"""Fetch the AA search page, retrying past mirrors that are not actually AA.
|
||||
|
||||
A parked or seized domain answers 200 with a page that has no results table and no
|
||||
@@ -592,10 +706,21 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
|
||||
if search_deadline.expired():
|
||||
raise SearchUnavailableError(search_deadline.deadline_message())
|
||||
|
||||
# include_response_url is what makes the diagnostics below name the mirror that
|
||||
# actually answered. html_get_page rotates mirrors and follows redirects on its
|
||||
# own, so `attempt_url` is only where this iteration started: #1298's bundle
|
||||
# reported the untabled page against annas-archive.gl when the body had come
|
||||
# from .pk, which is precisely the triage cost #1289 added the line to remove.
|
||||
response = downloader.html_get_page(
|
||||
attempt_url, selector=selector, allow_bypasser_fallback=True
|
||||
attempt_url,
|
||||
selector=selector,
|
||||
allow_bypasser_fallback=True,
|
||||
include_response_url=True,
|
||||
)
|
||||
if not response:
|
||||
html = _html_response_text(response)
|
||||
# Checked on the body, not on `response`: with include_response_url the give-up
|
||||
# shape is the tuple ("", url), and a tuple is truthy.
|
||||
if not html:
|
||||
# Network/mirror exhaustion path bubbles up so API can notify clients.
|
||||
# html_get_page records the concrete give-up reason on the selector; fall
|
||||
# back to the generic line only if nothing was recorded.
|
||||
@@ -604,7 +729,7 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
|
||||
)
|
||||
raise SearchUnavailableError(f"Unable to reach download source. {detail}")
|
||||
|
||||
html = _html_response_text(response)
|
||||
answered_url = _html_response_url(response) or attempt_url
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
table = soup.find("table")
|
||||
if isinstance(table, Tag):
|
||||
@@ -615,20 +740,38 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
|
||||
if "No files found." in html:
|
||||
# A real, genuinely empty answer from a healthy mirror.
|
||||
return html, None
|
||||
|
||||
# A search page with no table is the one shape we cannot read off the response
|
||||
# alone, and the response body is not in the debug bundle. Fingerprint it here
|
||||
# so the next report says which branch fired and why, rather than costing
|
||||
# another round of guesswork - see #1289.
|
||||
_log_untabled_search_page(answered_url, html)
|
||||
|
||||
if _looks_like_aa_page(html):
|
||||
# A real AA response in a shape the caller should report as drift. Checked
|
||||
# ahead of the challenge branch: AA's own pages carry the protection's
|
||||
# markers, so an interstitial is only the better explanation once the page
|
||||
# has nothing of AA's about it. A genuine interstitial has no AA markers.
|
||||
return html, None
|
||||
if _looks_like_challenge_page(html):
|
||||
# The bypass did not actually clear the protection - the interstitial is
|
||||
# what came back. Rotating is pointless (every mirror shares the same
|
||||
# protection) and reporting it as an empty result is worse: the user is
|
||||
# told their query found nothing when the search never ran.
|
||||
#
|
||||
# The wording no longer blames the bypasser outright. In #1292 it was
|
||||
# reachable and working, and the page it was handed was DDoS-Guard's manual
|
||||
# CAPTCHA - so "check that the bypasser is working" was the one piece of
|
||||
# advice guaranteed to waste the reporter's time. Name the marker instead
|
||||
# and let the two causes be told apart.
|
||||
msg = (
|
||||
"Anna's Archive answered with an unsolved protection challenge. "
|
||||
"Check that the bypasser is reachable and working."
|
||||
"Anna's Archive answered with a protection challenge that was not "
|
||||
f"cleared (marker={challenge_marker(html)!r}). If the bypasser reports "
|
||||
"solving it, the host is serving a manual CAPTCHA that no bypasser can "
|
||||
"answer - try again shortly. Otherwise check that the bypasser is "
|
||||
"reachable and working."
|
||||
)
|
||||
raise SearchUnavailableError(msg)
|
||||
if _looks_like_aa_page(html):
|
||||
# A real AA response in a shape the caller should report as drift.
|
||||
# Not the mirror's fault.
|
||||
return html, None
|
||||
|
||||
new_base, action = selector.next_mirror_or_rotate_dns(
|
||||
fatal=True, reason="responded without an Anna's Archive page"
|
||||
@@ -1915,6 +2058,22 @@ class DirectDownloadSource(ReleaseSource):
|
||||
) -> list[Release]:
|
||||
"""Search for releases using the book's metadata.
|
||||
|
||||
The whole fan-out runs under one page cache, so a URL built twice by different
|
||||
passes is fetched once. See `_search_page_reuse`.
|
||||
"""
|
||||
with _search_page_reuse():
|
||||
return self._search(book, plan, expand_search=expand_search, content_type=content_type)
|
||||
|
||||
def _search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
plan: ReleaseSearchPlan,
|
||||
*,
|
||||
expand_search: bool = False,
|
||||
content_type: str = "ebook",
|
||||
) -> list[Release]:
|
||||
"""Search for releases using the book's metadata.
|
||||
|
||||
Priority: ISBN search first (most precise), then title+author fallback.
|
||||
For non-English languages, uses localized titles from book.titles_by_language.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
from shelfmark.api.websocket import ws_manager
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.search_plan import pick_search_author
|
||||
from shelfmark.core.utils import is_audiobook
|
||||
from shelfmark.release_sources import (
|
||||
ColumnColorHint,
|
||||
@@ -394,11 +395,13 @@ class IRCReleaseSource(ReleaseSource):
|
||||
if book.search_title or book.title:
|
||||
parts.append(book.search_title or book.title)
|
||||
|
||||
if book.search_author:
|
||||
parts.append(book.search_author)
|
||||
elif book.authors:
|
||||
# Use first author
|
||||
author = book.authors[0] if isinstance(book.authors, list) else book.authors
|
||||
# Only ever the first author: both metadata fields can arrive holding every
|
||||
# contributor joined with ", ", and an IRC query carrying an author plus two
|
||||
# translators matches nothing. The choice between them - and the narrowing - is
|
||||
# `pick_search_author`, shared with the search plan so this cannot drift from it
|
||||
# again. See issue #1252.
|
||||
author = pick_search_author(book)
|
||||
if author:
|
||||
parts.append(author)
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
@@ -41,6 +41,8 @@ from shelfmark.release_sources.prowlarr.api import (
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
AUTHOR_UNKNOWN,
|
||||
author_affinity,
|
||||
build_source_id,
|
||||
coerce_float_like,
|
||||
coerce_int_like,
|
||||
@@ -1008,10 +1010,17 @@ class ProwlarrSource(ReleaseSource):
|
||||
if time.monotonic() > deadline:
|
||||
_raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s")
|
||||
|
||||
def search_indexers(
|
||||
query: str, cats: list[int] | None, *, enriched_query: str | None = None
|
||||
) -> _IndexerSearchOutcome:
|
||||
"""Search indexers with given categories via Torznab/Newznab."""
|
||||
def search_indexers(query: str, cats: list[int] | None) -> _IndexerSearchOutcome:
|
||||
"""Search indexers with given categories via Torznab/Newznab.
|
||||
|
||||
Every indexer gets the same title-only query. Enriched indexers used
|
||||
to be sent "{title} {author}", but an indexer that ANDs its search
|
||||
terms (MyAnonamouse) returns nothing whenever the metadata provider
|
||||
spells the author differently to the tracker - "Timothy Ferriss" vs
|
||||
"Tim Ferriss" - and the UI reports the book as missing (#1293). The
|
||||
author still decides ordering below, where a spelling difference
|
||||
costs a release its position rather than its existence.
|
||||
"""
|
||||
outcome = _IndexerSearchOutcome(results=[])
|
||||
target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats)
|
||||
if not target_indexer_ids:
|
||||
@@ -1019,16 +1028,11 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
for indexer_id in target_indexer_ids:
|
||||
_check_timeout()
|
||||
indexer_query = (
|
||||
enriched_query
|
||||
if indexer_id in enriched_indexer_ids_set and enriched_query
|
||||
else query
|
||||
)
|
||||
outcome.attempted += 1
|
||||
try:
|
||||
raw = client.torznab_search(
|
||||
indexer_id=indexer_id,
|
||||
query=indexer_query,
|
||||
query=query,
|
||||
categories=cats,
|
||||
search_type="book",
|
||||
)
|
||||
@@ -1053,14 +1057,11 @@ class ProwlarrSource(ReleaseSource):
|
||||
for idx, variant in enumerate(variants, start=1):
|
||||
_check_timeout()
|
||||
query = variant.title
|
||||
enriched_query = variant.query # title + author
|
||||
|
||||
if len(variants) > 1:
|
||||
logger.debug("Prowlarr query %s/%s: '%s'", idx, len(variants), query)
|
||||
|
||||
outcome = search_indexers(
|
||||
query=query, cats=categories, enriched_query=enriched_query
|
||||
)
|
||||
outcome = search_indexers(query=query, cats=categories)
|
||||
|
||||
# Auto-expand: if no results with categories and auto-expand enabled, retry without.
|
||||
# Only when every indexer actually answered: a failed search says nothing about
|
||||
@@ -1077,9 +1078,7 @@ class ProwlarrSource(ReleaseSource):
|
||||
"Prowlarr: no results for query '%s' with category filter, auto-expanding search",
|
||||
query,
|
||||
)
|
||||
expanded = search_indexers(
|
||||
query=query, cats=None, enriched_query=enriched_query
|
||||
)
|
||||
expanded = search_indexers(query=query, cats=None)
|
||||
outcome.results = expanded.results
|
||||
outcome.attempted += expanded.attempted
|
||||
outcome.failed += expanded.failed
|
||||
@@ -1117,6 +1116,10 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
results: list[Release] = []
|
||||
enriched_source_ids: set[str] = set()
|
||||
affinity_by_source_id: dict[str, int] = {}
|
||||
# A manual query is the user's own words; ranking it against the
|
||||
# metadata author would second-guess what they typed.
|
||||
wanted_author = "" if plan.manual_query else plan.author
|
||||
|
||||
for raw_result in all_results:
|
||||
result_with_seed_settings = _apply_indexer_seed_settings(
|
||||
@@ -1136,13 +1139,20 @@ class ProwlarrSource(ReleaseSource):
|
||||
if idx_id_int is not None and idx_id_int in indexer_priority:
|
||||
release.extra["indexer_priority"] = indexer_priority[idx_id_int]
|
||||
results.append(release)
|
||||
affinity_by_source_id[release.source_id] = author_affinity(
|
||||
wanted_author, release.extra.get("author")
|
||||
)
|
||||
|
||||
if is_enriched:
|
||||
enriched_source_ids.add(release.source_id)
|
||||
|
||||
# Indexer priority first: it is an explicit user preference. Author
|
||||
# agreement then orders what one indexer returned, so the editions that
|
||||
# match the requested author lead and the rest stay reachable below.
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
_release_indexer_rank(r, indexer_priority),
|
||||
affinity_by_source_id.get(r.source_id, AUTHOR_UNKNOWN),
|
||||
0 if r.source_id in enriched_source_ids else 1,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,6 +14,20 @@ if TYPE_CHECKING:
|
||||
|
||||
_INTEGER_LIKE_PATTERN = re.compile(r"^[+-]?\d+$")
|
||||
_FLOAT_LIKE_PATTERN = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$")
|
||||
_AUTHOR_TOKEN_PATTERN = re.compile(r"\w+", re.UNICODE)
|
||||
_AUTHOR_NOISE_TOKENS = frozenset(
|
||||
{"jr", "sr", "ii", "iii", "iv", "phd", "md", "dr", "mr", "mrs", "ms", "et", "al", "and", "the"}
|
||||
)
|
||||
|
||||
# Ordering tiers for author agreement between the requested book and what an
|
||||
# indexer reported. Lower sorts first.
|
||||
AUTHOR_MATCH = 0
|
||||
AUTHOR_UNKNOWN = 1
|
||||
AUTHOR_MISMATCH = 2
|
||||
|
||||
# A mononym ("Homer") can only ever agree on one token; a longer name needs a
|
||||
# given name and a surname to agree before it counts as the same person.
|
||||
_AUTHOR_TOKENS_REQUIRED = 2
|
||||
|
||||
|
||||
def coerce_int_like(value: object) -> int | None:
|
||||
@@ -32,6 +46,49 @@ def coerce_int_like(value: object) -> int | None:
|
||||
return int(normalized)
|
||||
|
||||
|
||||
def _author_tokens(value: object) -> list[str]:
|
||||
"""Split an author string into comparable lowercase name tokens."""
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
tokens = [token.lower() for token in _AUTHOR_TOKEN_PATTERN.findall(value)]
|
||||
return [token for token in tokens if token not in _AUTHOR_NOISE_TOKENS]
|
||||
|
||||
|
||||
def _author_tokens_compatible(wanted: str, offered: str) -> bool:
|
||||
"""Treat an abbreviated given name as the name it abbreviates."""
|
||||
return wanted == offered or wanted.startswith(offered) or offered.startswith(wanted)
|
||||
|
||||
|
||||
def author_affinity(wanted: object, offered: object) -> int:
|
||||
"""Rank how far an indexer's author field is from the requested author.
|
||||
|
||||
Shelfmark ranks on this rather than filtering on it, so a wrong verdict only
|
||||
costs a release its position in the list, never its visibility. That is what
|
||||
makes the loose token comparison safe: "Tim"/"Timothy" and "T."/"Timothy"
|
||||
agree, while a transliteration ("Dostoevsky"/"Dostoyevsky") is merely sorted
|
||||
last instead of being hidden.
|
||||
|
||||
Three-way on purpose: an indexer that reports no author at all must not sort
|
||||
below one that reports a wrong author, so "no metadata" ranks between
|
||||
agreement and disagreement rather than counting as either.
|
||||
"""
|
||||
wanted_tokens = _author_tokens(wanted)
|
||||
offered_tokens = _author_tokens(offered)
|
||||
if not wanted_tokens or not offered_tokens:
|
||||
return AUTHOR_UNKNOWN
|
||||
|
||||
matched = sum(
|
||||
1
|
||||
for wanted_token in wanted_tokens
|
||||
if any(
|
||||
_author_tokens_compatible(wanted_token, offered_token)
|
||||
for offered_token in offered_tokens
|
||||
)
|
||||
)
|
||||
required = min(_AUTHOR_TOKENS_REQUIRED, len(wanted_tokens))
|
||||
return AUTHOR_MATCH if matched >= required else AUTHOR_MISMATCH
|
||||
|
||||
|
||||
def build_source_id(result: dict) -> str:
|
||||
"""Build the Release.source_id for a raw Prowlarr result.
|
||||
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
// React Compiler advisories, enforced everywhere with no per-file exemptions.
|
||||
// The violations inherited from the oxlint 1.70 -> 1.80 bump are all resolved:
|
||||
// three by widening a dependency to the object the compiler infers, and seven
|
||||
// by an `oxlint-disable-next-line` that says, at the callsite, why the flagged
|
||||
// dependency is load-bearing - five are re-run triggers that are never read,
|
||||
// and two are values the callback genuinely uses.
|
||||
"react/preserve-manual-memoization": "error",
|
||||
"react/exhaustive-effect-dependencies": "error",
|
||||
"react/memo-dependencies": "error",
|
||||
"react/no-danger": "error",
|
||||
"react/no-clone-element": "error",
|
||||
"react/no-react-children": "error",
|
||||
|
||||
Generated
+318
-287
File diff suppressed because it is too large
Load Diff
@@ -24,17 +24,17 @@
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"knip": "^6.32.2",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"oxfmt": "^0.65.0",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
+107
-100
@@ -32,6 +32,7 @@ import {
|
||||
import { useActivity } from './hooks/useActivity';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useDownloadTracking } from './hooks/useDownloadTracking';
|
||||
import { useLatestCallback } from './hooks/useLatestCallback';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { useMountEffect } from './hooks/useMountEffect';
|
||||
import { useRealtimeStatus } from './hooks/useRealtimeStatus';
|
||||
@@ -73,6 +74,7 @@ import type {
|
||||
ActingAsUserSelection,
|
||||
MetadataProviderSummary,
|
||||
MetadataSearchConfig,
|
||||
MetadataSearchField,
|
||||
QueuedDownloadResult,
|
||||
QueryTargetOption,
|
||||
SearchMode,
|
||||
@@ -492,8 +494,6 @@ function App() {
|
||||
});
|
||||
|
||||
// When a book is removed from the Hardcover list currently being browsed, remove it from results
|
||||
const searchFieldValuesRef = useRef(searchFieldValues);
|
||||
searchFieldValuesRef.current = searchFieldValues;
|
||||
useBookTargetDeselectSync({
|
||||
activeListValue: searchFieldValues.hardcover_list,
|
||||
setBooks,
|
||||
@@ -605,24 +605,6 @@ function App() {
|
||||
};
|
||||
}, [effectiveActingAsUser, pendingOnBehalfDownload]);
|
||||
|
||||
// Wire up logout callback to clear search state
|
||||
const handleLogoutWithCleanup = useCallback(async () => {
|
||||
await handleLogout();
|
||||
resetSearchResultsState();
|
||||
setActiveQueryTarget('general');
|
||||
setPendingRequestPayload(null);
|
||||
setPendingRequestExtraPayloads([]);
|
||||
setActingAsUser(null);
|
||||
setAdminUsers([]);
|
||||
setAdminUsersError(null);
|
||||
setHasLoadedAdminUsers(false);
|
||||
setPendingOnBehalfDownload(null);
|
||||
setFulfillingRequest(null);
|
||||
resetActivity();
|
||||
setSettingsOpen(false);
|
||||
setSelfSettingsOpen(false);
|
||||
}, [handleLogout, resetActivity, resetSearchResultsState]);
|
||||
|
||||
// Combined mode state (ebook + audiobook in one transaction)
|
||||
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(null);
|
||||
|
||||
@@ -655,20 +637,6 @@ function App() {
|
||||
setDownloadsSidebarOpen(true);
|
||||
prefetchActivityHistory();
|
||||
}, [downloadsSidebarOpen, prefetchActivityHistory]);
|
||||
const handleSettingsClick = useCallback(() => {
|
||||
if (config?.settings_enabled) {
|
||||
if (authIsAdmin) {
|
||||
void primeUsersCache();
|
||||
void primeSettingsCache();
|
||||
setSettingsOpen(true);
|
||||
} else {
|
||||
setSelfSettingsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setConfigBannerOpen(true);
|
||||
}, [authIsAdmin, config?.settings_enabled]);
|
||||
|
||||
const headerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (headerObserverRef.current) {
|
||||
headerObserverRef.current.disconnect();
|
||||
@@ -685,6 +653,39 @@ function App() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
|
||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||
|
||||
// Wire up logout callback to clear search state
|
||||
const handleLogoutWithCleanup = useCallback(async () => {
|
||||
await handleLogout();
|
||||
resetSearchResultsState();
|
||||
setActiveQueryTarget('general');
|
||||
setPendingRequestPayload(null);
|
||||
setPendingRequestExtraPayloads([]);
|
||||
setActingAsUser(null);
|
||||
setAdminUsers([]);
|
||||
setAdminUsersError(null);
|
||||
setHasLoadedAdminUsers(false);
|
||||
setPendingOnBehalfDownload(null);
|
||||
setFulfillingRequest(null);
|
||||
resetActivity();
|
||||
setSettingsOpen(false);
|
||||
setSelfSettingsOpen(false);
|
||||
}, [handleLogout, resetActivity, resetSearchResultsState]);
|
||||
|
||||
const handleSettingsClick = useCallback(() => {
|
||||
if (config?.settings_enabled) {
|
||||
if (authIsAdmin) {
|
||||
void primeUsersCache();
|
||||
void primeSettingsCache();
|
||||
setSettingsOpen(true);
|
||||
} else {
|
||||
setSelfSettingsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setConfigBannerOpen(true);
|
||||
}, [authIsAdmin, config?.settings_enabled]);
|
||||
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
useShowOnboardingDebug({
|
||||
setOnboardingOpen,
|
||||
@@ -1075,48 +1076,43 @@ function App() {
|
||||
|
||||
// When downloading a book while browsing a Hardcover list the user owns,
|
||||
// automatically remove it from that list (fire-and-forget).
|
||||
const searchFieldLabelsRef = useRef(searchFieldLabels);
|
||||
searchFieldLabelsRef.current = searchFieldLabels;
|
||||
const metadataConfigRef = useRef(activeMetadataConfig);
|
||||
metadataConfigRef.current = activeMetadataConfig;
|
||||
// Stable identity for the download handlers below, while still reading the current
|
||||
// search field values, labels and metadata config. Not an Effect Event: the callers
|
||||
// are download handlers, not Effects. See useLatestCallback.
|
||||
const removeBookFromActiveList = useLatestCallback((book: Book) => {
|
||||
if (config?.hardcover_auto_remove_on_download === false) return;
|
||||
if (!bookSupportsTargets(book)) return;
|
||||
const activeList = searchFieldValues.hardcover_list;
|
||||
if (!activeList) return;
|
||||
const target = String(activeList);
|
||||
const provider = book.provider;
|
||||
const bookId = book.provider_id;
|
||||
if (!provider || !bookId) return;
|
||||
|
||||
const removeBookFromActiveList = useCallback(
|
||||
(book: Book) => {
|
||||
if (config?.hardcover_auto_remove_on_download === false) return;
|
||||
if (!bookSupportsTargets(book)) return;
|
||||
const activeList = searchFieldValuesRef.current.hardcover_list;
|
||||
if (!activeList) return;
|
||||
const target = String(activeList);
|
||||
const provider = book.provider;
|
||||
const bookId = book.provider_id;
|
||||
if (!provider || !bookId) return;
|
||||
// Only auto-remove from lists the user owns (Reading Status / My Lists)
|
||||
const listField = activeMetadataConfig?.search_fields.find(
|
||||
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
|
||||
);
|
||||
if (listField && listField.type === 'DynamicSelectSearchField') {
|
||||
const group = getDynamicOptionGroup(listField.options_endpoint, target);
|
||||
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
|
||||
}
|
||||
|
||||
// Only auto-remove from lists the user owns (Reading Status / My Lists)
|
||||
const listField = metadataConfigRef.current?.search_fields.find(
|
||||
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
|
||||
);
|
||||
if (listField && listField.type === 'DynamicSelectSearchField') {
|
||||
const group = getDynamicOptionGroup(listField.options_endpoint, target);
|
||||
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
|
||||
}
|
||||
|
||||
void setBookTargetState(provider, bookId, target, false)
|
||||
.then((result) => {
|
||||
if (result.changed) {
|
||||
emitBookTargetChange({
|
||||
provider,
|
||||
bookId,
|
||||
target,
|
||||
selected: false,
|
||||
});
|
||||
const listName = searchFieldLabelsRef.current['hardcover_list'];
|
||||
showToast(`Removed from ${listName || 'list'}`, 'info');
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[config?.hardcover_auto_remove_on_download, showToast],
|
||||
);
|
||||
void setBookTargetState(provider, bookId, target, false)
|
||||
.then((result) => {
|
||||
if (result.changed) {
|
||||
emitBookTargetChange({
|
||||
provider,
|
||||
bookId,
|
||||
target,
|
||||
selected: false,
|
||||
});
|
||||
const listName = searchFieldLabels['hardcover_list'];
|
||||
showToast(`Removed from ${listName || 'list'}`, 'info');
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
});
|
||||
|
||||
const executeBookDownload = useCallback(
|
||||
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
|
||||
@@ -1909,14 +1905,27 @@ function App() {
|
||||
effectiveSearchMode === 'universal' &&
|
||||
(universalDefaultMode === 'download' || universalDefaultMode === 'request_release');
|
||||
|
||||
// Keep the last known search fields so queryTargets doesn't collapse to
|
||||
// [general] while the metadata config briefly reloads on content type switch.
|
||||
// Held in state rather than a ref written during render: a ref read back in the same
|
||||
// pass is what `react/refs` forbids, and this is the adjust-state-during-render shape
|
||||
// React documents for exactly this - carry the previous value until a new one arrives.
|
||||
const [stableSearchFields, setStableSearchFields] = useState<MetadataSearchField[]>(
|
||||
() => activeMetadataConfig?.search_fields ?? [],
|
||||
);
|
||||
const incomingSearchFields = activeMetadataConfig?.search_fields;
|
||||
if (incomingSearchFields && incomingSearchFields !== stableSearchFields) {
|
||||
setStableSearchFields(incomingSearchFields);
|
||||
}
|
||||
|
||||
const queryTargets = useMemo<QueryTargetOption[]>(
|
||||
() =>
|
||||
buildQueryTargets({
|
||||
searchMode: effectiveSearchMode,
|
||||
metadataSearchFields: activeMetadataConfig?.search_fields ?? [],
|
||||
metadataSearchFields: stableSearchFields,
|
||||
manualSearchAllowed,
|
||||
}),
|
||||
[effectiveSearchMode, activeMetadataConfig?.search_fields, manualSearchAllowed],
|
||||
[effectiveSearchMode, stableSearchFields, manualSearchAllowed],
|
||||
);
|
||||
const effectiveActiveQueryTarget = useMemo(() => {
|
||||
if (queryTargets.some((target) => target.key === activeQueryTarget)) {
|
||||
@@ -1945,27 +1954,27 @@ function App() {
|
||||
? (queryTargets.find((target) => target.field?.key === seriesBrowseCapability.field_key) ??
|
||||
null)
|
||||
: null,
|
||||
[queryTargets, seriesBrowseCapability?.field_key],
|
||||
// `seriesBrowseCapability` whole: the body reads `.field_key` off it unguarded
|
||||
// inside the ternary, so that object is the dependency the compiler infers.
|
||||
[queryTargets, seriesBrowseCapability],
|
||||
);
|
||||
|
||||
const activeQueryValue = useMemo(() => {
|
||||
if (
|
||||
!activeQueryOption ||
|
||||
activeQueryOption.source === 'general' ||
|
||||
activeQueryOption.source === 'manual'
|
||||
activeQueryOption.source === 'manual' ||
|
||||
activeQueryOption.source === 'direct-field'
|
||||
) {
|
||||
return searchInput;
|
||||
}
|
||||
|
||||
if (activeQueryOption.source === 'direct-field') {
|
||||
if (activeQueryOption.key === 'isbn') return advancedFilters.isbn;
|
||||
if (activeQueryOption.key === 'author') return advancedFilters.author;
|
||||
if (activeQueryOption.key === 'title') return advancedFilters.title;
|
||||
if (!activeQueryOption.field) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!activeQueryOption.field) {
|
||||
return '';
|
||||
if (activeQueryOption.field.type === 'TextSearchField') {
|
||||
return searchInput;
|
||||
}
|
||||
|
||||
if (activeQueryOption.field.type === 'CheckboxSearchField') {
|
||||
@@ -1975,7 +1984,7 @@ function App() {
|
||||
}
|
||||
|
||||
return searchFieldValues[activeQueryOption.field.key] ?? '';
|
||||
}, [activeQueryOption, searchInput, advancedFilters, searchFieldValues]);
|
||||
}, [activeQueryOption, searchInput, searchFieldValues]);
|
||||
|
||||
const activeQueryValueLabel = useMemo(() => {
|
||||
if (!activeQueryOption?.field) {
|
||||
@@ -2023,29 +2032,25 @@ function App() {
|
||||
if (
|
||||
!activeQueryOption ||
|
||||
activeQueryOption.source === 'general' ||
|
||||
activeQueryOption.source === 'manual'
|
||||
activeQueryOption.source === 'manual' ||
|
||||
activeQueryOption.source === 'direct-field'
|
||||
) {
|
||||
setSearchInput(typeof value === 'string' ? value : String(value ?? ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeQueryOption.source === 'direct-field') {
|
||||
const nextValue = typeof value === 'string' ? value : String(value ?? '');
|
||||
if (activeQueryOption.key === 'isbn') {
|
||||
updateAdvancedFilters({ isbn: nextValue });
|
||||
} else if (activeQueryOption.key === 'author') {
|
||||
updateAdvancedFilters({ author: nextValue });
|
||||
} else if (activeQueryOption.key === 'title') {
|
||||
updateAdvancedFilters({ title: nextValue });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeQueryOption.field) {
|
||||
if (activeQueryOption.field.type === 'TextSearchField') {
|
||||
setSearchInput(typeof value === 'string' ? value : String(value ?? ''));
|
||||
if (label !== undefined) {
|
||||
updateSearchFieldValue(activeQueryOption.field.key, value, label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateSearchFieldValue(activeQueryOption.field.key, value, label);
|
||||
}
|
||||
},
|
||||
[activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue],
|
||||
[activeQueryOption, setSearchInput, updateSearchFieldValue],
|
||||
);
|
||||
|
||||
const handleSearchModeChange = useCallback(
|
||||
@@ -2243,7 +2248,9 @@ function App() {
|
||||
|
||||
return book.provider === activeMetadataConfig.provider;
|
||||
},
|
||||
[activeMetadataConfig?.provider, seriesBrowseCapability?.sort, seriesBrowseTarget?.field],
|
||||
// `activeMetadataConfig` whole: the body reads `.provider` off it unguarded on
|
||||
// the last line, so that object is the dependency the compiler infers.
|
||||
[activeMetadataConfig, seriesBrowseCapability?.sort, seriesBrowseTarget?.field],
|
||||
);
|
||||
|
||||
const handleManualSearch = useCallback(() => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { getNestedValue, toComparableText, toStringValue } from '../utils/objectHelpers';
|
||||
import { toBookPlanPayload } from '../utils/packReview';
|
||||
import { getReleaseFormats } from '../utils/releaseFormats';
|
||||
import { INITIAL_ENTER_ANIMATION, nextEnterAnimation } from '../utils/releaseModalEnterAnimation';
|
||||
import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from '../utils/releasePayload';
|
||||
import {
|
||||
getBookTitleCandidates,
|
||||
@@ -889,6 +890,11 @@ const ReleaseModalSession = ({
|
||||
} finally {
|
||||
setIsRequestingBook(false);
|
||||
}
|
||||
// Kept against the advisory: the body really does read both. `handleClose` is aliased
|
||||
// from the `onClose` prop, which is why the compiler names the source instead, and
|
||||
// dropping `contentType` would let this close over a stale one and request the wrong
|
||||
// format. Correctness first; the cost is an extra callback identity.
|
||||
// oxlint-disable-next-line react/memo-dependencies
|
||||
}, [book, onRequestBook, isRequestingBook, contentType, handleClose]);
|
||||
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
@@ -1135,7 +1141,9 @@ const ReleaseModalSession = ({
|
||||
const narratorField = book.display_fields.find((f) => f.icon === 'microphone');
|
||||
|
||||
return { starField, ratingsField, usersField, pagesField, lengthField, narratorField };
|
||||
}, [book?.display_fields]);
|
||||
// `book`, not `book?.display_fields`: the body reads `book.display_fields`
|
||||
// unguarded after the early return, which is the dependency the compiler infers.
|
||||
}, [book]);
|
||||
|
||||
const getReleaseActionMode = useCallback(
|
||||
(release: Release): RequestPolicyMode => {
|
||||
@@ -1286,6 +1294,9 @@ const ReleaseModalSession = ({
|
||||
setPackSubmitting(false);
|
||||
}
|
||||
},
|
||||
// Same as handleRequestBook above: the body reads `onDownload`, `contentType` and
|
||||
// `handleClose`, so they stay in the list whatever the advisory infers.
|
||||
// oxlint-disable-next-line react/memo-dependencies
|
||||
[book, packReview, onDownload, contentType, handleClose],
|
||||
);
|
||||
|
||||
@@ -2450,7 +2461,7 @@ const ReleaseModalSession = ({
|
||||
|
||||
export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const previousSessionKeyRef = useRef<string | null>(null);
|
||||
const [enterAnimation, setEnterAnimation] = useState(INITIAL_ENTER_ANIMATION);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
@@ -2474,12 +2485,13 @@ export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
|
||||
].join('|')
|
||||
: null;
|
||||
|
||||
const animateEnter =
|
||||
!rest.combinedMode ||
|
||||
previousSessionKeyRef.current === null ||
|
||||
previousSessionKeyRef.current === sessionKey;
|
||||
|
||||
previousSessionKeyRef.current = sessionKey;
|
||||
// Decided once per session key and held for that session's lifetime, so a
|
||||
// re-render mid-session cannot restart the enter animation.
|
||||
const nextAnimation = nextEnterAnimation(enterAnimation, sessionKey, rest.combinedMode != null);
|
||||
if (nextAnimation !== enterAnimation) {
|
||||
setEnterAnimation(nextAnimation);
|
||||
}
|
||||
const animateEnter = nextAnimation.animate;
|
||||
|
||||
if (!book && !isClosing) return null;
|
||||
if (!book || !sessionKey) return null;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'reac
|
||||
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { useSearchBarAutocomplete } from '../hooks/searchBar/useSearchBarAutocomplete';
|
||||
import { useSearchBarHoverTimeout } from '../hooks/searchBar/useSearchBarHoverTimeout';
|
||||
import { useDismiss } from '../hooks/useDismiss';
|
||||
import { useLatestCallback } from '../hooks/useLatestCallback';
|
||||
import type { DynamicFieldOption } from '../services/api';
|
||||
import type { ContentType, MetadataSearchField, QueryTargetOption, SortOption } from '../types';
|
||||
import { SearchBarAutocompleteSession } from './SearchBarAutocompleteSession';
|
||||
@@ -51,6 +51,8 @@ const EMPTY_SORT_OPTIONS: SortOption[] = [];
|
||||
const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = [];
|
||||
const EMPTY_QUERY_TARGETS: QueryTargetOption[] = [];
|
||||
|
||||
const SEARCH_CONTROLS_PANEL_ID = 'search-bar-controls-panel';
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0"
|
||||
@@ -195,8 +197,9 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
const { searchMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
// Deferred submits below run from a timeout, not an Effect, so this is a latest-value
|
||||
// callback rather than an Effect Event. See useLatestCallback.
|
||||
const submitLatest = useLatestCallback(() => onSubmit());
|
||||
const selectorRef = useRef<HTMLDivElement>(null);
|
||||
const hasSearchQuery = hasActiveValue(value);
|
||||
const [isSelectorOpen, setIsSelectorOpen] = useState(false);
|
||||
@@ -205,7 +208,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
const selectTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const selectPanelRef = useRef<HTMLDivElement>(null);
|
||||
const autocompletePanelRef = useRef<HTMLDivElement>(null);
|
||||
const { hoverTimeoutRef: selectorHoverTimeout, clearHoverTimeout } = useSearchBarHoverTimeout();
|
||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
|
||||
const showContentTypeSelector =
|
||||
@@ -221,7 +224,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
() => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0],
|
||||
[queryTargets, activeQueryTarget],
|
||||
);
|
||||
const showActiveTargetLabel = queryTargets.length > 0 && activeTarget.source !== 'general';
|
||||
const showActiveTargetLabel = queryTargets.length > 0 && activeTarget?.source !== 'general';
|
||||
|
||||
// Manual search browses release sources directly, one media type at a time — the
|
||||
// combined ("both") flow doesn't apply. Present a plain, switchable Books/Audiobooks
|
||||
@@ -230,8 +233,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
const combinedSelectionActive = combinedMode && !isManualTarget;
|
||||
const combinedSelectorLocked = combinedModeLocked && !isManualTarget;
|
||||
const combinedToggleAvailable = !!onCombinedModeChange && !isManualTarget;
|
||||
const combinedLineColor = combinedSelectionActive
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
|
||||
|
||||
useDismiss(isSelectorOpen, [selectorRef], () => setIsSelectorOpen(false));
|
||||
useDismiss(isSelectorOpen, [selectorRef, controlsPanelRef], () => setIsSelectorOpen(false));
|
||||
useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false));
|
||||
useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () =>
|
||||
setIsAutocompleteOpen(false),
|
||||
@@ -333,18 +339,15 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
const handleContentTypeSelect = (type: ContentType) => {
|
||||
onContentTypeChange?.(type);
|
||||
onCombinedModeChange?.(false);
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const handleCombinedModeSelect = () => {
|
||||
if (combinedMode) {
|
||||
// Toggle off — revert to ebook-only
|
||||
onCombinedModeChange?.(false);
|
||||
} else {
|
||||
onContentTypeChange?.('ebook');
|
||||
onCombinedModeChange?.(true);
|
||||
}
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const handleQueryTargetSelect = (targetKey: string) => {
|
||||
@@ -357,7 +360,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
setIsSelectOpen(shouldOpenSelect);
|
||||
setIsAutocompleteOpen(false);
|
||||
resetAutocomplete();
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const effectivePlaceholder = getDefaultPlaceholder(
|
||||
@@ -412,7 +414,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
setAutocompleteDraftValue(nextValue);
|
||||
setIsAutocompleteOpen(nextValue.trim().length >= autocompleteMinQueryLength);
|
||||
setIsSelectOpen(false);
|
||||
setIsSelectorOpen(false);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
@@ -424,7 +425,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
textInputValue.trim().length >= autocompleteMinQueryLength
|
||||
) {
|
||||
setIsAutocompleteOpen(true);
|
||||
setIsSelectorOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -490,7 +490,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
onClick={() => {
|
||||
if (!disabled && !isDynamicLoading) {
|
||||
setIsSelectOpen((prev) => !prev);
|
||||
setIsSelectorOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
}
|
||||
}}
|
||||
@@ -601,25 +600,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
}}
|
||||
>
|
||||
{showQueryTargetSelector && (
|
||||
<div
|
||||
className="relative flex shrink-0 self-stretch"
|
||||
ref={selectorRef}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType !== 'mouse') return;
|
||||
clearHoverTimeout();
|
||||
setIsSelectorOpen(true);
|
||||
setIsSelectOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType !== 'mouse') return;
|
||||
clearHoverTimeout();
|
||||
selectorHoverTimeout.current = setTimeout(() => {
|
||||
setIsSelectorOpen(false);
|
||||
selectorHoverTimeout.current = null;
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
<div className="relative flex shrink-0 self-stretch" ref={selectorRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -627,11 +608,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
setIsSelectOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
}}
|
||||
className="hover-action flex items-center gap-1.5 rounded-l-full pr-2 pl-5 transition-colors"
|
||||
className="hover-action flex cursor-pointer items-center gap-1.5 rounded-l-full pr-2 pl-5 transition-colors"
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-label={`Searching ${selectorContentTypeLabel} by ${activeTarget?.label ?? 'general'}. Click to change.`}
|
||||
aria-expanded={isSelectorOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={SEARCH_CONTROLS_PANEL_ID}
|
||||
>
|
||||
{selectorIcon}
|
||||
{showActiveTargetLabel && (
|
||||
@@ -654,270 +635,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="absolute top-1/2 right-0 h-6 w-px -translate-y-1/2"
|
||||
style={{ background: 'var(--border-muted)' }}
|
||||
/>
|
||||
|
||||
{isSelectorOpen && (
|
||||
<div
|
||||
className="animate-fade-in-down absolute top-full left-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label="Search context"
|
||||
>
|
||||
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
|
||||
{showContentTypeSelector && (
|
||||
<div
|
||||
className={`border-b ${combinedToggleAvailable ? 'pb-0' : 'pb-3'}`}
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-1 pb-2">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Content
|
||||
</span>
|
||||
{onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSelectorOpen(false);
|
||||
onAdvancedToggle();
|
||||
}}
|
||||
className={`-mt-1.5 -mr-1 -mb-0.5 flex items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
isAdvancedActive ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
isAdvancedActive
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
contentType === 'ebook' || combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
contentType === 'ebook' || combinedSelectionActive
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
}
|
||||
>
|
||||
{contentType === 'ebook' || combinedSelectionActive ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<BookIcon />
|
||||
)}
|
||||
<span>Books</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
contentType === 'audiobook' || combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
contentType === 'audiobook' || combinedSelectionActive
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
}
|
||||
>
|
||||
{contentType === 'audiobook' || combinedSelectionActive ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<AudiobookIcon />
|
||||
)}
|
||||
<span>Audiobooks</span>
|
||||
</button>
|
||||
</div>
|
||||
{combinedToggleAvailable &&
|
||||
(() => {
|
||||
const lineColor = combinedSelectionActive
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
|
||||
return (
|
||||
<Tooltip
|
||||
content="Combined search"
|
||||
position="bottom"
|
||||
triggerClassName="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCombinedModeSelect}
|
||||
className="group w-full"
|
||||
aria-label="Combined search"
|
||||
>
|
||||
{/* Bracket connector: vertical drops + horizontal bar with icon */}
|
||||
<div className="relative flex h-7 items-end">
|
||||
{/* Left vertical */}
|
||||
<div
|
||||
className={`absolute top-1.5 bottom-[11px] left-[25%] w-px transition-colors ${lineColor}`}
|
||||
/>
|
||||
{/* Right vertical */}
|
||||
<div
|
||||
className={`absolute top-1.5 right-[25%] bottom-[11px] w-px transition-colors ${lineColor}`}
|
||||
/>
|
||||
{/* Horizontal bar – left segment */}
|
||||
<div
|
||||
className={`absolute bottom-[11px] left-[25%] h-px transition-colors ${lineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
{/* Horizontal bar – right segment */}
|
||||
<div
|
||||
className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${lineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
{/* Chain icon centered at bottom */}
|
||||
<div
|
||||
className={`relative z-10 mx-auto rounded-full p-1 transition-colors ${
|
||||
combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-(--bg) text-zinc-400 group-hover:bg-zinc-200 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:bg-zinc-700 dark:group-hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{combinedSelectorLocked ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={showContentTypeSelector ? 'pt-2' : ''}>
|
||||
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Search By
|
||||
</span>
|
||||
{!showContentTypeSelector && onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSelectorOpen(false);
|
||||
onAdvancedToggle();
|
||||
}}
|
||||
className={`-mt-1.5 -mr-1 -mb-0.5 flex items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
isAdvancedActive
|
||||
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
isAdvancedActive
|
||||
? {
|
||||
borderColor:
|
||||
searchMode === 'direct'
|
||||
? 'rgb(3 105 161 / 0.7)'
|
||||
: 'rgb(16 185 129 / 0.7)',
|
||||
}
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{queryTargets.map((target) => {
|
||||
const isActive = target.key === activeTarget?.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target.key}
|
||||
onClick={() => handleQueryTargetSelect(target.key)}
|
||||
title={target.description || target.label}
|
||||
aria-label={target.label}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
borderColor:
|
||||
searchMode === 'direct'
|
||||
? 'rgb(3 105 161 / 0.7)'
|
||||
: 'rgb(16 185 129 / 0.7)',
|
||||
}
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
}
|
||||
>
|
||||
{isActive && <CheckIcon />}
|
||||
<span className="block truncate">{target.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1011,7 +732,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
onClick={() => {
|
||||
onChange(option.value, option.label);
|
||||
setIsSelectOpen(false);
|
||||
setTimeout(() => onSubmitRef.current(), 0);
|
||||
setTimeout(() => submitLatest(), 0);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
|
||||
isSelected ? '' : 'hover-surface'
|
||||
@@ -1081,7 +802,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
setAutocompleteSelection(option.value, option.label);
|
||||
onChange(option.value, option.label);
|
||||
setIsAutocompleteOpen(false);
|
||||
setTimeout(() => onSubmitRef.current(), 0);
|
||||
setTimeout(() => submitLatest(), 0);
|
||||
}}
|
||||
className="hover-surface w-full px-5 py-3 text-left text-sm transition-colors"
|
||||
style={{ color: 'var(--text)' }}
|
||||
@@ -1099,6 +820,246 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQueryTargetSelector && isSelectorOpen && (
|
||||
<div
|
||||
id={SEARCH_CONTROLS_PANEL_ID}
|
||||
className="animate-fade-in-down flex flex-wrap items-start gap-x-8 gap-y-2 px-1 pt-2"
|
||||
ref={controlsPanelRef}
|
||||
>
|
||||
{showContentTypeSelector && (
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-between pb-1.5">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Content
|
||||
</span>
|
||||
{onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvancedToggle}
|
||||
className={`-mt-1.5 -mr-1 -mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
isAdvancedActive ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
isAdvancedActive
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid w-fit grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
contentType === 'ebook' || combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
contentType === 'ebook' || combinedSelectionActive
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
}
|
||||
>
|
||||
<span className="flex w-4 justify-center">
|
||||
{contentType === 'ebook' || combinedSelectionActive ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<BookIcon />
|
||||
)}
|
||||
</span>
|
||||
<span>Books</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
contentType === 'audiobook' || combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
contentType === 'audiobook' || combinedSelectionActive
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
}
|
||||
>
|
||||
<span className="flex w-4 justify-center">
|
||||
{contentType === 'audiobook' || combinedSelectionActive ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<AudiobookIcon />
|
||||
)}
|
||||
</span>
|
||||
<span>Audiobooks</span>
|
||||
</button>
|
||||
{combinedToggleAvailable && (
|
||||
<div className="col-span-2">
|
||||
<Tooltip
|
||||
content="Combined search"
|
||||
position="bottom"
|
||||
triggerClassName="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCombinedModeSelect}
|
||||
className="group w-full cursor-pointer"
|
||||
aria-label="Combined search"
|
||||
>
|
||||
<div className="relative flex h-7 items-end">
|
||||
<div
|
||||
className={`absolute top-1.5 bottom-[11px] left-[25%] w-px transition-colors ${combinedLineColor}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1.5 right-[25%] bottom-[11px] w-px transition-colors ${combinedLineColor}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-[11px] left-[25%] h-px transition-colors ${combinedLineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${combinedLineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
<div
|
||||
className={`relative z-10 mx-auto rounded-full p-1 transition-colors ${
|
||||
combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-(--bg) text-zinc-400 group-hover:bg-zinc-200 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:bg-zinc-700 dark:group-hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{combinedSelectorLocked ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryTargets.length > 1 && (
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-between pb-1.5">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Search By
|
||||
</span>
|
||||
{!showContentTypeSelector && onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvancedToggle}
|
||||
className={`-mt-1.5 -mr-1 -mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
isAdvancedActive
|
||||
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
isAdvancedActive
|
||||
? {
|
||||
borderColor:
|
||||
searchMode === 'direct'
|
||||
? 'rgb(3 105 161 / 0.7)'
|
||||
: 'rgb(16 185 129 / 0.7)',
|
||||
}
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queryTargets.map((target) => {
|
||||
const isActive = target.key === activeTarget?.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target.key}
|
||||
onClick={() => handleQueryTargetSelect(target.key)}
|
||||
title={target.description || target.label}
|
||||
aria-label={target.label}
|
||||
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
borderColor:
|
||||
searchMode === 'direct'
|
||||
? 'rgb(3 105 161 / 0.7)'
|
||||
: 'rgb(16 185 129 / 0.7)',
|
||||
}
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
}
|
||||
>
|
||||
{isActive && <CheckIcon />}
|
||||
<span className="block truncate">{target.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -562,6 +562,10 @@ export const ActivityCard = ({
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
// None of these are read here - they are all re-measure triggers. The title's overflow
|
||||
// depends on its text and on the width it is laid out in, and opening either panel
|
||||
// reflows the card. Drop them and the tooltip-on-truncation goes stale.
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [item.title, item.author, isRequestDetailsOpen, isRequestRejectOpen]);
|
||||
|
||||
const reviewRecord = item.requestRecord;
|
||||
|
||||
@@ -347,6 +347,10 @@ function SettingsContentPanel({
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
// `tab.name` is never read here - it is the trigger, and the whole point: the scroll
|
||||
// position resets *because* the tab changed. Removing it strands the new tab at the
|
||||
// previous one's offset.
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [embedded, tab.name]);
|
||||
|
||||
const updateCustomFieldUiState = useCallback((fieldKey: string, key: string, value: unknown) => {
|
||||
@@ -407,6 +411,9 @@ function SettingsContentPanel({
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
// `activeTakeOverFieldKey` is never read here - it is the trigger. Entering or leaving
|
||||
// a subpage takeover is exactly when the scroll must reset.
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [embedded, activeTakeOverFieldKey]);
|
||||
|
||||
const visibleFields = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { useLatestCallback } from '../../../hooks/useLatestCallback';
|
||||
import { useMountEffect } from '../../../hooks/useMountEffect';
|
||||
import type { AdminUser } from '../../../services/api';
|
||||
import { testAdminUserNotificationPreferences } from '../../../services/api';
|
||||
@@ -169,12 +170,12 @@ export const UsersManagementField = ({
|
||||
}
|
||||
}, [backToList, onRefreshOverrideSummary, onSettingsSaved, onUiStateChange, saveEditedUser]);
|
||||
|
||||
const handleSaveUserOverridesRef = useRef(handleSaveUserOverrides);
|
||||
handleSaveUserOverridesRef.current = handleSaveUserOverrides;
|
||||
|
||||
const triggerSaveUserOverrides = useCallback(async () => {
|
||||
await handleSaveUserOverridesRef.current();
|
||||
}, []);
|
||||
// Stored in parent UI state, so it must keep a stable identity while still invoking the
|
||||
// latest handler. Not an Effect Event: those must not be handed to another component.
|
||||
// See useLatestCallback.
|
||||
const triggerSaveUserOverrides = useLatestCallback(async () => {
|
||||
await handleSaveUserOverrides();
|
||||
});
|
||||
|
||||
const handleOpenOverrides = () => {
|
||||
if (editingUser) {
|
||||
|
||||
@@ -44,162 +44,172 @@ const getOptionsIdentity = (options: MultiSelectFieldConfig['options']): string
|
||||
const getSelectionIdentity = (values: string[]): string =>
|
||||
values.toSorted((left, right) => left.localeCompare(right)).join('\u0001');
|
||||
|
||||
export const MultiSelectField = ({
|
||||
interface MultiSelectVariantProps {
|
||||
field: MultiSelectFieldConfig;
|
||||
selected: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
// Dropdown variant - use DropdownList with checkboxes
|
||||
const MultiSelectDropdownField = ({
|
||||
field,
|
||||
value: fieldValue,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
}: MultiSelectFieldProps) => {
|
||||
const selected = fieldValue ?? EMPTY_SELECTION;
|
||||
// disabled prop is already computed by SettingsContent.getDisabledState()
|
||||
const isDisabled = disabled ?? false;
|
||||
isDisabled,
|
||||
}: MultiSelectVariantProps) => {
|
||||
const optionValues = field.options.map((opt) => opt.value);
|
||||
const optionSet = new Set(optionValues);
|
||||
const hasAllOption = optionSet.has(ALL_OPTION_VALUE);
|
||||
const orderedOptions = hasAllOption
|
||||
? [
|
||||
...field.options.filter((opt) => opt.value === ALL_OPTION_VALUE),
|
||||
...field.options.filter((opt) => opt.value !== ALL_OPTION_VALUE),
|
||||
]
|
||||
: field.options;
|
||||
const nonAllValues = orderedOptions
|
||||
.map((opt) => opt.value)
|
||||
.filter((optValue) => optValue !== ALL_OPTION_VALUE);
|
||||
|
||||
// Dropdown variant - use DropdownList with checkboxes
|
||||
if (field.variant === 'dropdown') {
|
||||
const optionValues = field.options.map((opt) => opt.value);
|
||||
const optionSet = new Set(optionValues);
|
||||
const hasAllOption = optionSet.has(ALL_OPTION_VALUE);
|
||||
const orderedOptions = hasAllOption
|
||||
? [
|
||||
...field.options.filter((opt) => opt.value === ALL_OPTION_VALUE),
|
||||
...field.options.filter((opt) => opt.value !== ALL_OPTION_VALUE),
|
||||
]
|
||||
: field.options;
|
||||
const nonAllValues = orderedOptions
|
||||
.map((opt) => opt.value)
|
||||
.filter((optValue) => optValue !== ALL_OPTION_VALUE);
|
||||
const normalizeValues = (values: string[]): string[] => {
|
||||
const deduped = new Set(
|
||||
values
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0 && optionSet.has(entry)),
|
||||
);
|
||||
return orderedOptions.map((opt) => opt.value).filter((optValue) => deduped.has(optValue));
|
||||
};
|
||||
|
||||
const normalizeValues = (values: string[]): string[] => {
|
||||
const deduped = new Set(
|
||||
values
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0 && optionSet.has(entry)),
|
||||
);
|
||||
return orderedOptions.map((opt) => opt.value).filter((optValue) => deduped.has(optValue));
|
||||
};
|
||||
const selectedExplicit = normalizeValues(selected);
|
||||
const allSelected =
|
||||
hasAllOption &&
|
||||
(selectedExplicit.includes(ALL_OPTION_VALUE) ||
|
||||
(nonAllValues.length > 0 &&
|
||||
nonAllValues.every((optValue) => selectedExplicit.includes(optValue))));
|
||||
|
||||
const selectedExplicit = normalizeValues(selected);
|
||||
const allSelected =
|
||||
hasAllOption &&
|
||||
(selectedExplicit.includes(ALL_OPTION_VALUE) ||
|
||||
(nonAllValues.length > 0 &&
|
||||
nonAllValues.every((optValue) => selectedExplicit.includes(optValue))));
|
||||
// Build parent -> children map for cascading selection
|
||||
const parentChildMap = new Map<string, string[]>();
|
||||
orderedOptions.forEach((opt) => {
|
||||
if (opt.childOf) {
|
||||
const children = parentChildMap.get(opt.childOf) || [];
|
||||
children.push(opt.value);
|
||||
parentChildMap.set(opt.childOf, children);
|
||||
}
|
||||
});
|
||||
|
||||
// Build parent -> children map for cascading selection
|
||||
const parentChildMap = new Map<string, string[]>();
|
||||
orderedOptions.forEach((opt) => {
|
||||
if (opt.childOf) {
|
||||
const children = parentChildMap.get(opt.childOf) || [];
|
||||
children.push(opt.value);
|
||||
parentChildMap.set(opt.childOf, children);
|
||||
// Check which children are implicitly selected via parent
|
||||
const selectedForCascade = allSelected
|
||||
? selectedExplicit.filter((optValue) => optValue !== ALL_OPTION_VALUE)
|
||||
: selectedExplicit;
|
||||
const implicitlySelected = new Set<string>();
|
||||
selectedForCascade.forEach((val) => {
|
||||
const children = parentChildMap.get(val);
|
||||
if (children) {
|
||||
children.forEach((child) => implicitlySelected.add(child));
|
||||
}
|
||||
});
|
||||
|
||||
// Build options with disabled state for implicitly selected children
|
||||
const dropdownOptions = orderedOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
disabled: !allSelected && implicitlySelected.has(opt.value),
|
||||
}));
|
||||
|
||||
// For display purposes:
|
||||
// - if "all" is active, check every option
|
||||
// - otherwise show explicit + implicit parent/child selections
|
||||
const displayValue = allSelected
|
||||
? [ALL_OPTION_VALUE, ...nonAllValues]
|
||||
: normalizeValues([...selectedExplicit, ...Array.from(implicitlySelected)]);
|
||||
|
||||
const handleDropdownChange = (newValue: string | string[]) => {
|
||||
const nextValues = normalizeValues(Array.isArray(newValue) ? newValue : [newValue]);
|
||||
|
||||
if (hasAllOption) {
|
||||
const includesAll = nextValues.includes(ALL_OPTION_VALUE);
|
||||
|
||||
// When currently "all" is active:
|
||||
// - unticking "all" clears everything
|
||||
// - unticking a specific option converts to explicit subset
|
||||
if (allSelected && !includesAll && nextValues.length === nonAllValues.length) {
|
||||
onChange([]);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Check which children are implicitly selected via parent
|
||||
const selectedForCascade = allSelected
|
||||
? selectedExplicit.filter((optValue) => optValue !== ALL_OPTION_VALUE)
|
||||
: selectedExplicit;
|
||||
const implicitlySelected = new Set<string>();
|
||||
selectedForCascade.forEach((val) => {
|
||||
const children = parentChildMap.get(val);
|
||||
if (children) {
|
||||
children.forEach((child) => implicitlySelected.add(child));
|
||||
}
|
||||
});
|
||||
|
||||
// Build options with disabled state for implicitly selected children
|
||||
const dropdownOptions = orderedOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
disabled: !allSelected && implicitlySelected.has(opt.value),
|
||||
}));
|
||||
|
||||
// For display purposes:
|
||||
// - if "all" is active, check every option
|
||||
// - otherwise show explicit + implicit parent/child selections
|
||||
const displayValue = allSelected
|
||||
? [ALL_OPTION_VALUE, ...nonAllValues]
|
||||
: normalizeValues([...selectedExplicit, ...Array.from(implicitlySelected)]);
|
||||
|
||||
const handleDropdownChange = (newValue: string | string[]) => {
|
||||
const nextValues = normalizeValues(Array.isArray(newValue) ? newValue : [newValue]);
|
||||
|
||||
if (hasAllOption) {
|
||||
const includesAll = nextValues.includes(ALL_OPTION_VALUE);
|
||||
|
||||
// When currently "all" is active:
|
||||
// - unticking "all" clears everything
|
||||
// - unticking a specific option converts to explicit subset
|
||||
if (allSelected && !includesAll && nextValues.length === nonAllValues.length) {
|
||||
onChange([]);
|
||||
return;
|
||||
}
|
||||
if (allSelected && includesAll && nextValues.length < optionValues.length) {
|
||||
onChange(nextValues.filter((entry) => entry !== ALL_OPTION_VALUE));
|
||||
return;
|
||||
}
|
||||
|
||||
if (includesAll) {
|
||||
onChange([ALL_OPTION_VALUE]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If user selects every specific option individually, collapse to "all".
|
||||
if (
|
||||
nonAllValues.length > 0 &&
|
||||
nonAllValues.every((optValue) => nextValues.includes(optValue))
|
||||
) {
|
||||
onChange([ALL_OPTION_VALUE]);
|
||||
return;
|
||||
}
|
||||
if (allSelected && includesAll && nextValues.length < optionValues.length) {
|
||||
onChange(nextValues.filter((entry) => entry !== ALL_OPTION_VALUE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out implicitly selected values - only store explicit selections.
|
||||
const explicitOnly = nextValues.filter((entry) => !implicitlySelected.has(entry));
|
||||
onChange(explicitOnly);
|
||||
};
|
||||
if (includesAll) {
|
||||
onChange([ALL_OPTION_VALUE]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom summary formatter - only count explicit selections
|
||||
const summaryFormatter = () => {
|
||||
if (allSelected) {
|
||||
return orderedOptions.find((opt) => opt.value === ALL_OPTION_VALUE)?.label || 'All';
|
||||
// If user selects every specific option individually, collapse to "all".
|
||||
if (
|
||||
nonAllValues.length > 0 &&
|
||||
nonAllValues.every((optValue) => nextValues.includes(optValue))
|
||||
) {
|
||||
onChange([ALL_OPTION_VALUE]);
|
||||
return;
|
||||
}
|
||||
if (selectedExplicit.length === 0) {
|
||||
return <span className="opacity-60">{field.placeholder || 'Select categories...'}</span>;
|
||||
}
|
||||
const selectedLabels = selectedExplicit
|
||||
.map((v) => orderedOptions.find((o) => o.value === v)?.label)
|
||||
.filter(Boolean);
|
||||
if (selectedLabels.length === 1) {
|
||||
return selectedLabels[0];
|
||||
}
|
||||
const [first, second, ...rest] = selectedLabels;
|
||||
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
|
||||
return `${first}, ${second ?? ''}${suffix}`.trim();
|
||||
};
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
|
||||
{summaryFormatter()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out implicitly selected values - only store explicit selections.
|
||||
const explicitOnly = nextValues.filter((entry) => !implicitlySelected.has(entry));
|
||||
onChange(explicitOnly);
|
||||
};
|
||||
|
||||
// Custom summary formatter - only count explicit selections
|
||||
const summaryFormatter = () => {
|
||||
if (allSelected) {
|
||||
return orderedOptions.find((opt) => opt.value === ALL_OPTION_VALUE)?.label || 'All';
|
||||
}
|
||||
if (selectedExplicit.length === 0) {
|
||||
return <span className="opacity-60">{field.placeholder || 'Select categories...'}</span>;
|
||||
}
|
||||
const selectedLabels = selectedExplicit
|
||||
.map((v) => orderedOptions.find((o) => o.value === v)?.label)
|
||||
.filter(Boolean);
|
||||
if (selectedLabels.length === 1) {
|
||||
return selectedLabels[0];
|
||||
}
|
||||
const [first, second, ...rest] = selectedLabels;
|
||||
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
|
||||
return `${first}, ${second ?? ''}${suffix}`.trim();
|
||||
};
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<DropdownList
|
||||
options={dropdownOptions}
|
||||
value={displayValue}
|
||||
onChange={handleDropdownChange}
|
||||
multiple
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
placeholder={field.placeholder || 'Select categories...'}
|
||||
widthClassName="w-full"
|
||||
summaryFormatter={summaryFormatter}
|
||||
/>
|
||||
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
|
||||
{summaryFormatter()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
options={dropdownOptions}
|
||||
value={displayValue}
|
||||
onChange={handleDropdownChange}
|
||||
multiple
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
placeholder={field.placeholder || 'Select categories...'}
|
||||
widthClassName="w-full"
|
||||
summaryFormatter={summaryFormatter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Pill variant - inline toggle buttons that collapse past a threshold
|
||||
const MultiSelectPillsField = ({
|
||||
field,
|
||||
selected,
|
||||
onChange,
|
||||
isDisabled,
|
||||
}: MultiSelectVariantProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// Initialize based on option count to avoid flash of expanded content
|
||||
const [needsCollapse, setNeedsCollapse] = useState(
|
||||
@@ -353,3 +363,35 @@ export const MultiSelectField = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiSelectField = ({
|
||||
field,
|
||||
value: fieldValue,
|
||||
onChange,
|
||||
disabled,
|
||||
}: MultiSelectFieldProps) => {
|
||||
const selected = fieldValue ?? EMPTY_SELECTION;
|
||||
// disabled prop is already computed by SettingsContent.getDisabledState()
|
||||
const isDisabled = disabled ?? false;
|
||||
|
||||
// Each variant is its own component so neither calls hooks conditionally.
|
||||
if (field.variant === 'dropdown') {
|
||||
return (
|
||||
<MultiSelectDropdownField
|
||||
field={field}
|
||||
selected={selected}
|
||||
onChange={onChange}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSelectPillsField
|
||||
field={field}
|
||||
selected={selected}
|
||||
onChange={onChange}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -139,6 +139,10 @@ export function Tooltip({
|
||||
}
|
||||
|
||||
if (deltaX !== 0 || deltaY !== 0) {
|
||||
// Genuine measure-and-adjust: the tooltip must be laid out before we know
|
||||
// whether it overflows the viewport. The loop converges in one pass because
|
||||
// the corrected position yields deltaX/deltaY of 0 on the next run.
|
||||
// oxlint-disable-next-line react/set-state-in-effect
|
||||
setCoords((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef } from 'react';
|
||||
import { useEffectEvent } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { Book } from '../../types';
|
||||
import { onBookTargetChange } from '../../utils/bookTargetEvents';
|
||||
import { onBookTargetChange, type BookTargetChangeEvent } from '../../utils/bookTargetEvents';
|
||||
import { useMountEffect } from '../useMountEffect';
|
||||
|
||||
interface UseBookTargetDeselectSyncOptions {
|
||||
@@ -14,15 +14,14 @@ export const useBookTargetDeselectSync = ({
|
||||
activeListValue,
|
||||
setBooks,
|
||||
}: UseBookTargetDeselectSyncOptions): void => {
|
||||
const activeListValueRef = useRef(activeListValue);
|
||||
activeListValueRef.current = activeListValue;
|
||||
|
||||
useMountEffect(() => {
|
||||
return onBookTargetChange((event) => {
|
||||
if (event.selected) return;
|
||||
const currentValue = activeListValueRef.current;
|
||||
if (!currentValue || String(currentValue) !== event.target) return;
|
||||
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
|
||||
});
|
||||
const handleTargetChange = useEffectEvent((event: BookTargetChangeEvent) => {
|
||||
if (event.selected) return;
|
||||
if (!activeListValue || String(activeListValue) !== event.target) return;
|
||||
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
|
||||
});
|
||||
|
||||
// Wrapped rather than handed over directly: an Effect Event must not be given to
|
||||
// something that stores it, and `onBookTargetChange` puts its argument in a
|
||||
// module-level listener set. Same shape as useDismiss.
|
||||
useMountEffect(() => onBookTargetChange((event) => handleTargetChange(event)));
|
||||
};
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { ContentType } from '../../types';
|
||||
import { useDependencyEffect } from '../useMountEffect';
|
||||
|
||||
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
|
||||
|
||||
const readInitialPreference = (): { contentType: ContentType; combinedMode: boolean } => {
|
||||
interface ContentTypePreference {
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
}
|
||||
|
||||
const readInitialPreference = (): ContentTypePreference => {
|
||||
try {
|
||||
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
|
||||
if (saved === 'combined') {
|
||||
@@ -26,53 +32,32 @@ export const useContentTypePreferences = (): {
|
||||
combinedMode: boolean;
|
||||
setCombinedMode: Dispatch<SetStateAction<boolean>>;
|
||||
} => {
|
||||
const initialPreference = readInitialPreference();
|
||||
const [contentType, setContentTypeState] = useState<ContentType>(
|
||||
() => initialPreference.contentType,
|
||||
);
|
||||
const [combinedMode, setCombinedModeState] = useState<boolean>(
|
||||
() => initialPreference.combinedMode,
|
||||
);
|
||||
const contentTypeRef = useRef(contentType);
|
||||
const combinedModeRef = useRef(combinedMode);
|
||||
contentTypeRef.current = contentType;
|
||||
combinedModeRef.current = combinedMode;
|
||||
// Both values live in one state object so each setter can derive the other
|
||||
// from a pure updater instead of mirroring it into a ref during render.
|
||||
const [preference, setPreference] = useState<ContentTypePreference>(readInitialPreference);
|
||||
const { contentType, combinedMode } = preference;
|
||||
|
||||
const persistPreference = useCallback(
|
||||
(nextContentType: ContentType, nextCombinedMode: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CONTENT_TYPE_STORAGE_KEY,
|
||||
nextCombinedMode ? 'combined' : nextContentType,
|
||||
);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setContentType: Dispatch<SetStateAction<ContentType>> = useCallback((value) => {
|
||||
setPreference((current) => ({
|
||||
...current,
|
||||
contentType: typeof value === 'function' ? value(current.contentType) : value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setContentType: Dispatch<SetStateAction<ContentType>> = useCallback(
|
||||
(value) => {
|
||||
setContentTypeState((current) => {
|
||||
const nextContentType = typeof value === 'function' ? value(current) : value;
|
||||
persistPreference(nextContentType, combinedModeRef.current);
|
||||
return nextContentType;
|
||||
});
|
||||
},
|
||||
[persistPreference],
|
||||
);
|
||||
const setCombinedMode: Dispatch<SetStateAction<boolean>> = useCallback((value) => {
|
||||
setPreference((current) => ({
|
||||
...current,
|
||||
combinedMode: typeof value === 'function' ? value(current.combinedMode) : value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setCombinedMode: Dispatch<SetStateAction<boolean>> = useCallback(
|
||||
(value) => {
|
||||
setCombinedModeState((current) => {
|
||||
const nextCombinedMode = typeof value === 'function' ? value(current) : value;
|
||||
persistPreference(contentTypeRef.current, nextCombinedMode);
|
||||
return nextCombinedMode;
|
||||
});
|
||||
},
|
||||
[persistPreference],
|
||||
);
|
||||
useDependencyEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing
|
||||
}
|
||||
}, [contentType, combinedMode]);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
|
||||
@@ -44,6 +44,10 @@ export function useDescriptionOverflow({
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
// `descriptionKey` is never read here - it is the trigger. When the modal swaps to a
|
||||
// different release the text changes under the same element, and the overflow has to
|
||||
// be measured again; drop it and the clamp keeps the previous release's answer.
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [descriptionExpanded, descriptionKey, descriptionRef]);
|
||||
|
||||
return descriptionOverflows;
|
||||
|
||||
@@ -164,7 +164,6 @@ export function useReleaseSearchSession(
|
||||
const lastStatusTimeRef = useRef(0);
|
||||
const pendingStatusRef = useRef<SearchStatusData | null>(null);
|
||||
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
activeTabRef.current = activeTab;
|
||||
|
||||
const allTabs = useMemo(() => {
|
||||
return buildReleaseTabs(
|
||||
@@ -363,7 +362,6 @@ export function useReleaseSearchSession(
|
||||
indexerFilterInitializedRef.current = new Set<string>();
|
||||
const nextInitialActiveTab = preferredDefaultReleaseSource || '';
|
||||
initialActiveTabRef.current = nextInitialActiveTab;
|
||||
activeTabRef.current = nextInitialActiveTab;
|
||||
pendingStatusRef.current = null;
|
||||
lastStatusTimeRef.current = 0;
|
||||
if (statusTimeoutRef.current) {
|
||||
@@ -381,6 +379,7 @@ export function useReleaseSearchSession(
|
||||
? nextInitialActiveTab
|
||||
: (tabs[0]?.name ?? '');
|
||||
|
||||
activeTabRef.current = nextActiveTab;
|
||||
setActiveTabState(nextActiveTab);
|
||||
setReleasesBySource({});
|
||||
setLoadingBySource({});
|
||||
@@ -458,6 +457,7 @@ export function useReleaseSearchSession(
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tabName: string) => {
|
||||
activeTabRef.current = tabName;
|
||||
setActiveTabState(tabName);
|
||||
|
||||
if (!tabName) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { useMountEffect } from '@/hooks/useMountEffect';
|
||||
|
||||
export const useSearchBarHoverTimeout = () => {
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearHoverTimeout = useCallback(() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => {
|
||||
return () => {
|
||||
clearHoverTimeout();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hoverTimeoutRef,
|
||||
clearHoverTimeout,
|
||||
};
|
||||
};
|
||||
@@ -5,24 +5,30 @@ interface TabIndicatorStyle {
|
||||
width: number;
|
||||
}
|
||||
|
||||
// One shared instance, so the no-active-tab path below can set it repeatedly and React
|
||||
// bails out on reference equality instead of re-rendering on every resize event.
|
||||
const HIDDEN_INDICATOR: TabIndicatorStyle = { left: 0, width: 0 };
|
||||
|
||||
export function useTabIndicator(
|
||||
tabRefs: MutableRefObject<Record<string, HTMLButtonElement | null>>,
|
||||
activeTab: string,
|
||||
tabsDependency: unknown,
|
||||
): TabIndicatorStyle {
|
||||
const [tabIndicatorStyle, setTabIndicatorStyle] = useState({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const [tabIndicatorStyle, setTabIndicatorStyle] = useState<TabIndicatorStyle>(HIDDEN_INDICATOR);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const activeButton = tabRefs.current[activeTab];
|
||||
if (!activeButton) {
|
||||
setTabIndicatorStyle({ left: 0, width: 0 });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Single measurement path, so a resize that removes the active tab also
|
||||
// resets the indicator instead of leaving it stranded.
|
||||
const updateIndicator = () => {
|
||||
const activeButton = tabRefs.current[activeTab];
|
||||
if (!activeButton) {
|
||||
// The shared constant, not a fresh literal: this path now runs on every resize
|
||||
// event, and a new object would never be Object.is-equal to the current state,
|
||||
// so React would re-render on every frame of a window drag for an unchanged value.
|
||||
setTabIndicatorStyle(HIDDEN_INDICATOR);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = activeButton.parentElement?.getBoundingClientRect();
|
||||
const buttonRect = activeButton.getBoundingClientRect();
|
||||
if (!containerRect) {
|
||||
@@ -41,6 +47,10 @@ export function useTabIndicator(
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateIndicator);
|
||||
};
|
||||
// `tabsDependency` is never read here - it exists only to re-run the measurement when
|
||||
// the tab set changes (callers pass `allTabs` / `showRequestsTab`). The buttons move
|
||||
// when tabs are added or removed, so without it the indicator sits under the old one.
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [activeTab, tabRefs, tabsDependency]);
|
||||
|
||||
return tabIndicatorStyle;
|
||||
|
||||
@@ -1,47 +1,43 @@
|
||||
import { useEffect, useEffectEvent, useRef, type RefObject } from 'react';
|
||||
import { useEffect, useEffectEvent, type RefObject } from 'react';
|
||||
|
||||
export const useDismiss = (
|
||||
isOpen: boolean,
|
||||
refs: RefObject<HTMLElement | null>[],
|
||||
onClose: () => void,
|
||||
) => {
|
||||
const handleClose = useEffectEvent(() => {
|
||||
const handlePointerDown = useEffectEvent((event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (refs.some((ref) => ref.current?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
});
|
||||
|
||||
const refsRef = useRef(refs);
|
||||
refsRef.current = refs;
|
||||
const handleEscape = useEffectEvent((event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (refsRef.current.some((ref) => ref.current?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
const handleClickOutside = (event: MouseEvent) => handlePointerDown(event);
|
||||
const handleKeyDown = (event: KeyboardEvent) => handleEscape(event);
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A callback with a stable identity that always runs the latest render's implementation.
|
||||
*
|
||||
* `useEffectEvent` is React's answer to this shape, but its contract is narrower than it
|
||||
* looks: an Effect Event may only be called from inside an Effect, and must not be handed
|
||||
* to another component, stored in state, or registered with something that outlives the
|
||||
* Effect. Handlers that run from a DOM event, from an async continuation, or from a parent
|
||||
* holding the function in its own UI state are all outside that contract - React documents
|
||||
* the behaviour there as undefined, and the React Compiler advisories oxlint reports
|
||||
* ("existing memoization could not be preserved") are the same fact from the other side.
|
||||
*
|
||||
* So this is the supported shape for those callers. The ref is published in a layout
|
||||
* effect - after commit, before paint - rather than assigned during render, so a render
|
||||
* React later throws away cannot leak its closure into a handler, and no event can
|
||||
* observe the gap.
|
||||
*
|
||||
* Use `useEffectEvent` when the caller really is an Effect; use this everywhere else.
|
||||
*/
|
||||
export function useLatestCallback<Args extends unknown[], Result>(
|
||||
callback: (...args: Args) => Result,
|
||||
): (...args: Args) => Result {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
});
|
||||
|
||||
return useCallback((...args: Args) => callbackRef.current(...args), []);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useRef, type DependencyList, type EffectCallback } from 'react';
|
||||
import { useEffect, useEffectEvent, type DependencyList, type EffectCallback } from 'react';
|
||||
|
||||
export function useMountEffect(effect: EffectCallback): void {
|
||||
const effectRef = useRef(effect);
|
||||
effectRef.current = effect;
|
||||
const runEffect = useEffectEvent(effect);
|
||||
|
||||
useEffect(() => effectRef.current(), []);
|
||||
useEffect(() => runEffect(), []);
|
||||
}
|
||||
|
||||
export function useDependencyEffect(effect: EffectCallback, deps: DependencyList): void {
|
||||
const effectRef = useRef(effect);
|
||||
effectRef.current = effect;
|
||||
const runEffect = useEffectEvent(effect);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => effectRef.current(), deps);
|
||||
useEffect(() => runEffect(), deps);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_SUPPORTED_FORMATS } from '../data/languages';
|
||||
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
|
||||
import type { Book, AppConfig, AdvancedFilterState, ContentType, SearchMode } from '../types';
|
||||
import { LANGUAGE_OPTION_DEFAULT } from '../utils/languageFilters';
|
||||
import { describeSearchFailure } from '../utils/searchFailureMessage';
|
||||
|
||||
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS;
|
||||
|
||||
@@ -263,12 +264,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
handleSearchError(error, 'Search failed');
|
||||
} else {
|
||||
console.error('Search failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Search failed';
|
||||
const friendly =
|
||||
message.includes('Network restricted') || message.includes('Unable to reach')
|
||||
? message
|
||||
: 'Unable to reach download source. Network may be restricted or mirrors blocked.';
|
||||
showToast(friendly, 'error');
|
||||
showToast(describeSearchFailure(error), 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
|
||||
import type {
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
setThemePreference,
|
||||
THEME_FIELD,
|
||||
} from '../utils/themePreference';
|
||||
import { useLatestCallback } from './useLatestCallback';
|
||||
import { useMountEffect } from './useMountEffect';
|
||||
|
||||
interface FetchSettingsOptions {
|
||||
@@ -137,13 +138,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
() => initialState?.originalValues ?? {},
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const valuesRef = useRef<SettingsValues>({});
|
||||
const originalValuesRef = useRef<SettingsValues>({});
|
||||
|
||||
valuesRef.current = values;
|
||||
originalValuesRef.current = originalValues;
|
||||
|
||||
const applySettingsResponse = useCallback(
|
||||
// Stable identity, latest `values`/`originalValues`. Not an Effect Event: it is called
|
||||
// from async fetch and save continuations, not from an Effect. See useLatestCallback.
|
||||
const applySettingsResponse = useLatestCallback(
|
||||
(response: SettingsResponse, options: { preserveDirtyValues?: boolean } = {}) => {
|
||||
const { preserveDirtyValues = false } = options;
|
||||
cachedSettingsResponse = response;
|
||||
@@ -156,11 +153,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
setError(null);
|
||||
|
||||
const nextValues = preserveDirtyValues
|
||||
? mergeFetchedSettingsWithDirtyValues(
|
||||
hydratedState.values,
|
||||
valuesRef.current,
|
||||
originalValuesRef.current,
|
||||
)
|
||||
? mergeFetchedSettingsWithDirtyValues(hydratedState.values, values, originalValues)
|
||||
: hydratedState.values;
|
||||
|
||||
setValues(nextValues);
|
||||
@@ -170,7 +163,6 @@ export function useSettings(): UseSettingsReturn {
|
||||
setSelectedTab((current) => current ?? hydratedState.selectedTab);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchSettings = useCallback(
|
||||
|
||||
@@ -84,6 +84,9 @@ type ApiResponseErrorShape = Error & {
|
||||
code?: string;
|
||||
requiredMode?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
// Set only when the server explained itself, so callers can tell a real explanation
|
||||
// apart from the `503 SERVICE UNAVAILABLE` placeholder built from the status line.
|
||||
serverMessage?: string;
|
||||
};
|
||||
|
||||
class ApiResponseError extends Error {
|
||||
@@ -91,6 +94,7 @@ class ApiResponseError extends Error {
|
||||
code?: string;
|
||||
requiredMode?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
serverMessage?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -99,6 +103,7 @@ class ApiResponseError extends Error {
|
||||
code?: string;
|
||||
requiredMode?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
serverMessage?: string;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
@@ -107,6 +112,7 @@ class ApiResponseError extends Error {
|
||||
this.code = params.code;
|
||||
this.requiredMode = params.requiredMode;
|
||||
this.payload = params.payload;
|
||||
this.serverMessage = params.serverMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +120,13 @@ export const isApiResponseError = (error: unknown): error is ApiResponseErrorSha
|
||||
return error instanceof ApiResponseError;
|
||||
};
|
||||
|
||||
// The client gave up before the server answered. Distinguishable so callers can report
|
||||
// the wait rather than guessing at a cause: a search that hits this has told us nothing
|
||||
// about the network or the mirrors, and saying it did is what issue #1285 was about.
|
||||
export const isTimeoutError = (error: unknown): error is Error => {
|
||||
return error instanceof TimeoutError;
|
||||
};
|
||||
|
||||
const mapApiErrorToActionResult = (error: unknown): ActionResult | null => {
|
||||
if (!isApiResponseError(error) || !error.payload) {
|
||||
return null;
|
||||
@@ -149,7 +162,31 @@ const DEFAULT_TIMEOUT_MS = 30000;
|
||||
// Release searches can be long-running: a source behind Cloudflare/DDoS-Guard has
|
||||
// to spin up the bypasser and solve the challenge before any results come back,
|
||||
// which routinely takes well over the default timeout.
|
||||
const SEARCH_TIMEOUT_MS = 180000;
|
||||
//
|
||||
// The server bounds them itself (RELEASE_SEARCH_TIMEOUT, reported by /api/config) and
|
||||
// answers a spent budget with a message naming the real cause. This client abort is only
|
||||
// the backstop for a server that never answers at all, so it has to fire *after* the
|
||||
// server's own deadline - a fixed 180s here beat the 300s default, so the accurate
|
||||
// message was never reachable and raising the setting did nothing. See issue #1285.
|
||||
//
|
||||
// The margin has to cover what the server still has to do *after* its budget trips, not
|
||||
// just the budget itself. The deadline is cooperative: it is handed to the bypasser as a
|
||||
// cancel flag, and internal_bypasser._CDP_UNWIND_GRACE_SECONDS allows 15s on its own for a
|
||||
// cancelled solve to close its browser - before the handler has serialized releases, built
|
||||
// the column config and put bytes on the wire. A 15s margin is entirely spent by that
|
||||
// unwind, so give it room for the unwind plus the response.
|
||||
const SEARCH_TIMEOUT_MARGIN_MS = 45000;
|
||||
const FALLBACK_SEARCH_TIMEOUT_MS = 300000; // search_deadline.DEFAULT_SEARCH_BUDGET_SECONDS
|
||||
let searchTimeoutMs = FALLBACK_SEARCH_TIMEOUT_MS + SEARCH_TIMEOUT_MARGIN_MS;
|
||||
|
||||
// Exported for tests; callers get this applied automatically via getConfig().
|
||||
export const setSearchTimeoutFromConfig = (budgetSeconds: unknown): void => {
|
||||
if (typeof budgetSeconds === 'number' && Number.isFinite(budgetSeconds) && budgetSeconds > 0) {
|
||||
searchTimeoutMs = budgetSeconds * 1000 + SEARCH_TIMEOUT_MARGIN_MS;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSearchTimeoutMs = (): number => searchTimeoutMs;
|
||||
|
||||
// Utility function for JSON fetch with credentials and timeout
|
||||
async function fetchJSON<T>(
|
||||
@@ -183,12 +220,15 @@ async function fetchJSON<T>(
|
||||
if (isRecord(parsed) && !Array.isArray(parsed)) {
|
||||
errorData = parsed;
|
||||
}
|
||||
// Prefer user-friendly 'message' field, fall back to 'error'
|
||||
if (typeof errorData?.message === 'string') {
|
||||
errorMessage = errorData.message;
|
||||
hasServerMessage = true;
|
||||
} else if (typeof errorData?.error === 'string') {
|
||||
errorMessage = errorData.error;
|
||||
// Prefer user-friendly 'message' field, fall back to 'error'. Both must carry
|
||||
// actual text: an empty string is not the server explaining itself, and treating
|
||||
// it as one suppresses the placeholder below and shows the user a blank toast.
|
||||
const explanation = [errorData?.message, errorData?.error].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim() !== '',
|
||||
);
|
||||
if (explanation !== undefined) {
|
||||
errorMessage = explanation;
|
||||
hasServerMessage = true;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -213,6 +253,7 @@ async function fetchJSON<T>(
|
||||
|
||||
throw new ApiResponseError(errorMessage, {
|
||||
status: res.status,
|
||||
serverMessage: hasServerMessage ? errorMessage : undefined,
|
||||
code: typeof errorData?.code === 'string' ? errorData.code : undefined,
|
||||
requiredMode:
|
||||
typeof errorData?.required_mode === 'string' ? errorData.required_mode : undefined,
|
||||
@@ -243,7 +284,7 @@ export const searchBooks = async (query: string): Promise<Book[]> => {
|
||||
const response = await fetchJSON<ReleasesResponse>(
|
||||
`${API_BASE}/releases?source=direct_download&${query}`,
|
||||
{},
|
||||
SEARCH_TIMEOUT_MS,
|
||||
searchTimeoutMs,
|
||||
);
|
||||
return response.releases.map(transformReleaseToDirectBook);
|
||||
};
|
||||
@@ -586,7 +627,9 @@ export const retryDownload = async (id: string): Promise<void> => {
|
||||
};
|
||||
|
||||
export const getConfig = async (): Promise<AppConfig> => {
|
||||
return fetchJSON<AppConfig>(API.config);
|
||||
const config = await fetchJSON<AppConfig>(API.config);
|
||||
setSearchTimeoutFromConfig(config.release_search_timeout);
|
||||
return config;
|
||||
};
|
||||
|
||||
interface ActivityDismissedItem {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
INITIAL_ENTER_ANIMATION,
|
||||
nextEnterAnimation,
|
||||
type EnterAnimationState,
|
||||
} from '../utils/releaseModalEnterAnimation';
|
||||
|
||||
describe('nextEnterAnimation', () => {
|
||||
it('animates the first session', () => {
|
||||
const next = nextEnterAnimation(INITIAL_ENTER_ANIMATION, 'book-1', false);
|
||||
expect(next).toEqual({ key: 'book-1', animate: true });
|
||||
});
|
||||
|
||||
it('animates the first session in combined mode too', () => {
|
||||
const next = nextEnterAnimation(INITIAL_ENTER_ANIMATION, 'book-1', true);
|
||||
expect(next).toEqual({ key: 'book-1', animate: true });
|
||||
});
|
||||
|
||||
it('does not animate a step transition between combined-mode sessions', () => {
|
||||
const current: EnterAnimationState = { key: 'book-1', animate: true };
|
||||
expect(nextEnterAnimation(current, 'book-2', true)).toEqual({
|
||||
key: 'book-2',
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('animates a session swap outside combined mode', () => {
|
||||
const current: EnterAnimationState = { key: 'book-1', animate: true };
|
||||
expect(nextEnterAnimation(current, 'book-2', false)).toEqual({
|
||||
key: 'book-2',
|
||||
animate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('holds the decision across re-renders of the same session', () => {
|
||||
// Regression: the decision used to flip back to `true` on the next render,
|
||||
// replaying the enter animation mid-session.
|
||||
const stepped = nextEnterAnimation({ key: 'book-1', animate: true }, 'book-2', true);
|
||||
expect(stepped.animate).toBe(false);
|
||||
|
||||
let state = stepped;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
state = nextEnterAnimation(state, 'book-2', true);
|
||||
expect(state.animate).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the same reference when the session is unchanged', () => {
|
||||
const current: EnterAnimationState = { key: 'book-1', animate: false };
|
||||
expect(nextEnterAnimation(current, 'book-1', true)).toBe(current);
|
||||
});
|
||||
|
||||
it('animates again after the modal closes and reopens', () => {
|
||||
const open = nextEnterAnimation(INITIAL_ENTER_ANIMATION, 'book-1', true);
|
||||
const closed = nextEnterAnimation(open, null, true);
|
||||
expect(closed.key).toBeNull();
|
||||
|
||||
const reopened = nextEnterAnimation(closed, 'book-1', true);
|
||||
expect(reopened.animate).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import { searchBooks } from '../services/api';
|
||||
import {
|
||||
describeSearchFailure,
|
||||
CLIENT_TIMEOUT_MESSAGE,
|
||||
UNREACHABLE_SOURCE_MESSAGE,
|
||||
} from '../utils/searchFailureMessage';
|
||||
|
||||
/**
|
||||
* What a failed direct-mode search tells the user.
|
||||
*
|
||||
* Every non-auth failure used to be relabelled "Unable to reach download source. Network
|
||||
* may be restricted or mirrors blocked.", which threw away the server's explanation and
|
||||
* blamed the user's network for a protection challenge. See issue #1285.
|
||||
*/
|
||||
|
||||
const jsonResponse = (body: unknown, status: number): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
statusText: 'SERVICE UNAVAILABLE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/** Drive a real searchBooks() failure so the error is the one the hook actually sees. */
|
||||
const failedSearch = async (respond: () => Promise<Response>): Promise<unknown> => {
|
||||
vi.stubGlobal('fetch', vi.fn(respond));
|
||||
return searchBooks('q=dune').catch((error: unknown) => error);
|
||||
};
|
||||
|
||||
describe('describeSearchFailure', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows the sentence the server sent', async () => {
|
||||
const sentence = 'The release search ran out of time (300s).';
|
||||
const error = await failedSearch(() => Promise.resolve(jsonResponse({ error: sentence }, 503)));
|
||||
|
||||
expect(describeSearchFailure(error)).toBe(sentence);
|
||||
});
|
||||
|
||||
it('names the wait when the client gave up first', async () => {
|
||||
// The client's abort is the backstop for a server that never answered. It tells us
|
||||
// nothing about mirrors or the network, and the old chain reported it as if it did.
|
||||
const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' });
|
||||
const error = await failedSearch(() => Promise.reject(abort));
|
||||
|
||||
expect(describeSearchFailure(error)).toBe(CLIENT_TIMEOUT_MESSAGE);
|
||||
expect(describeSearchFailure(error)).not.toBe(UNREACHABLE_SOURCE_MESSAGE);
|
||||
expect(describeSearchFailure(error)).not.toContain('mirrors');
|
||||
});
|
||||
|
||||
it('falls back to the mirrors line only when nothing explained itself', async () => {
|
||||
const error = await failedSearch(() => Promise.resolve(jsonResponse({}, 503)));
|
||||
|
||||
expect(describeSearchFailure(error)).toBe(UNREACHABLE_SOURCE_MESSAGE);
|
||||
});
|
||||
|
||||
it('keeps a reachability message that already says the right thing', () => {
|
||||
const error = new Error('Unable to reach download source. Every mirror was quarantined.');
|
||||
|
||||
expect(describeSearchFailure(error)).toBe(error.message);
|
||||
});
|
||||
|
||||
it('never produces an empty sentence from a blank server message', async () => {
|
||||
// `{"message": ""}` is not the server explaining itself. Treating it as one used to
|
||||
// reach showToast('') and render an empty error toast.
|
||||
const error = await failedSearch(() => Promise.resolve(jsonResponse({ message: '' }, 503)));
|
||||
|
||||
expect(describeSearchFailure(error)).toBe(UNREACHABLE_SOURCE_MESSAGE);
|
||||
});
|
||||
|
||||
it('handles a non-Error rejection without inventing detail', () => {
|
||||
expect(describeSearchFailure('something odd')).toBe(UNREACHABLE_SOURCE_MESSAGE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
getConfig,
|
||||
getSearchTimeoutMs,
|
||||
setSearchTimeoutFromConfig,
|
||||
searchBooks,
|
||||
isApiResponseError,
|
||||
} from '../services/api';
|
||||
|
||||
/**
|
||||
* The client's abort must fire *after* the server's own search deadline.
|
||||
*
|
||||
* `/api/releases` bounds itself with RELEASE_SEARCH_TIMEOUT and answers a spent budget
|
||||
* with a message naming the real cause. The client aborted at a fixed 180s against a
|
||||
* 300s default, so it always won the race and replaced that message with "Request timed
|
||||
* out. Check your network connection or proxy configuration." Raising the setting had no
|
||||
* visible effect either, the 180s being baked into the hashed bundle. See issue #1285.
|
||||
*/
|
||||
|
||||
// Must stay ahead of what the server still has to do after its budget trips: the deadline
|
||||
// is cooperative, and internal_bypasser._CDP_UNWIND_GRACE_SECONDS alone allows 15s for a
|
||||
// cancelled solve to close its browser before the response is even built.
|
||||
const MARGIN_MS = 45_000;
|
||||
const SERVER_UNWIND_GRACE_MS = 15_000;
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'SERVICE UNAVAILABLE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const configBody = (releaseSearchTimeout: number): Record<string, unknown> => ({
|
||||
release_search_timeout: releaseSearchTimeout,
|
||||
});
|
||||
|
||||
describe('release search timeout', () => {
|
||||
beforeEach(() => {
|
||||
setSearchTimeoutFromConfig(300);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setSearchTimeoutFromConfig(300);
|
||||
});
|
||||
|
||||
it('defaults behind the server default rather than ahead of it', () => {
|
||||
expect(getSearchTimeoutMs()).toBe(300 * 1000 + MARGIN_MS);
|
||||
});
|
||||
|
||||
it('follows the budget the server reports', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse(configBody(900)))),
|
||||
);
|
||||
|
||||
await getConfig();
|
||||
|
||||
expect(getSearchTimeoutMs()).toBe(900 * 1000 + MARGIN_MS);
|
||||
});
|
||||
|
||||
it('still outlasts the server when the budget is lowered', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse(configBody(30)))),
|
||||
);
|
||||
|
||||
await getConfig();
|
||||
|
||||
// Exact, not a lower bound: `> 30_000` is also satisfied by the 345_000 left over
|
||||
// from the previous budget, so a setter that silently stopped applying the config
|
||||
// would pass it.
|
||||
expect(getSearchTimeoutMs()).toBe(30 * 1000 + MARGIN_MS);
|
||||
});
|
||||
|
||||
it('leaves the server room to unwind a cancelled solve and answer', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse(configBody(300)))),
|
||||
);
|
||||
|
||||
await getConfig();
|
||||
|
||||
// The failure this exists for is a budget spent mid-solve. The server then has to
|
||||
// close a browser before it can serialize anything, so a margin merely equal to that
|
||||
// unwind is entirely spent by it and the client aborts first all over again.
|
||||
expect(getSearchTimeoutMs() - 300 * 1000).toBeGreaterThan(SERVER_UNWIND_GRACE_MS);
|
||||
});
|
||||
|
||||
it('ignores a missing or nonsensical budget instead of disabling the backstop', () => {
|
||||
const before = getSearchTimeoutMs();
|
||||
|
||||
setSearchTimeoutFromConfig(undefined);
|
||||
setSearchTimeoutFromConfig(0);
|
||||
setSearchTimeoutFromConfig(-1);
|
||||
setSearchTimeoutFromConfig('600');
|
||||
setSearchTimeoutFromConfig(Number.NaN);
|
||||
|
||||
expect(getSearchTimeoutMs()).toBe(before);
|
||||
});
|
||||
|
||||
it('applies the derived timeout to the direct_download search', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse(configBody(600)))),
|
||||
);
|
||||
await getConfig();
|
||||
|
||||
const seen: Array<AbortSignal | undefined> = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init: RequestInit) => {
|
||||
seen.push(init.signal ?? undefined);
|
||||
return Promise.resolve(jsonResponse({ releases: [] }));
|
||||
}),
|
||||
);
|
||||
|
||||
await searchBooks('q=dune');
|
||||
|
||||
// The request carries an abort signal, and it is not yet aborted: the point is that
|
||||
// the clock it runs on is the server's, not a constant.
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.aborted).toBe(false);
|
||||
expect(getSearchTimeoutMs()).toBe(600 * 1000 + MARGIN_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-provided failure messages', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('carries the server sentence through instead of a status placeholder', async () => {
|
||||
const sentence =
|
||||
'The release search ran out of time (300s). Anna’s Archive is behind a ' +
|
||||
'protection challenge the bypasser could not solve in that window.';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse({ error: sentence }, 503))),
|
||||
);
|
||||
|
||||
const error = await searchBooks('q=dune').catch((e: unknown) => e);
|
||||
|
||||
expect(isApiResponseError(error)).toBe(true);
|
||||
if (isApiResponseError(error)) {
|
||||
expect(error.serverMessage).toBe(sentence);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves serverMessage unset when the server explained nothing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse({}, 503))),
|
||||
);
|
||||
|
||||
const error = await searchBooks('q=dune').catch((e: unknown) => e);
|
||||
|
||||
expect(isApiResponseError(error)).toBe(true);
|
||||
if (isApiResponseError(error)) {
|
||||
expect(error.serverMessage).toBeUndefined();
|
||||
// Without this the UI would show a bare "503 SERVICE UNAVAILABLE".
|
||||
expect(error.message).toContain('Server unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a blank message as no explanation at all', async () => {
|
||||
// An empty string is not the server explaining itself. Taking it as one suppresses
|
||||
// the placeholder below *and* survives a `??` fallback, leaving an empty toast.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse({ message: ' ' }, 503))),
|
||||
);
|
||||
|
||||
const error = await searchBooks('q=dune').catch((e: unknown) => e);
|
||||
|
||||
expect(isApiResponseError(error)).toBe(true);
|
||||
if (isApiResponseError(error)) {
|
||||
expect(error.serverMessage).toBeUndefined();
|
||||
expect(error.message).toContain('Server unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to `error` when `message` is blank', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(jsonResponse({ message: '', error: 'the real reason' }, 503))),
|
||||
);
|
||||
|
||||
const error = await searchBooks('q=dune').catch((e: unknown) => e);
|
||||
|
||||
expect(isApiResponseError(error)).toBe(true);
|
||||
if (isApiResponseError(error)) {
|
||||
expect(error.serverMessage).toBe('the real reason');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -285,6 +285,7 @@ export interface AppConfig {
|
||||
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
||||
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
|
||||
download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types
|
||||
release_search_timeout: number; // Server-side budget for one release search, in seconds
|
||||
settings_enabled: boolean; // Whether config directory is mounted and writable
|
||||
onboarding_complete: boolean; // Whether the user has completed initial setup
|
||||
default_sort: string; // Default sort for direct mode
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type BookTargetChangeEvent = {
|
||||
export type BookTargetChangeEvent = {
|
||||
provider: string;
|
||||
bookId: string;
|
||||
target: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface EnterAnimationState {
|
||||
key: string | null;
|
||||
animate: boolean;
|
||||
}
|
||||
|
||||
export const INITIAL_ENTER_ANIMATION: EnterAnimationState = { key: null, animate: true };
|
||||
|
||||
/**
|
||||
* Decide whether a release modal session should play its enter animation.
|
||||
*
|
||||
* The decision is made once, when a session key first appears, and then held for
|
||||
* that session's lifetime — re-rendering mid-session must not restart the
|
||||
* animation. Swapping between sessions in combined mode is a step transition
|
||||
* rather than an entrance, so it does not animate.
|
||||
*/
|
||||
export const nextEnterAnimation = (
|
||||
current: EnterAnimationState,
|
||||
sessionKey: string | null,
|
||||
isCombinedMode: boolean,
|
||||
): EnterAnimationState => {
|
||||
if (current.key === sessionKey) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
key: sessionKey,
|
||||
animate: !isCombinedMode || current.key === null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isApiResponseError, isTimeoutError } from '../services/api';
|
||||
|
||||
// Shown when we genuinely have nothing better: the request failed without the server
|
||||
// saying why, which really can mean blocked mirrors or a restricted network.
|
||||
export const UNREACHABLE_SOURCE_MESSAGE =
|
||||
'Unable to reach download source. Network may be restricted or mirrors blocked.';
|
||||
|
||||
// Shown when the client's own abort fired. It is the backstop for a server that never
|
||||
// answered at all, and it says nothing about mirrors - the budget the client waited out
|
||||
// is the server's own, which the user can raise. See issue #1285.
|
||||
export const CLIENT_TIMEOUT_MESSAGE =
|
||||
'The search took longer than the server said it would. Raise the release search ' +
|
||||
'timeout if your setup is simply slow.';
|
||||
|
||||
/**
|
||||
* The sentence to show for a failed direct-mode search.
|
||||
*
|
||||
* The server knows why a search failed - a spent search budget, an unsolved protection
|
||||
* challenge - and blanket-replacing that with the mirrors line told users their network
|
||||
* was broken when it was not. So: the server's own words when it explained itself, the
|
||||
* timeout line when we gave up before it answered, and the mirrors line only when
|
||||
* neither applies. See issue #1285.
|
||||
*/
|
||||
export const describeSearchFailure = (error: unknown): string => {
|
||||
if (isTimeoutError(error)) {
|
||||
return CLIENT_TIMEOUT_MESSAGE;
|
||||
}
|
||||
|
||||
if (isApiResponseError(error) && error.serverMessage) {
|
||||
return error.serverMessage;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Network restricted') || message.includes('Unable to reach')) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return UNREACHABLE_SOURCE_MESSAGE;
|
||||
};
|
||||
@@ -266,3 +266,50 @@ def test_page_load_loop_still_makes_one_attempt_on_a_spent_budget(monkeypatch, b
|
||||
|
||||
assert bypass._run_bypass_in_current_process("https://example.com", 10) == "<html>solved</html>"
|
||||
assert attempts["n"] == 1
|
||||
|
||||
|
||||
class _FakeElement:
|
||||
async def get_html_async(self) -> str:
|
||||
return "<html>solved</html>"
|
||||
|
||||
|
||||
class _FakePage:
|
||||
"""A page that only produces its document after `ready_after` seconds of waiting."""
|
||||
|
||||
def __init__(self, ready_after: float = 0.0) -> None:
|
||||
self.ready_after = ready_after
|
||||
self.waited_with: list[float] = []
|
||||
|
||||
async def find(self, selector: str, timeout: float = 1):
|
||||
self.waited_with.append(timeout)
|
||||
if timeout < self.ready_after:
|
||||
msg = f"Time ran out while waiting for: {{{selector}}}"
|
||||
raise TimeoutError(msg)
|
||||
return _FakeElement()
|
||||
|
||||
|
||||
def test_page_source_waits_longer_than_seleniumbases_one_second(bypass):
|
||||
"""A page still navigating after a solve must not lose the solve.
|
||||
|
||||
SeleniumBase's get_page_source() allows one second for the document. Anna's Archive
|
||||
hands back a redirect to the real content instead, so the read raised TimeoutError
|
||||
while the challenge had in fact been cleared.
|
||||
"""
|
||||
page = _FakePage(ready_after=5.0)
|
||||
|
||||
assert asyncio.run(bypass._read_page_source(page)) == "<html>solved</html>"
|
||||
assert page.waited_with == [bypass._PAGE_SOURCE_TIMEOUT_DEFAULT]
|
||||
|
||||
|
||||
def test_page_source_timeout_is_configurable(bypass, monkeypatch):
|
||||
"""BYPASS_PAGE_SOURCE_TIMEOUT overrides the default for slow or fast setups."""
|
||||
monkeypatch.setattr(
|
||||
bypass.app_config,
|
||||
"get",
|
||||
lambda key, default=None: 45 if key == "BYPASS_PAGE_SOURCE_TIMEOUT" else default,
|
||||
)
|
||||
page = _FakePage()
|
||||
|
||||
asyncio.run(bypass._read_page_source(page))
|
||||
|
||||
assert page.waited_with == [45.0]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""No method in the list may be a step another method already takes first.
|
||||
|
||||
`BYPASS_METHODS` used to open with a solve-only entry that called `page.solve_captcha()`
|
||||
and checked the result. `_bypass_method_cdp_gui_click`, the entry behind it, opens by
|
||||
doing exactly that and returns the moment it works - so against a challenge that
|
||||
`solve_captcha()` cannot clear, the first method could only repeat the half that had
|
||||
already failed, then charge the loop's backoff before the method that does work started.
|
||||
Measured on Anna's Archive at 0/19 successes and ~5.5s of the ~26s each solve cost
|
||||
(issue #1285).
|
||||
|
||||
The passive-solve window added in v1.3.13 keeps most solves away from this loop entirely,
|
||||
so this is about what the loop costs when it does run.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import shelfmark.bypass.internal_bypasser as ib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_sleep(monkeypatch):
|
||||
async def _no_sleep(_seconds) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ib.asyncio, "sleep", _no_sleep)
|
||||
monkeypatch.setattr(ib._RNG, "uniform", lambda _a, _b: 0)
|
||||
|
||||
|
||||
class _Page:
|
||||
"""Records what a method asked the page to do."""
|
||||
|
||||
def __init__(self, *, solve_clears: bool) -> None:
|
||||
self.solve_clears = solve_clears
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def solve_captcha(self) -> None:
|
||||
self.calls.append("solve_captcha")
|
||||
|
||||
async def is_element_visible(self, selector: str) -> bool:
|
||||
self.calls.append(f"visible:{selector}")
|
||||
return False
|
||||
|
||||
async def click_with_offset(self, selector: str, _x, _y, center=True) -> None:
|
||||
self.calls.append(f"click:{selector}")
|
||||
|
||||
|
||||
def _stub_is_bypassed(monkeypatch, page: _Page) -> None:
|
||||
async def _is_bypassed(*_a, **_kw) -> bool:
|
||||
return page.solve_clears and "solve_captcha" in page.calls
|
||||
|
||||
monkeypatch.setattr(ib, "_is_bypassed", _is_bypassed)
|
||||
|
||||
|
||||
def test_no_solve_only_method_remains_in_the_list():
|
||||
names = [method.__name__ for method in ib.BYPASS_METHODS]
|
||||
assert "_bypass_method_cdp_solve" not in names
|
||||
assert names[0] == "_bypass_method_cdp_gui_click"
|
||||
|
||||
|
||||
def test_the_first_method_still_tries_solve_captcha_first(monkeypatch, no_sleep):
|
||||
"""Coverage is only preserved because gui_click opens with the same call."""
|
||||
page = _Page(solve_clears=True)
|
||||
_stub_is_bypassed(monkeypatch, page)
|
||||
|
||||
assert asyncio.run(ib.BYPASS_METHODS[0](page)) is True
|
||||
assert page.calls == ["solve_captcha"], "it must return before touching any selector"
|
||||
|
||||
|
||||
def test_it_falls_through_to_clicking_when_solve_does_not_clear(monkeypatch, no_sleep):
|
||||
"""The half that actually works on DDoS-Guard still runs in the same attempt."""
|
||||
page = _Page(solve_clears=False)
|
||||
_stub_is_bypassed(monkeypatch, page)
|
||||
|
||||
assert asyncio.run(ib.BYPASS_METHODS[0](page)) is False
|
||||
assert page.calls[0] == "solve_captcha"
|
||||
assert any(call.startswith("visible:") for call in page.calls), (
|
||||
"the selector pass should have been reached in the same attempt"
|
||||
)
|
||||
|
||||
|
||||
def test_the_derived_budgets_follow_the_shortened_list():
|
||||
"""Both budgets are computed from the list, so removing an entry must not strand them."""
|
||||
assert ib._BYPASS_METHOD_ATTEMPTS == len(ib.BYPASS_METHODS) + 1
|
||||
assert ib._BYPASS_METHOD_ATTEMPTS >= len(ib.BYPASS_METHODS), (
|
||||
"every method must still get a turn"
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""A challenge page is a failed solve, whatever verdict the solver reports on itself.
|
||||
|
||||
Regression for #1292. FlareSolverr answers "Challenge solved!" for anything it does not
|
||||
recognise as a Cloudflare challenge, and DDoS-Guard's manual CAPTCHA page is one such
|
||||
thing. The external bypasser logged a warning that the solve had not cleared the
|
||||
protection and then returned the page as a success anyway, which had three consequences:
|
||||
the retry-and-rotate loop that could still have reached a working mirror was never
|
||||
entered, the CAPTCHA page's own __ddg cookies were filed as that host's clearance, and
|
||||
the user was told to go and check a bypasser that was working perfectly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.bypass import ChallengeNotSolvedError
|
||||
|
||||
# Verbatim from the annas-archive.pk page in #1292, trimmed to the markers. This is the
|
||||
# *manual* CAPTCHA - "could not verify your browser automatically" - not the ~900 byte
|
||||
# JS interstitial that a browser clears on its own.
|
||||
DDOS_GUARD_CAPTCHA = (
|
||||
'<html><head><title>DDOS-GUARD</title><meta charset="utf-8">'
|
||||
'<link rel="stylesheet" href="/.well-known/ddos-guard/ddg-captcha-page/index.css">'
|
||||
'<script defer="defer" src="/.well-known/ddos-guard/ddg-captcha-page/index.js"></script>'
|
||||
'</head><body><div class="container"><h1 id="title">Checking your browser before '
|
||||
'accessing annas-archive.pk</h1><p id="description">Sorry, we could not verify your '
|
||||
"browser automatically. Complete the manual check to continue</p>"
|
||||
'<div id="ddg-captcha"></div></div></body></html>'
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
def _stub_solution(monkeypatch, external_bypasser, solution: dict) -> None:
|
||||
"""Answer every bypass with `solution`, with config and SSL stubbed out."""
|
||||
|
||||
def fake_get(key, default=""):
|
||||
values = {
|
||||
"EXT_BYPASSER_URL": "https://bypass.example",
|
||||
"EXT_BYPASSER_PATH": "/v1",
|
||||
"EXT_BYPASSER_TIMEOUT": 60000,
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
monkeypatch.setattr(external_bypasser.config, "get", fake_get)
|
||||
monkeypatch.setattr(
|
||||
external_bypasser.requests,
|
||||
"post",
|
||||
# "Challenge solved!" is the solver's verdict; the page is the evidence.
|
||||
lambda *_a, **_k: _FakeResponse(
|
||||
{"status": "ok", "message": "Challenge solved!", "solution": solution}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(external_bypasser, "get_ssl_verify", lambda _url: False)
|
||||
|
||||
|
||||
def test_a_captcha_page_is_reported_as_unsolved_not_returned(monkeypatch):
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_stub_solution(monkeypatch, external_bypasser, {"response": DDOS_GUARD_CAPTCHA})
|
||||
|
||||
with pytest.raises(ChallengeNotSolvedError) as excinfo:
|
||||
external_bypasser._fetch_via_bypasser("https://annas-archive.pk/search?q=dune")
|
||||
|
||||
# The marker travels with the failure so the user-facing message can name it.
|
||||
assert str(excinfo.value) == "/.well-known/ddos-guard/"
|
||||
|
||||
|
||||
def test_cookies_from_a_captcha_page_are_never_filed_as_clearance(monkeypatch):
|
||||
"""They belong to an unsolved check, so replaying them only re-arms the gate."""
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
_stub_solution(
|
||||
monkeypatch,
|
||||
external_bypasser,
|
||||
{
|
||||
"response": DDOS_GUARD_CAPTCHA,
|
||||
"userAgent": "Mozilla/5.0 (solver)",
|
||||
"cookies": [{"name": "__ddg1_", "value": "from-a-captcha"}],
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ChallengeNotSolvedError):
|
||||
external_bypasser._fetch_via_bypasser("https://annas-archive.pk/search?q=dune")
|
||||
|
||||
assert cookie_store.get_cf_cookies_for_domain("annas-archive.pk") == {}
|
||||
assert cookie_store.get_cf_user_agent_for_domain("annas-archive.pk") is None
|
||||
|
||||
|
||||
class _FakeSelector:
|
||||
"""Two mirrors, rotated on demand - each is its own DDoS-Guard host."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.current_base = "https://mirror-one.example"
|
||||
self.rotate_calls = 0
|
||||
|
||||
def rewrite(self, url: str) -> str:
|
||||
return url.replace("https://orig.example", self.current_base, 1)
|
||||
|
||||
def next_mirror_or_rotate_dns(self) -> tuple[str | None, str]:
|
||||
self.rotate_calls += 1
|
||||
self.current_base = "https://mirror-two.example"
|
||||
return self.current_base, "mirror"
|
||||
|
||||
|
||||
def _no_sleeping(monkeypatch, external_bypasser) -> None:
|
||||
monkeypatch.setattr(external_bypasser, "_sleep_with_cancellation", lambda _seconds, _flag: None)
|
||||
|
||||
|
||||
def test_an_unsolved_challenge_rotates_to_the_next_mirror(monkeypatch):
|
||||
"""The recovery the old code skipped by calling the CAPTCHA page a success."""
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_no_sleeping(monkeypatch, external_bypasser)
|
||||
fetched: list[str] = []
|
||||
|
||||
def fake_fetch(url: str) -> str | None:
|
||||
fetched.append(url)
|
||||
if "mirror-one" in url:
|
||||
raise ChallengeNotSolvedError("/.well-known/ddos-guard/")
|
||||
return "<html>real page</html>"
|
||||
|
||||
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", fake_fetch)
|
||||
|
||||
selector = _FakeSelector()
|
||||
result = external_bypasser.get_bypassed_page("https://orig.example/search", selector=selector)
|
||||
|
||||
assert result == "<html>real page</html>"
|
||||
assert fetched == [
|
||||
"https://mirror-one.example/search",
|
||||
"https://mirror-two.example/search",
|
||||
]
|
||||
assert selector.rotate_calls == 1
|
||||
|
||||
|
||||
def test_every_attempt_challenged_blames_the_host_not_the_bypasser(monkeypatch):
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_no_sleeping(monkeypatch, external_bypasser)
|
||||
|
||||
def always_challenged(_url: str) -> str | None:
|
||||
raise ChallengeNotSolvedError("/.well-known/ddos-guard/")
|
||||
|
||||
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", always_challenged)
|
||||
|
||||
with pytest.raises(ChallengeNotSolvedError) as excinfo:
|
||||
external_bypasser.get_bypassed_page("https://orig.example/search", selector=_FakeSelector())
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "manual CAPTCHA" in message
|
||||
assert "the bypasser itself is working" in message
|
||||
|
||||
|
||||
def test_an_unreachable_bypasser_still_reports_as_such(monkeypatch):
|
||||
"""The other cause must stay distinguishable: None, not an unsolved challenge."""
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_no_sleeping(monkeypatch, external_bypasser)
|
||||
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", lambda _url: None)
|
||||
|
||||
assert (
|
||||
external_bypasser.get_bypassed_page("https://orig.example/search", selector=_FakeSelector())
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_html_get_page_surfaces_the_host_as_the_cause(monkeypatch):
|
||||
"""The message the user actually reads must not send them to fix FlareSolverr.
|
||||
|
||||
`_run_bypasser`'s generic handler says "the protection bypasser failed", and the
|
||||
search layer's give-up used to add "check that the bypasser is reachable and
|
||||
working" - which is what #1292 spent its investigation doing.
|
||||
"""
|
||||
import shelfmark.download.http as http
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
|
||||
|
||||
def challenged(*_args, **_kwargs):
|
||||
msg = "the site kept answering with a protection challenge - manual CAPTCHA"
|
||||
raise ChallengeNotSolvedError(msg)
|
||||
|
||||
monkeypatch.setattr(http, "get_bypassed_page", challenged)
|
||||
|
||||
statuses: list[tuple[str, str | None]] = []
|
||||
selector = network.AAMirrorSelector()
|
||||
|
||||
html = http.html_get_page(
|
||||
"https://annas-archive.pk/search?q=dune",
|
||||
retry=1,
|
||||
selector=selector,
|
||||
status_callback=lambda stage, detail: statuses.append((stage, detail)),
|
||||
use_bypasser=True,
|
||||
success_delay=0,
|
||||
)
|
||||
|
||||
assert html == ""
|
||||
assert selector.last_failure is not None
|
||||
assert "manual CAPTCHA" in selector.last_failure
|
||||
assert "reachable" not in selector.last_failure
|
||||
assert ("error", "the site kept answering with a protection challenge - manual CAPTCHA") in (
|
||||
statuses
|
||||
)
|
||||
@@ -137,3 +137,44 @@ def test_no_budget_leaks_out_of_the_request(client, main_module):
|
||||
_request(client, main_module, [{"name": "direct_download", "enabled": True}], lambda *_: [])
|
||||
|
||||
assert search_deadline.current() is None
|
||||
|
||||
|
||||
def test_the_client_is_told_what_the_budget_is(client, main_module):
|
||||
"""The browser has to outlast the server, or the message above never arrives.
|
||||
|
||||
The frontend puts its own AbortController on the direct_download search. That abort
|
||||
was a fixed 180s while this budget defaults to 300s, so the client always gave up
|
||||
first and replaced the sentence tested above with a generic network/proxy error -
|
||||
and raising RELEASE_SEARCH_TIMEOUT changed nothing a user could see, because the
|
||||
hard-coded 180s was in the hashed bundle inside the image. Reporting the budget lets
|
||||
the client set its backstop behind it. See issue #1285.
|
||||
"""
|
||||
_authenticate(client)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
resp = client.get("/api/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
reported = resp.get_json()["release_search_timeout"]
|
||||
assert reported == search_deadline.budget_seconds()
|
||||
assert reported > 0
|
||||
|
||||
|
||||
def test_the_reported_budget_is_the_one_actually_enforced(client, main_module):
|
||||
"""An out-of-range setting is clamped, so the raw config value would mislead."""
|
||||
_authenticate(client)
|
||||
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value="none"),
|
||||
patch.object(
|
||||
main_module.app_config,
|
||||
"get",
|
||||
side_effect=lambda key, default=None, **_kw: (
|
||||
99999 if key == "RELEASE_SEARCH_TIMEOUT" else default
|
||||
),
|
||||
),
|
||||
):
|
||||
resp = client.get("/api/config")
|
||||
reported = resp.get_json()["release_search_timeout"]
|
||||
|
||||
assert reported == search_deadline._MAX_SEARCH_BUDGET_SECONDS
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""A release search uses one author, not every contributor the book lists.
|
||||
|
||||
`bookTransformers.ts` joins a book's authors with ", " for display, and the release modal
|
||||
sends that display string back as the `author` request parameter; several providers set
|
||||
`search_author` from equally joined text. `pick_search_author` returned it verbatim while
|
||||
the authors[] fallback beside it deliberately narrowed to the first name, so a book with
|
||||
translators was searched for as
|
||||
|
||||
Blindness Jose Saramago, Giovanni Pontiero, <persian translator>
|
||||
|
||||
which matches nothing on Anna's Archive. The bypass succeeds, the search returns empty,
|
||||
and the user is told the book has no releases. Reported on issue #1252.
|
||||
"""
|
||||
|
||||
from shelfmark.core.search_plan import build_release_search_plan, first_author, pick_search_author
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
# The exact string from the report, as the frontend would join it.
|
||||
JOINED = "José Saramago, Giovanni Pontiero, زهره افتخاری"
|
||||
|
||||
|
||||
def _book(**kwargs) -> BookMetadata:
|
||||
base = {
|
||||
"provider": "manual",
|
||||
"provider_id": "x",
|
||||
"title": "Blindness",
|
||||
"search_title": "Blindness",
|
||||
}
|
||||
return BookMetadata(**{**base, **kwargs})
|
||||
|
||||
|
||||
def _queries(book: BookMetadata) -> list[str]:
|
||||
plan = build_release_search_plan(book, languages=["en"])
|
||||
return [f"{v.title} {plan.author}".strip() for v in plan.grouped_title_variants]
|
||||
|
||||
|
||||
def test_search_author_is_narrowed_to_the_first_author():
|
||||
book = _book(search_author=JOINED, authors=[a.strip() for a in JOINED.split(",")])
|
||||
|
||||
assert _queries(book) == ["Blindness José Saramago"]
|
||||
|
||||
|
||||
def test_the_authors_fallback_still_narrows():
|
||||
"""Unchanged behaviour, kept under test so the two paths cannot drift apart again."""
|
||||
book = _book(authors=[JOINED])
|
||||
|
||||
assert _queries(book) == ["Blindness José Saramago"]
|
||||
|
||||
|
||||
def test_a_single_author_is_left_alone():
|
||||
book = _book(search_author="José Saramago")
|
||||
|
||||
assert _queries(book) == ["Blindness José Saramago"]
|
||||
|
||||
|
||||
def test_a_book_with_no_author_still_searches_by_title():
|
||||
book = _book(authors=[])
|
||||
|
||||
assert _queries(book) == ["Blindness"]
|
||||
|
||||
|
||||
def test_last_first_collapses_to_the_surname():
|
||||
"""Still a usable search term, and what the fallback has always done."""
|
||||
assert first_author("Saramago, José") == "Saramago"
|
||||
|
||||
|
||||
def test_first_author_trims_and_tolerates_odd_input():
|
||||
assert first_author(" José Saramago ") == "José Saramago"
|
||||
assert first_author("") == ""
|
||||
assert first_author(",") == ""
|
||||
assert first_author("José Saramago,") == "José Saramago"
|
||||
|
||||
|
||||
def test_manual_query_is_untouched():
|
||||
"""A manual query is the user's own words; narrowing it would rewrite their search."""
|
||||
book = _book(search_author=JOINED)
|
||||
plan = build_release_search_plan(book, manual_query=JOINED)
|
||||
|
||||
assert plan.manual_query == JOINED
|
||||
assert plan.author == ""
|
||||
|
||||
|
||||
def test_irc_query_uses_one_author_too():
|
||||
"""The IRC source builds its own query and had the same verbatim preference."""
|
||||
from shelfmark.release_sources.irc.source import IRCReleaseSource
|
||||
|
||||
book = _book(search_author=JOINED, authors=[a.strip() for a in JOINED.split(",")])
|
||||
|
||||
assert IRCReleaseSource()._build_query(book) == "Blindness José Saramago"
|
||||
|
||||
|
||||
def test_a_blank_leading_contributor_falls_back_instead_of_dropping_the_author():
|
||||
"""`authors.join(', ')` does not drop an empty entry, so the join can start with ",".
|
||||
|
||||
Narrowing that to "" and stopping would search by title alone - losing an author the
|
||||
book was holding all along, in authors[], one line below.
|
||||
"""
|
||||
book = _book(search_author=", Giovanni Pontiero", authors=["", "Giovanni Pontiero"])
|
||||
|
||||
assert _queries(book) == ["Blindness Giovanni Pontiero"]
|
||||
|
||||
|
||||
def test_a_blank_leading_contributor_does_not_strand_the_irc_query_either():
|
||||
from shelfmark.release_sources.irc.source import IRCReleaseSource
|
||||
|
||||
book = _book(search_author=", Giovanni Pontiero", authors=["", "Giovanni Pontiero"])
|
||||
|
||||
assert IRCReleaseSource()._build_query(book) == "Blindness Giovanni Pontiero"
|
||||
|
||||
|
||||
def test_an_all_blank_author_leaves_a_clean_title_only_query():
|
||||
"""No trailing space, no empty part: the query is just the title."""
|
||||
from shelfmark.release_sources.irc.source import IRCReleaseSource
|
||||
|
||||
book = _book(search_author=", ,", authors=["", " "])
|
||||
|
||||
assert _queries(book) == ["Blindness"]
|
||||
assert IRCReleaseSource()._build_query(book) == "Blindness"
|
||||
|
||||
|
||||
def test_a_bare_string_in_authors_is_not_iterated_character_by_character():
|
||||
"""The IRC source guarded against this before it shared `pick_search_author`."""
|
||||
book = _book(authors="José Saramago")
|
||||
|
||||
assert pick_search_author(book) == "José Saramago"
|
||||
|
||||
|
||||
def test_the_producers_narrow_before_the_field_is_ever_set():
|
||||
"""The two places that join also hold the split, so they set search_author from it.
|
||||
|
||||
Narrowing downstream cannot tell a comma that joins contributors from one inside a
|
||||
single name; here the information is still present. See the same-named finding.
|
||||
"""
|
||||
from shelfmark.release_sources import BrowseRecord, browse_record_to_book_metadata
|
||||
|
||||
record = BrowseRecord(id="abc", title="Blindness", source="direct_download")
|
||||
book = browse_record_to_book_metadata(record, author_override=JOINED)
|
||||
|
||||
assert book.search_author == "José Saramago"
|
||||
assert book.authors == [a.strip() for a in JOINED.split(",")]
|
||||
@@ -122,3 +122,54 @@ class TestReleaseSearchPlan:
|
||||
plan = build_release_search_plan(book, languages=["fr"], user_id=7)
|
||||
|
||||
assert plan.languages == ["fr"]
|
||||
|
||||
|
||||
class TestSearchAuthorNormalization:
|
||||
"""A credit list must not reach the query, whichever field carries it.
|
||||
|
||||
Anna's Archive answers "Blindness Jose Saramago, Giovanni Pontiero, ..." with
|
||||
nothing, so a book whose author string lists translators alongside the author
|
||||
finds no releases at all.
|
||||
"""
|
||||
|
||||
MULTI = "Jose Saramago, Giovanni Pontiero, Zohreh Eftekhari"
|
||||
|
||||
def test_search_author_is_trimmed_to_the_first_name(self):
|
||||
book = BookMetadata(
|
||||
provider="manual",
|
||||
provider_id="1",
|
||||
title="Blindness",
|
||||
authors=[self.MULTI],
|
||||
search_author=self.MULTI,
|
||||
)
|
||||
|
||||
assert build_release_search_plan(book).primary_query == "Blindness Jose Saramago"
|
||||
|
||||
def test_search_author_matches_the_authors_list(self):
|
||||
"""Same credit list, two fields, one query."""
|
||||
via_authors = BookMetadata(
|
||||
provider="manual", provider_id="1", title="Blindness", authors=[self.MULTI]
|
||||
)
|
||||
via_search_author = BookMetadata(
|
||||
provider="manual",
|
||||
provider_id="1",
|
||||
title="Blindness",
|
||||
authors=[self.MULTI],
|
||||
search_author=self.MULTI,
|
||||
)
|
||||
|
||||
assert (
|
||||
build_release_search_plan(via_search_author).primary_query
|
||||
== build_release_search_plan(via_authors).primary_query
|
||||
)
|
||||
|
||||
def test_single_author_is_untouched(self):
|
||||
book = BookMetadata(
|
||||
provider="manual",
|
||||
provider_id="1",
|
||||
title="Elantris",
|
||||
authors=["Brandon Sanderson"],
|
||||
search_author="Brandon Sanderson",
|
||||
)
|
||||
|
||||
assert build_release_search_plan(book).primary_query == "Elantris Brandon Sanderson"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""A real Anna's Archive page must not be mistaken for a protection interstitial.
|
||||
|
||||
Regression for #1289/#1292. `_looks_like_challenge_page` substring-matched "ddos-guard"
|
||||
over the whole document, and DDoS-Guard-fronted sites carry that string on their *own*
|
||||
pages - Anna's Archive ships a `DDOS-GUARD` comment in the inline JS it serves on every
|
||||
page. Any real AA response that was not a results table was therefore reported as an
|
||||
unsolved challenge, telling users to go fix a bypasser that had just succeeded.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from bs4 import Tag
|
||||
|
||||
# Verbatim from a live annas-archive.pk 403, trimmed of nothing that matters: this is
|
||||
# what an interstitial actually looks like, and it is under a kilobyte.
|
||||
DDOS_GUARD_INTERSTITIAL = (
|
||||
'<!doctype html><html><head><title>DDoS-Guard</title><meta charset="utf-8"/>'
|
||||
'<link rel="stylesheet" href="/.well-known/ddos-guard/js-challenge/index.css">'
|
||||
'<script defer="defer" src="/.well-known/ddos-guard/js-challenge/index.js"></script>'
|
||||
'<script src="https://check.ddos-guard.net/check.js"></script></head>'
|
||||
'<body data-ddg-origin="true"><div class="container"><h1 id="ddg-l10n-title">'
|
||||
'Checking your browser before accessing <span class="ddg-origin"></span></h1>'
|
||||
"<p>Please wait a few seconds.</p></div></body></html>"
|
||||
)
|
||||
|
||||
# The marker that made every real AA page look like a challenge, quoted from the live
|
||||
# site's inline JS, plus enough real page to clear the 64 KB size guard.
|
||||
_AA_DDG_COMMENT = '// "text/css" for DDOS-GUARD caching.'
|
||||
AA_PAGE_WITHOUT_TABLE = (
|
||||
"<!doctype html><html><head><title>Anna’s Archive</title></head><body>"
|
||||
f"<script>function f(){{ {_AA_DDG_COMMENT} fetch('/dyn/recent_downloads/'); }}</script>"
|
||||
'<main><a href="/md5/abc123">a record</a>'
|
||||
+ ("<p>real page body content</p>" * 3000)
|
||||
+ "</main></body></html>"
|
||||
)
|
||||
|
||||
|
||||
class _Selector:
|
||||
def __init__(self, bases: list[str]) -> None:
|
||||
self._bases = bases
|
||||
self._index = 0
|
||||
self.current_base = bases[0]
|
||||
self.quarantined: list[str] = []
|
||||
|
||||
def rewrite(self, url: str) -> str:
|
||||
for base in self._bases:
|
||||
if url.startswith(base):
|
||||
return url.replace(base, self.current_base, 1)
|
||||
return url
|
||||
|
||||
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
|
||||
if fatal:
|
||||
self.quarantined.append(self.current_base)
|
||||
self._index += 1
|
||||
if self._index >= len(self._bases):
|
||||
return None, "exhausted"
|
||||
self.current_base = self._bases[self._index]
|
||||
return self.current_base, "mirror"
|
||||
|
||||
|
||||
def _patch_pages(monkeypatch, pages: list[str]):
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_get(url, **_kwargs):
|
||||
calls.append(url)
|
||||
return pages[len(calls) - 1] if len(calls) <= len(pages) else ""
|
||||
|
||||
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a", "b"])
|
||||
return dd, calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_logs():
|
||||
"""Collect this module's log messages.
|
||||
|
||||
setup_logger builds loggers outside the standard hierarchy, so their records never
|
||||
reach the root handler caplog installs - see tests/bypass/test_ddg_cookie_reuse.py.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
|
||||
messages: list[str] = []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
messages.append(record.getMessage())
|
||||
|
||||
handler = _Capture()
|
||||
dd.logger.addHandler(handler)
|
||||
previous = dd.logger.level
|
||||
dd.logger.setLevel(logging.DEBUG)
|
||||
# Logger.setLevel only invalidates the is-enabled cache through the manager, which
|
||||
# these loggers are not registered with; without this the DEBUG line stays filtered.
|
||||
dd.logger._cache.clear()
|
||||
try:
|
||||
yield messages
|
||||
finally:
|
||||
dd.logger.removeHandler(handler)
|
||||
dd.logger.setLevel(previous)
|
||||
|
||||
|
||||
def test_the_size_guard_is_what_separates_a_real_page_from_an_interstitial():
|
||||
"""The two inputs this bug turned on, checked directly."""
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
from shelfmark.bypass.challenge import MAX_CHALLENGE_HTML_CHARS
|
||||
|
||||
assert len(DDOS_GUARD_INTERSTITIAL) < MAX_CHALLENGE_HTML_CHARS
|
||||
assert len(AA_PAGE_WITHOUT_TABLE) > MAX_CHALLENGE_HTML_CHARS
|
||||
# Both contain "ddos-guard"; only one is a challenge.
|
||||
assert "ddos-guard" in AA_PAGE_WITHOUT_TABLE.lower()
|
||||
assert dd._looks_like_challenge_page(DDOS_GUARD_INTERSTITIAL)
|
||||
assert not dd._looks_like_challenge_page(AA_PAGE_WITHOUT_TABLE)
|
||||
|
||||
|
||||
def test_real_aa_page_without_a_table_is_not_reported_as_a_challenge(monkeypatch):
|
||||
"""The #1289 failure: a served AA page raised "unsolved protection challenge"."""
|
||||
dd, calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
assert table is None
|
||||
assert html == AA_PAGE_WITHOUT_TABLE
|
||||
# A live mirror holding our clearance: neither quarantined nor rotated away from.
|
||||
assert selector.quarantined == []
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_aa_markers_win_over_challenge_markers_on_the_same_page(monkeypatch):
|
||||
"""Ordering, not just the size guard, keeps a marker-carrying AA page readable."""
|
||||
dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
|
||||
monkeypatch.setattr(dd, "_looks_like_challenge_page", lambda _html: True)
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
_html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
assert table is None
|
||||
|
||||
|
||||
def test_genuine_interstitial_still_raises(monkeypatch):
|
||||
"""The behaviour the check exists for is untouched."""
|
||||
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_INTERSTITIAL])
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
with pytest.raises(dd.SearchUnavailableError, match="protection challenge"):
|
||||
dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
assert selector.quarantined == []
|
||||
|
||||
|
||||
def test_results_table_is_still_returned(monkeypatch):
|
||||
"""A page carrying the marker and a table is read as results, as before."""
|
||||
page = AA_PAGE_WITHOUT_TABLE.replace(
|
||||
"<main>", "<main><table><tbody><tr><td>Malice</td></tr></tbody></table>"
|
||||
)
|
||||
dd, _calls = _patch_pages(monkeypatch, [page])
|
||||
selector = _Selector(["https://real.test"])
|
||||
|
||||
_html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
assert isinstance(table, Tag)
|
||||
|
||||
|
||||
def test_untabled_page_is_fingerprinted_in_the_log(monkeypatch, search_logs):
|
||||
"""#1289 was unsolvable from the bundle because no line said what came back.
|
||||
|
||||
The log must carry the facts that separate the two cases, so the next report is read
|
||||
rather than reverse-engineered: size, whether the size guard applied, the AA markers
|
||||
found, and the challenge marker (or its absence).
|
||||
"""
|
||||
dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
verdict = next(m for m in search_logs if "no results table" in m)
|
||||
assert f"bytes={len(AA_PAGE_WITHOUT_TABLE)}" in verdict
|
||||
assert "over_challenge_size_cap=True" in verdict
|
||||
assert "challenge_marker=None" in verdict
|
||||
assert "/md5/" in verdict
|
||||
# The head of the document is quoted too, bounded so a 180 KB page cannot flood the
|
||||
# log file that ships inside the debug bundle.
|
||||
head = next(m for m in search_logs if "Untabled search page head" in m)
|
||||
assert AA_PAGE_WITHOUT_TABLE[:200] in head
|
||||
assert len(head) < 2000
|
||||
|
||||
|
||||
def test_interstitial_fingerprint_names_the_marker_that_proved_it(monkeypatch, search_logs):
|
||||
"""The same line must also settle the opposite case, without needing the body."""
|
||||
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_INTERSTITIAL])
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
with pytest.raises(dd.SearchUnavailableError):
|
||||
dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
verdict = next(m for m in search_logs if "no results table" in m)
|
||||
assert "over_challenge_size_cap=False" in verdict
|
||||
assert "challenge_marker='/.well-known/ddos-guard/'" in verdict
|
||||
assert "aa_markers=none" in verdict
|
||||
|
||||
|
||||
def test_fingerprint_failure_never_breaks_a_search(monkeypatch):
|
||||
"""Diagnostics are best-effort; a bug in them must not cost the user their search."""
|
||||
dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
|
||||
|
||||
def boom(_html):
|
||||
raise RuntimeError("marker scan blew up")
|
||||
|
||||
monkeypatch.setattr(dd, "challenge_marker", boom)
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
_html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
|
||||
|
||||
assert table is None
|
||||
@@ -0,0 +1,193 @@
|
||||
"""One search fetches each distinct AA URL once, however many passes ask for it.
|
||||
|
||||
`DirectDownloadSource.search` fans out: a title variant per grouped variant, then -
|
||||
when nothing was found - the whole set again without the language filter. With
|
||||
DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH on, the requested language is applied locally
|
||||
rather than as `&lang=`, so both passes build a byte-identical URL and the retry
|
||||
re-fetches a page it already had. Behind DDoS-Guard that repeat is a fresh browser
|
||||
solve, tens of seconds for nothing. See issue #1285.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
from shelfmark.core import search_deadline
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ambient_deadline():
|
||||
token = search_deadline._current.set(None)
|
||||
yield
|
||||
search_deadline._current.reset(token)
|
||||
|
||||
|
||||
class _Selector:
|
||||
current_base = "https://annas-archive.gl"
|
||||
last_failure = None
|
||||
|
||||
def rewrite(self, url: str) -> str:
|
||||
return url
|
||||
|
||||
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
|
||||
return None, "exhausted"
|
||||
|
||||
|
||||
_PAGE = "<html><body><main><table><tbody></tbody></table></main></body></html>"
|
||||
|
||||
|
||||
def _count_fetches(monkeypatch) -> list[str]:
|
||||
fetched: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
dd.downloader, "html_get_page", lambda url, **_k: fetched.append(url) or _PAGE
|
||||
)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
|
||||
return fetched
|
||||
|
||||
|
||||
def test_repeated_url_is_fetched_once_within_one_search(monkeypatch):
|
||||
fetched = _count_fetches(monkeypatch)
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
|
||||
with dd._search_page_reuse():
|
||||
first_html, first_table = dd._fetch_search_table(url, _Selector())
|
||||
second_html, second_table = dd._fetch_search_table(url, _Selector())
|
||||
|
||||
assert fetched == [url], "the second ask should have been served from the search's cache"
|
||||
assert first_html == second_html
|
||||
assert second_table is first_table
|
||||
|
||||
|
||||
def test_distinct_urls_are_still_fetched_separately(monkeypatch):
|
||||
fetched = _count_fetches(monkeypatch)
|
||||
|
||||
with dd._search_page_reuse():
|
||||
dd._fetch_search_table("https://annas-archive.gl/search?q=dune", _Selector())
|
||||
dd._fetch_search_table("https://annas-archive.gl/search?q=dune&lang=en", _Selector())
|
||||
|
||||
assert len(fetched) == 2
|
||||
|
||||
|
||||
def test_cache_does_not_leak_between_searches(monkeypatch):
|
||||
"""A later request must not be answered from an earlier request's pages."""
|
||||
fetched = _count_fetches(monkeypatch)
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
|
||||
with dd._search_page_reuse():
|
||||
dd._fetch_search_table(url, _Selector())
|
||||
with dd._search_page_reuse():
|
||||
dd._fetch_search_table(url, _Selector())
|
||||
|
||||
assert fetched == [url, url]
|
||||
|
||||
|
||||
def test_without_the_context_every_fetch_still_goes_out(monkeypatch):
|
||||
"""Callers outside a search - `get_book_info`, downloads - are unaffected."""
|
||||
fetched = _count_fetches(monkeypatch)
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
|
||||
dd._fetch_search_table(url, _Selector())
|
||||
dd._fetch_search_table(url, _Selector())
|
||||
|
||||
assert fetched == [url, url]
|
||||
|
||||
|
||||
def test_a_failure_is_not_cached(monkeypatch):
|
||||
"""A spent budget raises; the next search must not inherit that as a stored answer."""
|
||||
fetched = _count_fetches(monkeypatch)
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
|
||||
with dd._search_page_reuse():
|
||||
with search_deadline.search_deadline(60) as deadline:
|
||||
deadline.event.set()
|
||||
with pytest.raises(dd.SearchUnavailableError):
|
||||
dd._fetch_search_table(url, _Selector())
|
||||
dd._fetch_search_table(url, _Selector())
|
||||
|
||||
assert fetched == [url], "the successful retry should be the only fetch"
|
||||
|
||||
|
||||
def test_a_give_up_page_is_not_cached(monkeypatch):
|
||||
"""Exhausting the mirrors is not an answer, and the retry must get a fresh attempt.
|
||||
|
||||
`_fetch_search_table_uncached` exists to rotate past domains that are not AA, and when
|
||||
it runs out it *returns* rather than raising: a page with no results table and no
|
||||
marker. Storing that would hand the language-filter retry - the pass this cache exists
|
||||
for - a mirror set that may have recovered since, turning a transient outage into
|
||||
"this book has no releases".
|
||||
"""
|
||||
parked = "<html><body>This domain is for sale.</body></html>"
|
||||
fetched: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
dd.downloader, "html_get_page", lambda url, **_k: fetched.append(url) or parked
|
||||
)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
|
||||
with dd._search_page_reuse():
|
||||
assert dd._fetch_search_table(url, _Selector()) == (parked, None)
|
||||
assert dd._fetch_search_table(url, _Selector()) == (parked, None)
|
||||
|
||||
assert fetched == [url, url], "the second pass must not inherit the first's give-up"
|
||||
|
||||
|
||||
def test_a_genuinely_empty_result_is_still_cached(monkeypatch):
|
||||
"""A page saying "No files found." is a real answer from a healthy mirror."""
|
||||
empty = "<html><body><main>No files found. <a href='/md5/x'>x</a></main></body></html>"
|
||||
fetched: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
dd.downloader, "html_get_page", lambda url, **_k: fetched.append(url) or empty
|
||||
)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
|
||||
url = "https://annas-archive.gl/search?q=nothing"
|
||||
|
||||
with dd._search_page_reuse():
|
||||
assert dd._fetch_search_table(url, _Selector()) == (empty, None)
|
||||
assert dd._fetch_search_table(url, _Selector()) == (empty, None)
|
||||
|
||||
assert fetched == [url], "an empty answer is an answer; re-solving for it buys nothing"
|
||||
|
||||
|
||||
def test_language_retry_reuses_the_page_it_already_fetched(monkeypatch):
|
||||
"""The end-to-end shape: language-from-path makes both passes build the same URL."""
|
||||
fetched = _count_fetches(monkeypatch)
|
||||
|
||||
original_get = dd.config.get
|
||||
|
||||
def _fake_get(key: str, default=None, user_id=None):
|
||||
del user_id
|
||||
if key == "DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH":
|
||||
return True
|
||||
return original_get(key, default)
|
||||
|
||||
monkeypatch.setattr(dd.config, "get", _fake_get)
|
||||
monkeypatch.setattr(dd.network, "get_aa_base_url", lambda: "https://annas-archive.gl")
|
||||
|
||||
filters_with_lang = dd.SearchFilters(lang=["en"])
|
||||
filters_without = dd.SearchFilters()
|
||||
|
||||
with dd._search_page_reuse():
|
||||
dd.search_books("dune", filters_with_lang)
|
||||
dd.search_books("dune", filters_without)
|
||||
|
||||
assert len(fetched) == 1, f"both passes build the same URL, got {fetched}"
|
||||
assert "lang=" not in fetched[0]
|
||||
|
||||
|
||||
def test_soup_reuse_is_safe_for_repeated_parsing(monkeypatch):
|
||||
"""The cached Tag is read repeatedly, so reuse must not consume it."""
|
||||
page = (
|
||||
"<html><body><main><table><tbody><tr><td>row</td></tr></tbody></table></main></body></html>"
|
||||
)
|
||||
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: page)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
|
||||
|
||||
with dd._search_page_reuse():
|
||||
_, first = dd._fetch_search_table("https://annas-archive.gl/search?q=dune", _Selector())
|
||||
_, second = dd._fetch_search_table("https://annas-archive.gl/search?q=dune", _Selector())
|
||||
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert len(first.find_all("tr")) == 1
|
||||
assert len(second.find_all("tr")) == 1
|
||||
assert isinstance(BeautifulSoup(str(second), "html.parser"), BeautifulSoup)
|
||||
@@ -306,8 +306,8 @@ def test_search_books_filters_locally_when_path_language_enabled(monkeypatch):
|
||||
|
||||
captured_url: dict[str, str] = {}
|
||||
|
||||
def _fake_html_get_page(url: str, selector, allow_bypasser_fallback=False):
|
||||
del selector, allow_bypasser_fallback
|
||||
def _fake_html_get_page(url: str, selector, **_kwargs):
|
||||
del selector
|
||||
captured_url["url"] = url
|
||||
return r"""
|
||||
<table>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""The untabled-page diagnostic must name the mirror that actually answered.
|
||||
|
||||
Regression for #1298. `html_get_page` rotates mirrors and follows redirects internally,
|
||||
so the URL the search layer passed in is only where the attempt started. Logging that
|
||||
one made the debug bundle report the untabled page against annas-archive.gl when the
|
||||
body had come from .pk - the exact triage cost the #1289 diagnostics were added to
|
||||
remove, reintroduced by reading the wrong variable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
# A protection challenge, so the fingerprint line fires without looking like AA.
|
||||
CHALLENGE_PAGE = (
|
||||
"<html><head><title>DDOS-GUARD</title>"
|
||||
'<link rel="stylesheet" href="/.well-known/ddos-guard/ddg-captcha-page/index.css">'
|
||||
"</head><body>Complete the manual check to continue</body></html>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_logs():
|
||||
"""Collect this module's log messages.
|
||||
|
||||
setup_logger builds loggers outside the standard hierarchy, so their records never
|
||||
reach the root handler caplog installs - see tests/bypass/test_ddg_cookie_reuse.py.
|
||||
"""
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
|
||||
messages: list[str] = []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
messages.append(record.getMessage())
|
||||
|
||||
handler = _Capture()
|
||||
dd.logger.addHandler(handler)
|
||||
previous = dd.logger.level
|
||||
dd.logger.setLevel(logging.DEBUG)
|
||||
dd.logger._cache.clear()
|
||||
try:
|
||||
yield messages
|
||||
finally:
|
||||
dd.logger.removeHandler(handler)
|
||||
dd.logger.setLevel(previous)
|
||||
|
||||
|
||||
class _Selector:
|
||||
current_base = "https://annas-archive.gl"
|
||||
last_failure = None
|
||||
|
||||
def rewrite(self, url: str) -> str:
|
||||
return url
|
||||
|
||||
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
|
||||
return None, "exhausted"
|
||||
|
||||
|
||||
REQUESTED = "https://annas-archive.gl/search?q=Ken+follett"
|
||||
ANSWERED = "https://annas-archive.pk/search?q=Ken+follett"
|
||||
|
||||
|
||||
def test_the_fingerprint_names_the_mirror_that_answered(monkeypatch, search_logs):
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
# The caller must ask for it, or there is nothing to report.
|
||||
assert kwargs["include_response_url"] is True
|
||||
assert url == REQUESTED
|
||||
# What an internal rotation looks like from the outside: a different host.
|
||||
return CHALLENGE_PAGE, ANSWERED
|
||||
|
||||
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
|
||||
|
||||
with pytest.raises(dd.SearchUnavailableError):
|
||||
dd._fetch_search_table_uncached(REQUESTED, _Selector())
|
||||
|
||||
fingerprint = [m for m in search_logs if m.startswith("Search page has no results table")]
|
||||
assert len(fingerprint) == 1
|
||||
assert ANSWERED in fingerprint[0]
|
||||
assert "annas-archive.gl" not in fingerprint[0]
|
||||
|
||||
|
||||
def test_a_downloader_that_reports_no_url_falls_back_to_the_request(monkeypatch, search_logs):
|
||||
"""The plain-string shape stays supported; the line is still worth having."""
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
|
||||
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: CHALLENGE_PAGE)
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
|
||||
|
||||
with pytest.raises(dd.SearchUnavailableError):
|
||||
dd._fetch_search_table_uncached(REQUESTED, _Selector())
|
||||
|
||||
fingerprint = [m for m in search_logs if m.startswith("Search page has no results table")]
|
||||
assert len(fingerprint) == 1
|
||||
assert REQUESTED in fingerprint[0]
|
||||
|
||||
|
||||
def test_the_empty_body_give_up_survives_the_tuple_shape(monkeypatch):
|
||||
"""`("", url)` is truthy, so the exhaustion check has to read the body."""
|
||||
import shelfmark.release_sources.direct_download as dd
|
||||
|
||||
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: ("", REQUESTED))
|
||||
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
|
||||
|
||||
selector = _Selector()
|
||||
selector.last_failure = "Every mirror refused the connection."
|
||||
|
||||
with pytest.raises(dd.SearchUnavailableError) as excinfo:
|
||||
dd._fetch_search_table_uncached(REQUESTED, selector)
|
||||
|
||||
assert "Every mirror refused the connection." in str(excinfo.value)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""A solver must never be handed DDoS-Guard's ?check=1 probe URL.
|
||||
|
||||
Regression for #1292. `html_get_page` follows Anna's Archive redirects by hand, and
|
||||
DDoS-Guard's gate answers /search with a 302 to the same path plus `check=1`. Because
|
||||
the follower walks that handshake by reassigning `current_url`, every downstream handoff
|
||||
- the 403 branch, the 503-challenge branch, the redirect-loop rescues - passed the
|
||||
*probe* URL to the bypasser rather than the page we wanted.
|
||||
|
||||
A solver opens that in a fresh browser holding none of the cookies the probe exists to
|
||||
collect, so DDoS-Guard cannot verify it automatically and serves the manual CAPTCHA page
|
||||
that nothing can solve. The reporter's log is exactly that: a 403 handed off on a
|
||||
`&check=1` URL, FlareSolverr answering "Challenge solved!", and a 4.7 KB DDOS-GUARD
|
||||
CAPTCHA page coming back.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
*,
|
||||
url: str,
|
||||
text: str = "",
|
||||
headers: dict[str, str] | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.text = text
|
||||
self.cookies = cookies or {}
|
||||
self.headers = {"Content-Type": "text/html;charset=utf-8", **(headers or {})}
|
||||
self.is_redirect = 300 <= status_code < 400
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
error = requests.exceptions.HTTPError(f"{self.status_code} Error")
|
||||
error.response = self
|
||||
raise error
|
||||
|
||||
|
||||
def _aa_http(monkeypatch):
|
||||
"""Import http with the network stubbed out and AA treated as an AA host."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
|
||||
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
|
||||
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
|
||||
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
|
||||
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: True)
|
||||
return http
|
||||
|
||||
|
||||
SEARCH_URL = "https://annas-archive.pk/search?index=&display=table&q=Ken+follett"
|
||||
PROBE_URL = f"{SEARCH_URL}&check=1"
|
||||
|
||||
|
||||
def test_403_on_the_check_probe_hands_over_the_pre_probe_url(monkeypatch):
|
||||
"""The reporter's exact sequence: 302 to ?check=1, then 403 on the probe."""
|
||||
http = _aa_http(monkeypatch)
|
||||
|
||||
bypassed: list[str] = []
|
||||
|
||||
def fake_get(url: str, **_kwargs):
|
||||
if "check=1" not in url:
|
||||
return _FakeResponse(302, url=url, headers={"Location": PROBE_URL})
|
||||
return _FakeResponse(403, url=url)
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
monkeypatch.setattr(
|
||||
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or "<html>ok</html>"
|
||||
)
|
||||
|
||||
html = http.html_get_page(SEARCH_URL, retry=1, success_delay=0)
|
||||
|
||||
assert html == "<html>ok</html>"
|
||||
# The page we wanted, not the handshake hop we happened to be standing on.
|
||||
assert bypassed == [SEARCH_URL]
|
||||
|
||||
|
||||
def test_redirect_loop_hands_over_the_pre_probe_url(monkeypatch):
|
||||
"""Stale clearance turns the gate into an endless ?check=1 bounce."""
|
||||
http = _aa_http(monkeypatch)
|
||||
|
||||
bypassed: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
http.requests,
|
||||
"get",
|
||||
lambda url, **_kwargs: _FakeResponse(302, url=url, headers={"Location": PROBE_URL}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or "<html>ok</html>"
|
||||
)
|
||||
|
||||
html = http.html_get_page(SEARCH_URL, retry=1, success_delay=0)
|
||||
|
||||
assert html == "<html>ok</html>"
|
||||
assert bypassed == [SEARCH_URL]
|
||||
|
||||
|
||||
def test_only_the_check_parameter_is_dropped(monkeypatch):
|
||||
"""Everything else about the URL survives - it is still the search we asked for."""
|
||||
http = _aa_http(monkeypatch)
|
||||
|
||||
url = (
|
||||
"https://annas-archive.pk/search?index=&page=1&display=table&acc=aa_download"
|
||||
"&acc=external_download&ext=epub&q=Ken+follett&check=1"
|
||||
)
|
||||
|
||||
assert http._solvable_url(url) == (
|
||||
"https://annas-archive.pk/search?index=&page=1&display=table&acc=aa_download"
|
||||
"&acc=external_download&ext=epub&q=Ken+follett"
|
||||
)
|
||||
|
||||
|
||||
def test_a_url_without_the_probe_is_returned_untouched(monkeypatch):
|
||||
"""No rewriting, no re-encoding: an unrelated URL must come back identical."""
|
||||
http = _aa_http(monkeypatch)
|
||||
|
||||
url = "https://annas-archive.pk/md5/abc?q=a%20b&empty="
|
||||
|
||||
assert http._solvable_url(url) is url
|
||||
|
||||
|
||||
def test_non_aa_hosts_keep_their_check_parameter(monkeypatch):
|
||||
"""Elsewhere `check` is an ordinary query parameter and none of our business."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
|
||||
|
||||
url = "https://example.com/api?check=1"
|
||||
|
||||
assert http._solvable_url(url) == url
|
||||
@@ -97,7 +97,7 @@ end-to-end (`docker compose up` + suite + teardown) and passes.
|
||||
> **`full` profile exercises the real end-to-end
|
||||
> CF solve**: AA search/detail are reachable, but the AA slow-download link points
|
||||
> at the gate, so downloading Moby-Dick forces the in-image headless Chromium to
|
||||
> detect the challenge, solve it (`_bypass_method_cdp_solve`), and fetch the file —
|
||||
> detect the challenge, solve it (`_bypass_method_cdp_gui_click`), and fetch the file —
|
||||
> verified live (`Challenge detected: cloudflare` → `Bypass successful` → Moby-Dick
|
||||
> in `/books`).
|
||||
>
|
||||
@@ -151,7 +151,7 @@ demand (excluded from the PR matrix). It spins up, with **no** mock bypasser, an
|
||||
gate (`mock-cf`), whose challenge page runs JS that issues `cf_clearance` and
|
||||
reloads. Downloading Moby-Dick forces the in-image headless Chromium (seleniumbase
|
||||
CDP, in the `shelfmark` image via `xvfb`+`chromium`) to load the gate, detect the
|
||||
challenge (`Challenge detected: cloudflare`), solve it (`_bypass_method_cdp_solve`),
|
||||
challenge (`Challenge detected: cloudflare`), solve it (`_bypass_method_cdp_gui_click`),
|
||||
and fetch the cleared "Download now" page → the file lands in `/books`. That
|
||||
outcome is *only* reachable if Chrome solved the gate — the literal "spin a Chrome
|
||||
browser" path and the strongest guard for the #1 cluster. Two subtleties this
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Author agreement ranks Prowlarr results; it never narrows the query (#1293).
|
||||
|
||||
MyAnonamouse - the only indexer Shelfmark treats as enriched - used to receive
|
||||
"{title} {author}". MAM ANDs its search terms, so any difference between the
|
||||
metadata provider's author spelling and the tracker's ("Timothy Ferriss" vs
|
||||
"Tim Ferriss") returned nothing at all and the UI reported the book as missing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
AUTHOR_MATCH,
|
||||
AUTHOR_MISMATCH,
|
||||
AUTHOR_UNKNOWN,
|
||||
author_affinity,
|
||||
)
|
||||
|
||||
MAM_INDEXER_ID = 1
|
||||
|
||||
|
||||
class TestAuthorAffinity:
|
||||
@pytest.mark.parametrize(
|
||||
("wanted", "offered"),
|
||||
[
|
||||
("Timothy Ferriss", "Tim Ferriss"),
|
||||
("Tim Ferriss", "Timothy Ferriss"),
|
||||
("T. Ferriss", "Timothy Ferriss"),
|
||||
("Ursula K. Le Guin", "Ursula Le Guin"),
|
||||
("Iain M. Banks", "Iain Banks"),
|
||||
("Frank Herbert", "Frank Herbert, Brian Herbert"),
|
||||
("Robert Jordan Jr.", "Robert Jordan"),
|
||||
("homer", "Homer"),
|
||||
],
|
||||
)
|
||||
def test_same_author_spelled_differently_agrees(self, wanted, offered):
|
||||
assert author_affinity(wanted, offered) == AUTHOR_MATCH
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wanted", "offered"),
|
||||
[
|
||||
("Timothy Ferriss", "Frank Herbert"),
|
||||
("Frank Herbert", "Brian Herbert"),
|
||||
("Homer", "Virgil"),
|
||||
],
|
||||
)
|
||||
def test_different_author_disagrees(self, wanted, offered):
|
||||
assert author_affinity(wanted, offered) == AUTHOR_MISMATCH
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wanted", "offered"),
|
||||
[
|
||||
("Timothy Ferriss", None),
|
||||
("Timothy Ferriss", ""),
|
||||
("", "Tim Ferriss"),
|
||||
(None, "Tim Ferriss"),
|
||||
("Timothy Ferriss", {"name": "Tim Ferriss"}),
|
||||
],
|
||||
)
|
||||
def test_missing_metadata_is_neither_agreement_nor_disagreement(self, wanted, offered):
|
||||
# An indexer that reports no author must not sort below one that reports
|
||||
# the wrong author, so this tier sits between the two.
|
||||
assert author_affinity(wanted, offered) == AUTHOR_UNKNOWN
|
||||
assert AUTHOR_MATCH < AUTHOR_UNKNOWN < AUTHOR_MISMATCH
|
||||
|
||||
def test_a_surname_alone_is_not_enough_for_a_full_name(self):
|
||||
# "Ferriss" appearing under some other given name is a different person.
|
||||
assert author_affinity("Timothy Ferriss", "Bruce Ferriss") == AUTHOR_MISMATCH
|
||||
|
||||
|
||||
class _EnrichedIndexerClient:
|
||||
"""Stands in for a Prowlarr with MyAnonamouse enabled."""
|
||||
|
||||
def __init__(self, search_results=None):
|
||||
self.queries: list[str] = []
|
||||
self.search_results = search_results or []
|
||||
self.indexer_timeout = 90
|
||||
|
||||
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
|
||||
del raise_on_error
|
||||
return [
|
||||
{
|
||||
"id": MAM_INDEXER_ID,
|
||||
"enable": True,
|
||||
"implementation": "MyAnonamouse",
|
||||
"capabilities": {
|
||||
"categories": [
|
||||
{"id": 7000, "subCategories": []},
|
||||
{"id": 3030, "subCategories": []},
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def torznab_search(
|
||||
self, *, indexer_id, query, categories=None, search_type="book", limit=100, offset=0
|
||||
):
|
||||
del indexer_id, categories, search_type, limit, offset
|
||||
self.queries.append(query)
|
||||
return self.search_results
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None, indexers=None):
|
||||
del restrict_to, indexers
|
||||
return [MAM_INDEXER_ID]
|
||||
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
del restrict_to
|
||||
return {}
|
||||
|
||||
|
||||
def _mam_result(guid: str, author: str | None) -> dict:
|
||||
return {
|
||||
"guid": guid,
|
||||
"title": "The Tao of Seneca",
|
||||
"author": author,
|
||||
"indexerId": MAM_INDEXER_ID,
|
||||
"indexer": "MyAnonamouse",
|
||||
"protocol": "torrent",
|
||||
"size": 1048576,
|
||||
"seeders": 10,
|
||||
"leechers": 1,
|
||||
"categories": [{"id": 7020}],
|
||||
"infoUrl": f"https://tracker.example/{guid}",
|
||||
}
|
||||
|
||||
|
||||
def _search(monkeypatch, client, *, manual_query=None):
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
|
||||
values = {"PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False}
|
||||
monkeypatch.setattr(
|
||||
prowlarr_source.config, "get", lambda key, default=None: values.get(key, default)
|
||||
)
|
||||
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: client)
|
||||
|
||||
book = BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id="123",
|
||||
title="The Tao of Seneca",
|
||||
authors=["Timothy Ferriss"],
|
||||
)
|
||||
plan = build_release_search_plan(book, languages=["en"], manual_query=manual_query)
|
||||
return source.search(book, plan, content_type="ebook")
|
||||
|
||||
|
||||
class TestEnrichedIndexerQuery:
|
||||
def test_enriched_indexer_is_queried_without_the_author(self, monkeypatch):
|
||||
client = _EnrichedIndexerClient()
|
||||
|
||||
_search(monkeypatch, client)
|
||||
|
||||
assert client.queries == ["The Tao of Seneca"]
|
||||
assert not any("Ferriss" in query for query in client.queries)
|
||||
|
||||
|
||||
class TestAuthorOrdering:
|
||||
def test_matching_author_leads_and_mismatch_stays_visible(self, monkeypatch):
|
||||
client = _EnrichedIndexerClient(
|
||||
search_results=[
|
||||
_mam_result("other-author", "Frank Herbert"),
|
||||
_mam_result("no-author", None),
|
||||
_mam_result("right-author", "Tim Ferriss"),
|
||||
]
|
||||
)
|
||||
|
||||
results = _search(monkeypatch, client)
|
||||
|
||||
# Ranked, not filtered: the wrong author is last but still reachable.
|
||||
assert [r.extra["author"] for r in results] == ["Tim Ferriss", None, "Frank Herbert"]
|
||||
|
||||
def test_manual_query_is_not_reordered_against_the_metadata_author(self, monkeypatch):
|
||||
client = _EnrichedIndexerClient(
|
||||
search_results=[
|
||||
_mam_result("other-author", "Frank Herbert"),
|
||||
_mam_result("right-author", "Tim Ferriss"),
|
||||
]
|
||||
)
|
||||
|
||||
results = _search(monkeypatch, client, manual_query="tao seneca")
|
||||
|
||||
assert client.queries == ["tao seneca"]
|
||||
assert [r.extra["author"] for r in results] == ["Frank Herbert", "Tim Ferriss"]
|
||||
@@ -451,11 +451,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.32.4"
|
||||
version = "3.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -598,11 +598,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.1.0"
|
||||
version = "26.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/b8/ec4ba3f6cace4091c34e27478b576bb80f2f06fab80fd42c0ecc785b308f/gunicorn-26.1.0.tar.gz", hash = "sha256:1413d777bf99d31ebeb08acd354b01f1ecc44db0aa7b811ae7b86c669232e4f7", size = 755923, upload-time = "2026-08-18T11:49:39.438Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/dc/7a55fc605543fd5cb11c003fbbb21a1911d5e88a582cce6c5e063bf5c176/gunicorn-26.1.0-py3-none-any.whl", hash = "sha256:9f45bcddec5e9dc7a25a3bdccb0c6832f11fd5d4739b1ee36c8d2fec25f1dc86", size = 216237, upload-time = "2026-08-18T11:49:38.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -911,11 +911,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.3"
|
||||
version = "4.11.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -929,26 +929,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.4.14"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/51/135dc6ba2c021ce32b40700c8c337db72d802893e15291f4b3056076582f/prek-0.4.14.tar.gz", hash = "sha256:f6d0952e31ffd6e508660749dd51b8d8de96e955ed12c40e411f3224f502fed2", size = 537668, upload-time = "2026-08-17T04:27:55.031Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/75/727724174d419cab6e11e0c22c3fbcdd083312b0a0202dfa6befdb7f1bcb/prek-0.4.14-py3-none-linux_armv6l.whl", hash = "sha256:cf7fe2e07c99948ca3326fbd4254054cf4caa71a69cf9032be09c3938544e1e3", size = 5878406, upload-time = "2026-08-17T04:27:30.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/b6/82dfb41347342b4c53c1ddd27cfa81d3e1cf4ad0d5d76493c51e8da58dc9/prek-0.4.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6c8043aa5555c2ada561f4c69429e5117f370ad92f7d708340613b0965e725bd", size = 6216669, upload-time = "2026-08-17T04:27:31.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/68/ade0ba8b8c0044a3f7ca1a5cd7c97bc10656398c67466b04235f0f0b7418/prek-0.4.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:355570f0d8366a56817e55f44edf736ffc9ff2323894e21cd739ab519d343541", size = 5728085, upload-time = "2026-08-17T04:27:33.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cc/8ddb67fb000d1ad4c95288e6c7e27989d3c172cd88893e3a6f9cdd7199fb/prek-0.4.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:334f48a2b19e63c5ab741e601cb6088caaa11d1b75146900dc298b055452d551", size = 6043650, upload-time = "2026-08-17T04:27:34.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/49/2780489798147fa1e81fa017af384a40229802cf36680cf72c74478d143c/prek-0.4.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5fbb17270df2dcb3c1aa6edfaa68f850d7968862413e58b1572e41c981f644f9", size = 5786094, upload-time = "2026-08-17T04:27:36.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/2a/27a4cdfa767b46663eb32a2b5f78ebaaeefc6e5caa87a21587216a504e31/prek-0.4.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ec46be0b1b45943a0746fdf69feeede274b0230bad32b11e2489bb600846081", size = 6239874, upload-time = "2026-08-17T04:27:37.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ca/5ae58d95bc9dbad75ea13aea83fe0acfaca37d027c6bd7f9f1d1d97f3666/prek-0.4.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a21eea6ee996dbba351c1af8c6bd1959a61c37949757c0b97aef3edde82c126a", size = 6968009, upload-time = "2026-08-17T04:27:39.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1c/c6a7800406f987559fc8e7c2cfd257a5a68096e806e953f143b488e00e4f/prek-0.4.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34169cb1c8dfbe4b9cb7af164f5c1c3dff68929c61ca1e1de28d378b32720836", size = 6443370, upload-time = "2026-08-17T04:27:41.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/38/4bf84223216ce2d31b500691d1644a9f35d6e468cb8ecb9a7dfbc66bc82c/prek-0.4.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e39772ebf579f957fdbbc66ef6a7f7433bf603128cd60d5c804c70caac5f69c0", size = 6056449, upload-time = "2026-08-17T04:27:42.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0a/f1980039ab7bf8342c4aa4da2a4cafe40e6a840f456d5bbf86ba024fcdca/prek-0.4.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a79e5940354a789e1311172a960c5daa75e3f643a47ee2be85b7d2ca1d7d980a", size = 5839941, upload-time = "2026-08-17T04:27:44.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e2/67f067dcb912e157bc7f4cd2aeb422a0de65e7744c675d7f439676f6c832/prek-0.4.14-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60b6539c1b28804807849173a6b49d467f6f36ca98bbb4536f5e34114d79942a", size = 5762723, upload-time = "2026-08-17T04:27:45.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/39/6669fff5c3336a2b79cf853c86b95547cf9be720a2ee7e4ae5eaa6cb46a1/prek-0.4.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:24cfabd9e5b8c5546ecaef562841a41a255a9dec1b7ae7761ce5a7a024ab9dd9", size = 6090040, upload-time = "2026-08-17T04:27:47.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/23/c23210be4c89795a854e76146c2465aaffc3d85ea83df61c1d4d5bb41bca/prek-0.4.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1f08d368da45439f949885bb0b637bc7dfe6910140d5285fa8bae058697ddd06", size = 6570273, upload-time = "2026-08-17T04:27:48.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/9c/4f10728ed36295347e84f01bb3e4c9078c9e337edc66215f5e2066b30551/prek-0.4.14-py3-none-win32.whl", hash = "sha256:5bfb30808ce2099c67d2a2d4cd68dc031e3fec1eeb61d73ef51a8f7c04d021cb", size = 5586715, upload-time = "2026-08-17T04:27:50.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/a7/d080a157d4e92927f7618d62d6a23979ef64a10c76a040cb8a8a194c50d7/prek-0.4.14-py3-none-win_amd64.whl", hash = "sha256:29364012d5704475d1092eb8a96ea30b163279096ad5e0c80a620fffa79bc639", size = 5969946, upload-time = "2026-08-17T04:27:51.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e1/6fc64bb82e7270f61707e00b6d3154a4592ae0b2d3bd308173aa7aabe0a1/prek-0.4.14-py3-none-win_arm64.whl", hash = "sha256:ff588c02e10c8d05150763607671a22d5585c0ff7036c884d3489c2726eb215c", size = 5729906, upload-time = "2026-08-17T04:27:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/10/5d99bdfc77b51ab427a139e0b9f2dd5eacc74fe02fd1228c7be1a8ede6ad/prek-0.5.0-py3-none-win32.whl", hash = "sha256:d4970878d5032ad101b4ec5662f3848a70f80c0295ed5a1fea0aed82979c1251", size = 5340535, upload-time = "2026-08-27T03:50:27.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/835019081b3a09acefc6e28a6dd561a45c8270f8a84e85fa00d6f2be0300/prek-0.5.0-py3-none-win_amd64.whl", hash = "sha256:7bf2df923ba48ec72af7dc69033d613f4775eb301cdf975e19e749ecda2a28c4", size = 5725188, upload-time = "2026-08-27T03:50:29.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/dc/a634d4c5951bd899526c6d8f2a4776bc42af1df4b01951e4f90412909cfc/prek-0.5.0-py3-none-win_arm64.whl", hash = "sha256:43e9d2d727435b0be28fe2195a6a22b0e5e0d937270ae0c88edfc42b71db8fab", size = 5492540, upload-time = "2026-08-27T03:50:31.49Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1377,27 +1377,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.4"
|
||||
version = "0.16.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1411,7 +1411,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "selenium"
|
||||
version = "4.47.0"
|
||||
version = "4.48.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1421,14 +1421,14 @@ dependencies = [
|
||||
{ name = "urllib3", extra = ["socks"] },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/a2/213190a606bc036b4db1b8129f399964988872a555b50dfbfddf612d333c/selenium-4.47.0.tar.gz", hash = "sha256:4f6667c23080646e045fb91d2039687e88f549d667961f6ce85832b17384b68e", size = 1014095, upload-time = "2026-08-10T17:54:11.99Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/8c/db97bdc1a8b41e7b6bf9d3099722ed8e7ac61328af8637f20004015c642b/selenium-4.48.0.tar.gz", hash = "sha256:045c1ec054c94e3be6c10febc509aa513b4c05e9146d1a9cf3de5375ec6ca2a1", size = 1055269, upload-time = "2026-08-27T20:05:51.235Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0b/652575986d2ed03d29103d8574580a03aefe50d243d225b441e2375bd0f6/selenium-4.47.0-py3-none-any.whl", hash = "sha256:2eac6b8e7c017f57ecc40820383da8881a6fd7a90ea555c1b0af322f2344b347", size = 9511195, upload-time = "2026-08-10T17:54:09.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/5d1b0e9cb43965902f0a9f7add527134db71cabf2f26766ae2fdb7774b9a/selenium-4.48.0-py3-none-any.whl", hash = "sha256:b2a1d77019db92513e59aa2376710fe3d42b65a4d493dccd0c799c1a0d574d93", size = 9561411, upload-time = "2026-08-27T20:05:48.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seleniumbase"
|
||||
version = "4.52.2"
|
||||
version = "4.53.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -1492,9 +1492,9 @@ dependencies = [
|
||||
{ name = "wheel" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/0e/23b3f5232caf0cdadaa67b9e05ff83c9438839c20fe3161c5f0b98f19a8a/seleniumbase-4.52.2.tar.gz", hash = "sha256:261271b3c6d18d404acbe7b7efc661061ff02f0835cfc7cc6e9f52b13f1fa530", size = 677927, upload-time = "2026-08-23T23:36:25.076Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/da/6521c25ce5498853a69cf01e98dd46ed9330b28be5a7b47d2757ea512129/seleniumbase-4.53.5.tar.gz", hash = "sha256:500c94bc86fb1c0f285aadaadf0332397eb4886e7b4fecc6808e8bbd02c4ab54", size = 690221, upload-time = "2026-09-02T05:34:51.789Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/36/d87a0f455bb80af763f99b0e3a1abea4f35700509338dbb80ce01d1a5422/seleniumbase-4.52.2-py3-none-any.whl", hash = "sha256:d7a7080767cf23ff9f4ace5589035f7eaf35f1ee9544dc038dbf0814f1a0701d", size = 682841, upload-time = "2026-08-23T23:36:21.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/03/cc31c8fb3cf096548c7716cfa6870481d7b85c4672f63b55ea301211acec/seleniumbase-4.53.5-py3-none-any.whl", hash = "sha256:0c0cdb16d56eb1f95ea73bd7428d0cdd8721f152e28a5aee164ef4b7c522e599", size = 695247, upload-time = "2026-09-02T05:34:49.366Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1575,7 +1575,7 @@ requires-dist = [
|
||||
{ name = "qbittorrent-api", specifier = ">=2026.8.1" },
|
||||
{ name = "rarfile" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.52.2" },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.53.5" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transmission-rpc" },
|
||||
]
|
||||
@@ -1588,7 +1588,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = "==0.16.4" },
|
||||
{ name = "ruff", specifier = "==0.16.5" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
@@ -1751,11 +1751,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
version = "1.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user