Compare commits

...

10 Commits

Author SHA1 Message Date
kikootwo 7e53f037af Bump package version to 1.0.4
Update package.json version from 1.0.3 to 1.0.4 to publish a new patch release.
2026-02-09 19:46:10 -05:00
kikootwo 4b90b35748 Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
2026-02-09 19:45:43 -05:00
kikootwo d7acd67aa4 Merge pull request #53 from gtronset/feature/unraid-template
Add Unraid Template to show in Unraid Community Apps (CA Store)
2026-02-08 22:44:55 -05:00
Gavin Tronset a663452658 Add Unraid Template to show in Unraid Community Apps 2026-02-07 21:54:23 -08:00
kikootwo d0e3c9c665 Bump package version to 1.0.3
Update package.json version from 1.0.2 to 1.0.3 to prepare a patch release.
2026-02-06 17:17:25 -05:00
kikootwo 95e63dfc36 Add ROOTLESS_CONTAINER and request UI updates
Introduce ROOTLESS_CONTAINER env to opt out of gosu (replace /proc uid_map detection) and update entrypoint messaging; adjust app-start.sh and redis-start.sh to skip gosu when ROOTLESS_CONTAINER=true and warn on UID/GID mismatch only when applicable. Backend: include audiobook audibleAsin in admin requests response (mapped to asin) and pass baseUrl through test-flaresolverr endpoint to the FlareSolverr tester. Frontend: RecentRequestsTable and RequestActionsDropdown now surface asin, accept/passthrough annasArchiveBaseUrl, and add a "View Details" flow using AudiobookDetailsModal; admin page passes ebook baseUrl from settings. InteractiveTorrentSearchModal refactor: improved UX/UI, keyboard handling, portal/modal mounting, skeleton/loading states, formatting helpers, and richer result display. Tests updated to match changes.
2026-02-06 17:13:39 -05:00
kikootwo 03371be81d Add optional rootless Podman support
Add documentation and example env var to docker-compose.yml for running with rootless Podman. Introduces a commented ROOTLESS_CONTAINER option that, when set to "true", skips gosu UID/GID switching since user namespaces handle mapping; includes a warning not to enable this for Docker or LXC to avoid creating files as root.
2026-02-06 16:09:00 -05:00
kikootwo 4c1d1c89e8 Audible regions: isEnglish flag + UI warnings
Add an isEnglish flag to AUDIBLE_REGIONS and update region handling across the app. UI: populate Audible region selects from AUDIBLE_REGIONS and mark non-English regions with a '*' and an amber warning explaining limited feature support. Service: set axios default param language=english on Audible requests (simplifies/fixes locale handling) and remove the previous locale-correction flow. API: validate regions dynamically from AUDIBLE_REGIONS. Also bump package version to 1.0.2. These changes make region metadata explicit and inform users about limited support for non-English regions while forcing English content where supported.
2026-02-06 11:48:00 -05:00
kikootwo d25d93680e Merge pull request #36 from aronjanosch/feature/german-audible-region-and-regional-title
Add German Audible region
2026-02-06 11:10:14 -05:00
Aron Wiederkehr 312421a96b Add German Audible region 2026-02-05 20:09:21 +01:00
144 changed files with 10564 additions and 2058 deletions
+9
View File
@@ -53,6 +53,15 @@ services:
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here"
# POSTGRES_PASSWORD: "your-custom-postgres-password-here"
# ========================================================================
# OPTIONAL: Rootless Podman Support
# ========================================================================
# Set to "true" ONLY if running with rootless Podman.
# This skips gosu UID/GID switching since the user namespace already
# handles mapping. Do NOT enable for Docker or LXC - it will cause
# files to be created as root.
# ROOTLESS_CONTAINER: "true"
# ========================================================================
# OPTIONAL: Application Configuration
# ========================================================================
+11 -50
View File
@@ -3,42 +3,11 @@
# Uses gosu to ensure correct PUID:PGID for file operations
#
# Supports:
# - Docker: Uses gosu to switch to PUID:PGID
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
# - Docker/LXC: Uses gosu to switch to PUID:PGID (default)
# - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu
set -e
# =============================================================================
# USER NAMESPACE DETECTION
# =============================================================================
# Detects if running in a user namespace where UID 0 is remapped to a non-root
# user on the host (e.g., rootless Podman). In this case, using gosu would
# cause a double-mapping that breaks volume permissions.
#
# How it works:
# - /proc/self/uid_map shows the UID mapping for the current namespace
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
# - In a normal container: "0 0 4294967295" (root maps to root)
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
#
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
# =============================================================================
is_user_namespace_root() {
if [ -f /proc/self/uid_map ]; then
# Read the first mapping line (covers UID 0)
read -r inside outside count < /proc/self/uid_map
# Trim whitespace (uid_map has leading spaces for alignment)
inside=$(echo "$inside" | xargs)
outside=$(echo "$outside" | xargs)
# If UID 0 inside maps to non-0 outside, we're in a user namespace
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
return 0 # true - rootless container detected
fi
fi
return 1 # false - normal container (Docker or rootful Podman)
}
# Load environment from /etc/environment (set by entrypoint)
if [ -f /etc/environment ]; then
set -a
@@ -58,23 +27,18 @@ cd /app
# =============================================================================
# START SERVER WITH APPROPRIATE UID:GID HANDLING
# =============================================================================
# Three scenarios:
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
# 3. Non-root fallback: Already running as non-root, run directly
# Two scenarios:
# 1. Default: Running as root, use gosu to switch to PUID:PGID
# 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping)
start_server() {
if [ "$(id -u)" = "0" ]; then
if is_user_namespace_root; then
# Rootless container (e.g., rootless Podman)
# Skip gosu - the user namespace already maps our "root" to the correct host UID
echo "[App] Detected rootless container (user namespace with remapped root)"
echo "[App] Skipping gosu to preserve user namespace UID mapping"
echo "[App] Process will run as namespace UID 0 (mapped to host user)"
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
# Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user
echo "[App] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)"
node server.js &
else
# Normal container (Docker or rootful Podman)
# Use gosu to switch to the specified PUID:PGID
# Default: Use gosu to switch to the specified PUID:PGID
echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
gosu "$PUID:$PGID" node server.js &
fi
@@ -104,11 +68,8 @@ if [ -f "/proc/$SERVER_PID/status" ]; then
ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}')
echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID"
# Only warn about mismatch in non-rootless scenarios
if ! is_user_namespace_root; then
if [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; then
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
fi
if [ "${ROOTLESS_CONTAINER}" != "true" ] && { [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; }; then
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
fi
fi
+5 -1
View File
@@ -329,6 +329,7 @@ PORT=$PORT
HOSTNAME=$HOSTNAME
PUID=${PUID:-}
PGID=${PGID:-}
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
EOF
echo "✅ Environment configured"
@@ -363,7 +364,10 @@ echo "📊 Services starting:"
echo " - PostgreSQL (internal, user=postgres)"
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})"
echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})"
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
echo ""
echo "🔐 ROOTLESS_CONTAINER=true - gosu will be skipped (user namespace handles UID mapping)"
elif [ -n "$PUID" ] && [ -n "$PGID" ]; then
echo ""
echo "🔐 Using gosu for reliable UID:GID switching"
echo " App and Redis will run as $PUID:$PGID"
+9 -45
View File
@@ -3,42 +3,11 @@
# Uses gosu to ensure correct PUID:PGID for file operations
#
# Supports:
# - Docker: Uses gosu to switch to PUID:PGID
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
# - Docker/LXC: Uses gosu to switch to PUID:PGID (default)
# - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu
set -e
# =============================================================================
# USER NAMESPACE DETECTION
# =============================================================================
# Detects if running in a user namespace where UID 0 is remapped to a non-root
# user on the host (e.g., rootless Podman). In this case, using gosu would
# cause a double-mapping that breaks volume permissions.
#
# How it works:
# - /proc/self/uid_map shows the UID mapping for the current namespace
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
# - In a normal container: "0 0 4294967295" (root maps to root)
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
#
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
# =============================================================================
is_user_namespace_root() {
if [ -f /proc/self/uid_map ]; then
# Read the first mapping line (covers UID 0)
read -r inside outside count < /proc/self/uid_map
# Trim whitespace (uid_map has leading spaces for alignment)
inside=$(echo "$inside" | xargs)
outside=$(echo "$outside" | xargs)
# If UID 0 inside maps to non-0 outside, we're in a user namespace
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
return 0 # true - rootless container detected
fi
fi
return 1 # false - normal container (Docker or rootful Podman)
}
# Load environment from /etc/environment (set by entrypoint)
if [ -f /etc/environment ]; then
set -a
@@ -56,24 +25,19 @@ echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
# =============================================================================
# START REDIS WITH APPROPRIATE UID:GID HANDLING
# =============================================================================
# Three scenarios:
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
# 3. Non-root fallback: Already running as non-root, run directly
# Two scenarios:
# 1. Default: Running as root, use gosu to switch to PUID:PGID
# 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping)
REDIS_CMD="/usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379"
if [ "$(id -u)" = "0" ]; then
if is_user_namespace_root; then
# Rootless container (e.g., rootless Podman)
# Skip gosu - the user namespace already maps our "root" to the correct host UID
echo "[Redis] Detected rootless container (user namespace with remapped root)"
echo "[Redis] Skipping gosu to preserve user namespace UID mapping"
echo "[Redis] Process will run as namespace UID 0 (mapped to host user)"
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
# Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user
echo "[Redis] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)"
exec $REDIS_CMD
else
# Normal container (Docker or rootful Podman)
# Use gosu to switch to the specified PUID:PGID
# Default: Use gosu to switch to the specified PUID:PGID
echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
exec gosu "$PUID:$PGID" $REDIS_CMD
fi
+6 -3
View File
@@ -52,7 +52,7 @@
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
- **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md)
- **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md)
- **Multi-download-client support (qBittorrent + SABnzbd)** → [phase3/download-clients.md](phase3/download-clients.md)
- **Multi-download-client support (qBittorrent, Transmission, SABnzbd, NZBGet)** → [phase3/download-clients.md](phase3/download-clients.md)
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
@@ -111,8 +111,11 @@
**"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation)
**"How do I configure multiple download clients?"** → [phase3/download-clients.md](phase3/download-clients.md)
**"How do torrent downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md)
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md)
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [phase3/download-clients.md](phase3/download-clients.md), [backend/services/jobs.md](backend/services/jobs.md)
**"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
**"How do I use NZBGet instead of SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
**"How do I use Transmission instead of qBittorrent?"** → [phase3/download-clients.md](phase3/download-clients.md)
**"How do I set different download paths per client?"** → [phase3/download-clients.md](phase3/download-clients.md#per-client-custom-download-path)
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
**"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar)
@@ -134,7 +137,7 @@
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
**"Why can't RMAB find my downloaded files?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
**"How do I set up volume mapping for qBittorrent/SABnzbd?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
**"How do I set up volume mapping for qBittorrent/Transmission/SABnzbd/NZBGet?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
**"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md)
**"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md)
**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
+18 -15
View File
@@ -26,11 +26,18 @@ Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallbac
Configurable Audible region for accurate metadata matching across different international Audible stores.
**Supported Regions:**
- United States (`us`) - `audible.com` (default)
- Canada (`ca`) - `audible.ca`
- United Kingdom (`uk`) - `audible.co.uk`
- Australia (`au`) - `audible.com.au`
- India (`in`) - `audible.in`
- United States (`us`) - `audible.com` (default, English)
- Canada (`ca`) - `audible.ca` (English)
- United Kingdom (`uk`) - `audible.co.uk` (English)
- Australia (`au`) - `audible.com.au` (English)
- India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English)
**`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *")
**Why Regions Matter:**
- Each Audible region uses different ASINs for the same audiobook
@@ -48,7 +55,7 @@ Configurable Audible region for accurate metadata matching across different inte
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
- Audnexus API calls include region parameter: `?region={code}`
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
- **Locale enforcement:** Cookie `lc-acbus=en_US` + `handleLocaleRedirect()` detects non-English culture codes in response URLs and re-requests using the English URL from Audible's locale picker
- **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
- Configuration service helper: `getAudibleRegion()` returns configured region
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
@@ -228,12 +235,8 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
- **Affects:** All Audiobookshelf metadata matching operations
**Non-English locale pages served to users outside US (2026-02-05)**
- **Problem:** Audible uses IP geolocation to add culture codes (e.g., `es_US`, `fr_CA`) to URLs, serving locale-specific pages. `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale redirects within the same region.
- **Impact:** Users self-hosting from non-English-speaking countries (e.g., Dominican Republic) got Spanish bestsellers/new releases on their homepage because the `audible_refresh` job scraped locale-redirected pages.
- **Fix:** Three-layer defense in `AudibleService`:
1. **Cookie:** `lc-acbus=en_US` header hints English locale preference
2. **Locale picker detection (primary):** After every request, checks response URL for non-`en_*` culture codes (`xx_YY` pattern). If found, parses page HTML for Audible's `<adbl-toggle-chip>` locale picker, extracts the English option's `data-value` URL, and re-requests. Data-driven — uses Audible's own English URL rather than guessing.
3. **Fallback URL rewrite:** If no locale picker found, strips the culture code from the path and adds `language=en_US` query param (mirrors picker pattern).
- **Verification:** After correction, validates the response URL no longer contains a non-English culture code and logs success/failure.
- **Location:** `src/lib/integrations/audible.service.ts``handleLocaleRedirect()`, `initialize()`
- **Affects:** All Audible scraping: popular, new releases, search, detail pages (via `fetchWithRetry`)
- **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes.
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
- **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available.
- **Location:** `src/lib/integrations/audible.service.ts``initialize()` (axios default params)
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
+4 -2
View File
@@ -15,7 +15,7 @@ Request → search_indexers → rank_results → download_torrent
1. **search_indexers** - Search Prowlarr for torrents
2. **rank_results** - Apply ranking algorithm, select best
3. **download_torrent** - Add to qBittorrent
3. **download_torrent** - Add to download client (qBittorrent/Transmission/SABnzbd)
4. **monitor_download** - Poll progress (10s intervals)
5. **process_audiobook** - Organize files to media directory
6. **update_plex** - Trigger scan, fuzzy match
@@ -23,7 +23,7 @@ Request → search_indexers → rank_results → download_torrent
## Integration Points
**Indexers:** Prowlarr (primary), Jackett (fallback)
**Download Clients:** qBittorrent (primary), Transmission (fallback)
**Download Clients:** qBittorrent or Transmission (torrent), SABnzbd (usenet) — [details](./download-clients.md)
**Media Server:** Plex (scan + match)
## Job Queue (Bull)
@@ -43,7 +43,9 @@ Request → search_indexers → rank_results → download_torrent
## Related Docs
- [Prowlarr](./prowlarr.md)
- [Download Clients](./download-clients.md) - Multi-client management, protocol routing
- [qBittorrent](./qbittorrent.md)
- [SABnzbd](./sabnzbd.md)
- [Ranking Algorithm](./ranking-algorithm.md)
- [File Organization](./file-organization.md)
- [Plex Integration](../integrations/plex.md)
+121 -20
View File
@@ -1,45 +1,127 @@
# Multi-Download-Client Support
**Status:** ✅ Implemented | Simultaneous qBittorrent + SABnzbd support
**Status:** ✅ Implemented | qBittorrent, Transmission, SABnzbd, and NZBGet support
## Overview
Users can configure both qBittorrent (torrents) and SABnzbd (Usenet) simultaneously. System selects best release across all indexer types regardless of protocol.
Users can configure one torrent client (qBittorrent or Transmission) and one usenet client (SABnzbd or NZBGet) simultaneously. System selects best release across all indexer types regardless of protocol.
**Constraint:** 1 client per type (torrent/usenet) for now; architecture supports future expansion.
**Constraint:** 1 client per protocol (torrent/usenet). Users must remove an existing torrent client before adding a different one.
## Key Details
### Supported Clients
| Client | Protocol | Auth | Categories |
|--------|----------|------|------------|
| qBittorrent | torrent | Cookie-based (login endpoint) | Categories |
| Transmission | torrent | HTTP Basic Auth + CSRF (`X-Transmission-Session-Id`) | Labels |
| SABnzbd | usenet | API key | Categories |
| NZBGet | usenet | HTTP Basic Auth (JSON-RPC) | Config-based categories |
### Protocol Map
**File:** `src/lib/interfaces/download-client.interface.ts`
```typescript
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
qbittorrent: 'torrent',
sabnzbd: 'usenet',
nzbget: 'usenet',
transmission: 'torrent',
};
```
Used by manager's `getClientForProtocol()` and UI's protocol-level enforcement.
### Configuration Structure
**Key:** `download_clients` (JSON array, replaces legacy flat keys)
```typescript
interface DownloadClientConfig {
id: string; // UUID
type: 'qbittorrent' | 'sabnzbd';
type: 'qbittorrent' | 'sabnzbd' | 'nzbget' | 'transmission';
name: string; // User-friendly name
enabled: boolean;
url: string;
username?: string; // qBittorrent only
username?: string; // qBittorrent/Transmission/NZBGet only
password: string; // Password or API key
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
remotePath?: string;
localPath?: string;
category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir
}
```
### Transmission Service
**File:** `src/lib/integrations/transmission.service.ts`
- **RPC endpoint:** `POST /transmission/rpc` (JSON-RPC)
- **CSRF:** 409 → capture `X-Transmission-Session-Id` header → retry
- **Auth:** HTTP Basic Auth (optional)
- **Categories:** Uses `labels` array on `torrent-add`
- **Download path:** `download-dir` argument on `torrent-add`
- **Torrent files:** Base64-encoded via `metainfo` field
- **Status codes:** 0=stopped→paused, 1=check-pending→checking, 2=checking→checking, 3=download-pending→queued, 4=downloading→downloading, 5=seed-pending→seeding, 6=seeding→seeding
- **Error handling:** `error > 0` → failed status
- **postProcess():** No-op (same as qBittorrent)
### NZBGet Service
**File:** `src/lib/integrations/nzbget.service.ts`
- **RPC endpoint:** `POST /jsonrpc` (JSON-RPC with Basic Auth)
- **Auth:** HTTP Basic Auth (username + password)
- **Categories:** Config-based (`Category1.Name`, `Category1.DestDir`), managed via `config()` + `saveconfig()`
- **Adding NZBs:** Downloads NZB content from Prowlarr, base64-encodes, uploads via `append()`
- **Queue status:** `listgroups(0)` — QUEUED, PAUSED, DOWNLOADING, FETCHING, PP_* (processing states)
- **History status:** `history(false)` — SUCCESS/*, WARNING/* → completed; FAILURE/*, DELETED/* → failed
- **Pause/Resume/Delete:** `editqueue()` with GroupPause/GroupResume/GroupDelete/HistoryDelete commands
- **postProcess():** `editqueue('HistoryDelete')` — archives from visible history (preserves in hidden archive)
- **IDs:** Integer NZBIDs (stored as strings in RMAB system)
### Per-Client Custom Download Path
**Field:** `customPath` (optional string, blank = use base `download_dir` as-is)
Allows each download client to download to a different subdirectory under `download_dir`. Useful for separating torrent and usenet downloads.
**Path Resolution (in `createService()`):**
```
finalPath = config.customPath ? path.join(downloadDir, config.customPath) : downloadDir
```
**Example:**
- `download_dir` = `/downloads`, qBittorrent `customPath` = `torrents``/downloads/torrents`
- `download_dir` = `/downloads`, SABnzbd `customPath` = `usenet``/downloads/usenet`
- `download_dir` = `/downloads`, `customPath` = blank → `/downloads`
**Validation:**
- Leading/trailing slashes stripped on save
- Paths containing `..` rejected (frontend + API)
- Backward-compatible: existing configs without `customPath` default to base `download_dir`
**Resolved path used by:**
- Service constructors (`defaultSavePath` / `defaultDownloadDir`)
- Category creation (qBittorrent `ensureCategory`, SABnzbd `ensureCategory`)
- Torrent/NZB addition (save path / download-dir)
- Remote path mapping (applied after customPath resolution)
- Singleton getters (`getQBittorrentService`, `getSABnzbdService`)
- Retry fallback path construction (`retry-failed-imports.processor.ts`)
**UI:** Modal shows real-time path preview: `Downloads to: /downloads/torrents`
### Download Client Manager Service
**File:** `src/lib/services/download-client-manager.service.ts`
**Methods:**
- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol
- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol (uses `CLIENT_PROTOCOL_MAP`)
- `hasClientForProtocol(protocol)` - Check if protocol configured
- `getAllClients()` - List all configs
- `testConnection(config)` - Test specific config
- `invalidate()` - Clear cache on config change
- `getClientServiceForProtocol(protocol)` - Get instantiated service
**Factory Cases:** `qbittorrent``QBittorrentService`, `sabnzbd``SABnzbdService`, `nzbget``NZBGetService`, `transmission``TransmissionService`
**Singleton Pattern:** Uses caching with invalidation on config changes.
### Protocol Filtering
@@ -57,7 +139,7 @@ interface DownloadClientConfig {
**Logic:**
1. Detect protocol from result (`ProwlarrService.isNZBResult()`)
2. Get appropriate client via manager (`getClientForProtocol()`)
3. Route to qBittorrent or SABnzbd service
3. Route to correct service (qBittorrent, Transmission, or SABnzbd)
4. Create download history record
### Migration
@@ -76,7 +158,7 @@ interface DownloadClientConfig {
**POST /api/admin/settings/download-clients/test** - Test connection
**Validation:**
- Only 1 client per type allowed (enforced on add)
- Only 1 client per protocol allowed (enforced on add via `CLIENT_PROTOCOL_MAP`)
- Test connection required before save
- Password masking in responses (`********`)
@@ -86,14 +168,18 @@ interface DownloadClientConfig {
| Component | Purpose |
|-----------|---------|
| `DownloadClientManagement.tsx` | Container with add buttons + configured cards |
| `DownloadClientCard.tsx` | Card with name, type badge, edit/delete |
| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields |
| `DownloadClientManagement.tsx` | Container with add cards (4-column: qBittorrent, Transmission, SABnzbd, NZBGet) + configured cards; protocol-level enforcement (grayed out when protocol taken) |
| `DownloadClientCard.tsx` | Card with name, type badge (blue=qBittorrent, green=Transmission, purple=SABnzbd, orange=NZBGet), custom path display, edit/delete |
| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields; Username shown for qBittorrent + Transmission + NZBGet; URL placeholder per-type |
**UI Flow:**
1. **Add Client Section:** Two cards (qBittorrent, SABnzbd) with "Add" button or "Already configured" badge
2. **Configured Clients:** Grid of cards showing name, type, URL, status
3. **Modal:** Type-specific fields, SSL toggle, path mapping, test connection
1. **Add Client Section:** Four cards (qBittorrent, Transmission, SABnzbd, NZBGet) with "Add" button or "Protocol already configured" when protocol is taken (card grayed out with `opacity-50`)
2. **Configured Clients:** Grid of cards showing name, type, URL, custom path (if set), status
3. **Modal:** Type-specific fields, custom download path with live preview, SSL toggle, path mapping, test connection
**downloadDir Prop Flow:**
- **Settings mode:** `DownloadClientManagement` fetches from `GET /api/admin/settings``settings.paths.downloadDir` on mount
- **Wizard mode:** `setup/page.tsx` passes `state.downloadDir``DownloadClientStep``DownloadClientManagement``DownloadClientModal`
## Integration Points
@@ -107,6 +193,8 @@ Replaced legacy form with `<DownloadClientManagement mode="settings" />`
Replaced single-client form with `<DownloadClientManagement mode="wizard" />`
**Props:** Accepts `downloadDir` from setup page state, passes to management component
**Validation:** At least 1 enabled client required to proceed
### Setup Complete API
@@ -123,25 +211,38 @@ Accepts both legacy single client and new array format:
**Client disabled:** Results for that protocol filtered out
**Connection failure:** Per-download error handling (existing)
**Mixed results:** Best release selected regardless of protocol when both clients configured
**Custom path blank:** Uses base `download_dir` (backward-compatible default)
**Custom path with slashes:** Leading/trailing slashes stripped automatically
**Custom path with `..`:** Rejected by frontend validation and API validation
**Switching torrent clients:** Must delete existing torrent client before adding Transmission (or vice versa)
## Verification Steps
1. **Migration:** Existing single-client users see config as card after update
2. **Single client:** Configure only qBittorrent → only torrent results shown
3. **Both clients:** Configure both → mixed results, best selected across protocols
4. **Download routing:** Torrent result → qBittorrent; NZB result → SABnzbd
3. **Both clients:** Configure torrent + usenet → mixed results, best selected across protocols
4. **Download routing:** Torrent result → torrent client; NZB result → usenet client (SABnzbd or NZBGet)
5. **Wizard:** Must add at least one client to proceed
6. **Settings:** Can add/edit/delete/test clients; changes persist
7. **Custom path:** Set `torrents` on torrent client → save path includes subdirectory
8. **Custom path preview:** Modal shows resolved path in real-time as user types
9. **Custom path persistence:** Save, reopen modal → value persists
10. **Custom path on card:** Configured cards show custom path if set
11. **Transmission CSRF:** First RPC call gets 409, captures session ID, retry succeeds
12. **Protocol enforcement:** Adding qBittorrent grays out Transmission card (and vice versa)
## Critical Files
| File | Changes |
|------|---------|
| `src/lib/services/download-client-manager.service.ts` | **NEW** - Core multi-client service |
| `src/lib/interfaces/download-client.interface.ts` | Client types, display names, `CLIENT_PROTOCOL_MAP` |
| `src/lib/integrations/nzbget.service.ts` | NZBGet JSON-RPC implementation |
| `src/lib/integrations/transmission.service.ts` | Transmission RPC implementation |
| `src/lib/services/download-client-manager.service.ts` | Core multi-client service, protocol-based routing |
| `src/lib/integrations/prowlarr.service.ts:379` | Protocol filtering logic (both clients = all results) |
| `src/lib/processors/download-torrent.processor.ts:44` | Download routing (detect protocol → route) |
| `src/app/api/admin/settings/download-clients/*` | **NEW** - CRUD API routes |
| `src/components/admin/download-clients/*` | **NEW** - UI components (card-based) |
| `src/app/api/admin/settings/download-clients/*` | CRUD API routes, protocol-level duplicate check |
| `src/components/admin/download-clients/*` | UI components (3-column card layout, protocol enforcement) |
| `src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx` | Replaced with management component |
| `src/app/setup/steps/DownloadClientStep.tsx` | Replaced with management component |
| `src/app/api/setup/complete/route.ts` | Save as JSON array, support legacy |
@@ -149,5 +250,5 @@ Accepts both legacy single client and new array format:
## Related
- [qBittorrent Integration](./qbittorrent.md) - Torrent client details
- [SABnzbd Integration](./sabnzbd.md) - Usenet client details
- [SABnzbd Integration](./sabnzbd.md) - Usenet client details (SABnzbd)
- [Prowlarr Integration](./prowlarr.md) - Indexer search
+3 -1
View File
@@ -37,7 +37,8 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
## Process
1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file)
2. Identify audiobook files (.m4b, .m4a, .mp3) - supports both directories and single files
1b. **Path stored** in `DownloadHistory.downloadPath` (mapped local path) for retry reliability — avoids reconstructing path from `torrentName` which may differ from actual folder name
2. Identify audiobook files (.m4b, .m4a, .mp3, .mp4, .aa, .aax, .flac, .ogg) - supports both directories and single files
3. Read media directory and path template from database config (`media_dir`, `audiobook_path_template`)
4. Apply template to create target path: `[media_dir]/[template result]/`
5. **Copy** files (not move - originals stay for seeding)
@@ -94,6 +95,7 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
**Supported Formats:**
- m4b, m4a, mp4 (AAC audiobooks)
- mp3 (ID3v2 tags)
- flac (Vorbis comment tags)
**Metadata Written:**
- `title` - Book title
+33 -6
View File
@@ -46,7 +46,7 @@ Free, open-source BitTorrent client with comprehensive Web API.
- `download_client_url` - qBittorrent Web UI URL (supports HTTP and HTTPS)
- `download_client_username` - qBittorrent username
- `download_client_password` - qBittorrent password
- `download_dir` - Download save path (passed to qBittorrent for all torrents)
- `download_dir` - Base download save path (joined with per-client `customPath` if configured)
**Optional (SSL/TLS):**
- `download_client_disable_ssl_verify` - Disable SSL certificate verification for HTTPS (boolean as string "true"/"false", default: "false")
@@ -65,7 +65,8 @@ Validation: All required fields checked before service initialization. Path mapp
Service uses singleton pattern for performance. When settings change (via admin settings page), singleton is invalidated to force reload:
- `invalidateQBittorrentService()` called after updating paths or download client settings
- Forces service to re-read database config on next torrent addition
- Ensures category save path and credentials are always current
- Ensures category save path, credentials, and `customPath` resolution are always current
- Singleton getter resolves `customPath` from client config (consistent with manager's `createService()`)
## Category Management
@@ -73,9 +74,10 @@ Service uses singleton pattern for performance. When settings change (via admin
**Save Path Synchronization:**
- Category created/updated on every torrent addition
- Category save path always synced with `download_dir` config
- Handles config changes: if user changes `download_dir`, category updates automatically
- Category save path synced with resolved download path (`download_dir` + per-client `customPath`)
- Handles config changes: if user changes `download_dir` or `customPath`, category updates automatically
- Uses both `createCategory` and `editCategory` APIs for reliability
- Remote path mapping applied after `customPath` resolution (outgoing: local → remote)
**Why Both Create and Edit:**
1. Create: Ensures category exists (idempotent, won't fail if exists)
@@ -83,6 +85,11 @@ Service uses singleton pattern for performance. When settings change (via admin
This prevents issues where category retains old save path after user changes `download_dir` setting.
**Per-Client Custom Path:**
- If `customPath` is set (e.g., `torrents`), category save path becomes `/downloads/torrents`
- Remote path mapping applies to the resolved path: `reverseTransform(/downloads/torrents)` → remote equivalent
- See [download-clients.md](./download-clients.md#per-client-custom-download-path) for details
## Remote Path Mapping
**Use Case:** qBittorrent runs on different machine/container with different filesystem perspective.
@@ -167,8 +174,22 @@ interface TorrentInfo {
completionDate: number;
}
type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
'pausedDL' | 'queuedDL' | 'checkingDL' | 'error' | 'missingFiles';
type TorrentState =
// Core states
| 'downloading' | 'uploading'
| 'stalledDL' | 'stalledUP'
| 'pausedDL' | 'pausedUP'
| 'queuedDL' | 'queuedUP'
| 'checkingDL' | 'checkingUP'
| 'error' | 'missingFiles' | 'allocating'
// Forced states (user clicked "Force Resume")
| 'forcedDL' | 'forcedUP'
// Metadata fetching
| 'metaDL' | 'forcedMetaDL'
// qBittorrent v5.0+ (renamed paused → stopped)
| 'stoppedDL' | 'stoppedUP'
// Other
| 'checkingResumeData' | 'moving';
```
## Fixed Issues ✅
@@ -216,6 +237,12 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
- Service constructor accepts `PathMappingConfig` parameter
- Singleton loads path mapping config from database
**15. Missing qBittorrent torrent states** - Monitor never detected completion for force-resumed torrents (`forcedDL`/`forcedUP`), causing infinite polling at 100%. Also missing metadata states (`metaDL`/`forcedMetaDL`), qBittorrent v5.x renamed states (`stoppedDL`/`stoppedUP`), and utility states (`checkingResumeData`/`moving`). Fixed by:
- Adding all 8 missing states to `TorrentState` type union
- Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface)
- `forcedUP``seeding`/`completed` enables monitor to trigger import
- `stoppedDL`/`stoppedUP``paused` ensures qBittorrent v5.x compatibility
## Tech Stack
- axios (HTTP + cookie mgmt)
+7 -6
View File
@@ -135,12 +135,13 @@ Evaluates and scores torrents to automatically select best audiobook download.
- Proportional credit: If 2 of 3 authors match → 10 pts (2/3 × 15)
- Full credit: If all authors match → 15 pts
**2. Format Quality (25 pts max)**
- M4B with chapters: 25
- M4B without chapters: 22
- M4A: 16
- MP3: 10
- Other: 3
**2. Format Quality (10 pts max)**
- M4B with chapters: 10
- M4B without chapters: 9
- FLAC: 7 (lossless audio)
- M4A: 6
- MP3: 4
- Other: 1
**3. Seeder Count (15 pts max)**
- Formula: `Math.min(15, Math.log10(seeders + 1) * 6)`
+25 -8
View File
@@ -19,7 +19,7 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr
**Format:** All requests use `output=json` for JSON responses
**GET /api?mode=version&output=json&apikey={key}** - Get SABnzbd version
**GET /api?mode=addurl&name={url}&cat={category}&output=json&apikey={key}** - Add NZB by URL
**POST /api (multipart: mode=addfile, nzbfile={binary})** - Add NZB by file upload (RMAB downloads NZB from Prowlarr, uploads to SABnzbd)
**GET /api?mode=queue&output=json&apikey={key}** - Get active downloads
**GET /api?mode=history&limit=100&output=json&apikey={key}** - Get completed/failed downloads
**GET /api?mode=pause&value={nzbId}&output=json&apikey={key}** - Pause download
@@ -37,7 +37,7 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr
- `download_client_type` - Must be 'sabnzbd'
- `download_client_url` - SABnzbd Web UI URL (supports HTTP and HTTPS)
- `download_client_password` - API key (reuses password field)
- `download_dir` - Download save path (passed to SABnzbd category)
- `download_dir` - Base download save path (joined with per-client `customPath` if configured)
**Optional (SSL/TLS):**
- `download_client_disable_ssl_verify` - Disable SSL certificate verification (boolean as string "true"/"false", default: "false")
@@ -58,7 +58,8 @@ Validation: All required fields checked before service initialization. Path mapp
Service uses singleton pattern. When settings change, singleton invalidated to force reload:
- `invalidateSABnzbdService()` called after updating settings
- Forces service to re-read database config
- Ensures category and credentials are always current
- Ensures category, credentials, and `customPath` resolution are always current
- Singleton getter resolves `customPath` from client config (consistent with manager's `createService()`)
## Category Management
@@ -67,16 +68,21 @@ Service uses singleton pattern. When settings change, singleton invalidated to f
**Save Path Synchronization:**
- Category created/updated on every download (matches qBittorrent behavior)
- Fetches SABnzbd's `complete_dir` setting via API to understand download location
- Applies remote path mapping to translate RMAB's `download_dir` to SABnzbd's perspective
- Applies remote path mapping to translate RMAB's resolved download path to SABnzbd's perspective
- Calculates optimal category path (relative, absolute, or root)
- Resolved path includes per-client `customPath` if configured (e.g., `/downloads/usenet`)
**Smart Path Calculation:**
1. Get SABnzbd's `complete_dir` from `misc.complete_dir` config
2. Apply `PathMapper.reverseTransform()` to RMAB's `download_dir`
2. Apply `PathMapper.reverseTransform()` to RMAB's resolved download path (`download_dir` + `customPath`)
3. Compare transformed path to `complete_dir`:
- **Match:** Use empty string (downloads go to complete_dir root)
- **Subdirectory:** Use relative path (e.g., `audiobooks`)
- **Different:** Use absolute path (e.g., `/mnt/media/audiobooks`)
- **Subdirectory:** Use relative path (e.g., `usenet`)
- **Different:** Use absolute path (e.g., `/mnt/media/usenet`)
**Per-Client Custom Path:**
- If `customPath` is set (e.g., `usenet`), category path calculated from `/downloads/usenet`
- See [download-clients.md](./download-clients.md#per-client-custom-download-path) for details
## Post-Processing
@@ -290,9 +296,20 @@ organizePath = PathMapper.transform(sabPath, config)
| Path Mapping | ✅ Bidirectional (same as qBit) | ✅ Bidirectional |
| Category Sync | ✅ Every download | ✅ Every download |
## NZB Download Proxy
**RMAB proxies NZB files** — SABnzbd does not need network access to Prowlarr.
Prowlarr returns download URLs that point back to itself (proxy URLs like `http://prowlarr:9696/3/download?apikey=...&link=...`).
RMAB downloads the NZB file content from that URL, then uploads it to SABnzbd via `mode=addfile` (multipart POST).
This matches qBittorrent's pattern where RMAB downloads `.torrent` files and uploads the binary content.
**Result:** Download clients only need network access to RMAB. No direct Prowlarr connectivity required.
## Tech Stack
- axios (HTTP client)
- axios (HTTP client, NZB file download)
- form-data (multipart file upload to SABnzbd)
- Node.js https (SSL/TLS agent)
- JSON API responses
+3 -3
View File
@@ -36,7 +36,7 @@ src/app/admin/settings/
│ ├── IndexersTab.tsx
│ ├── useIndexersSettings.ts
│ └── index.ts
├── DownloadTab/ # qBittorrent/SABnzbd
├── DownloadTab/ # qBittorrent/Transmission/SABnzbd
│ ├── DownloadTab.tsx
│ ├── useDownloadSettings.ts
│ └── index.ts
@@ -67,7 +67,7 @@ src/app/admin/settings/
1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
4. **Download Client** - Type, URL, credentials (masked)
4. **Download Client** - Type (qBittorrent, Transmission, SABnzbd), URL, credentials (masked), custom download path (per-client relative sub-path with live preview)
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
@@ -324,7 +324,7 @@ src/app/admin/settings/
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent' or 'transmission'
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
## Tech Stack
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.0.1",
"version": "1.0.4",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "download_history" ADD COLUMN "download_path" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "interactive_search_access" BOOLEAN;
+4
View File
@@ -53,6 +53,9 @@ model User {
// Request approval preferences
autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval
// Fine-grained permissions
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
// Soft delete support
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
@@ -275,6 +278,7 @@ model DownloadHistory {
downloadStatus String? @map("download_status")
// Status values: queued, downloading, completed, failed, stalled
downloadError String? @map("download_error") @db.Text
downloadPath String? @map("download_path") @db.Text
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
@@ -13,11 +13,13 @@ import { RequestActionsDropdown } from './RequestActionsDropdown';
import { mutate } from 'swr';
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
interface RecentRequest {
requestId: string;
title: string;
author: string;
asin?: string | null;
status: string;
type?: 'audiobook' | 'ebook';
userId: string;
@@ -43,6 +45,7 @@ interface RequestsResponse {
interface RecentRequestsTableProps {
ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
}
const STATUS_OPTIONS = [
@@ -158,7 +161,7 @@ function getInitialParams(): {
};
}
export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) {
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
const toast = useToast();
// Get initial filter state from URL (only evaluated once due to lazy init)
@@ -185,6 +188,10 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
const [isDeleting, setIsDeleting] = useState(false);
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
// View Details modal state
const [viewDetailsAsin, setViewDetailsAsin] = useState<string | null>(null);
const [viewDetailsStatus, setViewDetailsStatus] = useState<string | null>(null);
// Build API URL with current local filters
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
@@ -314,6 +321,11 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
// Action handlers
const handleViewDetails = (asin: string, requestStatus?: string) => {
setViewDetailsAsin(asin);
setViewDetailsStatus(requestStatus || null);
};
const handleDeleteClick = (requestId: string, title: string) => {
setSelectedRequest({ id: requestId, title });
setShowDeleteConfirm(true);
@@ -659,13 +671,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
author: request.author,
status: request.status,
type: request.type,
asin: request.asin,
torrentUrl: request.torrentUrl,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook}
/>
</td>
@@ -808,6 +823,21 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
{/* Audiobook Details Modal */}
{viewDetailsAsin && (
<AudiobookDetailsModal
asin={viewDetailsAsin}
isOpen={!!viewDetailsAsin}
onClose={() => {
setViewDetailsAsin(null);
setViewDetailsStatus(null);
}}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus}
hideRequestActions
/>
)}
</div>
);
}
@@ -19,13 +19,16 @@ export interface RequestActionsDropdownProps {
author: string;
status: string;
type?: 'audiobook' | 'ebook';
asin?: string | null;
torrentUrl?: string | null;
};
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>;
onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>;
ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
isLoading?: boolean;
}
@@ -34,8 +37,10 @@ export function RequestActionsDropdown({
onDelete,
onManualSearch,
onCancel,
onViewDetails,
onFetchEbook,
ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
isLoading = false,
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
@@ -46,6 +51,9 @@ export function RequestActionsDropdown({
// Determine request type
const isEbook = request.type === 'ebook';
// View Details: available when ASIN exists (audiobook requests only)
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
@@ -64,7 +72,7 @@ export function RequestActionsDropdown({
if (Array.isArray(urls) && urls.length > 0) {
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
if (md5Match) {
viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`;
viewSourceUrl = `${annasArchiveBaseUrl.replace(/\/+$/, '')}/md5/${md5Match[1]}`;
}
}
} catch {
@@ -147,6 +155,13 @@ export function RequestActionsDropdown({
}
};
const handleViewDetails = () => {
setIsOpen(false);
if (request.asin && onViewDetails) {
onViewDetails(request.asin);
}
};
// Dropdown menu content (rendered via portal)
const dropdownMenu = isOpen && style && (
<div
@@ -155,6 +170,41 @@ export function RequestActionsDropdown({
className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
>
<div className="py-1" role="menu">
{/* View Details */}
{canViewDetails && (
<button
onClick={handleViewDetails}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
View Details
</button>
)}
{/* Divider after View Details */}
{canViewDetails && (canSearch || canViewSource || canFetchEbook || canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
{/* Manual Search */}
{canSearch && (
<button
+26 -24
View File
@@ -485,31 +485,8 @@ function AdminDashboardContent() {
/>
</div>
{/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} />
)}
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Active Downloads
</h2>
<ActiveDownloadsTable downloads={downloadsData.downloads} />
</div>
{/* Request Management */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Request Management
</h2>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
/>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Link
href="/admin/settings"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
@@ -595,6 +572,31 @@ function AdminDashboardContent() {
</div>
</Link>
</div>
{/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} />
)}
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Active Downloads
</h2>
<ActiveDownloadsTable downloads={downloadsData.downloads} />
</div>
{/* Request Management */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Request Management
</h2>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
annasArchiveBaseUrl={settingsData?.ebook?.baseUrl}
/>
</div>
</>
)}
</div>
+1
View File
@@ -97,6 +97,7 @@ export interface PathsSettings {
downloadDir: string;
mediaDir: string;
audiobookPathTemplate?: string;
ebookPathTemplate?: string;
metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean;
}
@@ -51,7 +51,10 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: ebook.flaresolverrUrl }),
body: JSON.stringify({
url: ebook.flaresolverrUrl,
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
}),
});
const result = await response.json();
@@ -6,6 +6,7 @@
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Settings, ABSLibrary } from '../../lib/types';
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
interface AudiobookshelfSectionProps {
settings: Settings;
@@ -161,12 +162,39 @@ export function AudiobookshelfSection({
onChange={(e) => handleAudibleRegionChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
{Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''}
</option>
))}
</select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Non-English Region
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Many features such as search, discovery, and metadata matching are not yet fully
supported for non-English regions. You may still proceed, but expect limited
functionality.
</p>
</div>
</div>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
@@ -6,6 +6,7 @@
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Settings, PlexLibrary } from '../../lib/types';
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
interface PlexSectionProps {
settings: Settings;
@@ -161,12 +162,39 @@ export function PlexSection({
onChange={(e) => handleAudibleRegionChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
{Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''}
</option>
))}
</select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Non-English Region
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Many features such as search, discovery, and metadata matching are not yet fully
supported for non-English regions. You may still proceed, but expect limited
functionality.
</p>
</div>
</div>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Plex. This ensures accurate book matching and metadata.
+133 -53
View File
@@ -18,6 +18,12 @@ interface PathsTabProps {
onValidationChange: (isValid: boolean) => void;
}
interface TemplatePreview {
isValid: boolean;
error?: string;
previewPaths?: string[];
}
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
paths,
@@ -25,31 +31,52 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
onValidationChange,
});
// Live preview state (client-side validation)
const [livePreview, setLivePreview] = useState<{
isValid: boolean;
error?: string;
previewPaths?: string[];
} | null>(null);
// Live preview state for audiobook template
const [audiobookPreview, setAudiobookPreview] = useState<TemplatePreview | null>(null);
// Update live preview whenever template changes
// Live preview state for ebook template
const [ebookPreview, setEbookPreview] = useState<TemplatePreview | null>(null);
// Update audiobook live preview whenever template changes
useEffect(() => {
const template = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const validation = validateTemplate(template);
if (validation.valid) {
setLivePreview({
setAudiobookPreview({
isValid: true,
previewPaths: generateMockPreviews(template),
});
} else {
setLivePreview({
setAudiobookPreview({
isValid: false,
error: validation.error,
});
}
}, [paths.audiobookPathTemplate]);
// Update ebook live preview whenever template changes
useEffect(() => {
const template = paths.ebookPathTemplate || '{author}/{title} {asin}';
const validation = validateTemplate(template);
if (validation.valid) {
setEbookPreview({
isValid: true,
previewPaths: generateMockPreviews(template),
});
} else {
setEbookPreview({
isValid: false,
error: validation.error,
});
}
}, [paths.ebookPathTemplate]);
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
return (
<div className="space-y-6 max-w-2xl">
<div>
@@ -74,7 +101,7 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Temporary location for torrent downloads (kept for seeding)
Temporary location for downloads before they are organized into the media library
</p>
</div>
@@ -111,61 +138,24 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
Customize how audiobooks are organized within the media directory
</p>
{/* Variable Reference Panel */}
<div className="mt-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
Available Variables
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
</div>
</div>
</div>
{/* Live Preview - Client-side validation */}
{livePreview && !livePreview.isValid && (
{/* Audiobook Validation Error */}
{audiobookPreview && !audiobookPreview.isValid && (
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
<span className="flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<span>{livePreview.error || 'Invalid template format'}</span>
<span>{audiobookPreview.error || 'Invalid template format'}</span>
</div>
</div>
)}
{/* Live Preview Examples - Show while editing */}
{livePreview && livePreview.isValid && livePreview.previewPaths && (
{/* Audiobook Preview Examples */}
{audiobookPreview && audiobookPreview.isValid && audiobookPreview.previewPaths && (
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Preview Examples
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{livePreview.previewPaths.map((preview, index) => (
{audiobookPreview.previewPaths.map((preview, index) => (
<div key={index} className="text-xs">
{paths.mediaDir || '/media/audiobooks'}/{preview}
</div>
@@ -175,6 +165,96 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
)}
</div>
{/* Ebook Organization Template */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Ebook Organization Template
</label>
<div className="flex gap-2">
<Input
type="text"
value={paths.ebookPathTemplate || '{author}/{title} {asin}'}
onChange={(e) => updatePath('ebookPathTemplate', e.target.value)}
placeholder="{author}/{title} {asin}"
className="font-mono flex-1"
/>
<Button
variant="outline"
onClick={() => updatePath('ebookPathTemplate', paths.audiobookPathTemplate || '{author}/{title} {asin}')}
disabled={ebookMatchesAudiobook}
className="whitespace-nowrap text-sm"
>
Match Audiobook
</Button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Customize how ebooks are organized within the media directory
</p>
{/* Ebook Validation Error */}
{ebookPreview && !ebookPreview.isValid && (
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
<span className="flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<span>{ebookPreview.error || 'Invalid template format'}</span>
</div>
</div>
)}
{/* Ebook Preview Examples */}
{ebookPreview && ebookPreview.isValid && ebookPreview.previewPaths && (
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Preview Examples
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{ebookPreview.previewPaths.map((preview, index) => (
<div key={index} className="text-xs">
{paths.mediaDir || '/media/audiobooks'}/{preview}
</div>
))}
</div>
</div>
)}
</div>
{/* Variable Reference Panel (shared for both templates) */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
Available Variables
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
</div>
</div>
</div>
{/* Metadata Tagging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
@@ -41,6 +41,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
downloadDir: paths.downloadDir,
mediaDir: paths.mediaDir,
audiobookPathTemplate: paths.audiobookPathTemplate,
ebookPathTemplate: paths.ebookPathTemplate,
}),
});
+131 -49
View File
@@ -11,6 +11,8 @@ import Link from 'next/link';
import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { GlobalUserSettingsModal } from '@/components/admin/users/GlobalUserSettingsModal';
import { UserPermissionsModal } from '@/components/admin/users/UserPermissionsModal';
interface User {
id: string;
@@ -25,6 +27,7 @@ interface User {
updatedAt: string;
lastLoginAt: string | null;
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
_count: {
requests: number;
};
@@ -48,6 +51,10 @@ function AdminUsersPageContent() {
'/api/admin/settings/auto-approve',
authenticatedFetcher
);
const { data: globalInteractiveSearchData, mutate: mutateGlobalInteractiveSearch } = useSWR(
'/api/admin/settings/interactive-search',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{
isOpen: boolean;
user: User | null;
@@ -66,6 +73,9 @@ function AdminUsersPageContent() {
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const toast = useToast();
const isLoading = !data && !error;
@@ -81,6 +91,15 @@ function AdminUsersPageContent() {
}
}, [globalAutoApproveData]);
// Sync global interactive search state (default to true if not set)
useEffect(() => {
if (globalInteractiveSearchData?.interactiveSearchAccess !== undefined) {
setGlobalInteractiveSearch(globalInteractiveSearchData.interactiveSearchAccess);
} else if (globalInteractiveSearchData !== undefined && globalInteractiveSearchData.interactiveSearchAccess === undefined) {
setGlobalInteractiveSearch(true);
}
}, [globalInteractiveSearchData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalAutoApprove(newValue);
@@ -102,6 +121,27 @@ function AdminUsersPageContent() {
}
};
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalInteractiveSearch(newValue);
try {
await fetchJSON('/api/admin/settings/interactive-search', {
method: 'PATCH',
body: JSON.stringify({ interactiveSearchAccess: newValue }),
});
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalInteractiveSearch();
mutate(); // Refresh users list to show updated state
} catch (err) {
// Revert on error
setGlobalInteractiveSearch(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
toast.error(errorMsg);
console.error(err);
}
};
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
@@ -136,6 +176,33 @@ function AdminUsersPageContent() {
}
};
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
// Optimistic update
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
);
mutate({ users: optimisticUsers }, false);
try {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({
role: user.role,
interactiveSearchAccess: newValue
}),
});
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate(); // Refresh users list
} catch (err) {
// Revert on error
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
toast.error(errorMsg);
console.error(err);
}
};
const showEditDialog = (user: User) => {
setEditRole(user.role);
setEditDialog({ isOpen: true, user });
@@ -273,6 +340,7 @@ function AdminUsersPageContent() {
}
const users: User[] = data?.users || [];
const permissionsUser = permissionsUserId ? users.find((u) => u.id === permissionsUserId) ?? null : null;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -287,40 +355,26 @@ function AdminUsersPageContent() {
Manage user roles and permissions
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{/* Global Auto-Approve Toggle */}
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<div className="flex items-start gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${globalAutoApprove ? 'translate-x-6' : 'translate-x-1'}`}
/>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Global User Permissions</span>
</button>
<div className="flex-1">
<label
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
className="block text-base font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Auto-Approve All Requests
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings below.
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
</div>
@@ -403,7 +457,7 @@ function AdminUsersPageContent() {
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Auto-Approve
Permissions
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requests
@@ -471,31 +525,34 @@ function AdminUsersPageContent() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<button
onClick={() => setPermissionsUserId(user.id)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{user.role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Always On
Full Access
</span>
) : globalAutoApprove ? (
<span className="text-xs text-gray-500 dark:text-gray-400">
Global Setting
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
Global Default
</span>
) : (user.autoApproveRequests ?? false) ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Auto-Approve
</span>
) : (
<button
onClick={() => handleUserAutoApproveToggle(user, !(user.autoApproveRequests ?? false))}
className="relative inline-flex h-5 w-10 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
style={{ backgroundColor: (user.autoApproveRequests ?? false) ? '#3b82f6' : '#d1d5db' }}
title={`Toggle auto-approve for ${user.plexUsername}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${(user.autoApproveRequests ?? false) ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Manual
</span>
)}
</div>
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user._count.requests}
@@ -587,7 +644,7 @@ function AdminUsersPageContent() {
<li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
<li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
<li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
<li> <strong>Auto-Approve:</strong> When the global setting is enabled, all requests are automatically processed. When disabled, you can control auto-approval per user. Admin requests are always auto-approved.</li>
<li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
<li> <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
<li> <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
@@ -722,6 +779,31 @@ function AdminUsersPageContent() {
isLoading={deleting}
variant="danger"
/>
{/* Global User Settings Modal */}
<GlobalUserSettingsModal
isOpen={globalSettingsOpen}
onClose={() => setGlobalSettingsOpen(false)}
globalAutoApprove={globalAutoApprove}
onToggleAutoApprove={handleGlobalAutoApproveToggle}
globalInteractiveSearch={globalInteractiveSearch}
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
/>
{/* User Permissions Modal */}
<UserPermissionsModal
isOpen={permissionsUser !== null}
onClose={() => setPermissionsUserId(null)}
user={permissionsUser}
globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch}
onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue);
}}
onToggleInteractiveSearch={(user, newValue) => {
handleUserInteractiveSearchToggle(user as User, newValue);
}}
/>
</div>
</div>
);
+18 -22
View File
@@ -6,10 +6,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('API.Admin.Downloads');
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
torrentName: true,
torrentHash: true,
nzbId: true,
downloadClientId: true,
downloadClient: true, // qbittorrent, sabnzbd, or direct
torrentSizeBytes: true,
startedAt: true,
@@ -68,9 +69,9 @@ export async function GET(request: NextRequest) {
take: 20,
});
// Get configured download client type
// Get download client manager
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
const manager = getDownloadClientManager(configService);
// Format response with speed and ETA from download client
const formatted = await Promise.all(
@@ -98,24 +99,19 @@ export async function GET(request: NextRequest) {
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
}
}
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
// Get torrent hash from download history
const torrentHash = downloadHistory?.torrentHash;
if (torrentHash) {
const qbService = await getQBittorrentService();
const torrentInfo = await qbService.getTorrent(torrentHash);
speed = torrentInfo.dlspeed;
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
}
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
// Get NZB ID from download history
const nzbId = downloadHistory?.nzbId;
if (nzbId) {
const sabnzbdService = await getSABnzbdService();
const nzbInfo = await sabnzbdService.getNZB(nzbId);
if (nzbInfo) {
speed = nzbInfo.downloadSpeed;
eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null;
} else {
// Use unified interface for all download clients (qBittorrent, SABnzbd, etc.)
const clientId = downloadHistory?.downloadClientId || downloadHistory?.torrentHash || downloadHistory?.nzbId;
if (clientId && downloadClient) {
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (client) {
const info = await client.getDownload(clientId);
if (info) {
speed = info.downloadSpeed;
eta = info.eta > 0 ? info.eta : null;
}
}
}
}
+2
View File
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
id: true,
title: true,
author: true,
audibleAsin: true,
},
},
user: {
@@ -129,6 +130,7 @@ export async function GET(request: NextRequest) {
requestId: request.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || null,
status: request.status,
type: request.type || 'audiobook', // Include request type for UI display
userId: request.user.id,
+3 -2
View File
@@ -8,11 +8,12 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
import { getConfigService } from '@/lib/services/config.service';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Audible');
const VALID_REGIONS = ['us', 'ca', 'uk', 'au', 'in'];
const VALID_REGIONS = Object.keys(AUDIBLE_REGIONS);
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -24,7 +25,7 @@ export async function PUT(request: NextRequest) {
if (!region || !VALID_REGIONS.includes(region)) {
logger.warn('Invalid region provided', { region });
return NextResponse.json(
{ success: false, error: 'Invalid Audible region. Must be one of: us, ca, uk, au, in' },
{ success: false, error: `Invalid Audible region. Must be one of: ${VALID_REGIONS.join(', ')}` },
{ status: 400 }
);
}
@@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { PathMapper } from '@/lib/utils/path-mapper';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -35,9 +36,9 @@ export async function PUT(request: NextRequest) {
logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.');
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -97,7 +98,7 @@ export async function PUT(request: NextRequest) {
const updatedClient: DownloadClientConfig = {
id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(),
type,
name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
name: getClientDisplayName(type),
enabled: true,
url,
username: username || undefined,
@@ -137,6 +138,12 @@ export async function PUT(request: NextRequest) {
} else if (type === 'sabnzbd') {
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
invalidateSABnzbdService();
} else if (type === 'nzbget') {
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
invalidateNZBGetService();
} else if (type === 'transmission') {
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
invalidateTransmissionService();
}
return NextResponse.json({
@@ -37,6 +37,7 @@ export async function PUT(
remotePath,
localPath,
category,
customPath,
} = body;
const config = await getConfigService();
@@ -53,6 +54,14 @@ export async function PUT(
const existingClient = clients[clientIndex];
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
// Build updated client (preserve fields not in request)
const updatedClient: DownloadClientConfig = {
...existingClient,
@@ -66,6 +75,7 @@ export async function PUT(
remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
};
// Validate path mapping if enabled
@@ -6,8 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES, CLIENT_PROTOCOL_MAP, DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -62,12 +62,13 @@ export async function POST(request: NextRequest) {
remotePath,
localPath,
category,
customPath,
} = body;
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -99,21 +100,30 @@ export async function POST(request: NextRequest) {
}
}
// Check for duplicate type (only one client per type for now)
// Check for duplicate protocol (only one client per protocol)
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const existingClients = await manager.getAllClients();
const duplicateType = existingClients.find(c => c.type === type && c.enabled);
if (duplicateType) {
const protocol = CLIENT_PROTOCOL_MAP[type as DownloadClientType];
const duplicateProtocol = existingClients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (duplicateProtocol) {
return NextResponse.json(
{ error: `A ${type} client is already configured. Please disable or remove it first.` },
{ error: `A ${protocol} client (${getClientDisplayName(duplicateProtocol.type)}) is already configured. Remove it first to add a different ${protocol} client.` },
{ status: 400 }
);
}
// Create new client config for testing (with plaintext password)
// qBittorrent credentials are optional (supports IP whitelist auth)
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
const newClient: DownloadClientConfig = {
id: randomUUID(),
type,
@@ -127,6 +137,7 @@ export async function POST(request: NextRequest) {
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: category || 'readmeabook',
customPath: customPath || undefined,
};
// Test connection before saving
@@ -6,8 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test');
@@ -23,6 +23,7 @@ export async function POST(request: NextRequest) {
const {
clientId, // Optional: existing client ID to use stored password
type,
name: clientName,
url,
username,
password,
@@ -33,9 +34,9 @@ export async function POST(request: NextRequest) {
} = body;
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
const testConfig: DownloadClientConfig = {
id: 'test',
type,
name: 'Test Client',
name: clientName || type,
enabled: true,
url,
username: effectiveUsername || '',
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url } = await request.json();
const { url, baseUrl } = await request.json();
if (!url) {
return NextResponse.json(
@@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
);
}
const result = await testFlareSolverrConnection(url);
const result = await testFlareSolverrConnection(url, baseUrl);
return NextResponse.json(result);
} catch (error) {
@@ -0,0 +1,91 @@
/**
* Component: Admin Interactive Search Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.InteractiveSearch');
const CONFIG_KEY = 'interactive_search_access';
/**
* GET /api/admin/settings/interactive-search
* Get current global interactive search access setting
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const config = await prisma.configuration.findUnique({
where: { key: CONFIG_KEY },
});
// Default to true if not configured (backward compatibility)
const interactiveSearchAccess = config === null ? true : config.value === 'true';
return NextResponse.json({ interactiveSearchAccess });
} catch (error) {
logger.error('Failed to fetch interactive search setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to fetch interactive search setting' },
{ status: 500 }
);
}
});
});
}
/**
* PATCH /api/admin/settings/interactive-search
* Update global interactive search access setting
*/
export async function PATCH(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { interactiveSearchAccess } = body;
// Validate input
if (typeof interactiveSearchAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid input. interactiveSearchAccess must be a boolean' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: CONFIG_KEY },
create: {
key: CONFIG_KEY,
value: interactiveSearchAccess.toString(),
},
update: {
value: interactiveSearchAccess.toString(),
},
});
logger.info(`Interactive search access setting updated to: ${interactiveSearchAccess}`, {
userId: req.user?.sub,
});
return NextResponse.json({ interactiveSearchAccess });
} catch (error) {
logger.error('Failed to update interactive search setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to update interactive search setting' },
{ status: 500 }
);
}
});
});
}
+25 -2
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -59,6 +59,20 @@ export async function PUT(request: NextRequest) {
});
}
// Update ebook path template
if (ebookPathTemplate !== undefined) {
await prisma.configuration.upsert({
where: { key: 'ebook_path_template' },
update: { value: ebookPathTemplate },
create: {
key: 'ebook_path_template',
value: ebookPathTemplate,
category: 'automation',
description: 'Template for organizing ebook files in media directory',
},
});
}
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
@@ -90,12 +104,21 @@ export async function PUT(request: NextRequest) {
configService.clearCache('download_dir');
configService.clearCache('media_dir');
configService.clearCache('audiobook_path_template');
configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
// Invalidate qBittorrent service singleton to force reload of download_dir
// Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
invalidateDownloadClientManager();
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
invalidateQBittorrentService();
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
invalidateSABnzbdService();
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
invalidateNZBGetService();
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
invalidateTransmissionService();
return NextResponse.json({
success: true,
+1
View File
@@ -125,6 +125,7 @@ export async function GET(request: NextRequest) {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
@@ -1,14 +1,16 @@
/**
* Component: Admin Settings Test Download Client API
* Component: Admin Settings Test Download Client API (DEPRECATED)
* Documentation: documentation/settings-pages.md
*
* DEPRECATED: Use /api/admin/settings/download-clients/test instead.
* Maintained for backward compatibility.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.TestDownloadClient');
@@ -19,6 +21,7 @@ export async function POST(request: NextRequest) {
try {
const {
type,
name: clientName,
url,
username,
password,
@@ -37,9 +40,9 @@ export async function POST(request: NextRequest) {
);
}
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -64,53 +67,28 @@ export async function POST(request: NextRequest) {
actualPassword = matchingClient.password;
}
// Validate required fields per client type and test connection
let version: string | undefined;
// Build a temporary config for testing
const testConfig: DownloadClientConfig = {
id: 'legacy-test',
type,
name: clientName || type,
enabled: true,
url,
username: username || '',
password: actualPassword || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: 'readmeabook',
};
if (type === 'qbittorrent') {
logger.debug('Testing qBittorrent connection');
if (!username || !actualPassword) {
return NextResponse.json(
{ success: false, error: 'Username and password are required for qBittorrent' },
{ status: 400 }
);
}
// Test qBittorrent connection
version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword,
disableSSLVerify || false
);
} else if (type === 'sabnzbd') {
logger.debug('Testing SABnzbd connection');
if (!actualPassword) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
{ status: 400 }
);
}
// Test SABnzbd connection
const sabnzbd = new SABnzbdService(url, actualPassword, 'readmeabook', disableSSLVerify || false);
const result = await sabnzbd.testConnection();
if (!result.success) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to connect to SABnzbd',
},
{ status: 500 }
);
}
version = result.version;
}
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const result = await manager.testConnection(testConfig);
// If path mapping enabled, validate local path exists
if (remotePathMappingEnabled) {
if (result.success && remotePathMappingEnabled) {
if (!remotePath || !localPath) {
return NextResponse.json(
{
@@ -136,10 +114,14 @@ export async function POST(request: NextRequest) {
}
}
return NextResponse.json({
success: true,
version,
});
if (result.success) {
return NextResponse.json({ success: true, message: result.message });
}
return NextResponse.json(
{ success: false, error: result.message },
{ status: 400 }
);
} catch (error) {
logger.error('Download client test failed', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
+22 -4
View File
@@ -19,7 +19,7 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
const { role, autoApproveRequests } = body;
const { role, autoApproveRequests, interactiveSearchAccess } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
@@ -37,6 +37,14 @@ export async function PUT(
);
}
// Validate interactiveSearchAccess (optional)
if (interactiveSearchAccess !== undefined && interactiveSearchAccess !== null && typeof interactiveSearchAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid interactiveSearchAccess. Must be a boolean or null' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
@@ -91,21 +99,30 @@ export async function PUT(
);
}
// Validate that admins cannot have autoApproveRequests set to false
// Validate that admins cannot have permissions set to false
if (role === 'admin' && autoApproveRequests === false) {
return NextResponse.json(
{ error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' },
{ status: 400 }
);
}
if (role === 'admin' && interactiveSearchAccess === false) {
return NextResponse.json(
{ error: 'Admins always have interactive search access. Cannot set interactiveSearchAccess to false for admin users.' },
{ status: 400 }
);
}
// Prepare update data
const updateData: { role: string; autoApproveRequests?: boolean | null } = { role };
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests;
}
if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess;
}
// Update user role and autoApproveRequests
// Update user
const updatedUser = await prisma.user.update({
where: { id },
data: updateData,
@@ -114,6 +131,7 @@ export async function PUT(
plexUsername: true,
role: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
},
});
+1
View File
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
updatedAt: true,
lastLoginAt: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
_count: {
select: {
requests: true,
@@ -17,6 +17,7 @@ import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
import {
searchByAsin,
searchByTitle,
@@ -83,6 +84,21 @@ export async function POST(
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
// Check interactive search access permission
if (req.user) {
const callingUser = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, interactiveSearchAccess: true },
});
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
{ status: 403 }
);
}
}
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
@@ -410,9 +426,14 @@ async function searchIndexersForInteractive(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -70,9 +70,14 @@ export async function POST(request: NextRequest) {
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
// Log each group for transparency
groups.forEach((group, index) => {
+13
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { resolvePermission, getGlobalBooleanSetting } from '@/lib/utils/permissions';
/**
* GET /api/auth/me
@@ -37,6 +38,7 @@ export async function GET(request: NextRequest) {
authProvider: true,
createdAt: true,
lastLoginAt: true,
interactiveSearchAccess: true,
},
});
@@ -53,6 +55,14 @@ export async function GET(request: NextRequest) {
// Determine if user is local admin (setup admin with local authentication)
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
// Resolve effective permissions
const globalInteractiveSearch = await getGlobalBooleanSetting('interactive_search_access', true);
const effectiveInteractiveSearch = resolvePermission(
user.role,
user.interactiveSearchAccess,
globalInteractiveSearch
);
return NextResponse.json({
user: {
id: user.id,
@@ -65,6 +75,9 @@ export async function GET(request: NextRequest) {
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
permissions: {
interactiveSearch: effectiveInteractiveSearch,
},
},
});
});
@@ -321,9 +321,14 @@ async function searchIndexersForInteractive(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
const logger = RMABLogger.create('API.InteractiveSearch');
@@ -71,6 +72,18 @@ export async function POST(
);
}
// Check interactive search access permission
const callingUser = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, interactiveSearchAccess: true },
});
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
{ status: 403 }
);
}
// Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
+35 -22
View File
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('API.RequestById');
@@ -200,28 +201,11 @@ export async function PATCH(
// Get download path from the appropriate download client
let downloadPath: string;
if (downloadHistory.torrentHash) {
// qBittorrent - get path from torrent info
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadPath = `${torrent.save_path}/${torrent.name}`;
} else if (downloadHistory.nzbId) {
// SABnzbd - get path from NZB info
const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (!nzbInfo || !nzbInfo.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Download path not available from SABnzbd',
},
{ status: 400 }
);
}
downloadPath = nzbInfo.downloadPath;
} else {
// Get download path via unified interface
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
if (!clientId || clientType === 'direct') {
return NextResponse.json(
{
error: 'ValidationError',
@@ -231,6 +215,35 @@ export async function PATCH(
);
}
const { getConfigService } = await import('@/lib/services/config.service');
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (!client) {
return NextResponse.json(
{
error: 'ValidationError',
message: `No ${clientType} client configured`,
},
{ status: 400 }
);
}
const info = await client.getDownload(clientId);
if (!info?.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Download path not available from ${client.clientType}`,
},
{ status: 400 }
);
}
downloadPath = info.downloadPath;
await jobQueue.addOrganizeJob(
id,
requestWithData.audiobook.id,
+6 -2
View File
@@ -9,11 +9,14 @@ import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.Complete');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const {
backendMode,
@@ -28,7 +31,7 @@ export async function POST(request: NextRequest) {
downloadClient,
paths,
bookdate,
} = await request.json();
} = await req.json();
// Validate backend mode
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
@@ -401,7 +404,7 @@ export async function POST(request: NextRequest) {
downloadClientsArray = [{
id: `temp-${Date.now()}`,
type: downloadClient.type,
name: downloadClient.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
name: getClientDisplayName(downloadClient.type),
enabled: true,
url: downloadClient.url,
username: downloadClient.username,
@@ -562,4 +565,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -4,10 +4,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { serverUrl, apiToken } = await request.json();
const { serverUrl, apiToken } = await req.json();
if (!serverUrl) {
return NextResponse.json(
@@ -79,4 +81,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+28 -44
View File
@@ -4,15 +4,18 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestDownloadClient');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { type, url, username, password, disableSSLVerify } = await request.json();
const { type, name, url, username, password, disableSSLVerify } = await req.json();
if (!type || !url) {
return NextResponse.json(
@@ -21,59 +24,39 @@ export async function POST(request: NextRequest) {
);
}
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
// Validate required fields per client type
// qBittorrent credentials are optional (supports IP whitelist auth)
if (type === 'qbittorrent') {
// Test qBittorrent connection (empty credentials work with IP whitelist)
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username || '',
password || '',
disableSSLVerify || false
);
// Build a temporary config for testing
const testConfig: DownloadClientConfig = {
id: 'setup-test',
type,
name: name || type,
enabled: true,
url,
username: username || '',
password: password || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: false,
};
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const result = await manager.testConnection(testConfig);
if (result.success) {
return NextResponse.json({
success: true,
version,
});
} else if (type === 'sabnzbd') {
if (!password) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
{ status: 400 }
);
}
// Test SABnzbd connection
const sabnzbd = new SABnzbdService(url, password, 'readmeabook', disableSSLVerify || false);
const result = await sabnzbd.testConnection();
if (!result.success) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to connect to SABnzbd',
},
{ status: 500 }
);
}
return NextResponse.json({
success: true,
version: result.version,
message: result.message,
});
}
// Should never reach here
return NextResponse.json(
{ success: false, error: 'Invalid client type' },
{ success: false, error: result.message },
{ status: 400 }
);
} catch (error) {
@@ -86,4 +69,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestOIDC');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const body = await request.json();
const body = await req.json();
const { issuerUrl, clientId, clientSecret } = body;
// Validate required fields
@@ -93,4 +95,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
@@ -45,8 +46,9 @@ async function testPath(dirPath: string): Promise<boolean> {
}
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -126,4 +128,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestPlex');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { url, token } = await request.json();
const { url, token } = await req.json();
if (!url || !token) {
return NextResponse.json(
@@ -61,4 +63,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestProwlarr');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { url, apiKey } = await request.json();
const { url, apiKey } = await req.json();
if (!url || !apiKey) {
return NextResponse.json(
@@ -50,4 +52,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+6 -1
View File
@@ -10,12 +10,14 @@ import { Header } from '@/components/layout/Header';
import { RequestCard } from '@/components/requests/RequestCard';
import { useRequests } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn';
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
export default function RequestsPage() {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const [filter, setFilter] = useState<FilterStatus>('all');
// Always fetch only the current user's requests (even for admins)
@@ -133,7 +135,10 @@ export default function RequestsPage() {
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
>
<div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
<div className={cn(
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
)}></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
+1
View File
@@ -495,6 +495,7 @@ export default function SetupWizard() {
return (
<DownloadClientStep
downloadClients={state.downloadClients}
downloadDir={state.downloadDir}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
+33 -6
View File
@@ -6,7 +6,7 @@
'use client';
import { Button } from '@/components/ui/Button';
import { AudibleRegion } from '@/lib/types/audible';
import { AudibleRegion, AUDIBLE_REGIONS } from '@/lib/types/audible';
interface BackendSelectionStepProps {
value: 'plex' | 'audiobookshelf';
@@ -113,12 +113,39 @@ export function BackendSelectionStep({
onChange={(e) => onAudibleRegionChange(e.target.value as AudibleRegion)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
{Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''}
</option>
))}
</select>
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Non-English Region
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Many features such as search, discovery, and metadata matching are not yet fully
supported for non-English regions. You may still proceed, but expect limited
functionality.
</p>
</div>
</div>
</div>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in {value === 'plex' ? 'Plex' : 'Audiobookshelf'}. This ensures accurate book matching and metadata.
+7 -2
View File
@@ -8,10 +8,11 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
import { DownloadClientType } from '@/lib/interfaces/download-client.interface';
interface DownloadClient {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
enabled: boolean;
url: string;
@@ -22,10 +23,12 @@ interface DownloadClient {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
}
interface DownloadClientStepProps {
downloadClients: DownloadClient[];
downloadDir?: string;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
@@ -33,6 +36,7 @@ interface DownloadClientStepProps {
export function DownloadClientStep({
downloadClients,
downloadDir,
onUpdate,
onNext,
onBack,
@@ -66,7 +70,7 @@ export function DownloadClientStep({
Configure Download Clients
</h2>
<p className="text-gray-600 dark:text-gray-400">
Add at least one download client. You can configure both qBittorrent (torrents) and SABnzbd (Usenet) to search across all indexer types.
Add at least one download client. You can configure a torrent client (qBittorrent or Transmission) and/or a usenet client (SABnzbd or NZBGet) to search across all indexer types.
</p>
</div>
@@ -80,6 +84,7 @@ export function DownloadClientStep({
mode="wizard"
initialClients={clients}
onClientsChange={handleClientsChange}
downloadDir={downloadDir}
/>
<div className="flex justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
+8 -8
View File
@@ -17,13 +17,13 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
<div className="text-center space-y-4">
<div className="flex justify-center">
<div
className="w-20 h-20 rounded-full flex items-center justify-center p-4"
className="w-20 h-20 rounded-full flex items-center justify-center p-2 overflow-hidden"
style={{ backgroundColor: '#f7f4f3' }}
>
<img
src="/rmab_32x32.png"
src="/RMAB_1024x1024_ICON.png"
alt="ReadMeABook Logo"
className="w-full h-full object-contain"
className="w-full h-full object-contain relative top-[3px]"
/>
</div>
</div>
@@ -57,9 +57,9 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
/>
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">Plex Media Server</strong>
<strong className="text-gray-900 dark:text-gray-100">Plex or Audiobookshelf</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Your Plex server URL and authentication token
Your media server URL and authentication credentials
</p>
</div>
</li>
@@ -79,7 +79,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
<div>
<strong className="text-gray-900 dark:text-gray-100">Prowlarr</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Indexer aggregator for searching torrents (URL and API key)
Indexer aggregator for searching torrents and usenet (URL and API key)
</p>
</div>
</li>
@@ -98,10 +98,10 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">
qBittorrent or SABnzbd
Download Client
</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download client for torrents (qBittorrent) or Usenet/NZB (SABnzbd)
qBittorrent, Transmission, SABnzbd, or NZBGet
</p>
</div>
</li>
@@ -6,22 +6,30 @@
'use client';
import React from 'react';
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
interface DownloadClientCardProps {
client: {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
url: string;
enabled: boolean;
customPath?: string;
};
onEdit: () => void;
onDelete: () => void;
}
export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientCardProps) {
const typeName = client.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
const typeColor = client.type === 'qbittorrent' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300';
const typeName = getClientDisplayName(client.type);
const typeColorMap: Record<string, string> = {
qbittorrent: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
};
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
// Truncate URL for display
const displayUrl = client.url.length > 40 ? `${client.url.substring(0, 40)}...` : client.url;
@@ -49,6 +57,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={client.url}>
{displayUrl}
</p>
{client.customPath && (
<p className="text-xs text-blue-600 dark:text-blue-400 truncate" title={`Custom path: ${client.customPath}`}>
Path: {client.customPath}
</p>
)}
</div>
</div>
@@ -10,10 +10,11 @@ import { Button } from '@/components/ui/Button';
import { DownloadClientCard } from './DownloadClientCard';
import { DownloadClientModal } from './DownloadClientModal';
import { fetchWithAuth } from '@/lib/utils/api';
import { DownloadClientType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
interface DownloadClient {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
url: string;
username?: string;
@@ -24,24 +25,27 @@ interface DownloadClient {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
}
interface DownloadClientManagementProps {
mode: 'wizard' | 'settings';
initialClients?: DownloadClient[];
onClientsChange?: (clients: DownloadClient[]) => void;
downloadDir?: string;
}
export function DownloadClientManagement({
mode,
initialClients = [],
onClientsChange,
downloadDir: downloadDirProp,
}: DownloadClientManagementProps) {
const [clients, setClients] = useState<DownloadClient[]>(initialClients);
const [modalState, setModalState] = useState<{
isOpen: boolean;
mode: 'add' | 'edit';
clientType?: 'qbittorrent' | 'sabnzbd';
clientType?: DownloadClientType;
currentClient?: DownloadClient;
}>({ isOpen: false, mode: 'add' });
const [loading, setLoading] = useState(false);
@@ -51,14 +55,23 @@ export function DownloadClientManagement({
clientId?: string;
clientName?: string;
}>({ isOpen: false });
const [resolvedDownloadDir, setResolvedDownloadDir] = useState(downloadDirProp || '/downloads');
// Fetch clients when in settings mode
// Fetch clients and download dir when in settings mode
useEffect(() => {
if (mode === 'settings') {
fetchClients();
fetchDownloadDir();
}
}, [mode]);
// Sync downloadDir prop (wizard mode)
useEffect(() => {
if (downloadDirProp) {
setResolvedDownloadDir(downloadDirProp);
}
}, [downloadDirProp]);
// Sync with parent when clients change
useEffect(() => {
if (onClientsChange) {
@@ -93,11 +106,26 @@ export function DownloadClientManagement({
}
};
const handleAddClient = (type: 'qbittorrent' | 'sabnzbd') => {
// Check if this type already exists
const existingClient = clients.find(c => c.type === type && c.enabled);
const fetchDownloadDir = async () => {
try {
const response = await fetchWithAuth('/api/admin/settings');
if (response.ok) {
const data = await response.json();
if (data.paths?.downloadDir) {
setResolvedDownloadDir(data.paths.downloadDir);
}
}
} catch {
// Non-critical: fall back to default
}
};
const handleAddClient = (type: DownloadClientType) => {
// Check if the protocol is already taken (regardless of enabled status)
const protocol = CLIENT_PROTOCOL_MAP[type];
const existingClient = clients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (existingClient) {
setError(`A ${type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} client is already configured.`);
setError(`A ${protocol} client (${getClientDisplayName(existingClient.type)}) is already configured. Remove it first to add a different ${protocol} client.`);
return;
}
@@ -210,8 +238,8 @@ export function DownloadClientManagement({
}
};
const hasQBittorrent = clients.some(c => c.type === 'qbittorrent' && c.enabled);
const hasSABnzbd = clients.some(c => c.type === 'sabnzbd' && c.enabled);
const hasTorrentClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'torrent');
const hasUsenetClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'usenet');
return (
<div className="space-y-6">
@@ -233,9 +261,9 @@ export function DownloadClientManagement({
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Add Download Client
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* qBittorrent Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
@@ -249,9 +277,9 @@ export function DownloadClientManagement({
Torrent
</span>
</div>
{hasQBittorrent ? (
{hasTorrentClient ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Already configured
Protocol already configured
</div>
) : (
<Button
@@ -265,8 +293,39 @@ export function DownloadClientManagement({
)}
</div>
{/* Transmission Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Transmission
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Torrent downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 font-medium">
Torrent
</span>
</div>
{hasTorrentClient ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Protocol already configured
</div>
) : (
<Button
onClick={() => handleAddClient('transmission')}
variant="primary"
size="sm"
disabled={loading}
>
Add Transmission
</Button>
)}
</div>
{/* SABnzbd Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
@@ -280,9 +339,9 @@ export function DownloadClientManagement({
Usenet
</span>
</div>
{hasSABnzbd ? (
{hasUsenetClient ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Already configured
Protocol already configured
</div>
) : (
<Button
@@ -295,6 +354,37 @@ export function DownloadClientManagement({
</Button>
)}
</div>
{/* NZBGet Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
NZBGet
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Usenet/NZB downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium">
Usenet
</span>
</div>
{hasUsenetClient ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Protocol already configured
</div>
) : (
<Button
onClick={() => handleAddClient('nzbget')}
variant="primary"
size="sm"
disabled={loading}
>
Add NZBGet
</Button>
)}
</div>
</div>
</div>
@@ -338,6 +428,7 @@ export function DownloadClientManagement({
initialClient={modalState.currentClient}
onSave={handleSaveClient}
apiMode={mode}
downloadDir={resolvedDownloadDir}
/>
{/* Delete Confirmation Modal */}
@@ -10,15 +10,16 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { fetchWithAuth } from '@/lib/utils/api';
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
interface DownloadClientModalProps {
isOpen: boolean;
onClose: () => void;
mode: 'add' | 'edit';
clientType?: 'qbittorrent' | 'sabnzbd';
clientType?: DownloadClientType;
initialClient?: {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
url: string;
username?: string;
@@ -29,9 +30,11 @@ interface DownloadClientModalProps {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
};
onSave: (client: any) => Promise<void>;
apiMode: 'wizard' | 'settings';
downloadDir?: string;
}
export function DownloadClientModal({
@@ -42,9 +45,10 @@ export function DownloadClientModal({
initialClient,
onSave,
apiMode,
downloadDir = '/downloads',
}: DownloadClientModalProps) {
const type = mode === 'edit' ? initialClient?.type : clientType;
const typeName = type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
const typeName = type ? getClientDisplayName(type) : '';
// Form state
const [name, setName] = useState('');
@@ -57,6 +61,7 @@ export function DownloadClientModal({
const [remotePath, setRemotePath] = useState('');
const [localPath, setLocalPath] = useState('');
const [category, setCategory] = useState('readmeabook');
const [customPath, setCustomPath] = useState('');
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
@@ -79,6 +84,7 @@ export function DownloadClientModal({
setRemotePath(initialClient.remotePath || '');
setLocalPath(initialClient.localPath || '');
setCategory(initialClient.category || 'readmeabook');
setCustomPath(initialClient.customPath || '');
} else {
// Add mode defaults
setName(typeName);
@@ -91,6 +97,7 @@ export function DownloadClientModal({
setRemotePath('');
setLocalPath('');
setCategory('readmeabook');
setCustomPath('');
}
setTestResult(null);
setErrors({});
@@ -113,6 +120,10 @@ export function DownloadClientModal({
newErrors.password = 'API key is required';
}
if (customPath.includes('..')) {
newErrors.customPath = 'Path cannot contain ".."';
}
if (remotePathMappingEnabled) {
if (!remotePath.trim()) {
newErrors.remotePath = 'Remote path is required when path mapping is enabled';
@@ -140,8 +151,9 @@ export function DownloadClientModal({
const testData = {
type,
name,
url,
username: type === 'qbittorrent' ? username : undefined,
username: username || undefined,
password: isPasswordMasked ? undefined : password,
// Include clientId when editing so server can use stored password
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
@@ -202,11 +214,14 @@ export function DownloadClientModal({
setSaving(true);
try {
// Strip leading/trailing slashes from customPath
const sanitizedCustomPath = customPath.replace(/^\/+|\/+$/g, '').trim();
const clientData: any = {
type,
name,
url,
username: type === 'qbittorrent' ? username : undefined,
username: type !== 'sabnzbd' ? username : undefined,
password: password === '********' ? undefined : password, // Don't send masked password on edit
enabled,
disableSSLVerify,
@@ -214,6 +229,7 @@ export function DownloadClientModal({
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
category,
customPath: sanitizedCustomPath || undefined,
};
if (mode === 'edit' && initialClient) {
@@ -264,7 +280,7 @@ export function DownloadClientModal({
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'}
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
error={errors.url}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
@@ -272,8 +288,8 @@ export function DownloadClientModal({
</p>
</div>
{/* Username (qBittorrent only) */}
{type === 'qbittorrent' && (
{/* Username (qBittorrent and Transmission) */}
{type !== 'sabnzbd' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
@@ -290,13 +306,13 @@ export function DownloadClientModal({
{/* Password / API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{type === 'qbittorrent' ? 'Password' : 'API Key'}
{type === 'sabnzbd' ? 'API Key' : 'Password'}
</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
placeholder={type === 'sabnzbd' ? 'API Key from SABnzbd Config > General' : 'Password'}
error={errors.password}
/>
{type === 'sabnzbd' && (
@@ -304,6 +320,11 @@ export function DownloadClientModal({
Found in SABnzbd under Config General API Key
</p>
)}
{type === 'nzbget' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Configured in NZBGet under Settings Security ControlPassword
</p>
)}
</div>
{/* SSL Verification */}
@@ -342,6 +363,27 @@ export function DownloadClientModal({
</label>
</div>
{/* Custom Download Path */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom Download Path
</label>
<Input
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
placeholder="e.g. torrents or usenet/books"
error={errors.customPath}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Optional relative sub-path appended to the base download directory
</p>
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
Downloads to: {customPath.replace(/^\/+|\/+$/g, '').trim()
? `${downloadDir}/${customPath.replace(/^\/+|\/+$/g, '').trim()}`
: downloadDir}
</p>
</div>
{/* Remote Path Mapping */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-start mb-3">
@@ -5,12 +5,13 @@
'use client';
import React from 'react';
import React, { useState, useMemo } from 'react';
import {
TORRENT_CATEGORIES,
getChildIds,
areAllChildrenSelected,
isParentCategory,
getAllStandardCategoryIds,
} from '@/lib/utils/torrent-categories';
interface CategoryTreeViewProps {
@@ -24,7 +25,19 @@ export function CategoryTreeView({
onChange,
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
}: CategoryTreeViewProps) {
const [customInput, setCustomInput] = useState('');
const [customError, setCustomError] = useState('');
const standardIds = useMemo(() => getAllStandardCategoryIds(), []);
// Derive custom categories from selected categories that aren't in the standard tree
const customCategories = useMemo(
() => selectedCategories.filter((id) => !standardIds.has(id)).sort((a, b) => a - b),
[selectedCategories, standardIds]
);
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
const handleParentToggle = (parentId: number) => {
const childIds = getChildIds(parentId);
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
@@ -57,6 +70,52 @@ export function CategoryTreeView({
}
};
const handleRemoveCustom = (categoryId: number) => {
onChange(selectedCategories.filter((id) => id !== categoryId));
};
const handleAddCustom = () => {
setCustomError('');
const trimmed = customInput.trim();
if (!trimmed) {
setCustomError('Enter a category ID');
return;
}
const parsed = parseInt(trimmed, 10);
if (isNaN(parsed) || !Number.isInteger(Number(trimmed)) || String(parsed) !== trimmed) {
setCustomError('Must be a whole number');
return;
}
if (parsed <= 0) {
setCustomError('Must be a positive number');
return;
}
if (standardIds.has(parsed)) {
setCustomError('This is a standard category — use the toggles above');
return;
}
if (selectedCategories.includes(parsed)) {
setCustomError('Already added');
return;
}
onChange([...selectedCategories, parsed]);
setCustomInput('');
};
const handleCustomKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCustom();
}
};
const isParentSelected = (parentId: number) => {
return areAllChildrenSelected(parentId, selectedCategories);
};
@@ -67,6 +126,7 @@ export function CategoryTreeView({
return (
<div className="space-y-5">
{/* Standard Categories */}
{TORRENT_CATEGORIES.map((category) => (
<div key={category.id} className="space-y-2">
{/* Parent Category Header */}
@@ -129,6 +189,85 @@ export function CategoryTreeView({
)}
</div>
))}
{/* Custom Categories Section */}
<div className="space-y-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 px-2 py-1">
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
Custom
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Add custom Newznab/Torznab category IDs
</span>
</div>
{/* Existing custom categories */}
{customCategories.length > 0 && (
<div className="ml-4 space-y-2">
{customCategories.map((catId) => (
<div
key={catId}
className="flex items-center justify-between p-2.5 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300">
Custom
</span>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{catId}]
</span>
</div>
<button
type="button"
onClick={() => handleRemoveCustom(catId)}
className="text-xs px-2.5 py-1 rounded-md text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border border-red-200 dark:border-red-800 transition-colors"
>
Remove
</button>
</div>
))}
</div>
)}
{/* Add custom category input */}
<div className="ml-4">
<div className="flex items-center gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={customInput}
onChange={(e) => {
setCustomInput(e.target.value);
setCustomError('');
}}
onKeyDown={handleCustomKeyDown}
placeholder="Category ID"
className={`
w-32 px-3 py-1.5 text-sm rounded-lg border bg-white dark:bg-gray-800
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900
${customError
? 'border-red-300 dark:border-red-700'
: 'border-gray-200 dark:border-gray-700'
}
`}
/>
<button
type="button"
onClick={handleAddCustom}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900"
>
Add
</button>
</div>
{customError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1.5">
{customError}
</p>
)}
</div>
</div>
</div>
);
}
@@ -96,8 +96,6 @@ export function IndexerConfigModal({
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
audiobookCategories?: string;
ebookCategories?: string;
}>({});
// Reset form when modal opens or indexer changes
@@ -134,26 +132,12 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (audiobookCategories.length === 0) {
newErrors.audiobookCategories = 'At least one audiobook category must be selected';
}
if (ebookCategories.length === 0) {
newErrors.ebookCategories = 'At least one ebook category must be selected';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (!validate()) {
// If there's a category error, switch to the relevant tab
if (errors.audiobookCategories && activeTab !== 'audiobook') {
setActiveTab('audiobook');
} else if (errors.ebookCategories && activeTab !== 'ebook') {
setActiveTab('ebook');
}
return;
}
@@ -202,9 +186,12 @@ export function IndexerConfigModal({
// Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories;
const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES;
// Warning state: no categories means this indexer is effectively disabled for that type
const audiobookDisabled = audiobookCategories.length === 0;
const ebookDisabled = ebookCategories.length === 0;
return (
<Modal
isOpen={isOpen}
@@ -342,8 +329,8 @@ export function IndexerConfigModal({
}`}
>
AudioBook
{errors.audiobookCategories && (
<span className="ml-2 text-red-500">!</span>
{audiobookDisabled && (
<span className="ml-2 text-amber-500" title="No categories — disabled for audiobooks">!</span>
)}
</button>
<button
@@ -356,8 +343,8 @@ export function IndexerConfigModal({
}`}
>
EBook
{errors.ebookCategories && (
<span className="ml-2 text-red-500">!</span>
{ebookDisabled && (
<span className="ml-2 text-amber-500" title="No categories — disabled for ebooks">!</span>
)}
</button>
</div>
@@ -372,15 +359,23 @@ export function IndexerConfigModal({
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
{activeTab === 'audiobook'
? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]'
: 'Categories to search for e-books. Default: Books/EBook [7020]'}
{currentCategories.length > 0
? `Will search categories: [${currentCategories.join(', ')}]`
: activeTab === 'audiobook'
? 'Default: Audio/Audiobook [3030]'
: 'Default: Books/EBook [7020]'}
</p>
{currentError && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{currentError}
</p>
{/* Warning when all categories are deselected for the active tab */}
{currentCategories.length === 0 && (
<div className="flex items-start gap-2 mt-2 p-2.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<svg className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
<p className="text-sm text-amber-700 dark:text-amber-300">
No categories selected. This indexer will not be searched for {activeTab === 'audiobook' ? 'audiobooks' : 'ebooks'}.
</p>
</div>
)}
</div>
@@ -0,0 +1,90 @@
/**
* Component: Global User Settings Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { Modal } from '@/components/ui/Modal';
interface GlobalUserSettingsModalProps {
isOpen: boolean;
onClose: () => void;
globalAutoApprove: boolean;
onToggleAutoApprove: (newValue: boolean) => void;
globalInteractiveSearch: boolean;
onToggleInteractiveSearch: (newValue: boolean) => void;
}
export function GlobalUserSettingsModal({
isOpen,
onClose,
globalAutoApprove,
onToggleAutoApprove,
globalInteractiveSearch,
onToggleInteractiveSearch,
}: GlobalUserSettingsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
<div className="space-y-6">
{/* Auto-Approve Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalAutoApprove}
aria-label="Auto-Approve All Requests"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalAutoApprove ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Auto-Approve All Requests
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings from the users table.
</p>
</div>
</div>
{/* Interactive Search Access Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalInteractiveSearch ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalInteractiveSearch}
aria-label="Interactive Search Access"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalInteractiveSearch ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Interactive Search Access
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all users can manually search and select torrents/ebooks. When disabled, you can grant access per-user from the users table.
</p>
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,184 @@
/**
* Component: User Permissions Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { Modal } from '@/components/ui/Modal';
interface UserPermissionsUser {
id: string;
plexUsername: string;
plexEmail: string;
avatarUrl: string | null;
role: 'user' | 'admin';
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
}
interface UserPermissionsModalProps {
isOpen: boolean;
onClose: () => void;
user: UserPermissionsUser | null;
globalAutoApprove: boolean;
globalInteractiveSearch: boolean;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
}
interface PermissionToggleProps {
label: string;
ariaLabel: string;
value: boolean;
disabled: boolean;
disabledMessage?: string;
description: string;
onToggle: () => void;
}
function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, description, onToggle }: PermissionToggleProps) {
return (
<div className="flex items-start gap-4 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
<button
onClick={() => {
if (!disabled) onToggle();
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5 ${
disabled ? 'opacity-60 cursor-not-allowed' : ''
}`}
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
disabled={disabled}
role="switch"
aria-checked={value}
aria-label={ariaLabel}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{label}
</div>
{disabledMessage ? (
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{disabledMessage}
</p>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{description}
</p>
)}
</div>
</div>
);
}
export function UserPermissionsModal({
isOpen,
onClose,
user,
globalAutoApprove,
globalInteractiveSearch,
onToggleAutoApprove,
onToggleInteractiveSearch,
}: UserPermissionsModalProps) {
if (!user) return null;
const isAdmin = user.role === 'admin';
// Auto-Approve resolution
const isAutoApproveGlobalOverride = !isAdmin && globalAutoApprove;
const isAutoApproveDisabled = isAdmin || isAutoApproveGlobalOverride;
const autoApproveValue = isAdmin ? true : isAutoApproveGlobalOverride ? true : (user.autoApproveRequests ?? false);
// Interactive Search resolution
const isSearchGlobalOverride = !isAdmin && globalInteractiveSearch;
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
if (isAdminUser) return adminMessage;
if (isGlobalOverride) return globalMessage;
return undefined;
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="User Permissions" size="sm">
<div className="space-y-6">
{/* User Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full"
/>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.plexUsername}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'}
</div>
</div>
<span
className={`ml-auto px-2 py-0.5 text-xs font-semibold rounded-full ${
isAdmin
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{user.role.toUpperCase()}
</span>
</div>
{/* Permissions Section */}
<div>
<h3 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Permissions
</h3>
<div className="space-y-3">
{/* Auto-Approve Permission */}
<PermissionToggle
label="Auto-Approve Requests"
ariaLabel="Auto-Approve Requests"
value={autoApproveValue}
disabled={isAutoApproveDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isAutoApproveGlobalOverride,
'Admin requests are always auto-approved',
'Controlled by global auto-approve setting'
)}
description="When enabled, this user's requests are automatically processed without admin approval"
onToggle={() => onToggleAutoApprove(user, !autoApproveValue)}
/>
{/* Interactive Search Access Permission */}
<PermissionToggle
label="Interactive Search Access"
ariaLabel="Interactive Search Access"
value={searchValue}
disabled={isSearchDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isSearchGlobalOverride,
'Admins always have interactive search access',
'Controlled by global interactive search setting'
)}
description="When enabled, this user can manually search and select torrents and ebooks"
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
/>
</div>
</div>
</div>
</Modal>
);
}
@@ -479,8 +479,8 @@ export function AudiobookDetailsModal({
)}
</div>
{/* Interactive Search - only if not available */}
{status.type !== 'available' && (
{/* Interactive Search - only if not available and user has permission */}
{status.type !== 'available' && (user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
<button
onClick={handleInteractiveSearch}
disabled={!user}
@@ -513,15 +513,17 @@ export function AudiobookDetailsModal({
</svg>
)}
</button>
<button
onClick={() => setShowInteractiveSearchEbook(true)}
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
title="Search Ebook Sources"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
{(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
<button
onClick={() => setShowInteractiveSearchEbook(true)}
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
title="Search Ebook Sources"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
)}
</>
)}
</div>
@@ -9,10 +9,8 @@
'use client';
import React, { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import {
useInteractiveSearch,
@@ -40,6 +38,46 @@ interface InteractiveTorrentSearchModalProps {
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
}
// Format relative time from publish date
const formatAge = (date: Date | string): string => {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
if (diffMs < 0) return 'Soon';
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1d ago';
if (diffDays < 30) return `${diffDays}d ago`;
const months = Math.floor(diffDays / 30.44);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(diffDays / 365.25);
return `${years}y ago`;
};
// Format file size
const formatSize = (bytes: number): string => {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
};
// Score badge color scheme
const getScoreStyle = (score: number) => {
if (score >= 90) return { bg: 'bg-emerald-500/15 dark:bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-400' };
if (score >= 70) return { bg: 'bg-blue-500/15 dark:bg-blue-400/15', text: 'text-blue-700 dark:text-blue-400' };
if (score >= 50) return { bg: 'bg-amber-500/15 dark:bg-amber-400/15', text: 'text-amber-700 dark:text-amber-400' };
return { bg: 'bg-gray-500/10 dark:bg-gray-400/10', text: 'text-gray-500 dark:text-gray-400' };
};
// Skeleton widths for loading state (deterministic to avoid hydration mismatch)
const skeletonRows = [
{ title: '72%', meta: '48%' },
{ title: '85%', meta: '58%' },
{ title: '64%', meta: '42%' },
{ title: '78%', meta: '52%' },
{ title: '68%', meta: '45%' },
];
export function InteractiveTorrentSearchModal({
isOpen,
onClose,
@@ -66,9 +104,15 @@ export function InteractiveTorrentSearchModal({
const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin();
const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin();
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]);
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title);
const [mounted, setMounted] = useState(false);
// Stable close handler via ref
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const handleClose = useCallback(() => { onCloseRef.current(); }, []);
// Determine which mode we're in
const isEbookMode = searchMode === 'ebook';
@@ -89,58 +133,72 @@ export function InteractiveTorrentSearchModal({
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError));
// Mount tracking for portal
useEffect(() => { setMounted(true); }, []);
// Reset search title when modal opens/closes or audiobook changes
React.useEffect(() => {
useEffect(() => {
setSearchTitle(audiobook.title);
setResults([]);
}, [isOpen, audiobook.title]);
// Perform search when modal opens
React.useEffect(() => {
useEffect(() => {
if (isOpen && results.length === 0) {
performSearch();
}
}, [isOpen]);
const performSearch = async () => {
// Clear existing results while searching
setResults([]);
// ESC key and body scroll lock
// ESC dismisses confirmation first, then closes modal
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (confirmTorrent) {
setConfirmTorrent(null);
} else {
handleClose();
}
}
};
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = '';
};
}, [isOpen, handleClose, confirmTorrent]);
const performSearch = async () => {
setResults([]);
try {
let data;
if (isEbookMode) {
// Ebook mode: search Anna's Archive + indexers
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
if (useAsinMode && asin) {
// ASIN-based ebook search (user flow from details modal)
data = await searchEbooksByAsin(asin, customTitle);
} else if (requestId) {
// Request ID-based ebook search (admin flow)
data = await searchEbooks(requestId, customTitle);
} else {
console.error('Ebook search requires either requestId or asin');
return;
}
} else if (hasRequestId) {
// Existing audiobook flow: search by requestId with optional custom title
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle);
} else {
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
const audiobookAsin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
}
setResults(data || []);
} catch (err) {
// Error already handled by hook
console.error('Search failed:', err);
}
};
const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
performSearch();
}
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') performSearch();
};
const handleDownloadClick = (torrent: TorrentResult) => {
@@ -149,270 +207,385 @@ export function InteractiveTorrentSearchModal({
const handleConfirmDownload = async () => {
if (!confirmTorrent) return;
try {
if (isEbookMode) {
// Ebook flow
if (useAsinMode && asin) {
// ASIN-based ebook selection (user flow from details modal)
await selectEbookByAsin(asin, confirmTorrent);
} else if (requestId) {
// Request ID-based ebook selection (admin flow)
await selectEbook(requestId, confirmTorrent);
} else {
throw new Error('Request ID or ASIN required for ebook selection');
}
} else if (hasRequestId) {
// Existing audiobook flow: select torrent for existing request
await selectTorrent(requestId, confirmTorrent);
} else {
// New audiobook flow: create request with torrent
if (!fullAudiobook) {
throw new Error('Audiobook data required to create request');
}
if (!fullAudiobook) throw new Error('Audiobook data required to create request');
await requestWithTorrent(fullAudiobook, confirmTorrent);
}
// Notify parent of successful selection
onSuccess?.();
// Close modals on success
setConfirmTorrent(null);
onClose();
// Request list will auto-refresh via SWR
} catch (err) {
// Error already handled by hook
console.error('Failed to download:', err);
setConfirmTorrent(null);
}
};
const formatSize = (bytes: number) => {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
};
const getQualityBadgeColor = (score: number) => {
if (score >= 90) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
if (score >= 70) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
if (score >= 50) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
};
// UI text based on mode
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent';
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title';
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
const modalTitle = isEbookMode ? 'Find Ebook' : 'Find Audiobook';
const noResultsText = isEbookMode ? 'No ebooks found' : 'No results found';
const resultCountText = (count: number) =>
isEbookMode
? `Found ${count} ebook${count !== 1 ? 's' : ''}`
: `Found ${count} torrent${count !== 1 ? 's' : ''}`;
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent';
? `${count} ebook${count !== 1 ? 's' : ''} found`
: `${count} result${count !== 1 ? 's' : ''} found`;
const confirmModalTitle = isEbookMode ? 'Download Ebook' : 'Confirm Download';
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full">
<div className="space-y-4">
{/* Search customization - editable for ALL modes */}
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{searchLabel}
</label>
<div className="flex gap-2">
<input
type="text"
value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)}
onKeyPress={handleSearchKeyPress}
placeholder={searchPlaceholder}
disabled={isSearching}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
/>
<Button
onClick={performSearch}
disabled={isSearching || !searchTitle.trim()}
variant="primary"
size="sm"
>
Search
</Button>
if (!isOpen || !mounted) return null;
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
style={{ height: '100dvh' }}
onClick={handleClose}
>
<div
className="relative w-full sm:max-w-2xl lg:max-w-3xl bg-white dark:bg-gray-900 sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-4 sm:slide-in-from-bottom-0 sm:zoom-in-95 duration-300"
style={{
maxHeight: 'calc(100dvh - env(safe-area-inset-top, 0px) - 1rem)',
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-700/50">
<h2 className="text-[17px] font-semibold text-gray-900 dark:text-white">{modalTitle}</h2>
<button
onClick={handleClose}
className="p-1.5 -mr-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain">
<div className="p-4 sm:p-5 space-y-4">
{/* Search Bar */}
<div>
<div className="flex items-center gap-2.5 bg-gray-100/80 dark:bg-white/[0.06] rounded-xl px-3.5 py-2.5 border border-transparent focus-within:border-blue-500/40 focus-within:bg-white dark:focus-within:bg-white/[0.08] focus-within:shadow-sm focus-within:shadow-blue-500/10 transition-all duration-200">
<svg className="w-[18px] h-[18px] text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search title..."
disabled={isSearching}
className="flex-1 bg-transparent outline-none text-[15px] text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 min-w-0"
/>
{isSearching ? (
<div className="flex-shrink-0 w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
) : (
<button
onClick={performSearch}
disabled={!searchTitle.trim()}
className="flex-shrink-0 px-3 py-1 text-[13px] font-semibold text-white bg-blue-600 hover:bg-blue-700 active:scale-[0.97] rounded-lg transition-all disabled:opacity-30 disabled:pointer-events-none"
>
Search
</button>
)}
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5 ml-1 truncate">
by {audiobook.author}
</p>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-start gap-2.5 px-3.5 py-3 bg-red-50/80 dark:bg-red-500/10 rounded-xl border border-red-200/60 dark:border-red-500/20">
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-red-600 dark:text-red-400 leading-snug">{error}</p>
</div>
)}
{/* Loading state */}
{isSearching && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span>
</div>
)}
{/* Loading Skeleton */}
{isSearching && (
<div className="space-y-0.5">
{skeletonRows.map((widths, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-3.5 rounded-xl animate-pulse">
<div className="w-11 h-11 rounded-xl bg-gray-200/80 dark:bg-gray-700/50 flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<div className="h-3.5 rounded-lg bg-gray-200/80 dark:bg-gray-700/50" style={{ width: widths.title }} />
<div className="h-3 rounded-lg bg-gray-100 dark:bg-gray-800/60" style={{ width: widths.meta }} />
</div>
<div className="w-14 h-[30px] rounded-full bg-gray-200/80 dark:bg-gray-700/50 flex-shrink-0" />
</div>
))}
</div>
)}
{/* No results */}
{!isSearching && results.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p>
<Button onClick={performSearch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
)}
{/* Empty State */}
{!isSearching && results.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-14">
<div className="w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-3">
<svg className="w-7 h-7 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="text-[15px] font-medium text-gray-500 dark:text-gray-400">{noResultsText}</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">Try adjusting your search terms</p>
<button
onClick={performSearch}
className="mt-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
Search Again
</button>
</div>
)}
{/* Results table */}
{!isSearching && results.length > 0 && (
<div className="overflow-x-auto -mx-6">
<div className="inline-block min-w-full align-middle px-6">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12">
#
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Title
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell w-24">
Size
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Base quality score (0-100): Title/Author match (50) + Format (25) + Seeders (15) + Size (10)">
Score
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Bonus points from indexer priority and other modifiers">
Bonus
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell w-20">
Seeds
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
{isEbookMode ? 'Source' : 'Indexer'}
</th>
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
Action
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{results.map((result) => (
<tr key={result.guid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-2 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{result.rank}
</td>
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
<div className="truncate">
<a
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
title={result.title}
>
{result.title}
</a>
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{/* Anna's Archive badge for ebook mode */}
{isEbookMode && result.source === 'annas_archive' && (
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
Anna's Archive
</span>
)}
{result.format && (
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
{result.format}
</span>
)}
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
</span>
{/* Hide seeds badge for Anna's Archive results */}
{!(isEbookMode && result.source === 'annas_archive') && (
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
</span>
)}
</div>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
{result.size > 0 ? formatSize(result.size) : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
{Math.round(result.score)}
</span>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-gray-400">N/A</span>
) : (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
) : (
result.indexer
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
<Button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
size="sm"
variant="primary"
{/* Results List */}
{!isSearching && results.length > 0 && (
<div className="space-y-0.5">
{results.map((result) => {
const score = Math.round(result.score);
const style = getScoreStyle(score);
const isUsenet = result.protocol === 'usenet';
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
const displayFormat = result.format || result.ebookFormat;
return (
<div
key={result.guid}
className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group"
>
{/* Score Badge */}
<div
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
>
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
{score}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title Row */}
<div className="flex items-center gap-1.5">
<a
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={result.title}
>
Download
</Button>
</td>
</tr>
))}
</tbody>
</table>
{result.title}
</a>
</div>
{/* Metadata Row */}
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{/* Rank */}
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{/* Indexer / Source */}
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : (
<span>{result.indexer}</span>
)}
{/* Size */}
{result.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatSize(result.size)}</span>
</>
)}
{/* Format */}
{displayFormat && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
)}
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
{!isAnnasArchive && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{isUsenet ? (
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
NZB
</span>
) : (
<span className="flex items-center gap-0.5">
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
</span>
)}
</>
)}
{/* Age */}
{result.publishDate && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatAge(result.publishDate)}</span>
</>
)}
{/* Bonus Points */}
{result.bonusPoints > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
</>
)}
</div>
</div>
{/* Action Button */}
<button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
>
Get
</button>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Sticky Footer */}
{!isSearching && results.length > 0 && (
<div className="flex items-center justify-between px-5 py-3 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50">
<p className="text-xs text-gray-400 dark:text-gray-500">
{resultCountText(results.length)}
</p>
<button
onClick={performSearch}
disabled={isSearching}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-40"
>
Refresh
</button>
</div>
)}
{/* Inline Confirmation Overlay */}
{confirmTorrent && (
<div
className="absolute inset-0 z-30 flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
onClick={() => !isDownloading && setConfirmTorrent(null)}
>
<div
className="mx-5 w-full max-w-sm bg-white dark:bg-gray-800 rounded-2xl shadow-2xl shadow-black/20 overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Confirm Header */}
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 dark:bg-blue-400/15 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">
{confirmModalTitle}
</h3>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
This will start the download
</p>
</div>
</div>
{/* Selected Item Preview */}
<div className="bg-gray-50 dark:bg-white/[0.04] rounded-xl px-3.5 py-3 border border-gray-100 dark:border-gray-700/50">
<p className="text-sm font-medium text-gray-900 dark:text-white leading-snug line-clamp-2">
{confirmTorrent.title}
</p>
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span>{confirmTorrent.indexer}</span>
{confirmTorrent.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span>{formatSize(confirmTorrent.size)}</span>
</>
)}
{confirmTorrent.format && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="uppercase font-medium">{confirmTorrent.format}</span>
</>
)}
{confirmTorrent.protocol === 'usenet' ? (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-sky-600 dark:text-sky-400">NZB</span>
</>
) : confirmTorrent.seeders !== undefined && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-emerald-600 dark:text-emerald-400">{confirmTorrent.seeders} seeds</span>
</>
)}
</div>
</div>
</div>
{/* Confirm Actions */}
<div className="flex border-t border-gray-200/80 dark:border-gray-700/50">
<button
onClick={() => setConfirmTorrent(null)}
disabled={isDownloading}
className="flex-1 px-4 py-3 text-[15px] font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors disabled:opacity-40 border-r border-gray-200/80 dark:border-gray-700/50"
>
Cancel
</button>
<button
onClick={handleConfirmDownload}
disabled={isDownloading}
className="flex-1 px-4 py-3 text-[15px] font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors disabled:opacity-60"
>
{isDownloading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-blue-300 dark:border-blue-600 border-t-blue-600 dark:border-t-blue-400 rounded-full animate-spin" />
Downloading...
</span>
) : (
'Download'
)}
</button>
</div>
</div>
)}
{/* Footer with result count */}
{!isSearching && results.length > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
{resultCountText(results.length)}
</p>
<Button onClick={performSearch} variant="outline" size="sm">
Refresh Results
</Button>
</div>
)}
</div>
</Modal>
{/* Confirmation Modal */}
<ConfirmModal
isOpen={!!confirmTorrent}
onClose={() => setConfirmTorrent(null)}
onConfirm={handleConfirmDownload}
title={confirmTitle}
message={`Download "${confirmTorrent?.title}"?`}
confirmText="Download"
isLoading={isDownloading}
variant="primary"
/>
</>
</div>
)}
</div>
</div>
);
return createPortal(modalContent, document.body);
}
+35 -2
View File
@@ -11,7 +11,10 @@ import { StatusBadge } from './StatusBadge';
import { Button } from '@/components/ui/Button';
import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { usePreferences } from '@/contexts/PreferencesContext';
import { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
interface RequestCardProps {
request: {
@@ -25,6 +28,7 @@ interface RequestCardProps {
completedAt?: string;
audiobook: {
id: string;
audibleAsin?: string;
title: string;
author: string;
coverArtUrl?: string;
@@ -36,8 +40,11 @@ interface RequestCardProps {
export function RequestCard({ request, showActions = true }: RequestCardProps) {
const { cancelRequest, isLoading } = useCancelRequest();
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
const { squareCovers } = usePreferences();
const { user } = useAuth();
const [showError, setShowError] = React.useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook';
@@ -46,7 +53,9 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
// Ebook requests don't support interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
// Interactive search also requires the interactiveSearch permission
const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false;
const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const handleCancel = async () => {
if (window.confirm('Are you sure you want to cancel this request?')) {
@@ -94,7 +103,19 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
{/* Cover Art */}
<div className="flex-shrink-0">
<div className="relative w-16 h-24 sm:w-24 sm:h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700">
<div
className={cn(
'relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700',
squareCovers
? 'w-16 sm:w-24 aspect-square'
: 'w-16 sm:w-24 aspect-[2/3]',
request.audiobook.audibleAsin && 'cursor-pointer hover:opacity-90 transition-opacity'
)}
onClick={() => request.audiobook.audibleAsin && setShowDetailsModal(true)}
role={request.audiobook.audibleAsin ? 'button' : undefined}
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
>
{request.audiobook.coverArtUrl ? (
<Image
src={request.audiobook.coverArtUrl}
@@ -277,6 +298,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
author: request.audiobook.author,
}}
/>
{/* Audiobook Details Modal */}
{request.audiobook.audibleAsin && (
<AudiobookDetailsModal
asin={request.audiobook.audibleAsin}
isOpen={showDetailsModal}
onClose={() => setShowDetailsModal(false)}
requestStatus={request.status}
isAvailable={['available', 'downloaded'].includes(request.status)}
hideRequestActions
/>
)}
</div>
);
}
+71 -5
View File
@@ -5,11 +5,29 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
const GITHUB_REPO = 'kikootwo/ReadMeABook';
const REMOTE_PACKAGE_URL = `https://raw.githubusercontent.com/${GITHUB_REPO}/refs/heads/main/package.json`;
const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
function compareVersions(current: string, latest: string): number {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
const a = parse(current);
const b = parse(latest);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (b[i] || 0) - (a[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
export function VersionBadge() {
const [version, setVersion] = useState<string | null>(null);
const [rawVersion, setRawVersion] = useState<string | null>(null);
const [commit, setCommit] = useState<string | null>(null);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
useEffect(() => {
// Try to get version from build-time env var first (instant, no API call)
@@ -17,6 +35,7 @@ export function VersionBadge() {
if (buildTimeVersion && buildTimeVersion !== 'unknown') {
setVersion(`v${buildTimeVersion}`);
setRawVersion(buildTimeVersion);
// Also get commit for tooltip if available
const buildTimeCommit = process.env.NEXT_PUBLIC_GIT_COMMIT;
if (buildTimeCommit && buildTimeCommit !== 'unknown') {
@@ -31,6 +50,7 @@ export function VersionBadge() {
.then((res) => res.json())
.then((data) => {
setVersion(data.version);
setRawVersion(data.fullVersion);
if (data.commit && data.commit !== 'unknown') {
setCommit(data.commit.substring(0, 7));
}
@@ -42,20 +62,66 @@ export function VersionBadge() {
}
}, []);
const checkForUpdates = useCallback(() => {
if (!rawVersion || rawVersion === 'unknown') return;
fetch(REMOTE_PACKAGE_URL)
.then((res) => res.json())
.then((data) => {
if (data.version) {
setLatestVersion(data.version);
setUpdateAvailable(compareVersions(rawVersion, data.version) > 0);
}
})
.catch(() => {
// Silently fail - update check is non-critical
});
}, [rawVersion]);
// Check for updates on mount and periodically (every 6 hours)
useEffect(() => {
if (!rawVersion || rawVersion === 'unknown') return;
checkForUpdates();
const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
return () => clearInterval(interval);
}, [rawVersion, checkForUpdates]);
if (!version) {
return null;
}
const tooltipText = commit ? `${version} (${commit})` : version;
const releaseUrl = rawVersion && rawVersion !== 'unknown'
? `https://github.com/${GITHUB_REPO}/releases/tag/v${rawVersion}`
: `https://github.com/${GITHUB_REPO}/releases`;
const tooltipText = updateAvailable && latestVersion
? `${version}${commit ? ` (${commit})` : ''} — Update available: v${latestVersion}`
: commit ? `${version} (${commit})` : version;
return (
<div
className="inline-flex items-center px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm"
<a
href={updateAvailable && latestVersion
? `https://github.com/${GITHUB_REPO}/releases/tag/v${latestVersion}`
: releaseUrl
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm hover:shadow-md transition-shadow no-underline"
title={tooltipText}
>
<span className="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">
{version}
</span>
</div>
{updateAvailable && latestVersion && (
<span className="inline-flex items-center gap-1 text-xs font-mono font-medium text-amber-600 dark:text-amber-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
</span>
v{latestVersion}
</span>
)}
</a>
);
}
+35 -1
View File
@@ -8,6 +8,10 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useRef } from 'react';
import { isTokenExpired, getRefreshTimeMs } from '@/lib/utils/jwt-client';
interface UserPermissions {
interactiveSearch: boolean;
}
interface User {
id: string;
plexId: string;
@@ -16,6 +20,7 @@ interface User {
role: string;
avatarUrl?: string;
authProvider?: string | null; // 'plex' | 'oidc' | 'local' | null
permissions?: UserPermissions;
}
interface AuthContextType {
@@ -73,7 +78,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const storedRefreshToken = localStorage.getItem('refreshToken');
if (storedRefreshToken && !isTokenExpired(storedRefreshToken)) {
// Refresh token is still valid, attempt refresh
refreshTokenInternal(storedRefreshToken).finally(() => {
refreshTokenInternal(storedRefreshToken).then(() => {
// Fetch fresh user data from server to pick up role changes,
// avatar updates, etc. - mirrors the non-expired path below.
const currentToken = localStorage.getItem('accessToken');
if (currentToken) {
fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${currentToken}` },
})
.then((res) => res.json())
.then((data) => {
if (data.user) {
setUser(data.user);
localStorage.setItem('user', JSON.stringify(data.user));
}
})
.catch((error) => {
console.error('Failed to fetch fresh user data:', error);
});
}
}).finally(() => {
setIsLoading(false);
});
return;
@@ -135,6 +159,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessToken(data.accessToken);
localStorage.setItem('accessToken', data.accessToken);
// Restore user state from localStorage if not already in React state.
// This is critical for the mount-time refresh path: when the access
// token has expired but the refresh token is still valid, the mount
// effect calls refreshTokenInternal without ever calling setUser,
// leaving user as null and the app appearing logged-out.
const storedUserData = localStorage.getItem('user');
if (storedUserData) {
setUser(JSON.parse(storedUserData));
}
// Schedule next refresh
scheduleTokenRefresh(data.accessToken);
} else {
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Audio Format Constants
* Documentation: documentation/phase3/file-organization.md
*
* Centralized audio format definitions used across the application.
* Add new formats here to enable support in all subsystems.
*/
/**
* All supported audio file extensions for audiobook detection and file organization.
* Used by: file-organizer.ts, files-hash.ts
*/
export const AUDIO_EXTENSIONS = [
'.m4b',
'.m4a',
'.mp3',
'.mp4',
'.aa',
'.aax',
'.flac',
'.ogg',
] as const;
/**
* Audio formats supported by the chapter merger (FFmpeg concat + M4B output).
* Formats here can be detected, probed, ordered, and merged into a single M4B.
* Note: .aa/.aax excluded (DRM-protected, cannot be decoded by FFmpeg without keys).
* Note: .ogg excluded (FFmpeg concat demuxer does not support Ogg container).
*/
export const CHAPTER_MERGE_FORMATS = [
'.mp3',
'.m4a',
'.m4b',
'.mp4',
'.aac',
'.flac',
] as const;
/**
* Audio formats supported by metadata tagging via FFmpeg.
* Each format maps to a specific FFmpeg output format flag and tagging strategy.
*/
export const METADATA_TAG_FORMATS = [
'.m4b',
'.m4a',
'.mp3',
'.mp4',
'.flac',
] as const;
/**
* Formats that use MP4/M4A container tags (iTunes-style metadata).
* These use `-f mp4` output format in FFmpeg.
*/
export const MP4_CONTAINER_FORMATS = ['.m4b', '.m4a', '.mp4'] as const;
/**
* Audio format identifiers detectable in torrent/NZB titles.
* Used by Prowlarr service for metadata extraction and ranking algorithm for scoring.
*/
export const TORRENT_TITLE_FORMATS = ['M4B', 'M4A', 'MP3', 'FLAC'] as const;
export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
/**
* Type helper for the format field on TorrentResult.
* 'OTHER' is used when no recognized format is detected in the title.
*/
export type AudioFormat = TorrentTitleFormat | 'OTHER';
+3 -108
View File
@@ -88,10 +88,10 @@ export class AudibleService {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Cookie': 'lc-acbus=en_US', // Force English locale (prevents IP-based language redirect for non-US IPs)
},
params: {
ipRedirectOverride: 'true', // Prevent IP-based region redirects
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs)
},
});
@@ -108,118 +108,16 @@ export class AudibleService {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Cookie': 'lc-acbus=en_US', // Force English locale
},
params: {
ipRedirectOverride: 'true',
language: 'english',
},
});
this.initialized = true;
}
}
/**
* Detect and correct non-English locale pages from Audible.
*
* Audible uses IP geolocation to serve locale-specific pages by adding culture
* codes to URLs (e.g., /adblbestsellers /es_US/charts/best for Spanish-speaking IPs).
* ipRedirectOverride only prevents region redirects (audible.com audible.co.uk),
* NOT language/locale redirects within the same region.
*
* Strategy (data-driven):
* 1. Check response URL for any non-English culture code (xx_YY where xx != 'en')
* 2. Parse the page's locale picker (adbl-toggle-chip elements) to find the English URL
* 3. Re-request using Audible's own English URL (from the picker's data-value attribute)
* 4. Fallback: strip culture code from URL + add language=en_US param if no picker found
*
* Returns corrected response, or null if no correction needed.
*/
private async handleLocaleRedirect(response: any): Promise<any | null> {
try {
// Extract final URL after all redirects (Node.js http internals)
const finalUrl: string = response.request?.res?.responseUrl ||
response.request?._redirectable?._currentUrl || '';
if (!finalUrl) return null;
// Check for non-English culture code in URL path
// Culture codes: xx_YY (e.g., es_US, fr_CA, pt_BR, de_DE, ja_JP)
// Match in path segment: must follow a / and be followed by / or end-of-path or query string
const localeMatch = finalUrl.match(/\/([a-z]{2}_[A-Z]{2})(\/|$|\?)/);
if (!localeMatch || localeMatch[1].startsWith('en')) {
return null; // No culture code found, or already English
}
const detectedLocale = localeMatch[1];
logger.warn(`Detected non-English locale (${detectedLocale}) in Audible response URL: ${finalUrl}`);
// --- Primary strategy: parse the locale picker from the page HTML ---
// Audible pages include a locale picker with <adbl-toggle-chip> web components:
// <adbl-toggle-chip data-locale="en_CA" data-value="/charts/best?language=en_CA">English</adbl-toggle-chip>
// <adbl-toggle-chip data-locale="fr_CA" data-value="/fr_CA/charts/best?language=fr_CA">Français</adbl-toggle-chip>
// The English option's data-value gives us the exact correct English URL for this page.
const $ = cheerio.load(response.data);
const englishChip = $('adbl-toggle-chip[data-locale^="en"]').first();
if (englishChip.length > 0) {
const englishPath = englishChip.attr('data-value');
const englishLocale = englishChip.attr('data-locale');
if (englishPath) {
logger.info(`Found English option (${englishLocale}) in locale picker: ${englishPath}`);
// Re-request using the English URL from the picker
// data-value is a relative path (e.g., "/charts/best?language=en_CA")
// Client defaults add ipRedirectOverride=true automatically
const correctedResponse = await this.client.get(englishPath);
// Verify the correction actually resolved to English
const correctedUrl: string = correctedResponse.request?.res?.responseUrl ||
correctedResponse.request?._redirectable?._currentUrl || '';
if (correctedUrl) {
const verifyMatch = correctedUrl.match(/\/([a-z]{2}_[A-Z]{2})(\/|$|\?)/);
if (verifyMatch && !verifyMatch[1].startsWith('en')) {
logger.warn(`Locale correction incomplete — corrected URL still contains non-English locale (${verifyMatch[1]}): ${correctedUrl}`);
} else {
logger.info(`Locale correction successful (${detectedLocale}${englishLocale})`);
}
}
return correctedResponse;
}
logger.warn('English locale chip found but missing data-value attribute');
} else {
logger.warn('No locale picker found on page, attempting fallback URL rewrite');
}
// --- Fallback strategy: URL rewrite ---
// Strip the non-English culture code from the path and add language=en_US param.
// This mirrors the locale picker pattern: English URLs have no prefix + language param.
try {
const urlObj = new URL(finalUrl);
urlObj.pathname = urlObj.pathname.replace(`/${detectedLocale}`, '');
urlObj.searchParams.set('language', 'en_US');
// Build relative path (client will prepend baseURL)
const fallbackPath = urlObj.pathname + urlObj.search;
logger.info(`Fallback: re-requesting with URL rewrite: ${fallbackPath}`);
return await this.client.get(fallbackPath);
} catch (urlError) {
logger.warn('Fallback URL rewrite failed', {
error: urlError instanceof Error ? urlError.message : String(urlError),
});
}
} catch (error) {
logger.debug('Locale correction failed entirely, using original response', {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
/**
* Fetch with retry logic and exponential backoff
* Retries on network errors and rate limiting (503, 429)
@@ -233,10 +131,7 @@ export class AudibleService {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await this.client.get(url, config);
// Check if redirected to non-English locale (e.g., /es_US/) and correct it
return await this.handleLocaleRedirect(response) || response;
return await this.client.get(url, config);
} catch (error: any) {
lastError = error;
const status = error.response?.status;
+925
View File
@@ -0,0 +1,925 @@
/**
* Component: NZBGet Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import zlib from 'zlib';
import { RMABLogger } from '@/lib/utils/logger';
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
const logger = RMABLogger.create('NZBGet');
// =========================================================================
// NZBGet-specific types
// =========================================================================
/** NZBGet queue group item from listgroups() */
interface NZBGetGroupItem {
NZBID: number;
NZBName: string;
Status: string;
FileSizeMB: number;
DownloadedSizeMB: number;
RemainingSizeMB: number;
DownloadTimeSec: number;
Category: string;
DestDir: string;
FinalDir: string;
MaxPriority: number;
ActiveDownloads: number;
Health: number;
PostInfoText: string;
PostStageProgress: number;
}
/** NZBGet history item from history() */
interface NZBGetHistoryItem {
NZBID: number;
Name: string;
Status: string;
Category: string;
FileSizeMB: number;
DownloadedSizeMB: number;
DestDir: string;
FinalDir: string;
DownloadTimeSec: number;
PostTotalTimeSec: number;
ParStatus: string;
UnpackStatus: string;
DeleteStatus: string;
MarkStatus: string;
HistoryTime: number;
FailedArticles: number;
TotalArticles: number;
}
/** NZBGet config entry from config() */
interface NZBGetConfigItem {
Name: string;
Value: string;
}
/** NZBGet status response from status() */
interface NZBGetStatus {
DownloadRate: number;
RemainingSizeMB: number;
DownloadedSizeMB: number;
DownloadPaused: boolean;
ServerStandBy: boolean;
}
/** Internal NZB info (normalized before mapping to DownloadInfo) */
interface NZBInfo {
nzbId: string;
name: string;
size: number;
bytesDownloaded: number;
progress: number;
status: DownloadStatus;
downloadSpeed: number;
eta: number;
category: string;
downloadPath?: string;
completedAt?: Date;
errorMessage?: string;
}
// =========================================================================
// NZBGet Service
// =========================================================================
export class NZBGetService implements IDownloadClient {
readonly clientType: DownloadClientType = 'nzbget';
readonly protocol: ProtocolType = 'usenet';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private defaultCategory: string;
private defaultDownloadDir: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
constructor(
baseUrl: string,
username: string,
password: string,
defaultCategory: string = 'readmeabook',
defaultDownloadDir: string = '/downloads',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username || '';
this.password = password || '';
this.defaultCategory = defaultCategory;
this.defaultDownloadDir = defaultDownloadDir;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: this.httpsAgent,
auth: {
username: this.username,
password: this.password,
},
});
}
// =========================================================================
// JSON-RPC Communication
// =========================================================================
/**
* Make a JSON-RPC call to NZBGet.
* All NZBGet API calls go through POST /jsonrpc with Basic Auth.
*/
private async rpc<T = any>(method: string, params: any[] = []): Promise<T> {
const response = await this.client.post('/jsonrpc', {
method,
params,
});
if (response.data?.error) {
const errorMsg = typeof response.data.error === 'string'
? response.data.error
: response.data.error.message || JSON.stringify(response.data.error);
throw new Error(`NZBGet RPC error (${method}): ${errorMsg}`);
}
return response.data?.result;
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Test connection to NZBGet
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
const version = await this.rpc<string>('version');
if (!version) {
return {
success: false,
message: 'Connected but failed to get NZBGet version',
};
}
return {
success: true,
version,
message: `Connected to NZBGet v${version}`,
};
} catch (error) {
return {
success: false,
message: this.formatConnectionError(error),
};
}
}
/**
* Add a download via the unified interface.
* Downloads the NZB file from the source URL and uploads to NZBGet via append().
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
const category = options?.category || this.defaultCategory;
// Ensure category exists with correct path before every download
// (Matches SABnzbd/qBittorrent behavior — lightweight config read + conditional write)
await this.ensureCategory();
// Download the NZB file content from the source URL (Prowlarr proxy)
let nzbBuffer: Buffer;
let filename: string;
try {
logger.info('Downloading NZB file from source URL...');
const nzbResponse = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
nzbBuffer = Buffer.from(nzbResponse.data);
if (nzbBuffer.length === 0) {
throw new Error('NZB file is empty (0 bytes)');
}
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
// Detect and decompress gzip-compressed NZB files
// Prowlarr/indexers may serve .nzb.gz files which need decompression before upload
if (nzbBuffer[0] === 0x1f && nzbBuffer[1] === 0x8b) {
logger.info('NZB file is gzip-compressed, decompressing...');
nzbBuffer = zlib.gunzipSync(nzbBuffer);
logger.info(`Decompressed NZB file: ${nzbBuffer.length} bytes`);
}
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status) {
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
}
if (error.code === 'ECONNREFUSED') {
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
}
}
throw error;
}
// Upload to NZBGet via append()
// Parameters: Filename, Content (base64), Category, Priority, AddToTop, AddPaused,
// DupeKey, DupeScore, DupeMode, AutoCategory, PPParameters
const base64Content = nzbBuffer.toString('base64');
const priority = this.mapPriority(options?.priority);
const nzbId = await this.rpc<number>('append', [
filename, // Filename
base64Content, // Content (base64-encoded NZB)
category, // Category
priority, // Priority (0=normal, 50=high, 100=very high, 900=force)
false, // AddToTop
options?.paused || false, // AddPaused
'', // DupeKey
0, // DupeScore
'FORCE', // DupeMode — RMAB manages its own lifecycle, skip NZBGet dupe detection
[], // PPParameters
]);
if (!nzbId || nzbId <= 0) {
// Log diagnostic info to help debug rejected NZBs
const contentPreview = nzbBuffer.slice(0, 100).toString('utf-8');
logger.error('NZBGet rejected the NZB file', {
filename,
contentLength: nzbBuffer.length,
base64Length: base64Content.length,
contentPreview: contentPreview.substring(0, 80),
returnedId: nzbId,
});
throw new Error('NZBGet rejected the NZB file');
}
const id = String(nzbId);
logger.info(`Added NZB: ${id} (${filename})`);
return id;
}
/**
* Get current status of a download.
* Checks queue (listgroups) first, then history.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const nzbId = parseInt(id, 10);
if (isNaN(nzbId)) {
logger.error(`Invalid NZB ID: ${id}`);
return null;
}
// Check queue first
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
const groupItem = groups?.find(g => g.NZBID === nzbId);
if (groupItem) {
return this.mapGroupToDownloadInfo(groupItem);
}
// Not in queue, check history
const history = await this.rpc<NZBGetHistoryItem[]>('history', [false]);
const historyItem = history?.find(h => h.NZBID === nzbId);
if (historyItem) {
return this.mapHistoryToDownloadInfo(historyItem);
}
return null;
}
/**
* Pause a download via editqueue GroupPause
*/
async pauseDownload(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
const result = await this.rpc<boolean>('editqueue', ['GroupPause', '', [nzbId]]);
if (!result) {
throw new Error(`Failed to pause download ${id}`);
}
logger.info(`Paused download: ${id}`);
}
/**
* Resume a download via editqueue GroupResume
*/
async resumeDownload(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
const result = await this.rpc<boolean>('editqueue', ['GroupResume', '', [nzbId]]);
if (!result) {
throw new Error(`Failed to resume download ${id}`);
}
logger.info(`Resumed download: ${id}`);
}
/**
* Delete a download from NZBGet.
* Tries queue first (GroupFinalDelete), then history (HistoryFinalDelete).
*/
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
const nzbId = parseInt(id, 10);
logger.info(`Deleting download: ${id} (deleteFiles: ${deleteFiles})`);
// Try deleting from queue first
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
const inQueue = groups?.some(g => g.NZBID === nzbId);
if (inQueue) {
const command = deleteFiles ? 'GroupFinalDelete' : 'GroupDelete';
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
if (!result) {
throw new Error(`Failed to delete download ${id} from queue`);
}
logger.info(`Deleted download ${id} from queue`);
return;
}
// Try deleting from history
const command = deleteFiles ? 'HistoryFinalDelete' : 'HistoryDelete';
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
if (!result) {
throw new Error(`Failed to delete download ${id} from history`);
}
logger.info(`Deleted download ${id} from history`);
}
/**
* Post-download cleanup: archive from NZBGet history.
* Uses HistoryDelete to hide the item from visible history (preserves in hidden archive).
* Analogous to SABnzbd's archive behavior.
*/
async postProcess(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
logger.info(`Archiving completed download from history: ${id}`);
try {
const result = await this.rpc<boolean>('editqueue', ['HistoryDelete', '', [nzbId]]);
if (!result) {
throw new Error(`NZBGet returned false for HistoryDelete`);
}
logger.info(`Successfully archived ${id} from history`);
} catch (error) {
logger.error(`Failed to archive ${id} from history`, {
error: error instanceof Error ? error.message : String(error),
});
throw new Error(`NZB ${id} not found in history or failed to archive`);
}
}
// =========================================================================
// Category Management
// =========================================================================
/**
* Ensure the category exists in NZBGet with the correct download path.
*
* NZBGet categories are config entries (Category1.Name, Category1.DestDir, etc.).
* Reads existing config, checks for our category, creates/updates via saveconfig().
*
* CRITICAL: NZBGet's saveconfig() does a FULL config replacement passing only
* our entries would wipe every other setting and destroy the instance. We must
* always read the full config, merge our changes, and write the entire config back.
*
* After creating a new category, we call reload() so NZBGet picks up the new
* category DestDir immediately. reload() is safe when the config is correct.
*
* Called before every download (matches SABnzbd/qBittorrent pattern).
* Lightweight: reads config, writes only if category is missing or path changed.
*/
async ensureCategory(): Promise<void> {
try {
logger.debug('ensureCategory() called - syncing category with NZBGet');
const config = await this.rpc<NZBGetConfigItem[]>('config');
if (!config) {
logger.warn('Failed to get NZBGet config, skipping category check');
return;
}
// Find the main DestDir (NZBGet's base download directory)
const destDirEntry = config.find(c => c.Name === 'DestDir');
const nzbgetDestDir = destDirEntry?.Value || '';
logger.debug('NZBGet config retrieved', {
destDir: nzbgetDestDir || '(not configured)',
});
// Apply reverse path mapping to get the path from NZBGet's perspective
const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig);
logger.debug('Category path calculation', {
rmabDownloadDir: this.defaultDownloadDir,
desiredPathForNZBGet: desiredPath,
nzbgetDestDir,
pathMappingEnabled: this.pathMappingConfig.enabled,
});
// Find existing categories and our category slot
const { existingSlot, nextSlot } = this.findCategorySlot(config, this.defaultCategory);
if (existingSlot !== null) {
// Category exists - check if DestDir needs updating
const currentDestDir = config.find(c => c.Name === `Category${existingSlot}.DestDir`)?.Value || '';
if (this.normalizePath(currentDestDir) !== this.normalizePath(desiredPath)) {
logger.info(`Updating category "${this.defaultCategory}" DestDir from "${currentDestDir}" to "${desiredPath}"`);
const updatedConfig = this.mergeConfigEntries(config, [
{ Name: `Category${existingSlot}.DestDir`, Value: desiredPath },
]);
await this.rpc('saveconfig', [updatedConfig]);
await this.reloadAndWait();
} else {
logger.debug(`Category "${this.defaultCategory}" already configured correctly`);
}
} else {
// Create new category — merge into full config so we don't wipe existing settings
logger.info(`Creating category "${this.defaultCategory}" in slot ${nextSlot} with DestDir: "${desiredPath}"`);
const updatedConfig = this.mergeConfigEntries(config, [
{ Name: `Category${nextSlot}.Name`, Value: this.defaultCategory },
{ Name: `Category${nextSlot}.DestDir`, Value: desiredPath },
{ Name: `Category${nextSlot}.Unpack`, Value: 'yes' },
]);
await this.rpc('saveconfig', [updatedConfig]);
await this.reloadAndWait();
}
} catch (error) {
logger.error('Failed to ensure category', {
error: error instanceof Error ? error.message : String(error),
});
// Don't throw - category issues shouldn't block downloads
}
}
/**
* Read-only entries returned by NZBGet's config() RPC that must NOT be
* written back via saveconfig(). These are runtime/system properties.
*/
private static readonly READ_ONLY_CONFIG_KEYS = new Set([
'ConfigFile',
'AppBin',
'AppDir',
'Version',
]);
/**
* Merge new/updated config entries into the full NZBGet config.
* Returns a complete config array safe to pass to saveconfig().
*
* Filters out read-only system entries (ConfigFile, AppBin, AppDir, Version)
* that config() returns but saveconfig() rejects.
*
* For entries that already exist (by Name), replaces the value.
* For new entries, appends them to the array.
*/
private mergeConfigEntries(
fullConfig: NZBGetConfigItem[],
changes: NZBGetConfigItem[]
): NZBGetConfigItem[] {
const merged: NZBGetConfigItem[] = [];
for (const entry of fullConfig) {
// Skip read-only system entries that saveconfig() rejects
if (NZBGetService.READ_ONLY_CONFIG_KEYS.has(entry.Name)) {
continue;
}
const override = changes.find(c => c.Name === entry.Name);
merged.push(override ? { Name: entry.Name, Value: override.Value } : { Name: entry.Name, Value: entry.Value });
}
// Append any entries that don't exist in the current config
for (const change of changes) {
if (!fullConfig.some(entry => entry.Name === change.Name)) {
merged.push({ Name: change.Name, Value: change.Value });
}
}
return merged;
}
/**
* Find the category slot number for an existing category or determine the next available slot.
*/
private findCategorySlot(
config: NZBGetConfigItem[],
categoryName: string
): { existingSlot: number | null; nextSlot: number } {
let maxSlot = 0;
let existingSlot: number | null = null;
for (const entry of config) {
const match = entry.Name.match(/^Category(\d+)\.Name$/);
if (match) {
const slot = parseInt(match[1], 10);
if (slot > maxSlot) {
maxSlot = slot;
}
if (entry.Value === categoryName) {
existingSlot = slot;
}
}
}
return { existingSlot, nextSlot: maxSlot + 1 };
}
/**
* Reload NZBGet so config changes (new categories, DestDir updates) take effect.
* Polls version() to confirm NZBGet is back online before continuing.
*/
private async reloadAndWait(): Promise<void> {
try {
logger.info('Reloading NZBGet to apply configuration changes...');
await this.rpc('reload');
const maxWait = 10000;
const pollInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
await this.rpc<string>('version');
logger.info('NZBGet reloaded successfully');
return;
} catch {
// Still restarting, keep polling
}
}
logger.warn('NZBGet did not respond after reload within 10s, continuing anyway');
} catch (error) {
logger.warn('NZBGet reload request failed, config changes may require manual restart', {
error: error instanceof Error ? error.message : String(error),
});
}
}
// =========================================================================
// Status Mapping
// =========================================================================
/**
* Map NZBGet queue group item to unified DownloadInfo
*/
private async mapGroupToDownloadInfo(group: NZBGetGroupItem): Promise<DownloadInfo> {
const totalBytes = group.FileSizeMB * 1024 * 1024;
const downloadedBytes = group.DownloadedSizeMB * 1024 * 1024;
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1.0) : 0;
// Get global download speed for active items
let downloadSpeed = 0;
let eta = 0;
const status = this.mapGroupStatus(group.Status);
if (status === 'downloading') {
try {
const serverStatus = await this.rpc<NZBGetStatus>('status');
downloadSpeed = serverStatus?.DownloadRate || 0;
const remainingBytes = group.RemainingSizeMB * 1024 * 1024;
eta = downloadSpeed > 0 ? Math.round(remainingBytes / downloadSpeed) : 0;
} catch {
// Non-critical: speed/eta will be 0
}
}
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = group.FinalDir || group.DestDir || undefined;
return {
id: String(group.NZBID),
name: group.NZBName,
size: totalBytes,
bytesDownloaded: downloadedBytes,
progress,
status,
downloadSpeed,
eta,
category: group.Category || '',
downloadPath,
completedAt: undefined,
errorMessage: undefined,
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map NZBGet history item to unified DownloadInfo
*/
private mapHistoryToDownloadInfo(history: NZBGetHistoryItem): DownloadInfo {
const totalBytes = history.FileSizeMB * 1024 * 1024;
const downloadedBytes = history.DownloadedSizeMB * 1024 * 1024;
const status = this.mapHistoryStatus(history.Status);
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = history.FinalDir || history.DestDir || undefined;
return {
id: String(history.NZBID),
name: history.Name,
size: totalBytes,
bytesDownloaded: status === 'completed' ? totalBytes : downloadedBytes,
progress: status === 'completed' ? 1.0 : (totalBytes > 0 ? downloadedBytes / totalBytes : 0),
status,
downloadSpeed: 0,
eta: 0,
category: history.Category || '',
downloadPath,
completedAt: history.HistoryTime ? new Date(history.HistoryTime * 1000) : undefined,
errorMessage: status === 'failed' ? this.buildHistoryErrorMessage(history) : undefined,
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map NZBGet queue status string to unified DownloadStatus
*/
private mapGroupStatus(status: string): DownloadStatus {
switch (status) {
case 'QUEUED':
return 'queued';
case 'PAUSED':
return 'paused';
case 'DOWNLOADING':
case 'FETCHING':
return 'downloading';
case 'PP_QUEUED':
case 'LOADING_PARS':
case 'VERIFYING_SOURCES':
case 'REPAIRING':
case 'VERIFYING_REPAIRED':
case 'RENAMING':
case 'UNPACKING':
case 'MOVING':
case 'POST_UNPACK_RENAMING':
case 'EXECUTING_SCRIPT':
case 'PP_FINISHED':
return 'processing';
default:
logger.warn(`Unknown NZBGet queue status: ${status}, defaulting to downloading`);
return 'downloading';
}
}
/**
* Map NZBGet history status string to unified DownloadStatus.
* History statuses have format: "PREFIX/DETAIL" (e.g., "SUCCESS/ALL", "FAILURE/PAR")
*/
private mapHistoryStatus(status: string): DownloadStatus {
const prefix = status.split('/')[0];
switch (prefix) {
case 'SUCCESS':
return 'completed';
case 'WARNING':
// WARNING means the download succeeded but post-processing had issues
// From RMAB's perspective, the download is still completed
return 'completed';
case 'FAILURE':
return 'failed';
case 'DELETED':
return 'failed';
default:
logger.warn(`Unknown NZBGet history status: ${status}, defaulting to failed`);
return 'failed';
}
}
/**
* Build a descriptive error message from NZBGet history item
*/
private buildHistoryErrorMessage(history: NZBGetHistoryItem): string {
const parts: string[] = [];
// Include the raw status for context
parts.push(history.Status);
if (history.ParStatus && history.ParStatus !== 'NONE' && history.ParStatus !== 'SUCCESS') {
parts.push(`Par: ${history.ParStatus}`);
}
if (history.UnpackStatus && history.UnpackStatus !== 'NONE' && history.UnpackStatus !== 'SUCCESS') {
parts.push(`Unpack: ${history.UnpackStatus}`);
}
if (history.DeleteStatus && history.DeleteStatus !== 'NONE') {
parts.push(`Delete: ${history.DeleteStatus}`);
}
// Article failure info
if (history.FailedArticles > 0) {
const failPercent = history.TotalArticles > 0
? Math.round((history.FailedArticles / history.TotalArticles) * 100)
: 0;
parts.push(`${history.FailedArticles} failed articles (${failPercent}%)`);
}
return parts.join(' | ');
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Extract a usable filename for the NZB upload.
* Tries Content-Disposition header first, then URL path, then falls back to a default.
*/
private extractNZBFilename(url: string, contentDisposition?: string): string {
if (contentDisposition) {
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
if (match?.[1]) {
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
if (decoded) {
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
}
}
try {
const urlPath = new URL(url).pathname;
const basename = urlPath.split('/').pop();
if (basename && basename.length > 0 && basename !== 'download') {
const decoded = decodeURIComponent(basename);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
} catch {
// URL parsing failed
}
return 'download.nzb';
}
/**
* Map priority string to NZBGet priority integer.
* NZBGet priorities: -100 (very low), -50 (low), 0 (normal), 50 (high), 100 (very high), 900 (force)
*/
private mapPriority(priority?: string): number {
switch (priority) {
case 'force':
return 900;
case 'high':
return 50;
case 'low':
return -50;
case 'normal':
default:
return 0;
}
}
/**
* Format connection error into a user-friendly message
*/
private formatConnectionError(error: unknown): string {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) {
return 'Authentication failed. Check your NZBGet username and password (Settings → Security).';
}
if (status === 403) {
return 'Access denied. Check your NZBGet credentials and access permissions.';
}
if (error.code === 'ECONNREFUSED') {
return `Connection refused. Is NZBGet running and accessible at this URL?`;
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
return 'Connection timed out. Check the URL and network connectivity.';
}
if (error.message?.includes('certificate') || error.message?.includes('SSL') || error.message?.includes('TLS')) {
return 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.';
}
}
return error instanceof Error ? error.message : 'Unknown error';
}
/**
* Normalize a path for comparison (forward slashes, no trailing slash, lowercase)
*/
private normalizePath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
}
// =========================================================================
// Singleton Factory
// =========================================================================
let nzbgetServiceInstance: NZBGetService | null = null;
let configLoaded = false;
export async function getNZBGetService(): Promise<NZBGetService> {
if (nzbgetServiceInstance && configLoaded) {
return nzbgetServiceInstance;
}
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
logger.info('Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('usenet');
if (!clientConfig) {
throw new Error('NZBGet is not configured. Please configure an NZBGet client in the admin settings.');
}
if (clientConfig.type !== 'nzbget') {
throw new Error(`Expected NZBGet client but found ${clientConfig.type}`);
}
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
const pathMappingConfig: PathMappingConfig = {
enabled: clientConfig.remotePathMappingEnabled || false,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
logger.info('Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
downloadDir,
pathMappingEnabled: pathMappingConfig.enabled,
});
if (!clientConfig.url || !clientConfig.password) {
throw new Error('NZBGet is not fully configured. Please check your configuration in admin settings.');
}
nzbgetServiceInstance = new NZBGetService(
clientConfig.url,
clientConfig.username || '',
clientConfig.password,
clientConfig.category || 'readmeabook',
downloadDir,
clientConfig.disableSSLVerify,
pathMappingConfig
);
await nzbgetServiceInstance.ensureCategory();
configLoaded = true;
return nzbgetServiceInstance;
} catch (error) {
logger.error('Failed to initialize service', {
error: error instanceof Error ? error.message : String(error),
});
nzbgetServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateNZBGetService(): void {
nzbgetServiceInstance = null;
configLoaded = false;
logger.info('Service singleton invalidated');
}
+4 -7
View File
@@ -121,11 +121,6 @@ export class ProwlarrService {
filters?: SearchFilters
): Promise<TorrentResult[]> {
try {
// Get configured download client type to determine if we should filter by category
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
// Determine which categories to search
// Priority: filters.categories > filters.category > defaultCategory
let categoriesToSearch: number[];
@@ -560,20 +555,22 @@ export class ProwlarrService {
* Extract audiobook metadata from torrent title
*/
private extractMetadata(title: string): {
format?: 'M4B' | 'M4A' | 'MP3';
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC';
bitrate?: string;
hasChapters?: boolean;
} {
const upperTitle = title.toUpperCase();
// Detect format
let format: 'M4B' | 'M4A' | 'MP3' | undefined;
let format: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | undefined;
if (upperTitle.includes('M4B')) {
format = 'M4B';
} else if (upperTitle.includes('M4A')) {
format = 'M4A';
} else if (upperTitle.includes('MP3')) {
format = 'MP3';
} else if (upperTitle.includes('FLAC')) {
format = 'FLAC';
}
// Detect bitrate (e.g., "64kbps", "128 KBPS")
+219 -17
View File
@@ -5,10 +5,20 @@
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
@@ -59,7 +69,19 @@ export type TorrentState =
| 'checkingUP'
| 'error'
| 'missingFiles'
| 'allocating';
| 'allocating'
// Forced states (user clicked "Force Resume" in qBittorrent UI)
| 'forcedDL'
| 'forcedUP'
// Metadata fetching states
| 'metaDL'
| 'forcedMetaDL'
// qBittorrent v5.0+ renamed paused → stopped
| 'stoppedDL'
| 'stoppedUP'
// Other states
| 'checkingResumeData'
| 'moving';
export interface TorrentFile {
name: string;
@@ -78,7 +100,10 @@ export interface DownloadProgress {
state: string;
}
export class QBittorrentService {
export class QBittorrentService implements IDownloadClient {
readonly clientType: DownloadClientType = 'qbittorrent';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
@@ -209,7 +234,7 @@ export class QBittorrentService {
/**
* Add torrent (magnet link or file URL) - Enterprise Implementation
*/
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
async addTorrent(url: string, options?: AddTorrentOptions, retried = false): Promise<string> {
// Validate URL parameter
if (!url || typeof url !== 'string' || url.trim() === '') {
logger.error('Invalid download URL', { url });
@@ -236,11 +261,11 @@ export class QBittorrentService {
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating if we get a 403
if (axios.isAxiosError(error) && error.response?.status === 403) {
// Try re-authenticating once if we get a 403
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
logger.info('[QBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options); // Retry once
return this.addTorrent(url, options, true);
}
logger.error('Failed to add torrent', { error: error instanceof Error ? error.message : String(error) });
@@ -279,12 +304,17 @@ export class QBittorrentService {
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload via 'urls' parameter
// Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's
// global seeding rules don't remove the torrent prematurely.
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const form = new URLSearchParams({
urls: magnetUrl,
savepath: remoteSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
ratioLimit: '-1',
seedingTimeLimit: '-1',
});
if (options?.tags) {
@@ -432,6 +462,9 @@ export class QBittorrentService {
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
// Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle
formData.append('ratioLimit', '-1');
formData.append('seedingTimeLimit', '-1');
if (options?.tags) {
formData.append('tags', options.tags.join(','));
@@ -729,13 +762,28 @@ export class QBittorrentService {
/**
* Test connection to qBittorrent
*/
async testConnection(): Promise<boolean> {
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
return true;
// Fetch version after successful login
let version: string | undefined;
try {
const versionResponse = await this.client.get('/app/version', {
headers: { Cookie: this.cookie },
});
const raw = versionResponse.data || '';
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
} catch {
// Version fetch is non-critical - connection is still valid
logger.debug('Could not fetch qBittorrent version');
}
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (error) {
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
return false;
const message = error instanceof Error ? error.message : 'Connection failed';
logger.error('Connection test failed', { error: message });
return { success: false, message };
}
}
@@ -835,7 +883,8 @@ export class QBittorrentService {
version: versionResponse.data,
});
return versionResponse.data || 'Connected';
const rawVersion = versionResponse.data || '';
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('[QBittorrent] Test connection failed with axios error', {
@@ -931,6 +980,144 @@ export class QBittorrentService {
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Add a download via the unified interface.
* Delegates to addTorrent with sensible defaults for audiobook downloads.
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
return this.addTorrent(url, {
category: options?.category,
paused: options?.paused,
tags: ['audiobook'],
sequentialDownload: true,
});
}
/**
* Get download status via the unified interface.
* Includes retry logic to handle the race condition where a torrent
* isn't immediately available after being added.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const maxRetries = 3;
const initialDelayMs = 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const torrent = await this.getTorrent(id);
return this.mapTorrentToDownloadInfo(torrent);
} catch (error) {
const message = error instanceof Error ? error.message : '';
const isNotFound = message.includes('not found');
// If not a "not found" error, don't retry
if (!isNotFound) {
throw error;
}
// If this is the last attempt, return null
if (attempt === maxRetries) {
return null;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
/** Pause a download via the unified interface */
async pauseDownload(id: string): Promise<void> {
return this.pauseTorrent(id);
}
/** Resume a download via the unified interface */
async resumeDownload(id: string): Promise<void> {
return this.resumeTorrent(id);
}
/** Delete a download via the unified interface */
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
return this.deleteTorrent(id, deleteFiles);
}
/**
* Post-download cleanup via the unified interface.
* No-op for qBittorrent torrents continue seeding until the
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
*/
async postProcess(_id: string): Promise<void> {
// No-op: torrents are managed by the seeding cleanup scheduler
}
/**
* Map a TorrentInfo object to the unified DownloadInfo format.
*/
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
return {
id: torrent.hash,
name: torrent.name,
size: torrent.size,
bytesDownloaded: torrent.downloaded,
progress: torrent.progress,
status: this.mapStateToDownloadStatus(torrent.state),
downloadSpeed: torrent.dlspeed,
eta: torrent.eta,
category: torrent.category,
downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name),
completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined,
seedingTime: torrent.seeding_time,
ratio: torrent.ratio,
};
}
/**
* Map qBittorrent torrent state to unified DownloadStatus.
*/
private mapStateToDownloadStatus(state: TorrentState): DownloadStatus {
const stateMap: Record<TorrentState, DownloadStatus> = {
downloading: 'downloading',
uploading: 'seeding',
stalledDL: 'downloading',
stalledUP: 'seeding',
pausedDL: 'paused',
pausedUP: 'paused',
queuedDL: 'queued',
queuedUP: 'seeding',
checkingDL: 'checking',
checkingUP: 'checking',
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
// Forced states (user clicked "Force Resume" in qBittorrent UI)
forcedDL: 'downloading',
forcedUP: 'seeding',
// Metadata fetching states
metaDL: 'downloading',
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
};
return stateMap[state] || 'downloading';
}
// =========================================================================
// Legacy Methods (used internally and by direct callers)
// =========================================================================
/**
* Get download progress details
*/
@@ -963,6 +1150,18 @@ export class QBittorrentService {
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
// Forced states (user clicked "Force Resume" in qBittorrent UI)
forcedDL: 'downloading',
forcedUP: 'completed',
// Metadata fetching states
metaDL: 'downloading',
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
};
return stateMap[state] || 'unknown';
@@ -1032,8 +1231,11 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
// Get download_dir from main config (not part of client config)
const downloadDir = await configService.get('download_dir') || '/downloads';
// Get download_dir from main config, applying customPath if configured
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
// Path mapping configuration
const pathMappingConfig: PathMappingConfig = {
@@ -1055,10 +1257,10 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
// Test connection
logger.info('[QBittorrent] Testing connection...');
const isConnected = await qbittorrentService.testConnection();
if (!isConnected) {
logger.warn('[QBittorrent] Connection test failed');
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
const connectionResult = await qbittorrentService.testConnection();
if (!connectionResult.success) {
logger.warn('[QBittorrent] Connection test failed', { message: connectionResult.message });
throw new Error(connectionResult.message || 'qBittorrent connection test failed. Please check your configuration in admin settings.');
} else {
logger.info('[QBittorrent] Connection test successful');
configLoaded = true; // Mark as successfully loaded
+234 -25
View File
@@ -5,8 +5,18 @@
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import FormData from 'form-data';
import { RMABLogger } from '@/lib/utils/logger';
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
const logger = RMABLogger.create('SABnzbd');
@@ -81,7 +91,10 @@ export interface DownloadProgress {
state: string;
}
export class SABnzbdService {
export class SABnzbdService implements IDownloadClient {
readonly clientType: DownloadClientType = 'sabnzbd';
readonly protocol: ProtocolType = 'usenet';
private client: AxiosInstance;
private baseUrl: string;
private apiKey: string;
@@ -123,13 +136,13 @@ export class SABnzbdService {
/**
* Test connection to SABnzbd
*/
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
async testConnection(): Promise<ConnectionTestResult> {
try {
// Validate API key is not empty
if (!this.apiKey || this.apiKey.trim() === '') {
return {
success: false,
error: 'API key is required for SABnzbd',
message: 'API key is required for SABnzbd',
};
}
@@ -151,7 +164,7 @@ export class SABnzbdService {
const errorMsg = response.data?.error || 'Authentication failed';
return {
success: false,
error: errorMsg.includes('API Key')
message: errorMsg.includes('API Key')
? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).'
: errorMsg,
};
@@ -160,7 +173,7 @@ export class SABnzbdService {
// Queue endpoint requires auth - if we got here, API key is valid
// Now get the version
const version = await this.getVersion();
return { success: true, version };
return { success: true, version, message: `Connected to SABnzbd v${version}` };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -168,28 +181,28 @@ export class SABnzbdService {
if (errorMessage.includes('ECONNREFUSED')) {
return {
success: false,
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
message: 'Connection refused. Is SABnzbd running and accessible at this URL?',
};
} else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) {
return {
success: false,
error: 'Connection timed out. Check the URL and network connectivity.',
message: 'Connection timed out. Check the URL and network connectivity.',
};
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) {
return {
success: false,
error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
message: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
};
} else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) {
return {
success: false,
error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
message: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
};
}
return {
success: false,
error: errorMessage,
message: errorMessage,
};
}
}
@@ -447,8 +460,16 @@ export class SABnzbdService {
}
/**
* Add NZB by URL
* Returns the NZB ID
* Add NZB to SABnzbd
*
* Downloads the NZB file content from the source URL (typically a Prowlarr proxy URL)
* and uploads it directly to SABnzbd via mode=addfile. This ensures SABnzbd does not
* need network access to Prowlarr RMAB acts as the intermediary, matching the pattern
* used by qBittorrent for .torrent files.
*
* @param url - NZB download URL (usually a Prowlarr proxy URL)
* @param options - Category, priority, and pause options
* @returns SABnzbd NZB ID (nzo_id)
*/
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
@@ -459,20 +480,70 @@ export class SABnzbdService {
// This syncs the category path with SABnzbd's complete_dir and handles path mapping
await this.ensureCategory();
const response = await this.client.get('/api', {
params: {
mode: 'addurl',
name: url,
cat: category,
priority: this.mapPriority(options?.priority),
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
output: 'json',
apikey: this.apiKey,
},
// Download the NZB file content from the source URL
// This decouples SABnzbd from needing direct network access to Prowlarr
let nzbBuffer: Buffer;
let filename: string;
try {
logger.info('Downloading NZB file from source URL...');
const nzbResponse = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
// Use the same SSL settings as the SABnzbd client if the NZB URL
// happens to be served over HTTPS with a self-signed cert
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
nzbBuffer = Buffer.from(nzbResponse.data);
if (nzbBuffer.length === 0) {
throw new Error('NZB file is empty (0 bytes)');
}
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
// Extract filename from Content-Disposition header, URL path, or use fallback
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status) {
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
}
if (error.code === 'ECONNREFUSED') {
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
}
}
throw error;
}
// Upload NZB file content to SABnzbd via mode=addfile (multipart POST)
const formData = new FormData();
formData.append('nzbfile', nzbBuffer, {
filename,
contentType: 'application/x-nzb',
});
formData.append('mode', 'addfile');
formData.append('cat', category);
formData.append('priority', this.mapPriority(options?.priority));
formData.append('pp', '3'); // Post-processing: +Repair, +Unpack, +Delete
formData.append('output', 'json');
formData.append('apikey', this.apiKey);
const response = await this.client.post('/api', formData, {
headers: formData.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
if (response.data?.status === false) {
throw new Error(response.data.error || 'Failed to add NZB');
throw new Error(response.data.error || 'Failed to add NZB to SABnzbd');
}
const nzbIds = response.data?.nzo_ids;
@@ -486,6 +557,39 @@ export class SABnzbdService {
return nzbId;
}
/**
* Extract a usable filename for the NZB upload.
* Tries Content-Disposition header first, then URL path, then falls back to a default.
*/
private extractNZBFilename(url: string, contentDisposition?: string): string {
// Try Content-Disposition header (e.g., 'attachment; filename="My.Audiobook.nzb"')
if (contentDisposition) {
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
if (match?.[1]) {
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
if (decoded) {
logger.debug(`Filename from Content-Disposition: ${decoded}`);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
}
}
// Try extracting from URL path (before query params)
try {
const urlPath = new URL(url).pathname;
const basename = urlPath.split('/').pop();
if (basename && basename.length > 0 && basename !== 'download') {
const decoded = decodeURIComponent(basename);
logger.debug(`Filename from URL path: ${decoded}`);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
} catch {
// URL parsing failed, fall through to default
}
return 'download.nzb';
}
/**
* Get NZB info by ID
* Checks queue first, then history
@@ -663,6 +767,108 @@ export class SABnzbdService {
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Add a download via the unified interface.
* Delegates to addNZB with mapped options.
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
const priorityMap: Record<string, 'low' | 'normal' | 'high' | 'force'> = {
low: 'low',
normal: 'normal',
high: 'high',
force: 'force',
};
return this.addNZB(url, {
category: options?.category,
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
paused: options?.paused,
});
}
/**
* Get download status via the unified interface.
* Checks both queue and history to find the NZB.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const nzbInfo = await this.getNZB(id);
if (!nzbInfo) {
return null;
}
return this.mapNZBInfoToDownloadInfo(nzbInfo);
}
/** Pause a download via the unified interface */
async pauseDownload(id: string): Promise<void> {
return this.pauseNZB(id);
}
/** Resume a download via the unified interface */
async resumeDownload(id: string): Promise<void> {
return this.resumeNZB(id);
}
/** Delete a download via the unified interface */
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
return this.deleteNZB(id, deleteFiles);
}
/**
* Post-download cleanup via the unified interface.
* Archives the completed NZB from SABnzbd history.
*/
async postProcess(id: string): Promise<void> {
await this.archiveCompletedNZB(id);
}
/**
* Map NZBInfo to the unified DownloadInfo format.
*/
private mapNZBInfoToDownloadInfo(nzb: NZBInfo): DownloadInfo {
return {
id: nzb.nzbId,
name: nzb.name,
size: nzb.size,
bytesDownloaded: Math.round(nzb.size * nzb.progress),
progress: nzb.progress,
status: this.mapNZBStatusToDownloadStatus(nzb.status),
downloadSpeed: nzb.downloadSpeed,
eta: nzb.timeLeft,
category: nzb.category,
downloadPath: nzb.downloadPath,
completedAt: nzb.completedAt,
errorMessage: nzb.errorMessage,
// Usenet has no seeding concept
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map SABnzbd NZB status to unified DownloadStatus.
*/
private mapNZBStatusToDownloadStatus(status: NZBStatus): DownloadStatus {
const statusMap: Record<NZBStatus, DownloadStatus> = {
downloading: 'downloading',
queued: 'queued',
paused: 'paused',
extracting: 'processing',
completed: 'completed',
failed: 'failed',
repairing: 'processing',
};
return statusMap[status] || 'downloading';
}
// =========================================================================
// Legacy Methods (used internally and by direct callers)
// =========================================================================
/**
* Get download progress from queue item
*/
@@ -796,8 +1002,11 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
}
// Get download_dir from main config
const downloadDir = await configService.get('download_dir') || '/downloads';
// Get download_dir from main config, applying customPath if configured
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
logger.debug('RMAB download_dir from config', { downloadDir });
@@ -0,0 +1,628 @@
/**
* Component: Transmission Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('Transmission');
/** Transmission RPC numeric status codes */
type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Transmission torrent fields we request */
interface TransmissionTorrent {
hashString: string;
name: string;
totalSize: number;
downloadedEver: number;
percentDone: number;
status: TransmissionStatus;
rateDownload: number;
eta: number;
labels: string[];
downloadDir: string;
doneDate: number;
errorString: string;
error: number;
secondsSeeding: number;
uploadRatio: number;
uploadedEver: number;
}
/** Fields we request from the Transmission RPC API */
const TORRENT_FIELDS = [
'hashString',
'name',
'totalSize',
'downloadedEver',
'percentDone',
'status',
'rateDownload',
'eta',
'labels',
'downloadDir',
'doneDate',
'errorString',
'error',
'secondsSeeding',
'uploadRatio',
'uploadedEver',
];
export class TransmissionService implements IDownloadClient {
readonly clientType: DownloadClientType = 'transmission';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private defaultSavePath: string;
private defaultCategory: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
private sessionId: string = '';
constructor(
baseUrl: string,
username: string,
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
logger.info('[Transmission] SSL certificate verification disabled');
}
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: this.httpsAgent,
});
}
/**
* Execute an RPC request to Transmission.
* Handles CSRF token (409 capture X-Transmission-Session-Id retry).
*/
private async rpc(method: string, args?: Record<string, any>): Promise<any> {
const body = { method, arguments: args };
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionId) {
headers['X-Transmission-Session-Id'] = this.sessionId;
}
// Add Basic Auth if credentials provided
const auth = this.username
? { username: this.username, password: this.password }
: undefined;
try {
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
// Capture CSRF token and retry
const newSessionId = error.response.headers['x-transmission-session-id'];
if (newSessionId) {
this.sessionId = newSessionId;
headers['X-Transmission-Session-Id'] = this.sessionId;
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
return response.data;
}
}
throw error;
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
async testConnection(): Promise<ConnectionTestResult> {
try {
const data = await this.rpc('session-get', { fields: ['version'] });
if (data.result !== 'success') {
return { success: false, message: `Transmission RPC error: ${data.result}` };
}
const version = data.arguments?.version;
return {
success: true,
version,
message: `Connected to Transmission${version ? ` ${version}` : ''}`,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
if (axios.isAxiosError(error)) {
const code = error.code;
const status = error.response?.status;
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) {
return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` };
}
if (code === 'ECONNREFUSED') {
return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` };
}
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` };
}
if (code === 'ENOTFOUND') {
return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` };
}
if (status === 401) {
return { success: false, message: 'Authentication failed. Check your username and password.' };
}
}
logger.error('Connection test failed', { error: message });
return { success: false, message };
}
}
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
if (!url || typeof url !== 'string' || url.trim() === '') {
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
}
const category = options?.category || this.defaultCategory;
if (url.startsWith('magnet:')) {
return this.addMagnetLink(url, category, options);
} else {
return this.addTorrentFile(url, category, options);
}
}
private async addMagnetLink(
magnetUrl: string,
category: string,
options?: AddDownloadOptions
): Promise<string> {
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) {
throw new Error('Invalid magnet link - could not extract info_hash');
}
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
// Check for duplicates
try {
await this.getTorrentByHash(infoHash);
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue
}
const localSavePath = this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
const args: Record<string, any> = {
filename: magnetUrl,
'download-dir': remoteSavePath,
paused: options?.paused || false,
labels: [category],
};
logger.info('[Transmission] Adding magnet link...');
const data = await this.rpc('torrent-add', args);
if (data.result !== 'success') {
throw new Error(`Transmission rejected magnet link: ${data.result}`);
}
// torrent-add returns torrent-added or torrent-duplicate
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
if (!added) {
throw new Error('Transmission did not return torrent info after adding');
}
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
await this.disableSeedLimits(added.hashString || infoHash);
logger.info(`Successfully added magnet link: ${infoHash}`);
return infoHash;
}
private async addTorrentFile(
torrentUrl: string,
category: string,
options?: AddDownloadOptions
): Promise<string> {
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer',
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300,
timeout: 30000,
});
// Check if response body is a magnet link
if (torrentResponse.data.length > 0) {
const responseText = torrentResponse.data.toString();
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
if (magnetMatch) {
logger.info('Response body is a magnet link');
return this.addMagnetLink(magnetMatch[0], category, options);
}
}
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) {
throw error;
}
const status = error.response.status;
if (status >= 300 && status < 400) {
const location = error.response.headers['location'];
if (location && location.startsWith('magnet:')) {
return this.addMagnetLink(location, category, options);
}
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
try {
torrentResponse = await axios.get(location, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
});
} catch {
throw new Error('Failed to download torrent file after redirect');
}
} else {
throw new Error(`Invalid redirect location: ${location}`);
}
} else {
throw new Error(`Failed to download torrent: HTTP ${status}`);
}
}
const torrentBuffer = Buffer.from(torrentResponse.data);
let parsedTorrentData: any;
try {
parsedTorrentData = await parseTorrent(torrentBuffer);
} catch {
throw new Error('Invalid .torrent file - failed to parse');
}
const infoHash = parsedTorrentData.infoHash;
if (!infoHash) {
throw new Error('Failed to extract info_hash from .torrent file');
}
logger.info(`Extracted info_hash: ${infoHash}`);
// Check for duplicates
try {
await this.getTorrentByHash(infoHash);
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue
}
const localSavePath = this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Transmission accepts base64-encoded .torrent content via 'metainfo' field
const metainfo = torrentBuffer.toString('base64');
const args: Record<string, any> = {
metainfo,
'download-dir': remoteSavePath,
paused: options?.paused || false,
labels: [category],
};
logger.info('[Transmission] Adding .torrent file...');
const data = await this.rpc('torrent-add', args);
if (data.result !== 'success') {
throw new Error(`Transmission rejected .torrent file: ${data.result}`);
}
// torrent-add returns torrent-added or torrent-duplicate
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
await this.disableSeedLimits(added?.hashString || infoHash);
logger.info(`Successfully added torrent: ${infoHash}`);
return infoHash;
}
async getDownload(id: string): Promise<DownloadInfo | null> {
const maxRetries = 3;
const initialDelayMs = 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const torrent = await this.getTorrentByHash(id);
return this.mapToDownloadInfo(torrent);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (!message.includes('not found')) {
throw error;
}
if (attempt === maxRetries) {
return null;
}
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
async pauseDownload(id: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-stop', { ids: [torrent.hashString] });
logger.info(`Paused torrent: ${id}`);
} catch (error) {
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to pause torrent');
}
}
async resumeDownload(id: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-start', { ids: [torrent.hashString] });
logger.info(`Resumed torrent: ${id}`);
} catch (error) {
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to resume torrent');
}
}
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-remove', {
ids: [torrent.hashString],
'delete-local-data': deleteFiles,
});
logger.info(`Deleted torrent: ${id}`);
} catch (error) {
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to delete torrent');
}
}
/**
* Post-download cleanup.
* No-op for Transmission torrents continue seeding until the
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
*/
async postProcess(_id: string): Promise<void> {
// No-op: torrents are managed by the seeding cleanup scheduler
}
// =========================================================================
// Internal Helpers
// =========================================================================
/**
* Disable Transmission's global seed ratio and idle time limits for a torrent.
* Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle
* via the cleanup-seeded-torrents processor using per-indexer seeding times.
*/
private async disableSeedLimits(hashOrId: string): Promise<void> {
try {
await this.rpc('torrent-set', {
ids: [hashOrId],
seedRatioMode: 2,
seedIdleMode: 2,
});
logger.info(`Disabled seed limits for torrent: ${hashOrId}`);
} catch (error) {
// Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules
logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get a torrent by its info hash.
*/
private async getTorrentByHash(hash: string): Promise<TransmissionTorrent> {
const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS });
if (data.result !== 'success') {
throw new Error(`Transmission RPC error: ${data.result}`);
}
const torrents: TransmissionTorrent[] = data.arguments?.torrents || [];
if (torrents.length === 0) {
throw new Error(`Torrent ${hash} not found`);
}
return torrents[0];
}
/**
* Map Transmission torrent to unified DownloadInfo.
*/
private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo {
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = path.join(torrent.downloadDir, torrent.name);
return {
id: torrent.hashString,
name: torrent.name,
size: torrent.totalSize,
bytesDownloaded: torrent.downloadedEver,
progress: torrent.percentDone,
status: this.mapStatus(torrent.status, torrent.error),
downloadSpeed: torrent.rateDownload,
eta: torrent.eta < 0 ? 0 : torrent.eta,
category: torrent.labels?.[0] || '',
downloadPath,
completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined,
errorMessage: torrent.error > 0 ? torrent.errorString : undefined,
seedingTime: torrent.secondsSeeding,
ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined,
};
}
/**
* Map Transmission numeric status to unified DownloadStatus.
* 0=stopped, 1=check-pending, 2=checking, 3=download-pending,
* 4=downloading, 5=seed-pending, 6=seeding
*/
private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus {
if (errorCode > 0) {
return 'failed';
}
const statusMap: Record<TransmissionStatus, DownloadStatus> = {
0: 'paused',
1: 'checking',
2: 'checking',
3: 'queued',
4: 'downloading',
5: 'seeding',
6: 'seeding',
};
return statusMap[status] || 'downloading';
}
/**
* Extract info_hash from magnet link.
*/
private extractHashFromMagnet(magnetUrl: string): string | null {
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) {
return match[1].toLowerCase();
}
return null;
}
}
// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern)
let transmissionServiceInstance: TransmissionService | null = null;
let configLoaded = false;
export async function getTransmissionService(): Promise<TransmissionService> {
if (transmissionServiceInstance && configLoaded) {
return transmissionServiceInstance;
}
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
logger.info('[Transmission] Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) {
throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.');
}
if (clientConfig.type !== 'transmission') {
throw new Error(`Expected Transmission client but found ${clientConfig.type}`);
}
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
const pathMappingConfig: PathMappingConfig = {
enabled: clientConfig.remotePathMappingEnabled || false,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
logger.info('[Transmission] Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasUsername: !!clientConfig.username,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
downloadDir,
pathMappingEnabled: pathMappingConfig.enabled,
});
if (!clientConfig.url) {
throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.');
}
transmissionServiceInstance = new TransmissionService(
clientConfig.url,
clientConfig.username || '',
clientConfig.password || '',
downloadDir,
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify,
pathMappingConfig
);
const connectionResult = await transmissionServiceInstance.testConnection();
if (!connectionResult.success) {
throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.');
}
logger.info('[Transmission] Connection test successful');
configLoaded = true;
return transmissionServiceInstance;
} catch (error) {
logger.error('[Transmission] Failed to initialize service', {
error: error instanceof Error ? error.message : String(error),
});
transmissionServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateTransmissionService(): void {
transmissionServiceInstance = null;
configLoaded = false;
logger.info('[Transmission] Service singleton invalidated');
}
@@ -0,0 +1,180 @@
/**
* Component: Download Client Interface
* Documentation: documentation/phase3/download-clients.md
*
* Defines the contract all download clients must implement.
* Enables protocol-agnostic download management across torrent and usenet clients.
*/
// =========================================================================
// TYPE DEFINITIONS
// =========================================================================
/** Supported download client types — single source of truth */
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const;
/** Identifies the specific download client software */
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
/** Human-readable display names for each client type */
export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
qbittorrent: 'qBittorrent',
sabnzbd: 'SABnzbd',
nzbget: 'NZBGet',
transmission: 'Transmission',
};
/** Get display name for a client type, falling back to the raw type */
export function getClientDisplayName(type: string): string {
return CLIENT_DISPLAY_NAMES[type as DownloadClientType] || type;
}
/** The download protocol a client operates on */
export type ProtocolType = 'torrent' | 'usenet';
/** Maps each client type to its download protocol */
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
qbittorrent: 'torrent',
sabnzbd: 'usenet',
nzbget: 'usenet',
transmission: 'torrent',
};
/** Unified download status across all clients */
export type DownloadStatus =
| 'downloading'
| 'completed'
| 'seeding'
| 'paused'
| 'queued'
| 'failed'
| 'processing'
| 'checking';
// =========================================================================
// DATA INTERFACES
// =========================================================================
/**
* Unified download information returned by all clients.
* Normalizes torrent and NZB data into a single shape.
*/
export interface DownloadInfo {
/** Client-assigned identifier (torrent hash or NZB ID) */
id: string;
/** Display name of the download */
name: string;
/** Total size in bytes */
size: number;
/** Bytes downloaded so far */
bytesDownloaded: number;
/** Download progress from 0.0 to 1.0 */
progress: number;
/** Normalized download status */
status: DownloadStatus;
/** Current download speed in bytes/sec */
downloadSpeed: number;
/** Estimated time remaining in seconds */
eta: number;
/** Category/label assigned to this download */
category: string;
/** Filesystem path where download is stored (available after completion) */
downloadPath?: string;
/** When the download completed */
completedAt?: Date;
/** Error message if download failed */
errorMessage?: string;
/** Time spent seeding in seconds (torrent clients only) */
seedingTime?: number;
/** Upload/download ratio (torrent clients only) */
ratio?: number;
}
/** Options for adding a new download */
export interface AddDownloadOptions {
/** Category/label to assign */
category?: string;
/** Priority level (interpretation varies by client) */
priority?: string;
/** Whether to add in paused state */
paused?: boolean;
}
/** Result of a connection test */
export interface ConnectionTestResult {
success: boolean;
message?: string;
version?: string;
}
// =========================================================================
// DOWNLOAD CLIENT INTERFACE
// =========================================================================
/**
* IDownloadClient the contract every download client must implement.
*
* Provides a unified API for managing downloads across different protocols
* and client software. Processors interact with this interface exclusively,
* enabling new download clients to be added without modifying consumer code.
*
* To add a new client (e.g., Transmission):
* 1. Create a service class implementing IDownloadClient
* 2. Add the type to DownloadClientType
* 3. Add factory case in DownloadClientManager
*/
export interface IDownloadClient {
/** Identifies the client software (e.g., 'qbittorrent', 'sabnzbd') */
readonly clientType: DownloadClientType;
/** The protocol this client operates on */
readonly protocol: ProtocolType;
/**
* Test the connection to the download client.
* @returns Connection test result with success/failure and optional version
*/
testConnection(): Promise<ConnectionTestResult>;
/**
* Add a new download.
* @param url - Download URL (magnet link, .torrent URL, or .nzb URL)
* @param options - Optional download settings
* @returns Client-assigned download ID (torrent hash or NZB ID)
*/
addDownload(url: string, options?: AddDownloadOptions): Promise<string>;
/**
* Get current status of a download.
* Includes retry logic for race conditions (e.g., torrent not immediately available after adding).
* @param id - Download ID returned by addDownload
* @returns Download info, or null if not found
*/
getDownload(id: string): Promise<DownloadInfo | null>;
/**
* Pause a download.
* @param id - Download ID
*/
pauseDownload(id: string): Promise<void>;
/**
* Resume a paused download.
* @param id - Download ID
*/
resumeDownload(id: string): Promise<void>;
/**
* Delete a download from the client.
* @param id - Download ID
* @param deleteFiles - Whether to also delete downloaded files (default: false)
*/
deleteDownload(id: string, deleteFiles?: boolean): Promise<void>;
/**
* Perform post-download cleanup specific to the client.
* - qBittorrent: No-op (torrents continue seeding, handled by cleanup job)
* - SABnzbd: Archives the completed NZB from history
* @param id - Download ID
*/
postProcess(id: string): Promise<void>;
}
+33
View File
@@ -220,3 +220,36 @@ export async function isLocalAdmin(userId: string): Promise<boolean> {
return user.isSetupAdmin && user.plexId.startsWith('local-');
}
/**
* Middleware: Require setup to be incomplete
* Blocks access to setup-only endpoints after initial setup is finished.
* Returns 403 if setup is already complete, otherwise invokes the handler.
*/
export async function requireSetupIncomplete(
request: NextRequest,
handler: (request: NextRequest) => Promise<NextResponse>
): Promise<NextResponse> {
try {
const config = await prisma.configuration.findUnique({
where: { key: 'setup_completed' },
});
if (config?.value === 'true') {
logger.warn('Setup endpoint called after setup is complete', {
path: request.nextUrl.pathname,
});
return NextResponse.json(
{
error: 'Forbidden',
message: 'Setup has already been completed',
},
{ status: 403 }
);
}
} catch {
// If database is not ready, setup is definitely not complete — allow through
}
return handler(request);
}
@@ -2,11 +2,13 @@
* Component: Cleanup Seeded Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Cleans up torrents that have met their seeding requirements
* Cleans up downloads that have met their seeding requirements.
* Uses the IDownloadClient interface for client-agnostic operation.
*/
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
export interface CleanupSeededTorrentsPayload {
jobId?: string;
@@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
try {
// Get indexer configuration with per-indexer seeding times
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
@@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
// Find all completed requests + soft-deleted requests (orphaned downloads)
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
// Before deleting torrent, we check if other active requests are using it
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
// NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too.
// Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type).
const completedRequests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
OR: [
// Active requests that are fully available (scanned by Plex/ABS)
// Audiobook requests that are fully available (matched in Plex/ABS)
{
type: 'audiobook',
status: 'available',
deletedAt: null,
},
// Soft-deleted requests (orphaned downloads)
// We'll check if torrent is shared with active requests before deletion
// Ebook requests that are fully downloaded (terminal state for ebooks)
{
type: 'ebook',
status: 'downloaded',
deletedAt: null,
},
// Soft-deleted requests of any type (orphaned downloads)
{
deletedAt: { not: null },
},
@@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
take: 100, // Limit to 100 requests per run
});
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`);
let cleaned = 0;
let skipped = 0;
let noConfig = 0;
const deletedHashes = new Set<string>(); // Track torrents already deleted this run
for (const request of completedRequests) {
try {
@@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
// Skip SABnzbd downloads - Usenet doesn't have seeding concept
// Skip Usenet downloads - no seeding concept
if (downloadHistory.nzbId && !downloadHistory.torrentHash) {
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
// For soft-deleted Usenet requests, hard delete immediately (no seeding needed)
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
logger.info(`Hard-deleted orphaned Usenet request ${request.id}`);
}
continue;
}
// Only process torrent downloads
if (!downloadHistory.torrentHash) {
// Only process downloads that have a client ID
if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) {
continue;
}
// Determine the download client ID and protocol
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType}, skipping`);
continue;
}
@@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
// Get torrent info from qBittorrent to check seeding time
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
// Skip if this torrent was already deleted earlier in this run
if (deletedHashes.has(clientId.toLowerCase())) {
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`);
}
cleaned++;
continue;
}
let torrent;
// Get download info from the appropriate client via the interface
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (!client) {
logger.warn(`No ${clientType} client configured, skipping request ${request.id}`);
skipped++;
continue;
}
let downloadInfo;
try {
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadInfo = await client.getDownload(clientId);
} catch (error) {
// Torrent might already be deleted, skip
// Download not found in client (already removed), skip
continue;
}
if (!downloadInfo) {
// Download not found in client (already removed)
continue;
}
// Check if seeding time requirement is met
const actualSeedingTime = torrent.seeding_time || 0;
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (!hasMetRequirement) {
@@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
// This prevents deleting shared torrents when user re-requests the same audiobook
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
type: 'audiobook', // Only check audiobook requests
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: downloadHistory.torrentHash,
selected: true,
// CRITICAL: Check if any other active (non-deleted) request is using this same download
const hashToCheck = downloadHistory.torrentHash;
if (hashToCheck) {
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: hashToCheck,
selected: true,
},
},
},
},
select: { id: true, status: true },
});
select: { id: true, status: true },
});
if (otherActiveRequests.length > 0) {
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
if (otherActiveRequests.length > 0) {
logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
// If this is a soft-deleted request, hard delete it but DON'T delete the download
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`);
}
skipped++;
continue;
}
skipped++;
continue;
}
// Safe to delete - no other active requests using this torrent
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
// Safe to delete - no other active requests using this download
await client.deleteDownload(clientId, true); // true = delete files
deletedHashes.add(clientId.toLowerCase());
// If this is a soft-deleted request (orphaned download), hard delete it now
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`);
} else {
logger.info(`Deleted torrent and files for active request ${request.id}`);
logger.info(`Deleted download and files for active request ${request.id}`);
}
cleaned++;
@@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
}
}
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
return {
success: true,
@@ -78,7 +78,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
// Get download configuration
const configService = getConfigService();
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
const downloadsDir = await configService.get('download_dir') || '/downloads';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
+62 -130
View File
@@ -5,8 +5,6 @@
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { getSABnzbdService } from '../integrations/sabnzbd.service';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { ProwlarrService } from '../integrations/prowlarr.service';
@@ -14,7 +12,7 @@ import { RMABLogger } from '../utils/logger';
/**
* Process download job
* Routes to appropriate download client based on configuration
* Routes to appropriate download client based on protocol detection
* Adds selected result to download client and starts monitoring
*/
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
@@ -41,151 +39,85 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
},
});
// Detect protocol from result and route to appropriate client
// Detect protocol from result and get appropriate client
const isUsenet = ProwlarrService.isNZBResult(torrent);
const protocol = isUsenet ? 'usenet' : 'torrent';
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
const client = await manager.getClientServiceForProtocol(protocol);
if (!clientConfig) {
const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
throw new Error(`No ${protocol} client configured`);
if (!client) {
throw new Error(`No ${protocol} download client configured. Please add a ${protocol} client in Settings > Download Clients.`);
}
let downloadClientId: string;
let downloadClient: 'qbittorrent' | 'sabnzbd';
// Get client config for category
const clientConfig = await manager.getClientForProtocol(protocol);
const category = clientConfig?.category || 'readmeabook';
if (isUsenet) {
// Route to SABnzbd
logger.info(`Routing to SABnzbd`);
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
const sabnzbd = await getSABnzbdService();
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
category: clientConfig.category || 'readmeabook',
priority: 'normal',
});
downloadClient = 'sabnzbd';
// Add download via unified interface
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
category,
priority: 'normal',
});
logger.info(`NZB added with ID: ${downloadClientId}`);
logger.info(`Download added with ID: ${downloadClientId}`);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'sabnzbd',
downloadClientId,
torrentName: torrent.title,
nzbId: downloadClientId, // Store NZB ID
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
leechers: 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
downloadHistory.id,
indexerName: torrent.indexer,
indexerId: torrent.indexerId,
downloadClient: client.clientType,
downloadClientId,
'sabnzbd',
3 // Wait 3 seconds before first check
);
torrentName: torrent.title,
// Set protocol-specific ID fields for backward compatibility
torrentHash: client.protocol === 'torrent' ? (torrent.infoHash || downloadClientId) : undefined,
nzbId: client.protocol === 'usenet' ? downloadClientId : undefined,
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl,
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
logger.info(`Created download history record: ${downloadHistory.id}`);
return {
success: true,
message: 'NZB added to SABnzbd and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
nzbId: downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
format: torrent.format,
},
};
} else {
// Route to qBittorrent (default)
logger.info(`Routing to qBittorrent`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
downloadClientId,
client.clientType,
3 // Wait 3 seconds before first check
);
const qbt = await getQBittorrentService();
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
category: clientConfig.category || 'readmeabook',
tags: ['audiobook'],
sequentialDownload: true,
paused: false,
});
downloadClient = 'qbittorrent';
logger.info(`Started monitoring job for request ${requestId} (${client.clientType}, 3s initial delay)`);
logger.info(`Torrent added with hash: ${downloadClientId}`);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'qbittorrent',
downloadClientId,
torrentName: torrent.title,
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
downloadClientId,
'qbittorrent',
3 // Wait 3 seconds before first check to avoid race condition
);
logger.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
return {
success: true,
message: 'Torrent added to qBittorrent and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
torrentHash: downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders || 0,
format: torrent.format,
},
};
}
return {
success: true,
message: `Download added to ${client.clientType} and monitoring started`,
requestId,
downloadHistoryId: downloadHistory.id,
downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders || 0,
format: torrent.format,
},
};
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -3,50 +3,13 @@
* Documentation: documentation/phase3/README.md
*/
import path from 'path';
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
/**
* Helper function to retry getTorrent with exponential backoff
* Handles race condition where torrent isn't immediately available after adding
*/
async function getTorrentWithRetry(
qbt: any,
hash: string,
logger: RMABLogger,
maxRetries: number = 3,
initialDelayMs: number = 500
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await qbt.getTorrent(hash);
} catch (error) {
lastError = error as Error;
// If this is the last attempt, throw the error
if (attempt === maxRetries - 1) {
break;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All retries failed
throw lastError || new Error('Failed to get torrent after retries');
}
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
/**
* Process monitor download job
@@ -59,57 +22,42 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
try {
let progress: any;
let downloadPath: string | undefined;
// Get the download client service via the manager
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType];
if (!protocol) {
throw new Error(`Unknown download client type: ${downloadClient}`);
}
const client = await manager.getClientServiceForProtocol(protocol);
if (downloadClient === 'qbittorrent') {
// qBittorrent flow
const qbt = await getQBittorrentService();
if (!client) {
throw new Error(`No ${downloadClient} client configured`);
}
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
progress = qbt.getDownloadProgress(torrent);
// Get download status via unified interface
const info = await client.getDownload(downloadClientId);
// Store download path for later use
downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
} else if (downloadClient === 'sabnzbd') {
// SABnzbd flow
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
if (!info) {
throw new Error(`Download ${downloadClientId} not found in ${downloadClient}`);
}
// Get NZB status
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
// Build progress object for request updates
const progressPercent = Math.round(info.progress * 100);
const progressState = info.status;
if (!nzbInfo) {
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
}
// Convert NZBInfo to progress format
progress = {
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
bytesTotal: nzbInfo.size,
speed: nzbInfo.downloadSpeed,
eta: nzbInfo.timeLeft,
state: nzbInfo.status,
};
// Store download path if available (only set after completion)
downloadPath = nzbInfo.downloadPath;
logger.info(`SABnzbd status: ${nzbInfo.status}`, {
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
if (client.protocol === 'usenet') {
logger.info(`${client.clientType} status: ${info.status}`, {
progress: `${(info.progress * 100).toFixed(1)}%`,
speed: `${(info.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
});
} else {
throw new Error(`Download client ${downloadClient} not supported`);
}
// Update request progress
await prisma.request.update({
where: { id: requestId },
data: {
progress: progress.percent,
progress: progressPercent,
updatedAt: new Date(),
},
});
@@ -118,23 +66,21 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: progress.state,
downloadStatus: progressState,
},
});
// Check download state
if (progress.state === 'completed') {
if (progressState === 'completed' || progressState === 'seeding') {
logger.info(`Download completed for request ${requestId}`);
// Ensure we have a download path
const downloadPath = info.downloadPath;
if (!downloadPath) {
throw new Error('Download path not available from download client');
}
// Get path mapping configuration from the specific download client
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
// Build path mapping config from client settings
@@ -150,17 +96,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const organizePath = PathMapper.transform(downloadPath, pathMappingConfig);
logger.info(`Download completed`, {
downloadClient,
downloadClient: client.clientType,
downloadPath,
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
});
// Update download history to completed
// Update download history to completed (store mapped path for retry reliability)
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'completed',
completedAt: new Date(),
downloadPath: organizePath,
},
});
@@ -197,10 +144,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
progress: 100,
downloadPath: organizePath,
};
} else if (progress.state === 'failed') {
} else if (progressState === 'failed') {
logger.error(`Download failed for request ${requestId}`);
const errorMessage = 'Download failed in qBittorrent';
const errorMessage = `Download failed in ${client.clientType}`;
// Update request to failed
await prisma.request.update({
@@ -249,7 +196,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
completed: true,
message: 'Download failed',
requestId,
progress: progress.percent,
progress: progressPercent,
};
} else {
// Still downloading - schedule another check in 10 seconds
@@ -263,11 +210,11 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
);
// Only log every 5% progress to reduce log spam
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5;
if (shouldLog) {
logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
speed: progress.speed,
eta: progress.eta,
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
speed: info.downloadSpeed,
eta: info.eta,
});
}
@@ -276,20 +223,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
completed: false,
message: 'Download in progress, monitoring continues',
requestId,
progress: progress.percent,
speed: progress.speed,
eta: progress.eta,
state: progress.state,
progress: progressPercent,
speed: info.downloadSpeed,
eta: info.eta,
state: progressState,
};
}
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Check if this is a transient "torrent not found" error
// Check if this is a transient "not found" error
const errorMessage = error instanceof Error ? error.message : '';
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
const isNotFound = errorMessage.includes('not found');
if (isTorrentNotFound) {
if (isNotFound) {
// Transient error - don't mark request as failed, let Bull retry
// The request stays in 'downloading' status until Bull exhausts all retries
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
+141 -197
View File
@@ -9,6 +9,8 @@ import { getFileOrganizer } from '../utils/file-organizer';
import { RMABLogger } from '../utils/logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
@@ -242,106 +244,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
);
}
// Cleanup Usenet downloads if configured
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find NZB ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
// Note: We only archive from history, not queue. If the NZB is still in the queue
// when we're organizing files, something went wrong with the download monitoring.
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
return {
success: true,
@@ -592,12 +496,20 @@ async function processEbookOrganization(
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
// Get file organizer and template
// Get file organizer and ebook-specific template (falls back to audiobook template)
const organizer = await getFileOrganizer();
const templateConfig = await prisma.configuration.findUnique({
where: { key: 'audiobook_path_template' },
const ebookTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'ebook_path_template' },
});
const template = templateConfig?.value || '{author}/{title} {asin}';
let template: string;
if (ebookTemplateConfig?.value) {
template = ebookTemplateConfig.value;
} else {
const audiobookTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'audiobook_path_template' },
});
template = audiobookTemplateConfig?.value || '{author}/{title} {asin}';
}
// Check if Kindle EPUB fix is needed
let effectiveDownloadPath = downloadPath;
@@ -739,99 +651,8 @@ async function processEbookOrganization(
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
}
// Cleanup Usenet downloads if configured (same logic as audiobooks)
try {
logger.info('Checking if cleanup is needed for ebook download');
// downloadHistory was already fetched earlier in this function
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
return {
success: true,
@@ -932,6 +753,129 @@ async function createEbookRequestIfEnabled(
}
}
// =========================================================================
// DOWNLOAD CLEANUP
// =========================================================================
/**
* Cleanup download files and archive from download client after successful organization.
* Uses the IDownloadClient.postProcess() method for client-specific cleanup (e.g., SABnzbd archive).
* Shared between audiobook and ebook organization flows.
*/
async function cleanupDownloadAfterOrganize(
requestId: string,
downloadPath: string,
configService: any,
jobId: string | undefined,
logger: RMABLogger
): Promise<void> {
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find client ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasDownloadClientId: !!downloadHistory?.downloadClientId,
hasIndexerId: !!downloadHistory?.indexerId,
downloadClient: downloadHistory?.downloadClient || 'none',
});
if (!downloadHistory?.indexerId || !downloadHistory?.downloadClientId) {
return;
}
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
if (!indexersConfig) {
return;
}
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a non-torrent indexer with cleanup enabled
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
return;
}
logger.info(`Cleaning up download ${downloadHistory.downloadClientId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then use the download client interface for client-specific post-processing
// (e.g., usenet clients archive from history, torrent clients are a no-op)
const clientType = downloadHistory.downloadClient;
if (clientType && clientType !== 'direct') {
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType}, skipping post-processing`);
return;
}
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (client) {
await client.postProcess(downloadHistory.downloadClientId);
logger.info(`Successfully post-processed download ${downloadHistory.downloadClientId} via ${client.clientType}`);
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
}
// =========================================================================
// HELPER FUNCTIONS
// =========================================================================
@@ -2,15 +2,18 @@
* Component: Retry Failed Imports Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries file organization for requests that are awaiting import
* Retries file organization for requests that are awaiting import.
* Uses the IDownloadClient interface for client-agnostic path resolution.
*/
import path from 'path';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
export interface RetryFailedImportsPayload {
jobId?: string;
@@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
// Helper function to get path mapping config for a specific download client type
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent';
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig && clientConfig.remotePathMappingEnabled) {
@@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
return { enabled: false, remotePath: '', localPath: '' };
};
// Find all active audiobook requests in awaiting_import status
// Note: Ebook requests use the same organize_files processor but with type branching
// Find all requests in awaiting_import status (both audiobook and ebook)
// The organize_files processor handles both types with type-based branching
const requests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
status: 'awaiting_import',
deletedAt: null,
},
@@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
let downloadPath: string;
// Try to get download path from the appropriate download client
// Get path mapping for this specific download client
const clientType = downloadHistory.downloadClient || 'qbittorrent';
const mappingConfig = await getPathMappingForClient(clientType);
if (downloadHistory.torrentHash) {
// qBittorrent download
try {
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
const qbPath = `${torrent.save_path}/${torrent.name}`;
downloadPath = PathMapper.transform(qbPath, mappingConfig);
logger.info(
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
(downloadPath !== qbPath ? `${downloadPath} (mapped)` : '')
);
} catch (qbtError) {
// Torrent not found in qBittorrent - try to construct path from config
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
if (!downloadHistory.torrentName) {
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
(downloadPath !== fallbackPath ? `${downloadPath} (mapped)` : '')
);
}
} else if (downloadHistory.nzbId) {
// SABnzbd download
try {
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (nzbInfo && nzbInfo.downloadPath) {
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
logger.info(
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
(downloadPath !== nzbInfo.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
if (!downloadHistory.torrentName) {
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
(downloadPath !== fallbackPath ? `${downloadPath} (mapped)` : '')
);
}
} catch (sabnzbdError) {
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
skipped++;
continue;
}
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
// Use stored path or construct from download_dir directly
if (clientType === 'direct') {
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
} else {
// No download client ID - use fallback path
if (!downloadHistory.torrentName) {
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
// Real download client — resolve path via client API with path mapping
const mappingConfig = await getPathMappingForClient(clientType);
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (clientId) {
// Try to get path from download client via unified interface
const client = await manager.getClientServiceForProtocol(protocol);
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
if (client) {
try {
const info = await client.getDownload(clientId);
if (info?.downloadPath) {
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
logger.info(
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
(downloadPath !== info.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
// Download found but no path — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} catch (clientError) {
// Client error — try stored path, then fallback
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client configured — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client ID — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
}
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
logger.info(
`Using configured download path for request ${request.id}: ${configuredPath}` +
(downloadPath !== configuredPath ? `${downloadPath} (mapped)` : '')
);
// Check if we got a valid path (getFallbackPath returns empty string on failure)
if (!downloadPath) {
skipped++;
continue;
}
await jobQueue.addOrganizeJob(
@@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
downloadPath
);
triggered++;
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
@@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
throw error;
}
}
/**
* Return the stored download path from the database (saved at download completion time).
* Returns empty string if not available (old records won't have this field).
*/
function getStoredPath(
downloadHistory: { downloadPath?: string | null },
requestId: string,
logger: RMABLogger
): string {
if (downloadHistory.downloadPath) {
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
return downloadHistory.downloadPath;
}
return '';
}
/**
* Construct a fallback download path from config when the download client can't provide one.
* Returns empty string if path cannot be determined (caller should skip the request).
*/
async function getFallbackPath(
downloadHistory: { torrentName: string | null },
configService: any,
mappingConfig: PathMappingConfig,
requestId: string,
logger: RMABLogger,
manager?: DownloadClientManager,
protocol?: ProtocolType
): Promise<string> {
if (!downloadHistory.torrentName) {
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
return '';
}
const baseDir = await configService.get('download_dir');
if (!baseDir) {
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
return '';
}
// Resolve customPath from the client config if available
let downloadDir = baseDir;
if (manager && protocol) {
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig?.customPath) {
downloadDir = path.join(baseDir, clientConfig.customPath);
}
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
(mappedPath !== fallbackPath ? `${mappedPath} (mapped)` : '')
);
return mappedPath;
}
+7 -2
View File
@@ -243,9 +243,14 @@ async function searchIndexers(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by their EBOOK category configuration
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
@@ -58,9 +58,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
@@ -2,37 +2,41 @@
* Component: Download Client Manager Service
* Documentation: documentation/phase3/download-clients.md
*
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
* Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing.
* Supports migration from legacy single-client config to multi-client JSON array format.
*/
import { randomUUID } from 'crypto';
import path from 'path';
import { ConfigurationService } from './config.service';
import { getEncryptionService } from './encryption.service';
import { isEncryptedFormat } from './credential-migration.service';
import { RMABLogger } from '@/lib/utils/logger';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { NZBGetService } from '@/lib/integrations/nzbget.service';
import { TransmissionService } from '@/lib/integrations/transmission.service';
import { PathMappingConfig } from '@/lib/utils/path-mapper';
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('DownloadClientManager');
export interface DownloadClientConfig {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
enabled: boolean;
url: string;
username?: string; // qBittorrent only
password: string; // Password (qBittorrent) or API key (SABnzbd)
username?: string; // qBittorrent/Transmission/NZBGet only
password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd)
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
remotePath?: string;
localPath?: string;
category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir
}
type ProtocolType = 'torrent' | 'usenet';
/**
* Download Client Manager
@@ -47,6 +51,7 @@ export class DownloadClientManager {
private static instance: DownloadClientManager | null = null;
private configService: ConfigurationService;
private clientsCache: DownloadClientConfig[] | null = null;
private serviceCache: Map<string, IDownloadClient> = new Map();
private migrationPerformed = false;
private constructor(configService: ConfigurationService) {
@@ -69,6 +74,7 @@ export class DownloadClientManager {
static invalidate(): void {
if (DownloadClientManager.instance) {
DownloadClientManager.instance.clientsCache = null;
DownloadClientManager.instance.serviceCache.clear();
DownloadClientManager.instance.migrationPerformed = false;
logger.debug('Download client cache invalidated');
}
@@ -127,16 +133,17 @@ export class DownloadClientManager {
}
/**
* Get client for specific protocol
* Get client for specific protocol.
* Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found
* (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol).
*/
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
const clients = await this.getAllClients();
const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
const client = clients.find(c => c.enabled && c.type === targetType);
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (!client) {
logger.warn(`No enabled ${targetType} client configured`);
logger.warn(`No enabled ${protocol} client configured`);
return null;
}
@@ -152,36 +159,83 @@ export class DownloadClientManager {
}
/**
* Get instantiated client service for protocol
* Get instantiated client service for protocol.
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
*/
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
const client = await this.getClientForProtocol(protocol);
if (!client) {
return null;
}
if (client.type === 'qbittorrent') {
return this.createQBittorrentService(client);
} else {
return this.createSABnzbdService(client);
return this.getOrCreateService(client);
}
/**
* Factory: create a new IDownloadClient from config.
* This is the single place where client type maps to a concrete class.
* Add new client types (e.g. Transmission, NZBGet) here.
*/
private async createService(config: DownloadClientConfig): Promise<IDownloadClient> {
const baseDir = await this.configService.get('download_dir') || '/downloads';
const downloadDir = config.customPath
? path.join(baseDir, config.customPath)
: baseDir;
switch (config.type) {
case 'qbittorrent':
return this.createQBittorrentService(config, downloadDir);
case 'sabnzbd':
return this.createSABnzbdService(config, downloadDir);
case 'nzbget':
return this.createNZBGetService(config, downloadDir);
case 'transmission':
return this.createTransmissionService(config, downloadDir);
default:
throw new Error(`Unsupported download client type: ${config.type}`);
}
}
/**
* Test connection for a specific client config
* Get a cached service instance or create a new one.
* Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie).
*/
private async getOrCreateService(config: DownloadClientConfig): Promise<IDownloadClient> {
const cached = this.serviceCache.get(config.id);
if (cached) {
return cached;
}
const service = await this.createService(config);
this.serviceCache.set(config.id, service);
return service;
}
/**
* Create an IDownloadClient instance from a config object.
* Uses cached instances when available to preserve session state.
*/
async createClientFromConfig(config: DownloadClientConfig): Promise<IDownloadClient> {
return this.getOrCreateService(config);
}
/**
* Test connection for a specific client config.
* Uses the unified IDownloadClient.testConnection() method.
*/
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
try {
if (config.type === 'qbittorrent') {
const service = this.createQBittorrentService(config);
await service.testConnection();
return { success: true, message: 'Successfully connected to qBittorrent' };
} else {
const service = this.createSABnzbdService(config);
const version = await service.getVersion();
return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
// Always create a fresh instance for connection testing (don't use cache)
const service = await this.createService(config);
const result = await service.testConnection();
if (result.success) {
const versionSuffix = result.version ? ` (v${result.version})` : '';
return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` };
}
return { success: false, message: result.message || 'Connection failed' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('Connection test failed', { type: config.type, error: message });
@@ -192,7 +246,7 @@ export class DownloadClientManager {
/**
* Create qBittorrent service instance
*/
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
@@ -205,8 +259,8 @@ export class DownloadClientManager {
config.url,
config.username || '',
config.password || '', // Optional for IP whitelist auth
'/downloads', // defaultSavePath
config.category || 'readmeabook', // defaultCategory
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
@@ -215,7 +269,7 @@ export class DownloadClientManager {
/**
* Create SABnzbd service instance
*/
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
@@ -227,8 +281,54 @@ export class DownloadClientManager {
return new SABnzbdService(
config.url,
config.password, // API key stored in password field
config.category || 'readmeabook', // defaultCategory
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
config.category || 'readmeabook',
downloadDir,
config.disableSSLVerify,
pathMapping
);
}
/**
* Create NZBGet service instance
*/
private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new NZBGetService(
config.url,
config.username || '',
config.password,
config.category || 'readmeabook',
downloadDir,
config.disableSSLVerify,
pathMapping
);
}
/**
* Create Transmission service instance
*/
private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new TransmissionService(
config.url,
config.username || '',
config.password || '',
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
@@ -272,8 +372,8 @@ export class DownloadClientManager {
const newClient: DownloadClientConfig = {
id: randomUUID(),
type: clientType as 'qbittorrent' | 'sabnzbd',
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
type: clientType as DownloadClientType,
name: getClientDisplayName(clientType),
enabled: true,
url: clientUrl,
username: clientUsername || undefined,
+4 -3
View File
@@ -127,13 +127,14 @@ async function fetchHtml(
* Test FlareSolverr connection
*/
export async function testFlareSolverrConnection(
flaresolverrUrl: string
flaresolverrUrl: string,
baseUrl: string = 'https://annas-archive.li'
): Promise<{ success: boolean; message: string; responseTime?: number }> {
const startTime = Date.now();
try {
// Test with a simple request to Anna's Archive homepage
const testUrl = 'https://annas-archive.li/';
// Test with a simple request to the configured Anna's Archive base URL
const testUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const html = await fetchViaFlareSolverr(testUrl, flaresolverrUrl, 30000);
const responseTime = Date.now() - startTime;
+3 -2
View File
@@ -7,6 +7,7 @@ import Queue, { Job as BullJob, JobOptions } from 'bull';
import Redis from 'ioredis';
import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
import { DownloadClientType } from '../interfaces/download-client.interface';
import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('JobQueue');
@@ -59,7 +60,7 @@ export interface MonitorDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadClientId: string;
downloadClient: 'qbittorrent' | 'sabnzbd';
downloadClient: DownloadClientType;
}
export interface OrganizeFilesPayload extends JobPayload {
@@ -545,7 +546,7 @@ export class JobQueueService {
requestId: string,
downloadHistoryId: string,
downloadClientId: string,
downloadClient: 'qbittorrent' | 'sabnzbd',
downloadClient: DownloadClientType,
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
@@ -21,6 +21,9 @@ import {
} from '../audiobookshelf/api';
import { ABSLibraryItem } from '../audiobookshelf/types';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('AudiobookshelfLibrary');
export class AudiobookshelfLibraryService implements ILibraryService {
private configService = getConfigService();
@@ -63,17 +66,26 @@ export class AudiobookshelfLibraryService implements ILibraryService {
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const items = await getABSLibraryItems(libraryId);
return items.map(this.mapABSItemToLibraryItem);
const audioItems = items.filter(this.hasAudioContent);
const skipped = items.length - audioItems.length;
if (skipped > 0) {
logger.info(`Filtered ${skipped} ebook-only item(s) from library (no audio files)`);
}
return audioItems.map(this.mapABSItemToLibraryItem);
}
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const items = await getABSRecentItems(libraryId, limit);
return items.map(this.mapABSItemToLibraryItem);
return items.filter(this.hasAudioContent).map(this.mapABSItemToLibraryItem);
}
async getItem(itemId: string): Promise<LibraryItem | null> {
try {
const item = await getABSItem(itemId);
if (!this.hasAudioContent(item)) {
logger.debug(`Item ${itemId} is ebook-only (no audio files), skipping`);
return null;
}
return this.mapABSItemToLibraryItem(item);
} catch {
return null;
@@ -82,7 +94,9 @@ export class AudiobookshelfLibraryService implements ILibraryService {
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const items = await searchABSItems(libraryId, query);
return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
return items
.filter((result: any) => this.hasAudioContent(result.libraryItem))
.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
}
async triggerLibraryScan(libraryId: string): Promise<void> {
@@ -117,6 +131,37 @@ export class AudiobookshelfLibraryService implements ILibraryService {
};
}
/**
* Check if an ABS library item contains audio content.
* ABS stores both audiobooks and ebooks under mediaType 'book'.
* Ebook-only items have no audio files and should be excluded from RMAB's audiobook pipeline.
*
* The list endpoint returns minified media (numAudioFiles, duration) without the full audioFiles array.
* The single-item endpoint returns the full audioFiles array.
* We check all available signals to handle both response shapes.
*/
private hasAudioContent(item: any): boolean {
if (!item?.media) return false;
// numAudioFiles: present in list/search endpoint responses (minified media)
if (typeof item.media.numAudioFiles === 'number') {
return item.media.numAudioFiles > 0;
}
// audioFiles array: present in full single-item responses
if (Array.isArray(item.media.audioFiles)) {
return item.media.audioFiles.length > 0;
}
// duration fallback: ebook-only items have 0 duration
if (typeof item.media.duration === 'number') {
return item.media.duration > 0;
}
// Cannot determine — assume audio content to avoid false filtering
return true;
}
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
const metadata = item.media.metadata;
return {
+62 -61
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { RMABLogger } from '../utils/logger';
import { buildAudiobookPath } from '../utils/file-organizer';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
const logger = RMABLogger.create('RequestDelete');
@@ -119,77 +120,73 @@ export async function deleteRequest(
);
}
// Handle based on download client type (check which ID is present)
if (downloadHistory.torrentHash) {
// qBittorrent download
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
// Handle download cleanup via unified interface
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
let torrent;
try {
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
} catch (error) {
// Torrent not found in qBittorrent (already removed)
logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
}
if (clientId && clientType !== 'direct') {
const { getDownloadClientManager } = await import('./download-client-manager.service');
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (torrent) {
// Torrent exists in qBittorrent
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
const isCompleted = downloadHistory.downloadStatus === 'completed';
if (client) {
// Get download info to check seeding status
let downloadInfo;
try {
downloadInfo = await client.getDownload(clientId);
} catch (error) {
logger.info(`Download ${clientId} not found in ${clientType}, skipping`);
}
if (isUnlimitedSeeding) {
// Unlimited seeding - keep in qBittorrent, stop monitoring
logger.info(
`Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
);
torrentsKeptUnlimited++;
} else if (!isCompleted) {
// Download not completed - delete immediately
logger.info(
`Deleting incomplete download: ${torrent.name}`
);
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
torrentsRemoved++;
} else {
// Check if seeding requirement is met
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const actualSeedingTime = torrent.seeding_time || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (downloadInfo) {
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
const isCompleted = downloadHistory.downloadStatus === 'completed';
if (hasMetRequirement) {
// Seeding requirement met - delete now
if (client.protocol === 'usenet') {
// Usenet - no seeding concept, delete immediately
try {
await client.deleteDownload(clientId, true);
logger.info(`Deleted download ${clientId} from ${client.clientType}`);
torrentsRemoved++;
} catch (error) {
logger.info(`Download ${clientId} not found in ${client.clientType}, skipping`);
}
} else if (isUnlimitedSeeding) {
// Unlimited seeding - keep in client, stop monitoring
logger.info(
`Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
actualSeedingTime / 60
)}/${seedingConfig.seedingTimeMinutes} minutes)`
`Keeping download ${downloadInfo.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
);
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
torrentsKeptUnlimited++;
} else if (!isCompleted) {
// Download not completed - delete immediately
logger.info(`Deleting incomplete download: ${downloadInfo.name}`);
await client.deleteDownload(clientId, true);
torrentsRemoved++;
} else {
// Still needs seeding - keep for cleanup job
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.info(
`Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
);
torrentsKeptSeeding++;
// Check if seeding requirement is met
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (hasMetRequirement) {
logger.info(
`Deleting download ${downloadInfo.name} (seeding complete: ${Math.floor(
actualSeedingTime / 60
)}/${seedingConfig.seedingTimeMinutes} minutes)`
);
await client.deleteDownload(clientId, true);
torrentsRemoved++;
} else {
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.info(
`Keeping download ${downloadInfo.name} for ${remainingMinutes} more minutes of seeding`
);
torrentsKeptSeeding++;
}
}
}
}
} else if (downloadHistory.nzbId) {
// SABnzbd download - no seeding concept for Usenet
try {
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
// Try to delete the NZB from SABnzbd (might already be completed/removed)
await sabnzbd.deleteNZB(downloadHistory.nzbId, true);
logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
torrentsRemoved++;
} catch (error) {
// NZB not found or already removed
logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
}
}
} catch (error) {
logger.error(
@@ -208,7 +205,11 @@ export async function deleteRequest(
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
// Use ebook-specific template for ebook requests, with fallback to audiobook template
const audiobookTemplate = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
const template = isEbook
? (await configService.get('ebook_path_template')) || audiobookTemplate
: audiobookTemplate;
// Fetch year from audible cache if ASIN is available
let year: number | undefined;
+14 -1
View File
@@ -3,13 +3,14 @@
* Documentation: documentation/integrations/audible.md
*/
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in';
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de';
export interface AudibleRegionConfig {
code: AudibleRegion;
name: string;
baseUrl: string;
audnexusParam: string;
isEnglish: boolean;
}
export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
@@ -18,30 +19,42 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
name: 'United States',
baseUrl: 'https://www.audible.com',
audnexusParam: 'us',
isEnglish: true,
},
ca: {
code: 'ca',
name: 'Canada',
baseUrl: 'https://www.audible.ca',
audnexusParam: 'ca',
isEnglish: true,
},
uk: {
code: 'uk',
name: 'United Kingdom',
baseUrl: 'https://www.audible.co.uk',
audnexusParam: 'uk',
isEnglish: true,
},
au: {
code: 'au',
name: 'Australia',
baseUrl: 'https://www.audible.com.au',
audnexusParam: 'au',
isEnglish: true,
},
in: {
code: 'in',
name: 'India',
baseUrl: 'https://www.audible.in',
audnexusParam: 'in',
isEnglish: true,
},
de: {
code: 'de',
name: 'Germany',
baseUrl: 'https://www.audible.de',
audnexusParam: 'de',
isEnglish: false,
},
};
+11 -8
View File
@@ -11,11 +11,12 @@ import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { RMABLogger } from './logger';
import { CHAPTER_MERGE_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
// Supported audio formats for chapter merging
const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac'];
// Supported audio formats for chapter merging (from shared constants)
const SUPPORTED_FORMATS: readonly string[] = CHAPTER_MERGE_FORMATS;
// Patterns that indicate chapter-based files
const CHAPTER_PATTERNS = [
@@ -629,9 +630,9 @@ export async function mergeChapters(
await fs.writeFile(metadataFile, chapterMetadata);
await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`);
// Determine if we need to re-encode (MP3 input requires conversion to AAC)
// Determine if we need to re-encode (non-AAC input requires conversion to AAC for M4B)
const inputFormat = path.extname(chapters[0].path).toLowerCase();
const needsReencode = inputFormat === '.mp3';
const needsReencode = inputFormat === '.mp3' || inputFormat === '.flac' || inputFormat === '.aac';
// Build ffmpeg command
const args: string[] = [
@@ -646,26 +647,28 @@ export async function mergeChapters(
];
if (needsReencode) {
// MP3 -> M4B requires re-encoding to AAC
// Non-AAC -> M4B requires re-encoding to AAC
const bitrate = determineOutputBitrate(chapters);
// Check for libfdk_aac (higher quality) or fall back to native aac
const hasFdkAac = await checkLibFdkAac();
const formatLabel = inputFormat.slice(1).toUpperCase(); // '.mp3' -> 'MP3', '.flac' -> 'FLAC'
if (hasFdkAac) {
args.push('-c:a', 'libfdk_aac');
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
} else {
// Use VBR for better quality at same average bitrate
const vbrQuality = bitrateToVbrQuality(bitrate);
args.push('-c:a', 'aac');
args.push('-q:a', vbrQuality.toString());
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
}
} else {
// M4A/M4B -> M4B can use codec copy (fast, lossless)
// M4A/M4B/MP4 -> M4B can use codec copy (fast, lossless)
args.push('-c', 'copy');
await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
}
+39 -2
View File
@@ -20,6 +20,7 @@ import {
} from './chapter-merger';
import { prisma } from '../db';
import { substituteTemplate, type TemplateVariables } from './path-template.util';
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
export interface AudiobookMetadata {
title: string;
@@ -362,6 +363,34 @@ export class FileOrganizer {
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
// If the tagged temp file failed to copy, clean it up and try the original untagged file
if (taggedFilePath) {
// Clean up the tagged temp file that failed to copy
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`);
} catch {
// Ignore cleanup errors
}
// Fallback: attempt to copy the original untagged file instead
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
try {
await fs.access(originalSourcePath, fs.constants.R_OK);
await fs.copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`);
continue;
} catch (fallbackError) {
const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error';
await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`);
}
}
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
// Continue with other files instead of throwing
}
@@ -411,7 +440,15 @@ export class FileOrganizer {
// This replaces the old inline ebook sidecar download that happened here.
result.targetPath = targetPath;
result.success = true;
// Only mark as success if at least one audio file was placed in the target directory
// (either freshly copied or already existed from a previous attempt)
if (result.audioFiles.length > 0) {
result.success = true;
} else {
result.errors.push('No audio files were successfully copied to the target directory');
await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`);
}
// DO NOT clean up download directory - files needed for seeding
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
@@ -431,7 +468,7 @@ export class FileOrganizer {
private async findAudiobookFiles(
downloadPath: string
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
const audioExtensions: readonly string[] = AUDIO_EXTENSIONS;
const coverPatterns = [
/cover\.(jpg|jpeg|png)$/i,
/folder\.(jpg|jpeg|png)$/i,
+2 -6
View File
@@ -8,11 +8,7 @@
import crypto from 'crypto';
import path from 'path';
/**
* Supported audio file extensions for hash generation
*/
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
/**
* Generates a SHA256 hash of audio filenames for library matching.
@@ -51,7 +47,7 @@ export function generateFilesHash(filePaths: string[]): string {
})
.filter((basename) => {
const ext = path.extname(basename).toLowerCase();
return AUDIO_EXTENSIONS.includes(ext);
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
})
.map((basename) => basename.toLowerCase()) // Normalize case
.sort(); // Sort alphabetically for deterministic hash
+33 -29
View File
@@ -5,6 +5,7 @@
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
* Supports separate audiobook and ebook category configurations per indexer.
* Indexers with no categories for a given type are skipped (effectively disabled).
*/
export type CategoryType = 'audiobook' | 'ebook';
@@ -25,22 +26,33 @@ export interface IndexerGroup {
indexers: IndexerConfig[];
}
export interface GroupingResult {
groups: IndexerGroup[];
skippedIndexers: IndexerConfig[]; // Indexers skipped due to no categories for the type
}
/**
* Gets the appropriate categories from an indexer based on the category type.
*
* Returns empty array when the field is explicitly set to [] (user disabled this type).
* Falls back to defaults only when the field is undefined/missing (legacy configs).
*
* @param indexer - The indexer configuration
* @param type - The category type ('audiobook' or 'ebook')
* @returns Array of category IDs
* @returns Array of category IDs (empty = disabled for this type)
*/
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
if (type === 'ebook') {
return indexer.ebookCategories && indexer.ebookCategories.length > 0
? indexer.ebookCategories
: [7020]; // Default ebook category
// Field exists (even if empty) — respect it
if (Array.isArray(indexer.ebookCategories)) {
return indexer.ebookCategories;
}
// Field missing — legacy config, use default
return [7020];
}
// Audiobook - check new field first, then legacy field
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
// Audiobook check new field first, then legacy field
if (Array.isArray(indexer.audiobookCategories)) {
return indexer.audiobookCategories;
}
if (indexer.categories && indexer.categories.length > 0) {
@@ -52,57 +64,49 @@ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType)
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
* Indexers with no categories for the specified type are skipped.
*
* @param indexers - Array of indexer configurations
* @param type - The category type to group by ('audiobook' or 'ebook')
* @returns Array of groups, each containing indexers with matching categories
* @returns GroupingResult with groups and skipped indexers
*
* @example
* const indexers = [
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [] },
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
* ];
*
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
* // Result:
* // [
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
* // ]
* const result = groupIndexersByCategories(indexers, 'ebook');
* // result.groups: [{ categories: [7020], indexerIds: [1, 3], indexers: [...] }]
* // result.skippedIndexers: [{ id: 2, ... }] (no ebook categories)
*/
export function groupIndexersByCategories(
indexers: IndexerConfig[],
type: CategoryType = 'audiobook'
): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
): GroupingResult {
const groupMap = new Map<string, IndexerConfig[]>();
const skippedIndexers: IndexerConfig[] = [];
for (const indexer of indexers) {
// Get categories for the specified type
const categories = getCategoriesForType(indexer, type);
// Skip indexers with no categories for this type (effectively disabled)
if (categories.length === 0) {
skippedIndexers.push(indexer);
continue;
}
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
const sortedCategories = [...categories].sort((a, b) => a - b);
const key = sortedCategories.join(',');
// Add indexer to group
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(indexer);
}
// Convert map to array of groups
const groups: IndexerGroup[] = [];
for (const [key, indexersInGroup] of groupMap.entries()) {
const categories = key.split(',').map(Number);
@@ -115,7 +119,7 @@ export function groupIndexersByCategories(
});
}
return groups;
return { groups, skippedIndexers };
}
/**
+28 -2
View File
@@ -7,6 +7,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { METADATA_TAG_FORMATS, MP4_CONTAINER_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
@@ -41,7 +42,7 @@ export async function tagAudioFileMetadata(
const ext = path.extname(filePath).toLowerCase();
// Only process supported formats
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
if (!(METADATA_TAG_FORMATS as readonly string[]).includes(ext)) {
return {
success: false,
filePath,
@@ -61,7 +62,7 @@ export async function tagAudioFileMetadata(
];
// For m4b/m4a/mp4 files, use standard metadata tags
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
if ((MP4_CONTAINER_FORMATS as readonly string[]).includes(ext)) {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
@@ -85,6 +86,31 @@ export async function tagAudioFileMetadata(
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4');
}
// For FLAC files, use Vorbis comment tags (native FLAC metadata)
else if (ext === '.flac') {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
'-metadata', `albumartist="${escapeMetadata(metadata.author)}"`,
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
);
if (metadata.narrator) {
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
if (metadata.asin) {
// FLAC supports arbitrary Vorbis comment tags
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
}
// Explicitly specify output format
args.push('-f', 'flac');
}
// For mp3 files, use ID3v2 tags
else if (ext === '.mp3') {
args.push(
+57
View File
@@ -0,0 +1,57 @@
/**
* Utility: Permission Resolution
* Documentation: documentation/admin-dashboard.md
*
* Resolves effective user permissions from the tri-state pattern:
* admin always granted
* per-user setting (true/false) explicit override
* null falls back to global setting
*/
import { prisma } from '@/lib/db';
/**
* Resolve a tri-state permission (admin per-user global fallback).
* @param userRole - 'admin' or 'user'
* @param userValue - per-user setting (true, false, or null)
* @param globalValue - global setting from Configuration table
* @returns effective boolean permission
*/
export function resolvePermission(
userRole: string,
userValue: boolean | null,
globalValue: boolean
): boolean {
if (userRole === 'admin') return true;
if (userValue === true) return true;
if (userValue === false) return false;
return globalValue;
}
/**
* Fetch a global boolean setting from the Configuration table.
* @param key - Configuration key
* @param defaultValue - Value to use if the key doesn't exist
*/
export async function getGlobalBooleanSetting(
key: string,
defaultValue: boolean = true
): Promise<boolean> {
const config = await prisma.configuration.findUnique({
where: { key },
});
return config == null ? defaultValue : config.value === 'true';
}
/**
* Resolve a user's effective interactive search access permission.
*/
export async function resolveInteractiveSearchAccess(
userRole: string,
userInteractiveSearchAccess: boolean | null
): Promise<boolean> {
if (userRole === 'admin') return true;
if (userInteractiveSearchAccess === true) return true;
if (userInteractiveSearchAccess === false) return false;
return getGlobalBooleanSetting('interactive_search_access', true);
}

Some files were not shown because too many files have changed in this diff Show More