Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f189a5f
fix(abb): improve search reliability and result completeness
NemesisHubris Jun 5, 2026
55e68a3
fix: three upstream bugs — mirror URL query params, rTorrent audioboo…
spin-drift Jun 14, 2026
3bed0c1
test: add regression tests for all three upstream bug fixes
NemesisHubris Jun 5, 2026
c72e6d9
fix: replace Python 2 except syntax across 27 files
NemesisHubris Jun 6, 2026
91e4328
fix(#1040): clean up empty destination dir when write probe fails
NemesisHubris Jun 6, 2026
0b7ba5f
fix(#1010): refresh activity snapshot after cancel
NemesisHubris Jun 6, 2026
cec601c
fix(#1021): cap AA slow-download countdown retries to prevent infinit…
NemesisHubris Jun 6, 2026
187d0a1
feat: detect language from AA distant path (PR #1031)
NemesisHubris Jun 6, 2026
5dddda8
feat: show display_name in activity feed for admin users
NemesisHubris Jun 6, 2026
a224038
feat: add noop "Leave in Place" output handler
NemesisHubris Jun 6, 2026
6b70b39
feat: add fuzzy text-matching utility for book title/author/ISBN comp…
NemesisHubris Jun 6, 2026
b4e65f7
feat: group multi-book flat folder into per-book subfolders before AB…
NemesisHubris Jun 6, 2026
a1e64c1
test: add integration tests for multi-book flat folder grouping
NemesisHubris Jun 6, 2026
e92d807
chore: track noop output handler patch files
NemesisHubris Jun 7, 2026
cf0e81e
feat: generate multi-variant title search queries to improve source m…
NemesisHubris Jun 7, 2026
ae9588d
fix: improve title variant generation for series/part/bracket patterns
NemesisHubris Jun 7, 2026
f92ce1a
fix: extend title variant patterns for biography, edition, and N-of-M…
NemesisHubris Jun 7, 2026
523f84c
fix: propagate full-title fallback to grouped_title_variants for Anna…
NemesisHubris Jun 7, 2026
33381b3
fix: fix AA title parser to handle nested edition spans and lgli garb…
NemesisHubris Jun 7, 2026
8e39e13
chore: drop orphan patches/ files from litfinder port
spin-drift Jun 14, 2026
cce8861
style: apply oxfmt to ActivitySidebar.tsx after litfinder port
spin-drift Jun 14, 2026
23d7d94
test(abb): use valid hex info hashes in scraper test fixtures
spin-drift Jun 14, 2026
2910577
style: ruff lint fixes for ported code
spin-drift Jun 14, 2026
b59498c
style: ruff format ported code to match project config
spin-drift Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions shelfmark/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,11 @@ def download_settings() -> list[SettingsField]:
"label": "Grimmory (API)",
"description": "Upload files directly to Grimmory",
},
{
"value": "noop",
"label": "Leave in Place",
"description": "Do nothing — file stays wherever it was downloaded",
},
],
default="folder",
user_overridable=True,
Expand Down Expand Up @@ -1448,6 +1453,17 @@ def download_source_settings() -> list[SettingsField]:
),
default=False,
),
CheckboxField(
key="DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH",
label="Detect Language From Distant Path",
description=(
"When language metadata is missing or unknown, parse the distant path "
"(file path shown in search results) for language tags like [BD FR] or [En]. "
"Also enables local language filtering so lgli files without AA language "
"metadata are not excluded before the distant path can be checked."
),
default=False,
),
PasswordField(
key="AA_DONATOR_KEY",
label="Account Donator Key",
Expand Down
23 changes: 18 additions & 5 deletions shelfmark/core/activity_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ def _minimal_request_snapshot(request_row: dict[str, Any], request_id: int) -> d
username = request_row.get("username")
if isinstance(username, str):
minimal_request["username"] = username
display_name = request_row.get("display_name")
if display_name is not None:
minimal_request["display_name"] = display_name
return {"kind": "request", "request": minimal_request}


Expand Down Expand Up @@ -1021,12 +1024,22 @@ def api_activity_history() -> Response | tuple[Response, int]:
download_row,
has_live_queue_entry=task_id in live_queue_index,
)
payload.append(
DownloadHistoryService.to_history_row(
effective_download_row,
dismissed_at=dismissed_at,
)
history_entry = DownloadHistoryService.to_history_row(
effective_download_row,
dismissed_at=dismissed_at,
)
uid = normalize_positive_int(download_row.get("user_id"))
if uid is not None:
try:
db_user = user_db.get_user(user_id=uid)
display_name = db_user.get("display_name") if db_user else None
except AttributeError, KeyError, TypeError:
display_name = None
if isinstance(history_entry.get("snapshot"), dict):
dl = history_entry["snapshot"].get("download")
if isinstance(dl, dict):
dl["display_name"] = display_name
payload.append(history_entry)
continue

if item_type == "request":
Expand Down
42 changes: 42 additions & 0 deletions shelfmark/core/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,50 @@ def _plugin_label(plugin: object, fallback_scheme: str) -> str:
return " ".join(parts)


def _apprise_proxy_env() -> dict[str, str]:
"""Build proxy env vars from app config so Apprise respects the proxy setting."""
import os

from shelfmark.core.config import config as _cfg

mode = str(_cfg.get("PROXY_MODE", "") or "").lower()
env: dict[str, str] = {}

if mode == "http":
http = str(_cfg.get("HTTP_PROXY", "") or "").strip()
https = str(_cfg.get("HTTPS_PROXY", "") or "").strip() or http
if http:
env["HTTP_PROXY"] = http
env["http_proxy"] = http
if https:
env["HTTPS_PROXY"] = https
env["https_proxy"] = https
elif mode == "socks5":
socks = str(_cfg.get("SOCKS5_PROXY", "") or "").strip()
if socks:
env["HTTP_PROXY"] = socks
env["http_proxy"] = socks
env["HTTPS_PROXY"] = socks
env["https_proxy"] = socks

no_proxy = str(_cfg.get("NO_PROXY", "") or "").strip()
if no_proxy and env:
env["NO_PROXY"] = no_proxy
env["no_proxy"] = no_proxy

# Don't override if the user already set these in the environment directly
return {k: v for k, v in env.items() if not os.environ.get(k)}


def _dispatch_to_apprise(
urls: Iterable[str],
*,
title: str,
body: str,
notify_type: object,
) -> dict[str, Any]:
import os

normalized_urls = _normalize_urls(list(urls))
url_schemes = _extract_url_schemes(normalized_urls)
if not normalized_urls:
Expand All @@ -408,6 +445,11 @@ def _dispatch_to_apprise(
if apprise is None:
return {"success": False, "message": "Apprise is not installed"}

proxy_env = _apprise_proxy_env()
if proxy_env:
logger.debug("Applying proxy env for Apprise dispatch: %s", list(proxy_env.keys()))
os.environ.update(proxy_env)

valid_urls = 0
invalid_urls = 0
delivered_urls = 0
Expand Down
13 changes: 9 additions & 4 deletions shelfmark/core/request_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,20 +157,25 @@ def normalize_optional_positive_int(value: object, field_name: str = "value") ->


def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> None:
"""Add 'username' to each request row by looking up user_id."""
"""Add 'username' and 'display_name' to each request row by looking up user_id."""
if not _is_user_db_like(user_db):
return

cache: dict[int, str] = {}
cache: dict[int, dict[str, str | None]] = {}
for row in rows:
requester_id = normalize_positive_int(row.get("user_id"))
if requester_id is None:
row["username"] = ""
row["display_name"] = None
continue
if requester_id not in cache:
requester = user_db.get_user(user_id=requester_id)
cache[requester_id] = requester.get("username", "") if requester else ""
row["username"] = cache[requester_id]
cache[requester_id] = {
"username": requester.get("username", "") if requester else "",
"display_name": requester.get("display_name") if requester else None,
}
row["username"] = cache[requester_id]["username"]
row["display_name"] = cache[requester_id]["display_name"]


def extract_release_source_id(release_data: object) -> str | None:
Expand Down
15 changes: 14 additions & 1 deletion shelfmark/core/search_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import TYPE_CHECKING

from shelfmark.core.config import config
from shelfmark.core.text_match import generate_title_search_variants
from shelfmark.metadata_providers import (
BookMetadata,
build_localized_search_titles,
Expand Down Expand Up @@ -111,7 +112,10 @@ def build_release_search_plan(
resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None

author = _pick_search_author(book)
base_title = _pick_search_title(book)
raw_title = _pick_search_title(book)
title_candidates = generate_title_search_variants(raw_title)
base_title = title_candidates[0]
full_title = title_candidates[-1]

if resolved_manual_query:
# Manual override: use the raw query as-is (no language/title expansion).
Expand Down Expand Up @@ -166,6 +170,15 @@ def build_release_search_plan(
if title
]

# If stripping produced a shorter clean form, append the full original title as a
# final fallback variant so sources can try the complete title if the short one misses.
if full_title != base_title:
full_variant = ReleaseSearchVariant(title=full_title, author=author, languages=None)
if not any(v.query == full_variant.query for v in title_variants):
title_variants.append(full_variant)
if not any(v.query == full_variant.query for v in grouped_variants):
grouped_variants.append(full_variant)

# If no titles could be built, fall back to ISBN queries.
if not title_variants and isbn_candidates:
title_variants = [
Expand Down
174 changes: 174 additions & 0 deletions shelfmark/core/text_match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Shared text-normalization + fuzzy token-matching helpers for book matching."""

from __future__ import annotations

import re

DEFAULT_TITLE_MATCH_THRESHOLD = 0.85

STOPWORDS = frozenset(
{
"a",
"an",
"the",
"of",
"and",
"or",
"to",
"in",
"on",
"for",
"with",
"is",
"by",
}
)


def tokens(text: str | None) -> list[str]:
"""Lowercase alphanumeric tokens from arbitrary text."""
if not text:
return []
return [tok for tok in re.split(r"[^a-z0-9]+", text.lower()) if tok]


def significant_tokens(text: str | None) -> list[str]:
"""Tokens with stopwords and 1-char noise removed."""
return [tok for tok in tokens(text) if len(tok) >= 2 and tok not in STOPWORDS]


def author_surname(author: str | None) -> str | None:
"""Return the most distinctive author token (the surname), or None."""
value = author or ""
if "," in value:
value = value.split(",")[0]
toks = significant_tokens(value)
return toks[-1] if toks else None


def title_tokens_match(
title: str | None,
haystack_tokens: set[str],
threshold: float = DEFAULT_TITLE_MATCH_THRESHOLD,
) -> bool:
"""True when enough significant title tokens appear in `haystack_tokens`."""
title_toks = significant_tokens(title)
if not title_toks:
return False
present = sum(1 for tok in title_toks if tok in haystack_tokens)
return (present / len(title_toks)) >= threshold


def normalize_isbn(value: object) -> str:
"""Normalize an ISBN to comparable form (digits + trailing X, uppercased)."""
if not value:
return ""
return re.sub(r"[^0-9xX]", "", str(value)).upper()


# ---- Title variant generation ----

# Square-bracket content anywhere in the string: [Dramatized Adaptation], [Unabridged]
# Stripped first so downstream patterns see a cleaner string.
_RE_SQUARE_BRACKETS = re.compile(r"\s*\[[^\]]+\]")

# "(N of M)" part indicator — strips the parens AND everything that follows.
# Handles mid-string positions like "(1 of 2) [Adaptation] Series Name 2".
# Applied before _RE_PAREN_SERIES so it catches the common "Title (1 of 2) Series X" pattern.
_RE_PART_OF_M = re.compile(r"\s*\(\d+\s+of\s+\d+\).*$")

# Trailing parenthetical with series/volume/part markers.
# Use #\s*\d for hash-number patterns since # is not a word character and \b won't match around it.
_RE_PAREN_SERIES = re.compile(
r"\s*\([^)]*(?:\b(?:book|vol\.?|volume|part)\b|#\s*\d)[^)]*\)\s*$",
re.IGNORECASE,
)

# Bare hash-number suffix without parens: "#13", "# 5"
_RE_BARE_NUMBER = re.compile(r"\s+#\s*\d+\s*$")

# Bare volume suffix without a separator: "Book 9", "Volume 2", "Part III" at end of string.
# Lookbehind ensures the preceding character is alphanumeric so we don't eat a separator
# that belongs to a different pattern (e.g. ", Book 1" should be handled by _RE_VOLUME_SUFFIX).
_RE_BARE_VOLUME = re.compile(
r"(?<=[a-zA-Z0-9])\s+(?:book|vol\.?|volume|part)\s+\S+\s*$",
re.IGNORECASE,
)

# Comma/hyphen/colon volume suffix with explicit separator: ", Book 3", "- Volume II", ": Part 1 of 3"
_RE_VOLUME_SUFFIX = re.compile(
r"\s*[,\-:]\s*(?:book|vol\.?|volume|part)\s+\S+(?:\s+of\s+\S+)?\s*$",
re.IGNORECASE,
)

# Genre/marketing descriptor after colon: ": A Novel", ": A Gripping Thriller", ": A Dual Biography"
_RE_GENRE_SUBTITLE = re.compile(
r"\s*:\s*(?:a|an)\s+(?:\w+\s+)?(?:novel|novella|memoir|thriller|mystery|romance|"
r"adventure|epic|saga|chronicle|fantasy|story|tale|biography|autobiography|"
r"collection|anthology)\s*$",
re.IGNORECASE,
)

# Edition/reprint subtitle: ": 25th Anniversary Edition", ": Revised Edition", ": Illustrated Edition"
_RE_EDITION_SUBTITLE = re.compile(
r"\s*:\s+(?:(?:\w+\s+)*)?(?:anniversary|revised|expanded|updated|illustrated|"
r"deluxe|collector'?s?|special|complete|uncut|definitive|enhanced|restored|"
r"original|critical)\s+edition\s*$",
re.IGNORECASE,
)

# Long subtitle after colon — only fires when subtitle side has ≥4 words.
# This conservatively avoids stripping "Mistborn: The Final Empire" (3 words).
_RE_LONG_COLON_SUBTITLE = re.compile(r"\s*:\s+(?:\S+\s+){3}\S.*$")

# Em/en-dash subtitle separator
_RE_DASH_SUBTITLE = re.compile(r"\s*[–—]\s+.+$")

# Applied in order. _RE_SQUARE_BRACKETS runs first since stripping [..] may
# expose other patterns. _RE_LONG_COLON_SUBTITLE runs before _RE_VOLUME_SUFFIX
# so a title like "Shadows of Sparta: A Long Subtitle, Book 1" strips all the
# way to "Shadows of Sparta" in one pass rather than just removing ", Book 1".
_TITLE_STRIP_PATTERNS = (
_RE_SQUARE_BRACKETS,
_RE_PART_OF_M,
_RE_PAREN_SERIES,
_RE_BARE_NUMBER,
_RE_BARE_VOLUME,
_RE_LONG_COLON_SUBTITLE,
_RE_GENRE_SUBTITLE,
_RE_EDITION_SUBTITLE,
_RE_VOLUME_SUFFIX,
_RE_DASH_SUBTITLE,
)


def _strip_one_pass(text: str) -> str:
"""Apply the first matching strip pattern and return the cleaned string."""
for pattern in _TITLE_STRIP_PATTERNS:
candidate = pattern.sub("", text).strip()
if candidate and candidate.lower() != text.lower() and len(candidate) >= 2:
return candidate
return text


def generate_title_search_variants(title: str) -> list[str]:
"""Return ordered search candidates from a book title.

Applies strip patterns iteratively until stable, then returns
``[short_form, original]`` if anything was removed, else ``[original]``.
The short form is tried first since indexers store the clean title.
"""
if not title:
return []
original = " ".join(title.split())

current = original
for _ in range(6):
nxt = _strip_one_pass(current)
if nxt == current:
break
current = nxt

if not significant_tokens(current) or current.lower() == original.lower():
return [original]
return [current, original]
7 changes: 7 additions & 0 deletions shelfmark/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ def normalize_http_url(
if scheme:
normalized = f"{scheme}://{normalized}"

# Strip query string and fragment — mirrors are used as base URLs for
# constructing search requests; params/fragments on the configured URL
# produce malformed URLs when paths are appended (issue #999).
parsed = urlparse(normalized)
if parsed.query or parsed.fragment:
normalized = parsed._replace(query="", fragment="").geturl()

if strip_trailing_slash:
normalized = normalized.rstrip("/")

Expand Down
Loading