Patch: Various additions (#564)

- Added rich Prowlarr search results for whitelisted indexers
- Added torznab query for whitelisted indexers
- Added flags for all Prowlarr indexers
- Added completed external download retry mechanism and "locating" state
- Added client side preference storage of Book/Audiobook search
preference
- Fixed reverse proxy base URL in edge cases
- Added gevent locking for I/O operations, keeps healthcheck alive on
intensive processing operations
- Added M4A supported audiobook option
- Improved file transfer counting and logging with hardlink fallback
warnings
- Fixed proxy auth header for REMOTE_USER scenario
- Dependency tweak for internal bypasser
This commit is contained in:
Alex
2026-01-31 12:53:11 +00:00
committed by GitHub
parent 86082c999c
commit e5ccabe1ef
35 changed files with 1867 additions and 563 deletions

View File

@@ -0,0 +1,39 @@
# Bypass testing - switch between dev build and v1.0.1
# Usage:
# Test dev build: docker compose -f docker-compose.bypass-test.yml up shelfmark-dev
# Test v1.0.1: docker compose -f docker-compose.bypass-test.yml up shelfmark-stable
# Pull latest dev: docker compose -f docker-compose.bypass-test.yml build shelfmark-dev
# Pull v1.0.1: docker compose -f docker-compose.bypass-test.yml pull shelfmark-stable
services:
# Dev image from registry
shelfmark-dev:
image: ghcr.io/calibrain/shelfmark:dev
container_name: shelfmark-bypass-dev
environment:
PUID: 1000
PGID: 1000
DEBUG: true
ports:
- 8084:8084
volumes:
- ./.local/bypass-test/config-dev:/config
- ./.local/bypass-test/books:/books
- ./.local/bypass-test/log-dev:/var/log/shelfmark
- ./.local/bypass-test/tmp:/tmp/shelfmark
# Stable v1.0.1 for comparison
shelfmark-stable:
image: ghcr.io/calibrain/shelfmark:1.0.1
container_name: shelfmark-bypass-stable
environment:
PUID: 1000
PGID: 1000
DEBUG: true
ports:
- 8085:8084
volumes:
- ./.local/bypass-test/config-stable:/config
- ./.local/bypass-test/books:/books
- ./.local/bypass-test/log-stable:/var/log/shelfmark
- ./.local/bypass-test/tmp:/tmp/shelfmark

View File

@@ -4,7 +4,7 @@ Shelfmark can run behind a reverse proxy at the root path (recommended) or under
## Root path setup (Recommended)
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids the workarounds needed for subpath deployments.
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
```nginx
server {
@@ -26,7 +26,7 @@ server {
## Subpath setup
Running Shelfmark under a subpath like `/shelfmark` requires additional configuration due to how the frontend generates certain URLs.
Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules.
### 1. Set the base path in Shelfmark
@@ -35,7 +35,7 @@ Running Shelfmark under a subpath like `/shelfmark` requires additional configur
### 2. Configure your reverse proxy
The frontend generates some URLs at the root level (`/socket.io/`, `/api/`, `/logo.png`) regardless of the `URL_BASE` setting. You'll need to add proxy rules to handle these paths.
All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. A single location block is enough.
---
@@ -44,56 +44,6 @@ The frontend generates some URLs at the root level (`/socket.io/`, `/api/`, `/lo
**Complete Nginx configuration for subpath deployment:**
```nginx
# Redirect /logo.png to subpath (frontend bug workaround)
location = /logo.png {
return 302 /shelfmark/logo.png;
}
# Proxy root /api/ to /shelfmark/api/ (frontend generates root paths)
location /api/ {
proxy_pass http://shelfmark:8084/shelfmark/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# Proxy root /socket.io/ to backend (frontend connects to root)
location /socket.io/ {
proxy_pass http://shelfmark:8084/socket.io/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# Rewrite /shelfmark/socket.io/ to /socket.io/ on backend
# (Socket.IO endpoint is always at /socket.io/ regardless of URL_BASE)
location ^~ /shelfmark/socket.io/ {
proxy_pass http://shelfmark:8084/socket.io/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# Main shelfmark location
location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
@@ -171,60 +121,6 @@ error_page 401 =302 https://auth.example.com/?rd=$target_url;
# Include Authelia auth endpoint in your server block
include /etc/nginx/snippets/authelia-authrequest.conf;
# Redirect /logo.png to subpath (frontend bug workaround)
location = /logo.png {
return 302 /shelfmark/logo.png;
}
# Proxy root /api/ to /shelfmark/api/ (frontend generates root paths)
location /api/ {
include /etc/nginx/snippets/authelia-location.conf;
proxy_pass http://shelfmark:8084/shelfmark/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# Proxy root /socket.io/ to backend (frontend connects to root)
location /socket.io/ {
include /etc/nginx/snippets/authelia-location.conf;
proxy_pass http://shelfmark:8084/socket.io/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# Rewrite /shelfmark/socket.io/ to /socket.io/ on backend
location ^~ /shelfmark/socket.io/ {
include /etc/nginx/snippets/authelia-location.conf;
proxy_pass http://shelfmark:8084/socket.io/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# Main shelfmark location
location /shelfmark/ {
include /etc/nginx/snippets/authelia-location.conf;
@@ -246,18 +142,6 @@ location /shelfmark/ {
---
## Known issues with subpath deployments
The following issues require the workarounds above:
1. **Socket.IO connects to root**: The frontend Socket.IO client connects to `https://yourdomain.com/socket.io/` instead of `https://yourdomain.com/shelfmark/socket.io/`
2. **API calls use root path**: Cover image requests and some API calls go to `/api/` instead of `/shelfmark/api/`
3. **Logo uses root path**: The logo is requested from `/logo.png` instead of `/shelfmark/logo.png`
4. **Socket.IO backend path**: The Socket.IO endpoint on the backend is always at `/socket.io/`, not `/shelfmark/socket.io/`, regardless of the `URL_BASE` setting
## Health checks
Health checks work at `/shelfmark/api/health` when using a subpath configuration.

View File

@@ -1,5 +1,4 @@
pyvirtualdisplay
pyautogui
selenium==4.39.0
seleniumbase==4.45.10
python-xlib

View File

@@ -177,6 +177,7 @@ _FORMAT_OPTIONS = [
_AUDIOBOOK_FORMAT_OPTIONS = [
{"value": "m4b", "label": "M4B"},
{"value": "m4a", "label": "M4A"},
{"value": "mp3", "label": "MP3"},
{"value": "zip", "label": "ZIP"},
{"value": "rar", "label": "RAR"},

View File

@@ -35,6 +35,7 @@ class QueueStatus(str, Enum):
"""Enum for possible book queue statuses."""
QUEUED = "queued"
RESOLVING = "resolving"
LOCATING = "locating"
DOWNLOADING = "downloading"
COMPLETE = "complete"
AVAILABLE = "available"

View File

@@ -154,7 +154,7 @@ class BookQueue:
current_status = self._status.get(task_id)
# Allow cancellation during any active state
if current_status in [QueueStatus.RESOLVING, QueueStatus.DOWNLOADING]:
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING]:
# Signal active download to stop
if task_id in self._cancel_flags:
self._cancel_flags[task_id].set()

View File

@@ -36,6 +36,7 @@ class ReleaseSearchPlan:
title_variants: List[ReleaseSearchVariant]
grouped_title_variants: List[ReleaseSearchVariant]
manual_query: Optional[str] = None
indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings)
@property
def primary_query(self) -> str:
@@ -86,6 +87,7 @@ def build_release_search_plan(
book: BookMetadata,
languages: Optional[List[str]] = None,
manual_query: Optional[str] = None,
indexers: Optional[List[str]] = None,
) -> ReleaseSearchPlan:
resolved_languages = _normalize_languages(languages)
@@ -106,6 +108,7 @@ def build_release_search_plan(
title_variants=[variant],
grouped_title_variants=[variant],
manual_query=resolved_manual_query,
indexers=indexers,
)
isbn_candidates: List[str] = []
@@ -161,4 +164,5 @@ def build_release_search_plan(
title_variants=title_variants,
grouped_title_variants=grouped_variants,
manual_query=None,
indexers=indexers,
)

View File

@@ -191,6 +191,11 @@ def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str
if not is_covers_cache_enabled():
return cover_url
from shelfmark.core.config import config as app_config
# Encode the original URL and create a proxy URL
encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode()
base_path = normalize_base_path(app_config.get("URL_BASE", ""))
if base_path:
return f"{base_path}/api/covers/{cache_id}?url={encoded_url}"
return f"/api/covers/{cache_id}?url={encoded_url}"

View File

@@ -10,12 +10,46 @@ import shutil
import subprocess
import time
from pathlib import Path
from typing import Any, Callable, Optional, TypeVar
from shelfmark.core.logger import setup_logger
from shelfmark.download.permissions_debug import log_transfer_permission_context
logger = setup_logger(__name__)
try:
from gevent import monkey as _gevent_monkey
from gevent.threadpool import ThreadPool as _GeventThreadPool
except Exception:
_gevent_monkey = None
_GeventThreadPool = None
T = TypeVar("T")
_IO_THREADPOOL: Optional["_GeventThreadPool"] = None
def _use_gevent_threadpool() -> bool:
return bool(
_gevent_monkey
and _GeventThreadPool
and _gevent_monkey.is_module_patched("threading")
)
def _get_io_threadpool() -> "_GeventThreadPool":
global _IO_THREADPOOL
if _IO_THREADPOOL is None:
pool_size = max(2, min(8, os.cpu_count() or 2))
_IO_THREADPOOL = _GeventThreadPool(pool_size)
return _IO_THREADPOOL
def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""Run blocking I/O in a native thread when under gevent."""
if _use_gevent_threadpool():
return _get_io_threadpool().apply(func, args, kwds=kwargs)
return func(*args, **kwargs)
_VERIFY_IO_WAIT_SECONDS = 3.0
@@ -96,11 +130,12 @@ def _is_permission_error(e: Exception) -> bool:
def _system_op(op: str, source: Path, dest: Path) -> None:
"""Execute system command (mv or cp) as final fallback."""
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
subprocess.run(
run_blocking_io(
subprocess.run,
[op, "-f", str(source), str(dest)],
check=True,
capture_output=True,
text=True
text=True,
)
@@ -110,7 +145,7 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
try:
# Fallback 1: copy content only
shutil.copyfile(str(source), str(dest))
run_blocking_io(shutil.copyfile, str(source), str(dest))
_verify_transfer_size(dest, expected_size, "copy")
if is_move:
@@ -225,7 +260,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
temp_path = try_path.parent / f".{try_path.name}.tmp"
try:
try:
shutil.copy2(str(source_path), str(temp_path))
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as copy_error:
if _is_permission_error(copy_error):
logger.debug(
@@ -365,7 +400,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
temp_path = try_path.parent / f".{try_path.name}.tmp"
try:
try:
shutil.copy2(str(source_path), str(temp_path))
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as e:
# Handle NFS permission errors immediately here
if _is_permission_error(e):

View File

@@ -392,6 +392,7 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N
status_map = {
'queued': QueueStatus.QUEUED,
'resolving': QueueStatus.RESOLVING,
'locating': QueueStatus.LOCATING,
'downloading': QueueStatus.DOWNLOADING,
'complete': QueueStatus.COMPLETE,
'available': QueueStatus.AVAILABLE,

View File

@@ -30,7 +30,12 @@ def _resolve_custom_script_target(target_path: Path, destination: Path, path_mod
except ValueError:
if target_path.is_absolute():
return Path(target_path.name)
return target_path
return target_path
def _format_op_counts(op_counts: dict[str, int]) -> str:
parts = [f"{op}={count}" for op, count in op_counts.items() if count]
return ", ".join(parts) if parts else "none"
@dataclass(frozen=True)
@@ -255,7 +260,7 @@ def process_folder_output(
record_step(steps, "cleanup_staging", path=str(prepared.working_path))
log_plan_steps(task.task_id, steps)
final_paths, error = transfer_book_files(
final_paths, error, op_counts = transfer_book_files(
prepared.files,
destination=plan.destination,
task=task,
@@ -271,12 +276,19 @@ def process_folder_output(
return None
logger.info(
"Task %s: transferred %d file(s) to %s (%s)",
"Task %s: transferred %d file(s) to %s (ops: %s)",
task.task_id,
len(final_paths),
plan.destination,
op_label.lower(),
_format_op_counts(op_counts),
)
if use_hardlink and op_counts.get("copy", 0):
logger.warning(
"Task %s: hardlink requested but %d of %d file(s) copied (fallback)",
task.task_id,
op_counts.get("copy", 0),
len(final_paths),
)
# Run custom script once per successful task, after transfer.
if core_config.config.CUSTOM_SCRIPT:

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
import shelfmark.core.config as core_config
from shelfmark.core.logger import setup_logger
@@ -142,15 +142,16 @@ def transfer_book_files(
is_torrent: bool,
preserve_source: bool = False,
organization_mode: Optional[str] = None,
) -> Tuple[List[Path], Optional[str]]:
) -> Tuple[List[Path], Optional[str], Dict[str, int]]:
if not book_files:
return [], "No book files found"
return [], "No book files found", {"hardlink": 0, "copy": 0, "move": 0}
is_audiobook = check_audiobook(task.content_type)
organization_mode = organization_mode or get_file_organization(is_audiobook)
max_attempts = _max_attempts_for_batch(len(book_files))
final_paths: List[Path] = []
op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
if organization_mode == "organize":
template = get_template(is_audiobook, "organize")
@@ -171,6 +172,7 @@ def transfer_book_files(
max_attempts=max_attempts,
)
final_paths.append(final_path)
op_counts[op] = op_counts.get(op, 0) + 1
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
else:
zero_pad_width = max(len(str(len(book_files))), 2)
@@ -191,9 +193,10 @@ def transfer_book_files(
max_attempts=max_attempts,
)
final_paths.append(final_path)
op_counts[op] = op_counts.get(op, 0) + 1
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
return final_paths, None
return final_paths, None, op_counts
for book_file in book_files:
if len(book_files) == 1 and organization_mode != "none":
@@ -223,9 +226,10 @@ def transfer_book_files(
max_attempts=max_attempts,
)
final_paths.append(final_path)
op_counts[op] = op_counts.get(op, 0) + 1
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
return final_paths, None
return final_paths, None, op_counts
def process_directory(
@@ -257,7 +261,7 @@ def process_directory(
if use_hardlink is None:
use_hardlink = should_hardlink(task)
final_paths, error = transfer_book_files(
final_paths, error, _op_counts = transfer_book_files(
book_files,
destination=ingest_dir,
task=task,
@@ -305,6 +309,12 @@ def transfer_file_to_library(
max_attempts=_max_attempts_for_batch(1),
)
logger.info(f"Library {op}: {final_path}")
if use_hardlink and op != "hardlink":
logger.warning(
"Library hardlink requested but %s used instead for %s",
op,
final_path,
)
if use_hardlink and temp_file and not is_torrent_source(temp_file, task):
safe_cleanup_path(temp_file, task)
@@ -344,6 +354,7 @@ def transfer_directory_to_library(
is_torrent = is_torrent_source(source_dir, task)
transferred_paths: List[Path] = []
op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
max_attempts = _max_attempts_for_batch(len(source_files))
if len(source_files) == 1:
@@ -359,6 +370,7 @@ def transfer_directory_to_library(
)
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
transferred_paths.append(final_path)
op_counts[op] = op_counts.get(op, 0) + 1
else:
zero_pad_width = max(len(str(len(source_files))), 2)
files_with_parts = assign_part_numbers(source_files, zero_pad_width)
@@ -378,14 +390,23 @@ def transfer_directory_to_library(
)
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
transferred_paths.append(final_path)
op_counts[op] = op_counts.get(op, 0) + 1
if use_hardlink:
operation = "hardlinks"
elif is_torrent:
operation = "copies"
else:
operation = "files"
logger.info(f"Created {len(transferred_paths)} library {operation} in {base_library_path.parent}")
op_summary = ", ".join(
f"{op}={count}" for op, count in op_counts.items() if count
) or "none"
logger.info(
"Created %d library file(s) in %s (ops: %s)",
len(transferred_paths),
base_library_path.parent,
op_summary,
)
if use_hardlink and op_counts.get("copy", 0):
logger.warning(
"Library hardlink requested but %d of %d file(s) copied (fallback)",
op_counts.get("copy", 0),
len(transferred_paths),
)
if use_hardlink and temp_file and not is_torrent_source(temp_file, task):
safe_cleanup_path(temp_file, task)

View File

@@ -7,6 +7,7 @@ from typing import List, Optional
from shelfmark.config import env as env_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.staging import STAGE_NONE
from .types import OutputPlan
@@ -59,7 +60,7 @@ def safe_cleanup_path(path: Optional[Path], task: DownloadTask) -> None:
try:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
run_blocking_io(shutil.rmtree, path, ignore_errors=True)
elif path.exists():
path.unlink(missing_ok=True)
except (OSError, PermissionError) as exc:

View File

@@ -7,6 +7,7 @@ from typing import Literal
from shelfmark.config import env as env_config
from shelfmark.core.logger import setup_logger
from shelfmark.download.fs import run_blocking_io
logger = setup_logger(__name__)
@@ -67,17 +68,17 @@ def stage_path(source: Path, staging_dir: Path, action: StageAction) -> Path:
staged_path = staging_dir / f"{source.name}_{counter}"
counter += 1
if action == STAGE_COPY:
shutil.copytree(str(source), str(staged_path))
run_blocking_io(shutil.copytree, str(source), str(staged_path))
else:
shutil.move(str(source), str(staged_path))
run_blocking_io(shutil.move, str(source), str(staged_path))
else:
while staged_path.exists():
staged_path = staging_dir / f"{source.stem}_{counter}{source.suffix}"
counter += 1
if action == STAGE_COPY:
shutil.copy2(str(source), str(staged_path))
run_blocking_io(shutil.copy2, str(source), str(staged_path))
else:
shutil.move(str(source), str(staged_path))
run_blocking_io(shutil.move, str(source), str(staged_path))
staged_kind = "directory" if source.is_dir() else "file"
logger.debug("Staged %s via %s: %s -> %s", staged_kind, action, source, staged_path)

View File

@@ -51,6 +51,7 @@ if BASE_PATH:
async_mode = 'gevent'
# Initialize Flask-SocketIO with reverse proxy support
socketio_path = f"{BASE_PATH}/socket.io" if BASE_PATH else "/socket.io"
socketio = SocketIO(
app,
cors_allowed_origins="*",
@@ -58,7 +59,7 @@ socketio = SocketIO(
logger=False,
engineio_logger=False,
# Reverse proxy / Traefik compatibility settings
path='/socket.io',
path=socketio_path,
ping_timeout=60, # Time to wait for pong response
ping_interval=25, # Send ping every 25 seconds
# Allow both websocket and polling for better compatibility
@@ -268,12 +269,29 @@ def proxy_auth_middleware():
from shelfmark.core.settings_registry import load_config_file
def get_proxy_header(header_name: str) -> str | None:
"""Resolve proxy auth values from headers with WSGI env fallbacks."""
value = request.headers.get(header_name)
if value:
return value
env_key = f"HTTP_{header_name.upper().replace('-', '_')}"
value = request.environ.get(env_key)
if value:
return value
# Some proxies set authenticated username in REMOTE_USER (not as a header).
if header_name.lower().replace("_", "-") == "remote-user":
return request.environ.get("REMOTE_USER")
return None
try:
security_config = load_config_file("security")
user_header = security_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User")
# Extract username from proxy header
username = request.headers.get(user_header)
username = get_proxy_header(user_header)
if not username:
if request.path.startswith('/api/auth/'):
@@ -291,7 +309,7 @@ def proxy_auth_middleware():
admin_group_name = security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "admins")
# Extract groups from proxy header (can be comma or pipe separated)
groups_header = request.headers.get(admin_group_header, "")
groups_header = get_proxy_header(admin_group_header) or ""
user_groups_delimiter = "," if "," in groups_header else "|"
user_groups = [g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()]
@@ -1423,6 +1441,10 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
manual_query = request.args.get('manual_query', '').strip()
# Accept indexer names for Prowlarr filtering (comma-separated)
indexers_param = request.args.get('indexers', '').strip()
indexers = [idx.strip() for idx in indexers_param.split(',') if idx.strip()] if indexers_param else None
if not provider or not book_id:
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
@@ -1463,7 +1485,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=languages, manual_query=manual_query)
plan = build_release_search_plan(book, languages=languages, manual_query=manual_query, indexers=indexers)
if plan.manual_query:
planned_query = plan.manual_query

View File

@@ -62,6 +62,9 @@ class ColumnRenderType(str, Enum):
SIZE = "size" # File size formatting
NUMBER = "number" # Numeric value
PEERS = "peers" # Peers display: "S/L" with color based on seeder count
INDEXER_PROTOCOL = "indexer_protocol" # Text + colored dot for torrent/usenet
FLAG_ICON = "flag_icon" # Icon with tooltip (VIP, freeleech, etc.)
FORMAT_CONTENT_TYPE = "format_content_type" # Content type icon + format badge
class ColumnAlign(str, Enum):
@@ -124,8 +127,10 @@ class ReleaseColumnConfig:
grid_template: str = "minmax(0,2fr) 60px 80px 80px" # CSS grid-template-columns
leading_cell: Optional[LeadingCellConfig] = None # Defaults to thumbnail mode if None
online_servers: Optional[List[str]] = None # For IRC: list of currently online server nicks
available_indexers: Optional[List[str]] = None # For Prowlarr: list of all enabled indexer names
default_indexers: Optional[List[str]] = None # For Prowlarr: indexers selected in settings (pre-selected in filter)
cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min)
supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language"]
supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language", "indexer"]
action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search)
@@ -170,6 +175,14 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
if config.online_servers is not None:
result["online_servers"] = config.online_servers
# Include available_indexers if provided (e.g., for Prowlarr source)
if config.available_indexers is not None:
result["available_indexers"] = config.available_indexers
# Include default_indexers if provided (indexers selected in settings, for pre-selection)
if config.default_indexers is not None:
result["default_indexers"] = config.default_indexers
# Include cache TTL if specified (sources can request longer caching)
if config.cache_ttl_seconds is not None:
result["cache_ttl_seconds"] = config.cache_ttl_seconds

View File

@@ -6,6 +6,7 @@ import requests
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
logger = setup_logger(__name__)
@@ -90,6 +91,44 @@ class ProwlarrClient:
logger.error(f"Failed to get indexers: {e}")
return []
def get_enabled_indexers_detailed(self) -> List[Dict[str, Any]]:
"""
Get enabled indexers, including implementation metadata.
Note: Prowlarr indexer "name" is user-configurable; prefer
"implementation"/"implementationName" for stable identification.
"""
indexers = self.get_indexers()
return [idx for idx in indexers if idx.get("enable", False)]
def get_enriched_indexer_ids(self, *, restrict_to: Optional[List[int]] = None) -> List[int]:
"""
Return enabled indexer IDs that should use Torznab for richer metadata.
Args:
restrict_to: Optional list of candidate indexer IDs to consider.
"""
enriched_ids: List[int] = []
for idx in self.get_enabled_indexers_detailed():
idx_id = idx.get("id")
if idx_id is None:
continue
try:
idx_id_int = int(idx_id)
except (TypeError, ValueError):
continue
if restrict_to is not None and idx_id_int not in restrict_to:
continue
impl = str(idx.get("implementation") or idx.get("implementationName") or idx.get("definitionName") or "")
# Currently only MyAnonamouse provides consistently rich Torznab metadata.
if impl.strip().lower() == "myanonamouse":
enriched_ids.append(idx_id_int)
return enriched_ids
def get_enabled_indexers(self) -> List[Dict[str, Any]]:
"""Get enabled indexers with book capability info."""
indexers = self.get_indexers()
@@ -112,6 +151,67 @@ class ProwlarrClient:
return result
def torznab_search(
self,
*,
indexer_id: int,
query: str,
categories: Optional[List[int]] = None,
search_type: str = "book",
limit: int = 100,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""
Search a specific indexer via Prowlarr's Torznab/Newznab endpoint.
This returns richer fields (e.g., author/booktitle, torznab tags like
FreeLeech) than the JSON /api/v1/search endpoint.
"""
if not query:
return []
endpoint = f"/api/v1/indexer/{int(indexer_id)}/newznab"
url = self.base_url + endpoint
params: Dict[str, Any] = {
"t": search_type,
"q": query,
"limit": limit,
"offset": offset,
}
if categories:
params["cat"] = ",".join(str(c) for c in categories)
logger.debug(f"Prowlarr API: GET {url} (torznab)")
try:
response = self._session.get(
url=url,
params=params,
timeout=self.timeout,
headers={
# Override the session default JSON accept header.
"Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8"
},
)
if not response.ok:
try:
error_body = response.text[:500]
logger.error(f"Prowlarr Torznab error response: {error_body}")
except Exception:
pass
response.raise_for_status()
results = parse_torznab_xml(response.text)
# Ensure indexerId is always set (Prowlarr includes it, but be defensive).
for r in results:
if r.get("indexerId") is None:
r["indexerId"] = int(indexer_id)
return results
except Exception as e:
logger.error(f"Prowlarr Torznab search failed for indexer {indexer_id}: {e}")
return []
def _has_book_categories(self, categories: List[Dict[str, Any]]) -> bool:
"""Check if any category or subcategory is in the book range (7000-7999)."""
for cat in categories:

View File

@@ -1,6 +1,7 @@
"""Prowlarr download handler - executes downloads via torrent/usenet clients."""
import shutil
import time
from pathlib import Path
from threading import Event
from typing import Callable, Optional
@@ -23,6 +24,9 @@ logger = setup_logger(__name__)
# How often to poll the download client for status (seconds)
POLL_INTERVAL = 2
# How long to wait for completed files to appear (seconds)
COMPLETED_PATH_RETRY_INTERVAL = 5
COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period
def _diagnose_path_issue(path: str) -> str:
@@ -195,6 +199,212 @@ class ProwlarrHandler(DownloadHandler):
f"Failed to remove download {download_id} from {client.name} after {reason}: {e}"
)
def _handle_cancelled_download(
self,
client: DownloadClient,
download_id: str,
protocol: str,
status_callback: Callable[[str, Optional[str]], None],
) -> None:
if protocol == "usenet":
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to remove download {download_id} from {client.name} after cancellation: {e}"
)
else:
logger.info(
f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}"
)
status_callback("cancelled", "Cancelled")
def _resolve_download_path_once(
self,
client: DownloadClient,
download_id: str,
*,
log_details: bool,
) -> tuple[Optional[Path], Optional[str]]:
"""Resolve and validate the completed download path once."""
try:
raw_path = client.get_download_path(download_id)
except Exception as e:
message = (
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
if log_details:
logger.error(
f"Failed to resolve download path for {client.name} {download_id}: {e}"
)
else:
logger.debug(
f"Failed to resolve download path for {client.name} {download_id}: {e}"
)
return None, message
if not raw_path:
message = (
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
if log_details:
logger.error(f"Download client returned empty path for {client.name} {download_id}")
else:
logger.debug(f"Download client returned empty path for {client.name} {download_id}")
return None, message
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(raw_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
if log_details:
logger.debug(
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
client.name,
host,
source_path_obj,
[(m.host, m.remote_path, m.local_path) for m in mappings],
)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
if log_details:
logger.debug(
"Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)",
source_path_obj,
remapped,
remapped.exists(),
remapped != source_path_obj,
matched_mapping,
)
if matched_mapping:
if remapped.exists():
logger.info(
"Remapped download path for %s (%s): %s -> %s",
client.name,
download_id,
source_path_obj,
remapped,
)
return remapped, None
message = (
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings."
)
if log_details:
logger.error(
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}."
)
else:
logger.debug(
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}."
)
return None, message
if mappings:
if source_path_obj.exists():
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
return source_path_obj, None
hint = _diagnose_path_issue(raw_path)
message = f"{hint} No remote path mapping matched for client '{client.name}'."
if log_details:
logger.error(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {raw_path}. {hint}"
)
else:
logger.debug(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {raw_path}. {hint}"
)
return None, message
if not source_path_obj.exists():
hint = _diagnose_path_issue(raw_path)
message = hint
if log_details:
logger.error(
f"Download path does not exist: {raw_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
else:
logger.debug(
f"Download path does not exist: {raw_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
return None, message
return source_path_obj, None
def _wait_for_completed_path(
self,
client: DownloadClient,
download_id: str,
*,
cancel_flag: Optional[Event],
status_callback: Callable[[str, Optional[str]], None],
) -> tuple[Optional[Path], Optional[str]]:
"""Wait briefly for completed files to appear on disk."""
last_error: Optional[str] = None
for attempt in range(1, COMPLETED_PATH_MAX_ATTEMPTS + 1):
if cancel_flag and cancel_flag.is_set():
return None, last_error
log_details = attempt == COMPLETED_PATH_MAX_ATTEMPTS
resolved_path, error = self._resolve_download_path_once(
client,
download_id,
log_details=log_details,
)
if resolved_path:
return resolved_path, None
last_error = error
if attempt < COMPLETED_PATH_MAX_ATTEMPTS:
status_callback("locating", "Waiting for completed files...")
logger.debug(
"Completed files not available yet for %s (%s) (attempt %d/%d)",
client.name,
download_id,
attempt,
COMPLETED_PATH_MAX_ATTEMPTS,
)
if cancel_flag:
if cancel_flag.wait(timeout=COMPLETED_PATH_RETRY_INTERVAL):
return None, last_error
else:
time.sleep(COMPLETED_PATH_RETRY_INTERVAL)
return None, last_error
def _build_progress_message(self, status) -> str:
"""Build a progress message from download status."""
msg = f"{status.progress:.0f}%"
@@ -265,86 +475,22 @@ class ProwlarrHandler(DownloadHandler):
logger.info("Existing download is complete, copying file directly")
status_callback("resolving", "Found existing download, copying to library")
source_path = client.get_download_path(download_id)
if not source_path:
logger.error(
f"Could not get path for existing download. "
f"Client: {client.name}, ID: {download_id}. "
f"The download may have been moved or deleted."
)
source_path_obj, path_error = self._wait_for_completed_path(
client=client,
download_id=download_id,
cancel_flag=cancel_flag,
status_callback=status_callback,
)
if not source_path_obj:
if cancel_flag.is_set():
return None
status_callback(
"error",
f"Could not locate existing download in {client.name}. "
f"Check that the file still exists."
path_error
or f"Could not locate existing download in {client.name}. Check that the file still exists.",
)
return None
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(source_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
if matched_mapping:
if remapped.exists():
logger.info(
"Remapped existing download path for %s (%s): %s -> %s",
client.name,
download_id,
source_path_obj,
remapped,
)
source_path_obj = remapped
else:
logger.error(
f"Download path does not exist after remapping: {source_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}. "
f"Check that the local path in your mapping is mounted correctly."
)
status_callback(
"error",
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.",
)
return None
elif mappings:
if source_path_obj.exists():
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
else:
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {source_path}. {hint}"
)
status_callback(
"error",
f"{hint} No remote path mapping matched for client '{client.name}'.",
)
return None
elif not source_path_obj.exists():
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist: {source_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
status_callback("error", hint)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=protocol,
@@ -495,122 +641,27 @@ class ProwlarrHandler(DownloadHandler):
# Handle cancellation
if cancel_flag.is_set():
if protocol == "usenet":
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to remove download {download_id} from {client.name} after cancellation: {e}"
)
else:
logger.info(
f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}"
)
status_callback("cancelled", "Cancelled")
self._handle_cancelled_download(client, download_id, protocol, status_callback)
return None
# Handle completed file
source_path = client.get_download_path(download_id)
if not source_path:
logger.error(
f"Download client returned empty path for completed download. "
f"Client: {client.name}, ID: {download_id}. "
f"Check that the download client's completion folder is accessible to Shelfmark."
)
# Handle completed file (wait briefly for files to appear)
source_path_obj, path_error = self._wait_for_completed_path(
client=client,
download_id=download_id,
cancel_flag=cancel_flag,
status_callback=status_callback,
)
if not source_path_obj:
if cancel_flag.is_set():
self._handle_cancelled_download(client, download_id, protocol, status_callback)
return None
status_callback(
"error",
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
path_error
or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.",
)
return None
# Apply remote path mappings (client path -> shelfmark container path)
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(source_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
logger.debug(
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
client.name,
host,
source_path_obj,
[(m.host, m.remote_path, m.local_path) for m in mappings],
)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
logger.debug(
"Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)",
source_path_obj,
remapped,
remapped.exists(),
remapped != source_path_obj,
matched_mapping,
)
if matched_mapping:
if remapped.exists():
logger.info(
"Remapped download path for %s (%s): %s -> %s",
client.name,
download_id,
source_path_obj,
remapped,
)
source_path_obj = remapped
else:
logger.error(
f"Download path does not exist after remapping: {source_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}. "
f"Check that the local path in your mapping is mounted correctly."
)
status_callback(
"error",
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.",
)
return None
elif mappings:
if source_path_obj.exists():
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
else:
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {source_path}. {hint}"
)
status_callback(
"error",
f"{hint} No remote path mapping matched for client '{client.name}'.",
)
return None
elif not source_path_obj.exists():
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist: {source_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
status_callback("error", hint)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=protocol,

View File

@@ -59,6 +59,51 @@ AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "o
# Combined list for format detection (audiobook formats first for priority)
ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS
# Map 3-char MAM language codes to 2-char ISO codes used by frontend color maps
MAM_LANGUAGE_MAP = {
"eng": "en",
"ita": "it",
"spa": "es",
"fra": "fr",
"fre": "fr",
"ger": "de",
"deu": "de",
"por": "pt",
"rus": "ru",
"jpn": "ja",
"jap": "ja",
"chi": "zh",
"zho": "zh",
"dut": "nl",
"nld": "nl",
"swe": "sv",
"nor": "no",
"dan": "da",
"fin": "fi",
"pol": "pl",
"cze": "cs",
"ces": "cs",
"hun": "hu",
"kor": "ko",
"ara": "ar",
"heb": "he",
"tur": "tr",
"gre": "el",
"ell": "el",
"hin": "hi",
"tha": "th",
"vie": "vi",
"ind": "id",
"ukr": "uk",
"rom": "ro",
"ron": "ro",
"bul": "bg",
"cat": "ca",
"hrv": "hr",
"slv": "sl",
"srp": "sr",
}
# Backend safeguard: cap total Prowlarr search time per request.
PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0
@@ -83,33 +128,79 @@ def _extract_format(title: str) -> Optional[str]:
return None
def _extract_language(title: str) -> Optional[str]:
"""Extract language code from release title (e.g., [German] -> 'de')."""
title_lower = title.lower()
def _extract_mam_language(raw_title: str) -> Optional[str]:
"""
Extract the language code from MyAnonamouse titles.
# Common language names and their codes
languages = {
"english": "en", "eng": "en", "[en]": "en", "(en)": "en",
"german": "de", "deutsch": "de", "[de]": "de", "(de)": "de", "ger": "de",
"french": "fr", "français": "fr", "[fr]": "fr", "(fr)": "fr", "fra": "fr",
"spanish": "es", "español": "es", "[es]": "es", "(es)": "es", "spa": "es",
"italian": "it", "italiano": "it", "[it]": "it", "(it)": "it", "ita": "it",
"portuguese": "pt", "[pt]": "pt", "(pt)": "pt", "por": "pt",
"dutch": "nl", "nederlands": "nl", "[nl]": "nl", "(nl)": "nl", "nld": "nl",
"russian": "ru", "[ru]": "ru", "(ru)": "ru", "rus": "ru",
"polish": "pl", "polski": "pl", "[pl]": "pl", "(pl)": "pl", "pol": "pl",
"chinese": "zh", "[zh]": "zh", "(zh)": "zh", "chi": "zh",
"japanese": "ja", "[ja]": "ja", "(ja)": "ja", "jpn": "ja",
"korean": "ko", "[ko]": "ko", "(ko)": "ko", "kor": "ko",
}
Prowlarr's MAM parser appends a structured bracket segment like:
[ENG / EPUB MOBI PDF]
for lang_pattern, lang_code in languages.items():
if lang_pattern in title_lower:
return lang_code
The language code appears before the "/" - we extract it and map to
the 2-char ISO code used by the frontend color maps.
"""
if not raw_title:
return None
for bracket in re.findall(r"\[([^\]]+)\]", raw_title):
if "/" not in bracket:
continue
before_slash, _ = bracket.split("/", 1)
# Extract the language token (should be a 3-char code like ENG, ITA, etc.)
tokens = re.findall(r"[A-Za-z]+", before_slash.strip())
for token in tokens:
lang_code = token.lower()
if lang_code in MAM_LANGUAGE_MAP:
return MAM_LANGUAGE_MAP[lang_code]
return None
def _extract_mam_formats(raw_title: str) -> List[str]:
"""
Extract a list of formats from MyAnonamouse titles.
Prowlarr's MAM parser appends a structured bracket segment like:
[ENG / EPUB MOBI PDF]
We only trust this structured segment (and do not attempt generic title
heuristics for other indexers).
"""
if not raw_title:
return []
format_set = set(ALL_BOOK_FORMATS)
for bracket in re.findall(r"\[([^\]]+)\]", raw_title):
if "/" not in bracket:
continue
_, after_slash = bracket.split("/", 1)
tokens = re.findall(r"[A-Za-z0-9]+", after_slash)
formats: List[str] = []
for token in tokens:
fmt = token.lower()
if fmt in format_set and fmt not in formats:
formats.append(fmt)
if formats:
return formats
return []
def _formats_display(formats: List[str]) -> Optional[str]:
if not formats:
return None
if len(formats) == 1:
return formats[0]
if len(formats) == 2:
return f"{formats[0]}, {formats[1]}"
# Show first two formats + count of others to prevent overflow
return f"{formats[0]}, {formats[1]} +{len(formats) - 2}"
# Prowlarr category IDs for content type detection
# See: https://wiki.servarr.com/prowlarr/cardigann-yml-definition#categories
AUDIOBOOK_CATEGORY_IDS = {3000, 3030} # 3000 = Audio, 3030 = Audio/Audiobook
@@ -144,9 +235,15 @@ def _detect_content_type_from_categories(categories: list, fallback: str = "book
return "other"
def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook") -> Release:
def _prowlarr_result_to_release(
result: dict,
search_content_type: str = "ebook",
*,
enable_format_detection: bool = False,
) -> Release:
"""Convert a Prowlarr API result to a Release object."""
title = result.get("title", "Unknown")
raw_title = result.get("title", "Unknown")
title = raw_title
size_bytes = result.get("size")
indexer = result.get("indexer", "Unknown")
protocol = get_protocol(result)
@@ -154,6 +251,27 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook"
leechers = result.get("leechers")
categories = result.get("categories", [])
is_torrent = protocol == ReleaseProtocol.TORRENT
raw_indexer_flags = result.get("indexerFlags") or []
indexer_flags: List[str] = []
seen_flags: set[str] = set()
def add_indexer_flag(flag: object) -> None:
if flag is None:
return
flag_str = str(flag).strip()
if not flag_str:
return
lowered = flag_str.lower()
if lowered in seen_flags:
return
seen_flags.add(lowered)
indexer_flags.append(flag_str)
if isinstance(raw_indexer_flags, list):
for flag in raw_indexer_flags:
add_indexer_flag(flag)
elif isinstance(raw_indexer_flags, str):
add_indexer_flag(raw_indexer_flags)
# Format peers display string: "seeders / leechers"
peers_display = (
@@ -162,22 +280,50 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook"
else None
)
# For format detection, prefer fileName over title (often cleaner)
file_name = result.get("fileName", "")
format_detected = _extract_format(file_name) if file_name else _extract_format(title)
format_detected: Optional[str] = None
formats: List[str] = []
formats_display: Optional[str] = None
language_detected: Optional[str] = None
if enable_format_detection:
book_title = str(result.get("bookTitle") or "").strip()
if book_title:
title = book_title
formats = _extract_mam_formats(str(raw_title or ""))
format_detected = formats[0] if formats else None
formats_display = _formats_display(formats)
language_detected = _extract_mam_language(str(raw_title or ""))
# Build the source_id from GUID or generate from indexer + title
source_id = result.get("guid") or f"{indexer}:{hash(title)}"
source_id = result.get("guid") or f"{indexer}:{hash(raw_title)}"
# Cache the raw Prowlarr result so handler can look it up by source_id
cache_release(source_id, result)
# Derive common indicators from torznab/newznab attrs when present.
download_volume_factor = result.get("downloadVolumeFactor")
is_freeleech = False
try:
if download_volume_factor is not None and float(download_volume_factor) == 0.0:
is_freeleech = True
except (TypeError, ValueError):
pass
if any(flag.lower() in {"freeleech", "fl"} for flag in indexer_flags):
is_freeleech = True
is_vip = "[vip]" in str(raw_title).lower()
if is_vip:
add_indexer_flag("VIP")
if is_freeleech:
add_indexer_flag("FreeLeech")
return Release(
source="prowlarr",
source_id=source_id,
title=title,
format=format_detected,
language=_extract_language(title),
language=language_detected,
size=_parse_size(size_bytes),
size_bytes=size_bytes,
download_url=get_preferred_download_url(result),
@@ -199,7 +345,20 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook"
"indexer_id": result.get("indexerId"),
"files": result.get("files"),
"grabs": result.get("grabs"),
"indexer_flags": result.get("indexerFlags", []),
"author": result.get("author"),
"book_title": result.get("bookTitle"),
"indexer_flags": indexer_flags,
"vip": is_vip,
"freeleech": is_freeleech,
"download_volume_factor": result.get("downloadVolumeFactor"),
"upload_volume_factor": result.get("uploadVolumeFactor"),
"minimum_ratio": result.get("minimumRatio"),
"minimum_seed_time": result.get("minimumSeedTime"),
"info_hash": result.get("infoHash"),
"formats": formats if formats else None,
"formats_display": formats_display,
# Raw torznab attributes for rich tooltips (enriched indexers)
"torznab_attrs": result.get("torznabAttrs"),
},
)
@@ -217,59 +376,85 @@ class ProwlarrSource(ReleaseSource):
def get_column_config(self) -> ReleaseColumnConfig:
"""Column configuration for Prowlarr releases."""
# Fetch available indexers from Prowlarr
available_indexers: Optional[List[str]] = None
default_indexers: Optional[List[str]] = None
client = self._get_client()
if client:
try:
enabled_indexers = client.get_enabled_indexers_detailed()
# Get user-selected indexer IDs if configured
selected_ids = self._get_selected_indexer_ids()
all_indexer_names = []
selected_indexer_names = []
for idx in enabled_indexers:
idx_id = idx.get("id")
idx_name = idx.get("name")
if not idx_name:
continue
# Add to all indexers list
all_indexer_names.append(idx_name)
# If user has selected specific indexers, track those separately
if selected_ids is not None:
try:
if int(idx_id) in selected_ids:
selected_indexer_names.append(idx_name)
except (TypeError, ValueError):
pass
available_indexers = sorted(all_indexer_names) if all_indexer_names else None
# Only set default_indexers if user has selected specific ones
default_indexers = sorted(selected_indexer_names) if selected_indexer_names else None
except Exception as e:
logger.warning(f"Failed to fetch indexer list for column config: {e}")
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="indexer",
label="Indexer",
render_type=ColumnRenderType.TEXT,
render_type=ColumnRenderType.INDEXER_PROTOCOL,
align=ColumnAlign.LEFT,
width="minmax(80px, 1fr)",
hide_mobile=True,
sortable=True,
),
ColumnSchema(
key="protocol",
label="Type",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="60px",
width="minmax(140px, 1fr)",
hide_mobile=False,
color_hint=ColumnColorHint(type="map", value="download_type"),
uppercase=True,
),
ColumnSchema(
key="peers",
label="Peers",
render_type=ColumnRenderType.PEERS,
align=ColumnAlign.CENTER,
width="70px",
hide_mobile=True,
fallback="-",
sortable=True,
sort_key="seeders",
),
ColumnSchema(
key="extra.indexer_flags",
label="Flags",
render_type=ColumnRenderType.TAGS,
align=ColumnAlign.CENTER,
width="minmax(80px, 1.5fr)",
width="50px",
hide_mobile=False,
color_hint=ColumnColorHint(type="map", value="flags"),
fallback="-",
fallback="",
uppercase=True,
),
ColumnSchema(
key="content_type",
label="Type",
key="language",
label="Lang",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="50px",
hide_mobile=True,
color_hint=ColumnColorHint(type="map", value="language"),
uppercase=True,
fallback="",
),
ColumnSchema(
key="extra.formats_display",
label="Format",
render_type=ColumnRenderType.FORMAT_CONTENT_TYPE,
align=ColumnAlign.CENTER,
width="90px",
hide_mobile=False,
color_hint=ColumnColorHint(type="map", value="content_type"),
color_hint=ColumnColorHint(type="map", value="format"),
uppercase=True,
fallback="-",
fallback="",
),
ColumnSchema(
key="size",
@@ -282,9 +467,11 @@ class ProwlarrSource(ReleaseSource):
sort_key="size_bytes",
),
],
grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 80px 90px 80px",
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px",
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr
supported_filters=["language"], # Enables multi-language query expansion; Prowlarr language metadata is unreliable
available_indexers=available_indexers,
default_indexers=default_indexers,
supported_filters=["language", "indexer"], # Enables multi-language query expansion and indexer filtering
)
def _get_client(self) -> Optional[ProwlarrClient]:
@@ -325,6 +512,39 @@ class ProwlarrSource(ReleaseSource):
logger.warning(f"Invalid PROWLARR_INDEXERS format: {selected} ({e})")
return None
def _resolve_indexer_ids_from_names(
self, client: ProwlarrClient, names: List[str]
) -> Optional[List[int]]:
"""
Convert indexer names to IDs by looking up enabled indexers.
Returns None if no names could be resolved.
"""
if not names:
return None
try:
enabled_indexers = client.get_enabled_indexers_detailed()
name_to_id = {
idx.get("name"): idx.get("id")
for idx in enabled_indexers
if idx.get("name") and idx.get("id") is not None
}
ids = []
for name in names:
idx_id = name_to_id.get(name)
if idx_id is not None:
try:
ids.append(int(idx_id))
except (TypeError, ValueError):
pass
return ids if ids else None
except Exception as e:
logger.warning(f"Failed to resolve indexer names to IDs: {e}")
return None
def search(
self,
book: BookMetadata,
@@ -348,8 +568,12 @@ class ProwlarrSource(ReleaseSource):
logger.warning("No search query available for book")
return []
# Get selected indexer IDs from config (None means search all)
indexer_ids = self._get_selected_indexer_ids()
# Get indexer IDs: prefer plan.indexers (from filter), else use settings
if plan.indexers:
indexer_ids = self._resolve_indexer_ids_from_names(client, plan.indexers)
logger.debug(f"Using filter-specified indexers: {plan.indexers} -> IDs {indexer_ids}")
else:
indexer_ids = self._get_selected_indexer_ids()
# Get search categories based on content type
# Audiobooks use 3030 (Audio/Audiobook), ebooks use 7000 (Books)
@@ -382,37 +606,61 @@ class ProwlarrSource(ReleaseSource):
f"Searching Prowlarr: {query_type} ({len(queries)} variants), {indexer_desc}, categories={categories}"
)
# Identify indexers that should be enriched via Torznab/Newznab.
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
non_enriched_indexer_ids: Optional[List[int]] = None
if indexer_ids:
non_enriched_indexer_ids = [i for i in indexer_ids if i not in enriched_indexer_ids]
def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]:
"""Search indexers with given categories, collecting results."""
results = []
if indexer_ids:
# Prefer a single request for all selected indexers to reduce latency.
try:
raw = client.search(query=query, indexer_ids=indexer_ids, categories=cats)
if raw:
results.extend(raw)
return results
except Exception as e:
logger.warning(
f"Search failed for selected indexers {indexer_ids}: {e}. Falling back to per-indexer search."
)
# Fallback: search specific indexers one at a time
for indexer_id in indexer_ids:
# Search standard indexers via JSON endpoint.
if indexer_ids:
if non_enriched_indexer_ids:
# Prefer a single request for selected indexers to reduce latency.
try:
raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
raw = client.search(query=query, indexer_ids=non_enriched_indexer_ids, categories=cats)
if raw:
results.extend(raw)
except Exception as e:
logger.warning(f"Search failed for indexer {indexer_id}: {e}")
logger.warning(
f"Search failed for selected indexers {non_enriched_indexer_ids}: {e}. Falling back to per-indexer search."
)
for indexer_id in non_enriched_indexer_ids:
try:
raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
if raw:
results.extend(raw)
except Exception as e:
logger.warning(f"Search failed for indexer {indexer_id}: {e}")
else:
# Search all enabled indexers at once
# Search all enabled indexers at once, then remove enriched results (re-fetched via Torznab).
try:
raw = client.search(query=query, indexer_ids=None, categories=cats)
if raw:
if enriched_indexer_ids:
raw = [r for r in raw if r.get("indexerId") not in enriched_indexer_ids]
results.extend(raw)
except Exception as e:
logger.warning(f"Search failed for all indexers: {e}")
# Search enriched indexers via Torznab/Newznab for richer metadata.
for indexer_id in enriched_indexer_ids:
raw = client.torznab_search(indexer_id=indexer_id, query=query, categories=cats, search_type="book")
if raw:
results.extend(raw)
else:
# Fallback to JSON search for enriched indexers if Torznab fails.
try:
raw_fallback = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
if raw_fallback:
results.extend(raw_fallback)
except Exception as e:
logger.warning(f"Fallback search failed for enriched indexer {indexer_id}: {e}")
return results
try:
@@ -454,7 +702,30 @@ class ProwlarrSource(ReleaseSource):
seen_keys.add(key)
all_results.append(r)
results = [_prowlarr_result_to_release(r, content_type) for r in all_results]
enriched_indexer_ids_set = set(enriched_indexer_ids)
results: List[Release] = []
enriched_source_ids: set[str] = set()
for r in all_results:
idx_id = r.get("indexerId")
try:
idx_id_int = int(idx_id) if idx_id is not None else None
except (TypeError, ValueError):
idx_id_int = None
is_enriched = bool(idx_id_int is not None and idx_id_int in enriched_indexer_ids_set)
release = _prowlarr_result_to_release(
r,
content_type,
enable_format_detection=is_enriched,
)
results.append(release)
if is_enriched:
enriched_source_ids.add(release.source_id)
# Sort results: enriched indexers first, then others
results.sort(key=lambda r: (0 if r.source_id in enriched_source_ids else 1))
if results:
torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT)

View File

@@ -0,0 +1,171 @@
"""
Torznab/Newznab (RSS/XML) helpers for Prowlarr.
Used to fetch richer metadata from specific indexers (e.g., MyAnonamouse) that
isn't available via Prowlarr's JSON search endpoint.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from xml.etree import ElementTree as ET
def _local_name(tag: str) -> str:
"""Return tag name without namespace."""
if tag.startswith("{"):
return tag.split("}", 1)[1]
return tag
def _coerce_int(value: Optional[str]) -> Optional[int]:
if value is None:
return None
value = value.strip()
if not value:
return None
try:
return int(value)
except ValueError:
return None
def _coerce_float(value: Optional[str]) -> Optional[float]:
if value is None:
return None
value = value.strip()
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _strip_author_from_title(title: str, author: Optional[str]) -> str:
"""
Prowlarr's MyAnonamouse parser appends " by {author}" into the title while
also emitting author/booktitle fields. Shelfmark's UI shows author
separately, so strip the duplicated " by author" segment when present.
"""
if not title or not author:
return title
needle = f" by {author}"
if needle in title:
return title.replace(needle, "", 1).strip()
return title
def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
"""
Parse a Torznab/Newznab XML response into a list of dicts that roughly match
Prowlarr's JSON search results shape.
"""
if not xml_text or not xml_text.strip():
return []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return []
items = root.findall(".//item")
results: List[Dict[str, Any]] = []
for item in items:
title = (item.findtext("title") or "").strip()
guid = (item.findtext("guid") or "").strip() or None
download_url = (item.findtext("link") or "").strip() or None
info_url = (item.findtext("comments") or "").strip() or None
pub_date = (item.findtext("pubDate") or "").strip() or None
size = _coerce_int(item.findtext("size"))
enclosure = item.find("enclosure")
enclosure_type = enclosure.get("type") if enclosure is not None else None
enclosure_url = enclosure.get("url") if enclosure is not None else None
protocol: Optional[str] = None
if enclosure_type == "application/x-bittorrent":
protocol = "torrent"
elif enclosure_type == "application/x-nzb":
protocol = "usenet"
if not download_url and enclosure_url:
download_url = enclosure_url.strip() or None
prowlarr_indexer_el = item.find("prowlarrindexer")
indexer_id = _coerce_int(prowlarr_indexer_el.get("id")) if prowlarr_indexer_el is not None else None
indexer_name = (prowlarr_indexer_el.text or "").strip() if prowlarr_indexer_el is not None else ""
categories: List[int] = []
for cat_el in item.findall("category"):
cat_id = _coerce_int(cat_el.text)
if cat_id is not None:
categories.append(cat_id)
# Collect torznab/newznab attr elements (namespaced).
attrs: Dict[str, str] = {}
tags: List[str] = []
for el in item.iter():
if _local_name(el.tag) != "attr":
continue
name = (el.get("name") or "").strip()
value = (el.get("value") or "").strip()
if not name:
continue
if name == "tag" and value:
tags.append(value)
continue
if value:
attrs[name] = value
seeders = _coerce_int(attrs.get("seeders"))
peers = _coerce_int(attrs.get("peers"))
leechers: Optional[int] = None
if peers is not None and seeders is not None and peers >= seeders:
leechers = peers - seeders
author = attrs.get("author") or None
book_title = attrs.get("booktitle") or None
info_hash = attrs.get("infohash") or None
download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor"))
upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor"))
minimum_ratio = _coerce_float(attrs.get("minimumratio"))
minimum_seed_time = _coerce_int(attrs.get("minimumseedtime"))
cleaned_title = _strip_author_from_title(title, author)
results.append({
"title": cleaned_title or title,
"guid": guid or info_url or download_url or f"{indexer_id}:{title}",
"size": size,
"protocol": protocol or "unknown",
"downloadUrl": download_url,
"infoUrl": info_url,
"publishDate": pub_date,
"indexer": indexer_name or None,
"indexerId": indexer_id,
"categories": categories,
"seeders": seeders,
"leechers": leechers,
"files": _coerce_int(attrs.get("files")),
"grabs": _coerce_int(attrs.get("grabs")),
"infoHash": info_hash,
"indexerFlags": tags,
# Optional richer fields (not available via JSON search)
"author": author,
"bookTitle": book_title,
"downloadVolumeFactor": download_volume_factor,
"uploadVolumeFactor": upload_volume_factor,
"minimumRatio": minimum_ratio,
"minimumSeedTime": minimum_seed_time,
# Pass through all torznab attributes for tooltip display
"torznabAttrs": attrs,
})
return results

View File

@@ -33,6 +33,20 @@ import { withBasePath } from './utils/basePath';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
const getInitialContentType = (): ContentType => {
try {
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
if (saved === 'ebook' || saved === 'audiobook') {
return saved;
}
} catch {
// localStorage may be unavailable in private browsing
}
return 'ebook';
};
function App() {
const { toasts, showToast, removeToast } = useToast();
@@ -73,7 +87,15 @@ function App() {
});
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
const [contentType, setContentType] = useState<ContentType>('ebook');
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
useEffect(() => {
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType]);
// Search state and handlers
const {
@@ -143,6 +165,7 @@ function App() {
const ongoing = [
currentStatus.queued,
currentStatus.resolving,
currentStatus.locating,
currentStatus.downloading,
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
@@ -506,6 +529,7 @@ function App() {
: [bookLanguages[0]?.code || 'en'];
const searchMode = config?.search_mode || 'direct';
const logoUrl = withBasePath('/logo.png');
// Handle "View Series" - trigger search with series field and series order sort
const handleSearchSeries = useCallback((seriesName: string) => {
@@ -537,7 +561,7 @@ function App() {
calibreWebUrl={config?.calibre_web_url || ''}
audiobookLibraryUrl={config?.audiobook_library_url || ''}
debug={config?.debug || false}
logoUrl="/logo.png"
logoUrl={logoUrl}
showSearch={!isInitialState}
searchInput={searchInput}
onSearchChange={setSearchInput}
@@ -604,7 +628,7 @@ function App() {
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguageCodes}
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
logoUrl="/logo.png"
logoUrl={logoUrl}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
showAdvanced={showAdvanced}

View File

@@ -63,7 +63,7 @@ export const BookDownloadButton = ({
const isCompleted = buttonState.state === 'complete';
const hasError = buttonState.state === 'error';
const isInProgress = ['queued', 'resolving', 'downloading'].includes(buttonState.state);
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
@@ -205,4 +205,3 @@ export const BookDownloadButton = ({
</button>
);
};

View File

@@ -56,7 +56,7 @@ export const BookGetButton = ({
// Determine states based on buttonState
const isCompleted = buttonState?.state === 'complete';
const hasError = buttonState?.state === 'error';
const isInProgress = buttonState && ['queued', 'resolving', 'downloading'].includes(buttonState.state);
const isInProgress = buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isLoading;
@@ -104,6 +104,7 @@ export const BookGetButton = ({
if (hasError) return 'Failed';
if (isLoading) return 'Loading';
if (buttonState?.state === 'downloading') return 'Downloading';
if (buttonState?.state === 'locating') return 'Locating files';
if (buttonState?.state === 'resolving') return 'Resolving';
if (buttonState?.state === 'queued') return 'Queued';
return 'Get';

View File

@@ -14,6 +14,7 @@ const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; w
queued: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300', label: 'Queued', waveColor: 'rgba(217, 119, 6, 0.3)' },
resolving: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300', label: 'Resolving', waveColor: 'rgba(79, 70, 229, 0.3)' },
downloading: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300', label: 'Downloading', waveColor: 'rgba(2, 132, 199, 0.3)' },
locating: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300', label: 'Locating files', waveColor: 'rgba(13, 148, 136, 0.3)' },
complete: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300', label: 'Complete', waveColor: '' },
error: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300', label: 'Error', waveColor: '' },
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' },
@@ -77,6 +78,8 @@ const getStatusProgress = (statusName: string, bookProgress?: number): number =>
return 20 + (bookProgress * 0.8);
}
return 20;
case 'locating':
return 90;
case 'complete':
case 'error':
return 100;
@@ -92,6 +95,7 @@ const getProgressBarColor = (statusName: string): string => {
if (statusName === 'queued') return 'bg-amber-600';
if (statusName === 'resolving') return 'bg-indigo-600';
if (statusName === 'downloading') return 'bg-sky-600';
if (statusName === 'locating') return 'bg-teal-600';
return 'bg-sky-600';
};
@@ -120,7 +124,7 @@ export const DownloadsSidebar = ({
// Collect all download items from different status sections
const allDownloadItems: Array<{ book: Book; status: string }> = [];
const statusTypes = ['downloading', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
const statusTypes = ['downloading', 'locating', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
statusTypes.forEach((statusName) => {
const items = (status as any)[statusName];
@@ -142,9 +146,9 @@ export const DownloadsSidebar = ({
label: statusName.charAt(0).toUpperCase() + statusName.slice(1),
};
const isInProgress = ['queued', 'resolving', 'downloading'].includes(statusName);
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(statusName);
const isQueued = statusName === 'queued';
const isActive = statusName === 'resolving' || statusName === 'downloading';
const isActive = statusName === 'resolving' || statusName === 'locating' || statusName === 'downloading';
const isCompleted = statusName === 'complete';
const hasError = statusName === 'error';

View File

@@ -1,6 +1,7 @@
import { ColumnSchema, Release } from '../types';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { getColorStyleFromHint, getProtocolDotColor, getFormatColor } from '../utils/colorMaps';
import { getNestedValue } from '../utils/objectHelpers';
import { Tooltip } from './shared/Tooltip';
interface ReleaseCellProps {
column: ColumnSchema;
@@ -37,12 +38,48 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return <span>{displayValue}</span>;
}
const colorStyle = getColorStyleFromHint(value, column.color_hint);
// Build rich tooltip content for formats
let tooltipContent: React.ReactNode = null;
if (column.key === 'extra.formats_display') {
const formats = (release.extra as Record<string, unknown> | undefined)?.formats;
if (Array.isArray(formats) && formats.length > 1) {
tooltipContent = (
<div className="flex flex-wrap gap-1.5">
{formats.map((fmt) => {
const fmtColor = getFormatColor(String(fmt));
return (
<span
key={String(fmt)}
className={`${fmtColor.bg} ${fmtColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
>
{String(fmt).toUpperCase()}
</span>
);
})}
</div>
);
}
}
const badge = (
<span
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
>
{displayValue}
</span>
);
return (
<div className={`flex items-center ${alignClass}`}>
{value !== column.fallback ? (
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
{displayValue}
</span>
tooltipContent ? (
<Tooltip content={tooltipContent} position="top">
{badge}
</Tooltip>
) : (
badge
)
) : (
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
)}
@@ -51,18 +88,50 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
case 'tags': {
// Tags display: render list of strings as distinct badges
// Treat non-array values as single-item array
const tags = (Array.isArray(rawValue) ? rawValue : (value ? [value] : [])) as any[];
const tags = Array.isArray(rawValue)
? rawValue.map((tag) => String(tag)).filter((tag) => tag.trim())
: rawValue !== undefined && rawValue !== null && String(rawValue).trim()
? [String(rawValue)]
: [];
const isFlags = column.color_hint?.type === 'map' && column.color_hint.value === 'flags';
const normalizeFlagLabel = (tag: string): string => {
const normalized = tag.trim().toLowerCase();
if (!normalized) return tag;
switch (normalized) {
case 'freeleech':
case 'free leech':
case 'fl':
return 'FL';
case 'double upload':
case 'doubleupload':
case 'du':
return 'DU';
case 'vip':
return 'VIP';
case 'internal':
case 'int':
return 'INT';
default:
return tag;
}
};
// Compact mode: render as comma-separated text
if (compact) {
if (tags.length === 0) return <span>{column.fallback}</span>;
const displayTags = column.uppercase ? tags.map(t => String(t).toUpperCase()) : tags;
if (tags.length === 0) {
return column.fallback ? <span>{column.fallback}</span> : null;
}
const displayTags = tags.map((tag) => {
const normalized = isFlags ? normalizeFlagLabel(tag) : tag;
return column.uppercase ? normalized.toUpperCase() : normalized;
});
return <span>{displayTags.join(', ')}</span>;
}
if (!tags.length) {
if (!column.fallback) {
return <div className={`flex items-center ${alignClass}`} />;
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
@@ -73,13 +142,13 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<div className={`flex flex-wrap items-center gap-1.5 ${alignClass}`}>
{tags.map((tag, idx) => {
const tagStr = String(tag);
const displayTag = column.uppercase ? tagStr.toUpperCase() : tagStr;
const colorStyle = getColorStyleFromHint(tagStr, column.color_hint);
const normalized = isFlags ? normalizeFlagLabel(tag) : tag;
const displayTag = column.uppercase ? normalized.toUpperCase() : normalized;
const colorStyle = getColorStyleFromHint(tag, column.color_hint);
return (
<span
key={idx}
key={`${tag}-${idx}`}
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}
>
{displayTag}
@@ -90,15 +159,109 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
);
}
case 'size':
case 'size': {
// Build tooltip from extra metadata (torznab attrs, publish date, etc.)
const extra = release.extra as Record<string, unknown> | undefined;
const torznabAttrs = extra?.torznab_attrs as Record<string, string> | undefined;
const publishDate = extra?.publish_date as string | undefined;
// Helper to format relative time
const formatRelativeTime = (dateStr: string): string | null => {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1 day ago';
if (diffDays < 30) return `${diffDays} days ago`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths === 1) return '1 month ago';
if (diffMonths < 12) return `${diffMonths} months ago`;
const diffYears = Math.floor(diffDays / 365);
if (diffYears === 1) return '1 year ago';
return `${diffYears} years ago`;
} catch {
return null;
}
};
const rows: Array<{ label: string; value: string }> = [];
// Add publish date first if available
if (publishDate) {
const relativeTime = formatRelativeTime(publishDate);
if (relativeTime) {
rows.push({ label: 'Added', value: relativeTime });
}
}
// Add torznab attributes if available (MAM, etc.)
if (torznabAttrs && Object.keys(torznabAttrs).length > 0) {
const displayAttrs: Array<{ key: string; label: string }> = [
{ key: 'description', label: 'Description' },
{ key: 'year', label: 'Year' },
{ key: 'genre', label: 'Genre' },
{ key: 'narrator', label: 'Narrator' },
{ key: 'bitrate', label: 'Bitrate' },
{ key: 'samplerate', label: 'Sample Rate' },
{ key: 'runtime', label: 'Runtime' },
{ key: 'pages', label: 'Pages' },
{ key: 'publisher', label: 'Publisher' },
{ key: 'language', label: 'Language' },
];
for (const attr of displayAttrs) {
const val = torznabAttrs[attr.key];
if (val && val.trim()) {
rows.push({ label: attr.label, value: val.trim() });
}
}
}
// Add files and grabs from extra
const files = extra?.files as number | undefined;
const grabs = extra?.grabs as number | undefined;
if (files !== undefined && files !== null) {
rows.push({ label: 'Files', value: String(files) });
}
if (grabs !== undefined && grabs !== null) {
rows.push({ label: 'Grabs', value: String(grabs) });
}
let sizeTooltipContent: React.ReactNode = null;
if (rows.length > 0) {
sizeTooltipContent = (
<div className="flex flex-col gap-1 max-w-xs">
{rows.map((row) => (
<div key={row.label} className="flex gap-2">
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{row.label}:</span>
<span className="truncate">{row.value}</span>
</div>
))}
</div>
);
}
if (compact) {
return <span>{displayValue}</span>;
}
const sizeText = <span>{displayValue}</span>;
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300`}>
{displayValue}
{sizeTooltipContent ? (
<Tooltip content={sizeTooltipContent} position="left">
{sizeText}
</Tooltip>
) : (
sizeText
)}
</div>
);
}
case 'peers': {
// Peers display: "S/L" string with badge colored by seeder count
@@ -141,6 +304,164 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
);
}
case 'indexer_protocol': {
// Indexer name with colored dot indicating protocol (torrent/usenet) and peers count
const protocol = release.protocol as string | undefined;
const dotColor = getProtocolDotColor(protocol);
const protocolLabel = protocol === 'torrent' ? 'Torrent' : protocol === 'nzb' ? 'Usenet' : protocol || 'Unknown';
const peers = release.peers;
if (compact) {
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}
title={protocolLabel}
/>
{displayValue}
{peers && <span className="text-gray-400">({peers})</span>}
</span>
);
}
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate gap-1.5`}>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`}
title={protocolLabel}
/>
<span className="truncate">{displayValue}</span>
{peers && <span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{peers}</span>}
</div>
);
}
case 'flag_icon': {
// Colored badge showing FL, VIP, or both
if (!value || value === column.fallback) {
if (compact) return null;
return <div className={`flex items-center ${alignClass}`} />;
}
const flagColor = getColorStyleFromHint(value, { type: 'map', value: 'flags' });
if (compact) {
return <span className={`${flagColor.text} font-medium`}>{value}</span>;
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className={`${flagColor.bg} ${flagColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}>
{value}
</span>
</div>
);
}
case 'format_content_type': {
// Content type icon + format badge combined
// Shows primary format as badge with colored dots for additional formats
const contentType = release.content_type as string | undefined;
const isAudiobook = contentType === 'audiobook';
const formats = (release.extra as Record<string, unknown> | undefined)?.formats as string[] | undefined;
const primaryFormat = formats?.[0] || null;
const additionalFormats = formats?.slice(1) || [];
// Use blue for book, violet for audiobook when no format specified
const noFormatStyle = isAudiobook
? { bg: 'bg-violet-500/20', text: 'text-violet-600 dark:text-violet-400' }
: { bg: 'bg-blue-500/20', text: 'text-blue-600 dark:text-blue-400' };
const colorStyle = primaryFormat ? getFormatColor(primaryFormat) : noFormatStyle;
// Build rich tooltip content for formats (if multiple)
let tooltipContent: React.ReactNode = null;
if (formats && formats.length > 1) {
tooltipContent = (
<div className="flex flex-wrap gap-1.5">
{formats.map((fmt) => {
const fmtColor = getFormatColor(String(fmt));
return (
<span
key={String(fmt)}
className={`${fmtColor.bg} ${fmtColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
>
{String(fmt).toUpperCase()}
</span>
);
})}
</div>
);
}
// Icon sized to match visual height of format text badges
const icon = isAudiobook ? (
// Headphones icon for audiobook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
) : (
// Book icon for ebook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
);
if (compact) {
if (!primaryFormat) {
return <span className="inline-flex items-center text-gray-500" title={isAudiobook ? 'Audiobook' : 'Book'}>{icon}</span>;
}
// Simple text tooltip for compact mode
const compactTooltip = formats && formats.length > 1
? formats.map((fmt) => String(fmt).toUpperCase()).join(', ')
: undefined;
return (
<span className={column.uppercase ? 'uppercase' : ''} title={compactTooltip}>
{primaryFormat.toUpperCase()}
{additionalFormats.length > 0 && ` +${additionalFormats.length}`}
</span>
);
}
// No format - just show icon with same width as format badges
if (!primaryFormat) {
return (
<div className="flex items-center justify-start" title={isAudiobook ? 'Audiobook' : 'Book'}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-[3.25rem]`}>
{icon}
</span>
</div>
);
}
// Format badge - left-aligned so primary format stays in place, +N appears to right
const formatBadge = (
<span className="inline-flex items-center gap-1">
<span
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-[3.25rem] text-center`}
>
{column.uppercase ? primaryFormat.toUpperCase() : primaryFormat}
</span>
{additionalFormats.length > 0 && (
<span
className="bg-gray-500/20 text-gray-700 dark:text-gray-300 text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide"
>
+{additionalFormats.length}
</span>
)}
</span>
);
return (
<div className="flex items-center justify-start">
{tooltipContent ? (
<Tooltip content={tooltipContent} position="top">
{formatBadge}
</Tooltip>
) : (
formatBadge
)}
</div>
);
}
case 'number':
if (compact) {
return <span>{displayValue}</span>;

View File

@@ -426,12 +426,38 @@ const ReleaseRow = ({
{/* Plugin-provided info line (format, size, indexer, seeders, etc.) */}
{mobileColumns.length > 0 && (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{mobileColumns.map((col, idx) => (
<span key={col.key} className="flex items-center gap-1.5">
{idx > 0 && <span className="text-gray-300 dark:text-gray-600">·</span>}
<ReleaseCell column={col} release={release} compact onlineServers={onlineServers} />
</span>
))}
{(() => {
// Pre-filter columns that will render content to avoid orphan dots
const columnsWithContent = mobileColumns.filter((col) => {
const rawValue = getNestedValue(release as unknown as Record<string, unknown>, col.key);
const value = rawValue !== undefined && rawValue !== null ? String(rawValue) : col.fallback;
if (col.render_type === 'flag_icon') {
// flag_icon returns null in compact mode when empty
if (!value || value === col.fallback) {
return false;
}
}
if (col.render_type === 'tags') {
// tags render null in compact mode when empty and no fallback
if (Array.isArray(rawValue)) {
return rawValue.some((tag) => String(tag).trim()) || Boolean(col.fallback);
}
if (!value || value === col.fallback) {
return Boolean(col.fallback);
}
}
return true;
});
return columnsWithContent.map((col, idx) => (
<span key={col.key} className="flex items-center gap-1.5">
{idx > 0 && <span className="text-gray-300 dark:text-gray-600">·</span>}
<ReleaseCell column={col} release={release} compact onlineServers={onlineServers} />
</span>
));
})()}
</div>
)}
</div>
@@ -622,6 +648,10 @@ export const ReleaseModal = ({
// A specific value means "show only that format"
const [formatFilter, setFormatFilter] = useState<string>('');
const [languageFilter, setLanguageFilter] = useState<string[]>([LANGUAGE_OPTION_DEFAULT]);
// Indexer filter - empty array means "show all", otherwise show only selected indexers
const [indexerFilter, setIndexerFilter] = useState<string[]>([]);
// Track which tabs have had indexer filter initialized (to avoid overriding user changes)
const indexerFilterInitializedRef = useRef<Set<string>>(new Set());
const [manualQuery, setManualQuery] = useState<string>('');
const [showManualQuery, setShowManualQuery] = useState<boolean>(false);
@@ -673,6 +703,8 @@ export const ReleaseModal = ({
setExpandedBySource({});
setFormatFilter('');
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
setIndexerFilter([]);
indexerFilterInitializedRef.current = new Set();
setManualQuery('');
setShowManualQuery(false);
setSearchStatus(null);
@@ -739,6 +771,22 @@ export const ReleaseModal = ({
}
}, [loadingBySource, activeTab]);
// Initialize indexer filter from default_indexers when results first load for a tab
useEffect(() => {
const response = releasesBySource[activeTab];
if (!response?.column_config) return;
// Only initialize once per tab per book
if (indexerFilterInitializedRef.current.has(activeTab)) return;
const defaultIndexers = response.column_config.default_indexers;
if (defaultIndexers && defaultIndexers.length > 0) {
setIndexerFilter(defaultIndexers);
}
// Mark as initialized even if no default_indexers (to avoid re-checking)
indexerFilterInitializedRef.current.add(activeTab);
}, [releasesBySource, activeTab]);
// Check if description text overflows (needs "more" button)
useEffect(() => {
const el = descriptionRef.current;
@@ -881,9 +929,13 @@ export const ReleaseModal = ({
? undefined
: langCodes;
// Pass indexer filter only if the source supports it (empty array = search all)
const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer');
const indexersParam = supportsIndexerFilter && indexerFilter.length > 0 ? indexerFilter : undefined;
// Fetch with expand_search=true (title+author search)
const expandedResponse = await getReleases(
provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined
provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined, indexersParam
);
// Merge with existing results, deduplicating by source_id
@@ -910,7 +962,7 @@ export const ReleaseModal = ({
} finally {
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
}
}, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery]);
}, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery, indexerFilter, releasesBySource]);
// Build list of tabs to show
// Only show enabled sources that support the current content type
@@ -992,6 +1044,26 @@ export const ReleaseModal = ({
return options;
}, [availableFormats]);
// Get available indexers for filter dropdown
// Prefer available_indexers from column config (all enabled Prowlarr indexers),
// fall back to unique indexers from results if not provided
const availableIndexers = useMemo(() => {
// Use column config's available_indexers if provided (e.g., from Prowlarr)
const configIndexers = releasesBySource[activeTab]?.column_config?.available_indexers;
if (configIndexers && configIndexers.length > 0) {
return configIndexers;
}
// Fall back to indexers found in results
const releases = releasesBySource[activeTab]?.releases || [];
const indexers = new Set<string>();
releases.forEach((r) => {
if (r.indexer) {
indexers.add(r.indexer);
}
});
return Array.from(indexers).sort();
}, [releasesBySource, activeTab]);
// Resolve language filter to actual language codes for filtering
const resolvedLanguageCodes = useMemo(() => {
return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
@@ -1073,6 +1145,7 @@ export const ReleaseModal = ({
const filteredReleases = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
// First, filter
let filtered = releases.filter((r) => {
@@ -1088,12 +1161,20 @@ export const ReleaseModal = ({
}
// Releases with no format pass through when no filter is set (show all)
// Language filtering
const releaseLang = r.extra?.language as string | undefined;
// Language filtering - use r.language when provided by enriched indexers
// Releases with no language (null/undefined) always pass
const releaseLang = r.language as string | undefined;
if (!releaseLanguageMatchesFilter(releaseLang, resolvedLanguageCodes ?? defaultLanguages)) {
return false;
}
// Indexer filtering - empty array means show all
if (supportsIndexerFilter && indexerFilter.length > 0 && r.indexer) {
if (!indexerFilter.includes(r.indexer)) {
return false;
}
}
return true;
});
@@ -1103,7 +1184,7 @@ export const ReleaseModal = ({
}
return filtered;
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, currentSort, sortableColumns]);
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, indexerFilter, currentSort, sortableColumns, columnConfig]);
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
const displayFields = useMemo(() => {
@@ -1137,6 +1218,9 @@ export const ReleaseModal = ({
progress: book.progress,
};
}
if (currentStatus.locating && currentStatus.locating[releaseId]) {
return { text: 'Locating files', state: 'locating' };
}
if (currentStatus.resolving && currentStatus.resolving[releaseId]) {
return { text: 'Resolving', state: 'resolving' };
}
@@ -1515,111 +1599,138 @@ export const ReleaseModal = ({
{/* Filter funnel button - stays fixed */}
{/* Only show filter button if source supports at least one filter type */}
{((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) ||
(columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0)) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
// Active filter: format is set, or language is not just default
const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT);
const hasActiveFilter = formatFilter !== '' || hasLanguageFilter;
return (
(columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0) ||
(columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1)) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
// Active filter: format is set, language is not default, or indexers differ from defaults
const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT);
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
// Check if indexer filter differs from defaults (only after initialization)
// Don't show dot while loading or before filter is initialized from defaults
const hasResults = releasesBySource[activeTab]?.releases !== undefined;
const isInitialized = indexerFilterInitializedRef.current.has(activeTab);
const defaultIndexers = columnConfig.default_indexers ?? [];
const indexersMatchDefault = (
indexerFilter.length === defaultIndexers.length &&
indexerFilter.every((idx) => defaultIndexers.includes(idx))
);
const hasIndexerFilter = supportsIndexerFilter && hasResults && isInitialized && !indexersMatchDefault;
const hasActiveFilter = formatFilter !== '' || hasLanguageFilter || hasIndexerFilter;
return (
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Filter releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
{hasActiveFilter && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
</button>
);
}}
>
{({ close }) => (
<div className="p-4 space-y-4">
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && (
<DropdownList
label="Format"
options={formatOptions}
value={formatFilter}
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
placeholder="All Formats"
/>
)}
{columnConfig.supported_filters?.includes('language') && (
<LanguageMultiSelect
label="Language"
options={bookLanguages}
value={languageFilter}
onChange={setLanguageFilter}
defaultLanguageCodes={defaultLanguages}
/>
)}
{columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1 && (
<DropdownList
label="Indexers"
options={availableIndexers.map((idx) => ({ value: idx, label: idx }))}
multiple
value={indexerFilter}
onChange={(val) => setIndexerFilter(Array.isArray(val) ? val : val ? [val] : [])}
placeholder="All Indexers"
/>
)}
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Filter releases"
onClick={async () => {
close();
if (!book?.provider || !book?.provider_id) return;
const provider = book.provider;
const bookId = book.provider_id;
// Clear cache and state
const key = getCacheKey(provider, bookId, activeTab, contentType);
releaseCache.delete(key);
cacheTimestamps.delete(key);
setExpandedBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
});
setErrorBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
});
// Fetch with language filter
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
try {
// Resolve language codes for the API call
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
// Don't pass languages if "All" is selected or null
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
? undefined
: langCodes;
// Pass indexer filter only if the source supports it (empty array = search all)
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
const indexersParam = supportsIndexerFilter && indexerFilter.length > 0 ? indexerFilter : undefined;
const response = await getReleases(
provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType, manualQuery.trim() || undefined, indexersParam
);
setCachedReleases(provider, bookId, activeTab, contentType, response);
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
setErrorBySource((prev) => ({ ...prev, [activeTab]: message }));
} finally {
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
}
}}
className="w-full px-3 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
{hasActiveFilter && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
Apply
</button>
);
}}
>
{({ close }) => (
<div className="p-4 space-y-4">
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && (
<DropdownList
label="Format"
options={formatOptions}
value={formatFilter}
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
placeholder="All Formats"
/>
)}
{columnConfig.supported_filters?.includes('language') && (
<LanguageMultiSelect
label="Language"
options={bookLanguages}
value={languageFilter}
onChange={setLanguageFilter}
defaultLanguageCodes={defaultLanguages}
/>
)}
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
<button
type="button"
onClick={async () => {
close();
if (!book?.provider || !book?.provider_id) return;
const provider = book.provider;
const bookId = book.provider_id;
// Clear cache and state
const key = getCacheKey(provider, bookId, activeTab, contentType);
releaseCache.delete(key);
cacheTimestamps.delete(key);
setExpandedBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
});
setErrorBySource((prev) => {
const next = { ...prev };
delete next[activeTab];
return next;
});
// Fetch with language filter
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
try {
// Resolve language codes for the API call
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
// Don't pass languages if "All" is selected or null
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
? undefined
: langCodes;
const response = await getReleases(
provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType, manualQuery.trim() || undefined
);
setCachedReleases(provider, bookId, activeTab, contentType, response);
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
setErrorBySource((prev) => ({ ...prev, [activeTab]: message }));
} finally {
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
}
}}
className="w-full px-3 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
>
Apply
</button>
)}
</div>
)}
</Dropdown>
)}
)}
</div>
)}
</Dropdown>
)}
</div>
</div>
)}

View File

@@ -0,0 +1,115 @@
import { useState, useRef, useEffect, ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface TooltipProps {
content: ReactNode;
children: ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
className?: string;
}
export function Tooltip({
content,
children,
position = 'top',
delay = 200,
className = '',
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
let top = 0;
let left = 0;
switch (position) {
case 'top':
top = rect.top - 8;
left = rect.left + rect.width / 2;
break;
case 'bottom':
top = rect.bottom + 8;
left = rect.left + rect.width / 2;
break;
case 'left':
top = rect.top + rect.height / 2;
left = rect.left - 8;
break;
case 'right':
top = rect.top + rect.height / 2;
left = rect.right + 8;
break;
}
setCoords({ top, left });
setIsVisible(true);
}
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsVisible(false);
setCoords(null);
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
if (!content) {
return <>{children}</>;
}
// Transform classes to center tooltip relative to the anchor point
const transformClass = {
top: '-translate-x-1/2 -translate-y-full',
bottom: '-translate-x-1/2',
left: '-translate-x-full -translate-y-1/2',
right: '-translate-y-1/2',
}[position];
return (
<>
<div
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
className="inline-flex"
>
{children}
</div>
{isVisible && coords && createPortal(
<div
role="tooltip"
className={`fixed z-[9999] px-3 py-2 text-xs rounded-lg shadow-lg
pointer-events-none ${transformClass} ${className}`}
style={{
top: coords.top,
left: coords.left,
background: 'var(--bg-soft)',
color: 'var(--text)',
border: '1px solid var(--border-muted)',
}}
>
{content}
</div>,
document.body
)}
</>
);
}
export default Tooltip;

View File

@@ -49,6 +49,9 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
progress: book.progress,
};
}
if (currentStatus.locating && currentStatus.locating[bookId]) {
return { text: 'Locating files', state: 'locating' };
}
if (currentStatus.resolving && currentStatus.resolving[bookId]) {
return { text: 'Resolving', state: 'resolving' };
}
@@ -84,6 +87,10 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
};
}
if (currentStatus.locating && currentStatus.locating[releaseId]) {
return { text: 'Locating files', state: 'locating' };
}
if (currentStatus.resolving && currentStatus.resolving[releaseId]) {
return { text: 'Resolving', state: 'resolving' };
}

View File

@@ -201,7 +201,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
} finally {
setIsSearching(false);
}
}, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError]);
}, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError, contentType]);
const handleResetSearch = useCallback((config: AppConfig | null) => {
setBooks([]);
@@ -257,7 +257,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
} finally {
setIsLoadingMore(false);
}
}, [currentPage, hasMore, isLoadingMore, handleSearchError]);
}, [currentPage, hasMore, isLoadingMore, handleSearchError, contentType]);
const handleSortChange = useCallback((value: string, config: AppConfig | null) => {
updateAdvancedFilters({ sort: value });

View File

@@ -1,5 +1,6 @@
import { LoginForm } from '../components/LoginForm';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
interface LoginPageProps {
onLogin: (credentials: LoginCredentials) => void;
@@ -8,6 +9,8 @@ interface LoginPageProps {
}
export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
const logoUrl = withBasePath('/logo.png');
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"
@@ -15,7 +18,7 @@ export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img src="/logo.png" alt="Logo" className="mx-auto mb-6 w-20 h-20" />
<img src={logoUrl} alt="Logo" className="mx-auto mb-6 w-20 h-20" />
<h1 className="text-2xl font-semibold">Sign in to continue</h1>
</div>
<div
@@ -33,4 +36,3 @@ export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
);
};

View File

@@ -339,7 +339,8 @@ export const getReleases = async (
expandSearch?: boolean,
languages?: string[],
contentType?: string,
manualQuery?: string
manualQuery?: string,
indexers?: string[]
): Promise<ReleasesResponse> => {
const params = new URLSearchParams({
provider,
@@ -366,6 +367,9 @@ export const getReleases = async (
if (manualQuery) {
params.set('manual_query', manualQuery);
}
if (indexers && indexers.length > 0) {
params.set('indexers', indexers.join(','));
}
// Let the backend control timeouts for release searches (can be long-running).
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
};

View File

@@ -54,6 +54,7 @@ export interface Book {
export interface StatusData {
queued?: Record<string, Book>;
resolving?: Record<string, Book>;
locating?: Record<string, Book>;
downloading?: Record<string, Book>;
complete?: Record<string, Book>;
error?: Record<string, Book>;
@@ -65,7 +66,7 @@ export interface ActiveDownloadsResponse {
}
// Button states
export type ButtonState = 'download' | 'queued' | 'resolving' | 'downloading' | 'complete' | 'error';
export type ButtonState = 'download' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error';
export interface ButtonStateInfo {
text: string;
@@ -204,7 +205,7 @@ export interface ReleaseSource {
}
// Column schema types for plugin-driven release list UI
export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers';
export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers' | 'indexer_protocol' | 'flag_icon' | 'format_content_type';
export type ColumnAlign = 'left' | 'center' | 'right';
export interface ColumnColorHint {
@@ -246,8 +247,10 @@ export interface ReleaseColumnConfig {
grid_template: string; // CSS grid-template-columns for dynamic section
leading_cell?: LeadingCellConfig; // Defaults to thumbnail from extra.preview
online_servers?: string[]; // For IRC: list of currently online server nicks
available_indexers?: string[]; // For Prowlarr: list of all enabled indexer names
default_indexers?: string[]; // For Prowlarr: indexers selected in settings (pre-selected in filter)
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
supported_filters?: string[]; // Which filters this source supports: ["format", "language"]
supported_filters?: string[]; // Which filters this source supports: ["format", "language", "indexer"]
action_button?: SourceActionButton; // Custom action button (replaces default expand search)
}
@@ -266,6 +269,7 @@ export interface Release {
indexer?: string; // Display name for the source/indexer
seeders?: number; // For torrents
peers?: string; // For torrents: "seeders/leechers" display string
content_type?: string; // "ebook", "audiobook", or "book"
extra?: Record<string, unknown>; // Source-specific metadata
}

View File

@@ -5,6 +5,7 @@ interface ColorStyle {
}
const FORMAT_COLORS: Record<string, ColorStyle> = {
// Ebook formats
pdf: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300' },
epub: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
mobi: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
@@ -14,6 +15,10 @@ const FORMAT_COLORS: Record<string, ColorStyle> = {
fb2: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300' },
cbr: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
cbz: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
// Audiobook formats
m4b: { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' },
mp3: { bg: 'bg-rose-500/20', text: 'text-rose-700 dark:text-rose-300' },
flac: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' },
};
const LANGUAGE_COLORS: Record<string, ColorStyle> = {
@@ -52,9 +57,12 @@ const CONTENT_TYPE_COLORS: Record<string, ColorStyle> = {
};
const FLAG_COLORS: Record<string, ColorStyle> = {
fl: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
freeleech: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
'double upload': { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
vip: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
'vip fl': { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
'fl vip': { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
sticky: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
};
@@ -67,7 +75,11 @@ const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700
export function getFormatColor(format?: string): ColorStyle {
if (!format || format === '-') return FALLBACK_COLOR;
return FORMAT_COLORS[format.toLowerCase()] || DEFAULT_FORMAT_COLOR;
const normalized = format.toLowerCase();
// Support display strings like "EPUB, MOBI +1" by using the first token for color mapping.
const match = normalized.match(/[a-z0-9]+/);
const key = match ? match[0] : normalized;
return FORMAT_COLORS[key] || DEFAULT_FORMAT_COLOR;
}
export function getLanguageColor(language?: string): ColorStyle {
@@ -80,6 +92,15 @@ export function getDownloadTypeColor(downloadType?: string): ColorStyle {
return DOWNLOAD_TYPE_COLORS[downloadType.toLowerCase()] || DEFAULT_DOWNLOAD_TYPE_COLOR;
}
// Returns just the dot color class for protocol indicators
export function getProtocolDotColor(protocol?: string): string {
if (!protocol) return 'bg-gray-400';
const p = protocol.toLowerCase();
if (p === 'torrent') return 'bg-orange-500';
if (p === 'nzb' || p === 'usenet') return 'bg-sky-500';
return 'bg-gray-400';
}
export function getContentTypeColor(contentType?: string): ColorStyle {
if (!contentType || contentType === '-') return FALLBACK_COLOR;
return CONTENT_TYPE_COLORS[contentType.toLowerCase()] || DEFAULT_CONTENT_TYPE_COLOR;
@@ -87,7 +108,9 @@ export function getContentTypeColor(contentType?: string): ColorStyle {
export function getFlagColor(flag?: string): ColorStyle {
if (!flag || flag === '-') return FALLBACK_COLOR;
return FLAG_COLORS[flag.toLowerCase()] || DEFAULT_FLAG_COLOR;
const normalized = flag.trim().toLowerCase();
if (!normalized) return FALLBACK_COLOR;
return FLAG_COLORS[normalized] || DEFAULT_FLAG_COLOR;
}
/**

View File

@@ -0,0 +1,24 @@
def test_bypass_tries_all_methods_before_abort(monkeypatch):
"""Regression test for issue #524: don't abort before cycling through bypass methods."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
calls: list[str] = []
def _make_method(name: str):
def _method(_sb) -> bool:
calls.append(name)
return False
_method.__name__ = name
return _method
methods = [_make_method(f"m{i}") for i in range(6)]
monkeypatch.setattr(internal_bypasser, "BYPASS_METHODS", methods)
monkeypatch.setattr(internal_bypasser, "_is_bypassed", lambda _sb, escape_emojis=True: False)
monkeypatch.setattr(internal_bypasser, "_detect_challenge_type", lambda _sb: "ddos_guard")
monkeypatch.setattr(internal_bypasser.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(internal_bypasser.random, "uniform", lambda _a, _b: 0)
assert internal_bypasser._bypass(object(), max_retries=10) is False
assert calls == [f"m{i}" for i in range(6)]

View File

@@ -0,0 +1,33 @@
from unittest.mock import MagicMock
def test_update_download_status_dedupes_identical_events(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
book_id = "test-book-id"
# Ensure clean module-level state
orchestrator._last_activity.clear()
orchestrator._last_status_event.clear()
mock_queue = MagicMock()
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "queue_status", lambda: {})
mock_ws = MagicMock()
monkeypatch.setattr(orchestrator, "ws_manager", mock_ws)
times = iter([1.0, 2.0])
monkeypatch.setattr(orchestrator.time, "time", lambda: next(times))
orchestrator.update_download_status(book_id, "resolving", "Bypassing protection...")
orchestrator.update_download_status(book_id, "resolving", "Bypassing protection...")
# Status + message should only be applied/broadcast once.
assert mock_queue.update_status.call_count == 1
assert mock_queue.update_status_message.call_count == 1
assert mock_ws.broadcast_status_update.call_count == 1
# Activity timestamp should still be updated on the duplicate keep-alive call.
assert orchestrator._last_activity[book_id] == 2.0