mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-05-19 11:34:53 -04:00
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:
39
docker-compose.bypass-test.yml
Normal file
39
docker-compose.bypass-test.yml
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
selenium==4.39.0
|
||||
seleniumbase==4.45.10
|
||||
python-xlib
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
171
shelfmark/release_sources/prowlarr/torznab.py
Normal file
171
shelfmark/release_sources/prowlarr/torznab.py
Normal 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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
115
src/frontend/src/components/shared/Tooltip.tsx
Normal file
115
src/frontend/src/components/shared/Tooltip.tsx
Normal 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;
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
24
tests/bypass/test_internal_bypasser.py
Normal file
24
tests/bypass/test_internal_bypasser.py
Normal 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)]
|
||||
33
tests/download/test_orchestrator_status.py
Normal file
33
tests/download/test_orchestrator_status.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user