Compare commits

..

18 Commits

Author SHA1 Message Date
kikootwo b013538b63 Tighten fetch assertion in change password test
Replace a broad fetch mock expectation with a more specific one to ensure the test only asserts that the change-password endpoint was not called. This avoids false failures when other fetch calls occur by checking not.toHaveBeenCalledWith('/api/auth/change-password', expect.anything()).
2026-02-10 22:12:06 -05:00
kikootwo bceb13f4dd Bump version to 1.0.6 and document auth envs
Bump package version from 1.0.5 to 1.0.6. Add two commented environment variable examples to docker-compose.yml for authentication configuration: DISABLE_LOCAL_LOGIN (force OAuth) and ALLOW_WEAK_PASSWORD (remove minimum password length). These entries are documented examples only and do not change runtime behavior.
2026-02-10 22:01:01 -05:00
kikootwo 6b83e5dac1 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-02-10 21:43:13 -05:00
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00
kikootwo 1d25f7f7b2 Merge pull request #65 from alceasan/spanish-audible-region-and-regional-title
Add Spain as Audible region
2026-02-10 09:46:12 -05:00
alceasan 4e84887d33 2026-02-10 09:43:52 +01:00
kikootwo 4a38dd3da8 Bump package version to 1.0.5
Update package.json version from 1.0.4 to 1.0.5 to prepare for a patch release.
2026-02-09 21:46:02 -05:00
kikootwo f9947b745e Add requireSetupIncompleteOrAdmin and adjust routes
Introduce a new middleware requireSetupIncompleteOrAdmin that allows unauthenticated access while the setup wizard is in progress but enforces admin authentication once setup is complete. Replace requireSetupIncomplete with the new guard in test-paths, test-abs and test-oidc API routes. Update the front-end hook to use fetchWithAuth for authenticated requests. Revise setup-guard tests to cover the new semantics: shared endpoints now return 401 when setup is complete and no auth is provided, return 403 for authenticated non-admin users, and allow admin access or unauthenticated access during setup/DB-unready conditions; also add jwt verification and user lookup mocks to the tests.
2026-02-09 21:45:37 -05:00
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
195 changed files with 14075 additions and 2915 deletions
+12
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
# ========================================================================
@@ -62,6 +71,9 @@ services:
# PLEX_CLIENT_IDENTIFIER: "readmeabook-custom-id"
# PLEX_PRODUCT_NAME: "ReadMeABook"
# LOG_LEVEL: "info"
# DISABLE_LOCAL_LOGIN: "true" # Set to "true" to disable local login (force OAuth)
# ALLOW_WEAK_PASSWORD: "true" # Set to "true" to remove minimum password length requirement
# ========================================================================
# IMPORTANT: Public URL Configuration (Required for OAuth)
+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)
+1 -1
View File
@@ -75,7 +75,7 @@ docker-compose logs -f app
## 📊 Feature Highlights
### AI-Powered Recommendations
- **Providers:** OpenAI (GPT-4o+) or Claude (Sonnet 4.5, Opus 4, Haiku)
- **Providers:** OpenAI (GPT-4+) or Claude (dynamically fetched from Anthropic Models API)
- **Personalization:** Based on your Plex library + swipe history
- **Context:** Max 50 books (40 library + 10 swipes)
- **Filtering:** Excludes books already in library, already requested, or already swiped
+62 -12
View File
@@ -1,14 +1,14 @@
# Notification System
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support
**Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support
## Overview
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
## Key Details
- **Backends:** Discord (webhooks), Pushover (API)
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys)
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -17,7 +17,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
```prisma
model NotificationBackend {
id String @id @default(uuid())
type String // 'discord' | 'pushover'
type String // 'apprise' | 'discord' | 'ntfy' | 'pushover'
name String // User-friendly label
config Json // Encrypted sensitive values
events Json // Array of subscribed events
@@ -70,7 +70,9 @@ model NotificationBackend {
## Configuration Encryption
**Encrypted Values:**
- Apprise: `urls`, `authToken`
- Discord: `webhookUrl`
- ntfy: `accessToken`
- Pushover: `userKey`, `appToken`
**Pattern:** `iv:authTag:encryptedData` (base64)
@@ -81,12 +83,26 @@ model NotificationBackend {
## Message Formatting
**Apprise (JSON via Apprise API):**
- Type: info (pending), success (approved/available), failure (error)
- Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter)
- Endpoint: `{serverUrl}/notify/` (stateless) or `{serverUrl}/notify/{configKey}` (stateful)
- Auth: Optional Bearer token via `authToken` config field
- Format: Event title + book details + user + error (if applicable)
**Discord (Rich Embeds):**
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
- Fields: Title, Author, Requested By, Error (if applicable)
- Footer: Request ID
- Timestamp: Event time
**ntfy (JSON with Tags):**
- Tags: mailbox_with_mail, white_check_mark, tada, x (rendered as emojis by ntfy)
- Priority: Default (3) for pending/approved, High (4) for available/error
- Format: Event title + book details + user + error (if applicable)
- Auth: Optional Bearer token via `accessToken` config field
- Server: Configurable `serverUrl` (default: https://ntfy.sh)
**Pushover (Plain Text with Emojis):**
- Emojis: 📬 📬 🎉 ❌
- Priority: Normal (0) for pending/approved, High (1) for available/error
@@ -154,15 +170,49 @@ model NotificationBackend {
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
## Architecture
**Provider Pattern:** `INotificationProvider` interface + registry (matches `IAuthProvider` pattern)
```
src/lib/services/notification/
INotificationProvider.ts # Interface + shared types
notification.service.ts # Core service with registry
index.ts # Re-exports
providers/
apprise.provider.ts # Apprise API (100+ services)
discord.provider.ts # Discord webhook
ntfy.provider.ts # ntfy API
pushover.provider.ts # Pushover API
```
**Registry:** Module-level `Map<string, INotificationProvider>` with `registerProvider()` / `getProvider()`
**INotificationProvider interface:**
- `type: string` — provider identifier (registry key)
- `sensitiveFields: string[]` — fields needing encryption/masking
- `metadata: ProviderMetadata` — self-describing UI/validation metadata
- `send(config, payload): Promise<void>` — receives decrypted config
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
**Helper functions:**
- `getRegisteredProviderTypes(): string[]` — all registered type keys
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
## Extensibility
**Adding New Backend (e.g., Email):**
1. Add 'email' to NotificationBackendType enum
2. Create EmailConfig interface
3. Add encryption logic for smtpPassword
4. Implement sendEmail() method in NotificationService
5. Add email card to type selector (green "E" badge)
6. Add email form fields to modal
**Adding New Backend (2 steps):**
1. Create `providers/email.provider.ts` implementing `INotificationProvider`:
- Set `type = 'email'`, `sensitiveFields = ['smtpPassword']`
- Set `metadata` with displayName, description, iconLabel, iconColor, configFields
- Implement `send()` with email-specific logic
2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts`
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
**Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum
@@ -173,7 +223,7 @@ model NotificationBackend {
## Tech Stack
- Bull (job queue)
- Node.js crypto (AES-256-GCM encryption)
- Discord webhooks, Pushover API
- Apprise API, Discord webhooks, ntfy API, Pushover API
- React (UI), Tailwind CSS (styling)
## Related
@@ -200,32 +200,23 @@ export async function POST(req: NextRequest) {
.map((m: any) => ({ id: m.id, name: m.id }));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
// Claude: Fetch models dynamically from the Anthropic Models API
const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json'
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Hi' }]
})
});
if (!response.ok) {
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
}
const data = await response.json();
models = data.data.map((m: any) => ({
id: m.id,
name: m.display_name || m.id,
}));
} else {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
+1 -1
View File
@@ -6,7 +6,7 @@
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
## Key Details
- **AI Providers:** OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku)
- **AI Providers:** OpenAI (GPT-4+), Claude (dynamically fetched from Anthropic Models API)
- **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
- **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
- **Library Scopes (per-user):**
+19 -15
View File
@@ -26,11 +26,19 @@ 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)
- Spain (`es`) - `audible.es` (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 +56,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 +236,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
+4 -2
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
@@ -206,7 +208,7 @@ async function organize(
## Fixed Issues ✅
**1. EPERM errors** - Fixed with `fs.readFile/writeFile` instead of `copyFile`
**1. EPERM errors** - Fixed with stream-based copy (`pipeline` + `createReadStream`/`createWriteStream`) instead of `fs.copyFile()` which uses `copy_file_range()` — a syscall that returns EPERM on cross-export NFS4 and some FUSE mounts
**2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
**3. Files moved not copied** - Now copies to support seeding
**4. Single file downloads** - Now supports files directly in downloads folder (not just directories)
+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
+4 -4
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
@@ -271,7 +271,7 @@ src/app/admin/settings/
**PUT /api/admin/settings/audible**
- Updates Audible region
- Body: `{ region: string }` (one of: us, ca, uk, au, in)
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
- No validation required
**PUT /api/admin/settings/prowlarr/indexers**
@@ -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.6",
"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.
@@ -6,6 +6,25 @@ import { fetchWithAuth } from '@/lib/utils/api';
const logger = RMABLogger.create('NotificationsTab');
interface ProviderConfigField {
name: string;
label: string;
type: 'text' | 'password' | 'select' | 'number';
required: boolean;
placeholder?: string;
defaultValue?: string | number;
options?: { label: string; value: string | number }[];
}
interface ProviderMetadata {
type: string;
displayName: string;
description: string;
iconLabel: string;
iconColor: string;
configFields: ProviderConfigField[];
}
interface NotificationBackend {
id: string;
type: string;
@@ -24,15 +43,6 @@ interface ModalState {
backend?: NotificationBackend;
}
const typeColors: Record<string, string> = {
discord: 'bg-indigo-500',
pushover: 'bg-blue-500',
email: 'bg-green-500',
slack: 'bg-purple-500',
telegram: 'bg-sky-500',
webhook: 'bg-gray-500',
};
const eventLabels: Record<string, string> = {
request_pending_approval: 'Request Pending Approval',
request_approved: 'Request Approved',
@@ -42,6 +52,7 @@ const eventLabels: Record<string, string> = {
export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]);
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [modalState, setModalState] = useState<ModalState>({
isOpen: false,
@@ -59,8 +70,23 @@ export function NotificationsTab() {
useEffect(() => {
fetchBackends();
fetchProviderMetadata();
}, []);
const fetchProviderMetadata = async () => {
try {
const response = await fetchWithAuth('/api/admin/notifications/providers');
if (response.ok) {
const data = await response.json();
if (data.success) {
setProviderMetadata(data.providers);
}
}
} catch (error) {
logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) });
}
};
const fetchBackends = async () => {
try {
setLoading(true);
@@ -83,11 +109,23 @@ export function NotificationsTab() {
}
};
const getMetadataForType = (type: string): ProviderMetadata | undefined => {
return providerMetadata.find((p) => p.type === type);
};
const openAddModal = (type: string) => {
const meta = getMetadataForType(type);
const defaultConfig: Record<string, any> = {};
if (meta) {
for (const field of meta.configFields) {
defaultConfig[field.name] = field.defaultValue ?? '';
}
}
setModalState({ isOpen: true, mode: 'add', selectedType: type });
setFormData({
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`,
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 },
name: `${meta?.displayName ?? type} Notifications`,
config: defaultConfig,
events: ['request_available', 'request_error'],
enabled: true,
});
@@ -193,6 +231,49 @@ export function NotificationsTab() {
}
};
const renderConfigField = (field: ProviderConfigField) => {
if (field.type === 'select' && field.options) {
return (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}{field.required ? ' *' : ''}
</label>
<select
value={formData.config[field.name] ?? field.defaultValue ?? ''}
onChange={(e) => {
const value = field.options?.some((o) => typeof o.value === 'number')
? Number(e.target.value)
: e.target.value;
setFormData({ ...formData, config: { ...formData.config, [field.name]: value } });
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{field.options.map((opt) => (
<option key={String(opt.value)} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
}
return (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}{field.required ? ' *' : field.label.includes('optional') ? '' : ' (optional)'}
</label>
<input
type={field.type === 'password' ? 'password' : 'text'}
value={formData.config[field.name] ?? ''}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, [field.name]: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder={field.placeholder}
/>
</div>
);
};
const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined;
return (
<div className="space-y-6">
{/* Header */}
@@ -206,32 +287,22 @@ export function NotificationsTab() {
{/* Type Selector */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => openAddModal('discord')}
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
D
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">Discord</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div>
</div>
</button>
<button
onClick={() => openAddModal('pushover')}
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
P
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
</div>
</button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{providerMetadata.map((meta) => (
<button
key={meta.type}
onClick={() => openAddModal(meta.type)}
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className={`flex-shrink-0 w-12 h-12 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
{meta.iconLabel}
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</div>
</div>
</button>
))}
</div>
</div>
@@ -244,43 +315,46 @@ export function NotificationsTab() {
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{backends.map((backend) => (
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}>
{backend.type.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div>
{backends.map((backend) => {
const meta = getMetadataForType(backend.type);
return (
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
{meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{meta?.displayName ?? backend.type}</div>
</div>
</div>
</div>
</div>
<div className="space-y-2 mb-3">
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
{backend.enabled ? 'Enabled' : 'Disabled'}
<div className="space-y-2 mb-3">
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
{backend.enabled ? 'Enabled' : 'Disabled'}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
<div className="flex space-x-2">
<button
onClick={() => openEditModal(backend)}
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(backend.id)}
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Delete
</button>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => openEditModal(backend)}
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(backend.id)}
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Delete
</button>
</div>
</div>
))}
);
})}
</div>
)}
</div>
@@ -292,7 +366,7 @@ export function NotificationsTab() {
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification
{modalState.mode === 'add' ? 'Add' : 'Edit'} {currentMeta?.displayName ?? modalState.selectedType} Notification
</h3>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,70 +388,8 @@ export function NotificationsTab() {
/>
</div>
{/* Config Fields */}
{modalState.selectedType === 'discord' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
<input
type="text"
value={formData.config.webhookUrl}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
<input
type="text"
value={formData.config.username}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="ReadMeABook"
/>
</div>
</>
)}
{modalState.selectedType === 'pushover' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
<input
type="text"
value={formData.config.userKey}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your Pushover user key"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
<input
type="text"
value={formData.config.appToken}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your Pushover app token"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
value={formData.config.priority}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="-2">Lowest</option>
<option value="-1">Low</option>
<option value="0">Normal</option>
<option value="1">High</option>
<option value="2">Emergency</option>
</select>
</div>
</>
)}
{/* Dynamic Config Fields */}
{currentMeta?.configFields.map((field) => renderConfigField(field))}
{/* Events */}
<div>
+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">
@@ -6,6 +6,7 @@
'use client';
import { useState } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import type { PathsSettings, TestResult } from '../../lib/types';
interface UsePathsSettingsProps {
@@ -34,13 +35,14 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
setTestResult(null);
try {
const response = await fetch('/api/setup/test-paths', {
const response = await fetchWithAuth('/api/setup/test-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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;
}
}
}
}
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
import { getNotificationService } from '@/lib/services/notification';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -50,7 +50,7 @@ export async function GET(
success: true,
backend: {
...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
config: notificationService.maskConfig(backend.type, backend.config),
},
});
} catch (error) {
@@ -114,7 +114,7 @@ export async function PUT(
});
// Encrypt new/changed values
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
}
// Update backend
@@ -139,7 +139,7 @@ export async function PUT(
success: true,
backend: {
...updated,
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
config: notificationService.maskConfig(updated.type, updated.config),
},
});
} catch (error) {
@@ -0,0 +1,42 @@
/**
* Component: Notification Providers Metadata API
* Documentation: documentation/backend/services/notifications.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getAllProviderMetadata } from '@/lib/services/notification';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Notifications.Providers');
/**
* GET /api/admin/notifications/providers
* Returns metadata for all registered notification providers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const providers = getAllProviderMetadata();
return NextResponse.json({
success: true,
providers,
});
} catch (error) {
logger.error('Failed to fetch provider metadata', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch provider metadata',
},
{ status: 500 }
);
}
});
});
}
+3 -3
View File
@@ -6,14 +6,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.Notifications');
const CreateBackendSchema = z.object({
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
name: z.string().min(1),
config: z.record(z.any()),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
// Mask sensitive config values
const maskedBackends = backends.map((backend) => ({
...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
config: notificationService.maskConfig(backend.type, backend.config),
}));
return NextResponse.json({
+26 -69
View File
@@ -5,31 +5,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
import { prisma } from '@/lib/db';
const logger = RMABLogger.create('API.Admin.Notifications.Test');
const TestNotificationSchema = z.discriminatedUnion('mode', [
// Test existing backend by ID (uses stored config)
z.object({
mode: z.literal('backend'),
backendId: z.string(),
}),
// Test new config before saving
z.object({
mode: z.literal('config'),
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
config: z.record(z.any()),
}),
]);
// Support legacy format without mode
const LegacyTestNotificationSchema = z.object({
// Flexible schema: supports both backendId and type+config formats
const TestNotificationSchema = z.object({
backendId: z.string().optional(),
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(),
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(),
config: z.record(z.any()).optional(),
});
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const parsed = TestNotificationSchema.parse(body);
// Support legacy format for backward compatibility
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
let type: NotificationBackendType;
let type: string;
let encryptedConfig: any;
const notificationService = getNotificationService();
if (legacyParsed.success) {
// Legacy format
if (legacyParsed.data.backendId) {
// Test existing backend
const backend = await prisma.notificationBackend.findUnique({
where: { id: legacyParsed.data.backendId },
});
if (parsed.backendId) {
// Test existing backend by ID (uses stored config)
const backend = await prisma.notificationBackend.findUnique({
where: { id: parsed.backendId },
});
if (!backend) {
return NextResponse.json(
{ error: 'NotFound', message: 'Backend not found' },
{ status: 404 }
);
}
type = backend.type as NotificationBackendType;
encryptedConfig = backend.config; // Already encrypted in DB
} else if (legacyParsed.data.type && legacyParsed.data.config) {
// Test new config
type = legacyParsed.data.type as NotificationBackendType;
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
} else {
if (!backend) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
{ status: 400 }
{ error: 'NotFound', message: 'Backend not found' },
{ status: 404 }
);
}
type = backend.type;
encryptedConfig = backend.config; // Already encrypted in DB
} else if (parsed.type && parsed.config) {
// Test new config before saving
type = parsed.type;
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
} else {
// New format with discriminated union
const parsed = TestNotificationSchema.parse(body);
if (parsed.mode === 'backend') {
// Test existing backend
const backend = await prisma.notificationBackend.findUnique({
where: { id: parsed.backendId },
});
if (!backend) {
return NextResponse.json(
{ error: 'NotFound', message: 'Backend not found' },
{ status: 404 }
);
}
type = backend.type as NotificationBackendType;
encryptedConfig = backend.config; // Already encrypted in DB
} else {
// Test new config
type = parsed.type;
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
}
return NextResponse.json(
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
{ status: 400 }
);
}
// Create test payload
@@ -117,7 +74,7 @@ export async function POST(request: NextRequest) {
// Send test notification synchronously (not via job queue)
try {
// Call sendToBackend directly
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
await notificationService.sendToBackend(type, encryptedConfig, testPayload);
logger.info(`Test notification sent successfully for ${type}`, {
adminId: req.user?.sub,
+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,8 @@ export async function PUT(
remotePath,
localPath,
category,
customPath,
postImportCategory,
} = body;
const config = await getConfigService();
@@ -53,6 +55,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 +76,8 @@ 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,
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
};
// Validate path mapping if enabled
@@ -0,0 +1,104 @@
/**
* Component: Fetch Download Client Categories API
* Documentation: documentation/phase3/download-clients.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
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 { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Categories');
/**
* POST - Fetch categories from a download client
* Accepts same connection config as the test endpoint
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const {
clientId,
type,
name: clientName,
url,
username,
password,
disableSSLVerify,
remotePathMappingEnabled,
remotePath,
localPath,
} = body;
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
if (!url) {
return NextResponse.json(
{ error: 'URL is required' },
{ status: 400 }
);
}
const config = await getConfigService();
const manager = getDownloadClientManager(config);
// If editing and password not provided, use stored password
let effectivePassword = password;
let effectiveUsername = username;
if (clientId && !password) {
const existingClients = await manager.getAllClients();
const existingClient = existingClients.find(c => c.id === clientId);
if (!existingClient) {
return NextResponse.json(
{ error: 'Client not found' },
{ status: 404 }
);
}
effectivePassword = existingClient.password;
if (!username && existingClient.username) {
effectiveUsername = existingClient.username;
}
}
const testConfig: DownloadClientConfig = {
id: 'categories-fetch',
type,
name: clientName || type,
enabled: true,
url,
username: effectiveUsername || '',
password: effectivePassword || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: 'readmeabook',
};
const service = await manager.createClientFromConfig(testConfig);
const categories = await service.getCategories();
return NextResponse.json({ success: true, categories });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('Failed to fetch categories', { error: message });
return NextResponse.json(
{ success: false, error: message },
{ status: 400 }
);
}
});
});
}
@@ -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,14 @@ export async function POST(request: NextRequest) {
remotePath,
localPath,
category,
customPath,
postImportCategory,
} = 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 +101,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 +138,8 @@ export async function POST(request: NextRequest) {
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: category || 'readmeabook',
customPath: customPath || undefined,
postImportCategory: postImportCategory || 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) => {
@@ -81,7 +86,6 @@ export async function POST(request: NextRequest) {
// Search Prowlarr for each group and combine results
const prowlarr = await getProwlarrService();
const searchQuery = title; // Title only - cast wide net
const allResults = [];
for (let i = 0; i < groups.length; i++) {
@@ -89,7 +93,7 @@ export async function POST(request: NextRequest) {
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.search(searchQuery, {
const groupResults = await prowlarr.searchWithVariations(title, author, {
categories: group.categories,
indexerIds: group.indexerIds,
maxResults: 100, // Limit per group
+2 -1
View File
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
}
// Validate new password length
if (newPassword.length < 8) {
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
if (!allowWeakPassword && newPassword.length < 8) {
return NextResponse.json(
{
success: false,
+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,
},
},
});
});
+7
View File
@@ -18,6 +18,9 @@ export async function GET() {
// Check if local login is disabled via environment variable
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
// Check if weak passwords are allowed via environment variable
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
const indexerType = await configService.get('indexer.type');
const prowlarrUrl = await configService.get('indexer.prowlarr_url');
@@ -47,6 +50,7 @@ export async function GET() {
hasLocalUsers,
oidcProviderName: oidcEnabled ? oidcProviderName : null,
localLoginDisabled,
allowWeakPassword,
automationEnabled,
});
} else {
@@ -65,6 +69,7 @@ export async function GET() {
hasLocalUsers,
oidcProviderName: null,
localLoginDisabled,
allowWeakPassword,
automationEnabled,
});
}
@@ -72,6 +77,7 @@ export async function GET() {
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) });
// Default to Plex mode if config can't be read
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
return NextResponse.json({
backendMode: 'plex',
providers: ['plex'],
@@ -79,6 +85,7 @@ export async function GET() {
hasLocalUsers: false,
oidcProviderName: null,
localLoginDisabled,
allowWeakPassword,
automationEnabled: false,
});
}
+51 -52
View File
@@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.BookDate.TestConnection');
// Fetch available Claude models from the Anthropic API
async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
const allModels: { id: string; name: string }[] = [];
let afterId: string | undefined;
// Paginate through all available models
do {
const params = new URLSearchParams({ limit: '1000' });
if (afterId) {
params.set('after_id', afterId);
}
const response = await fetch(
`https://api.anthropic.com/v1/models?${params.toString()}`,
{
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
}
);
if (!response.ok) {
const errorText = await response.text();
logger.error('Claude API error', { error: errorText });
throw new Error('Invalid Claude API key or connection failed');
}
const data = await response.json();
for (const model of data.data) {
allModels.push({
id: model.id,
name: model.display_name || model.id,
});
}
afterId = data.has_more ? data.last_id : undefined;
} while (afterId);
return allModels;
}
// Helper functions for custom provider
function isValidBaseUrl(url: string): boolean {
try {
@@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
.sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': testApiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Claude API error', { error: errorText });
// Claude: Fetch models dynamically from the Anthropic Models API
try {
models = await fetchClaudeModels(testApiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' },
{ status: 400 }
@@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) {
.sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Claude API error', { error: errorText });
// Claude: Fetch models dynamically from the Anthropic Models API
try {
models = await fetchClaudeModels(apiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' },
{ status: 400 }
@@ -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();
@@ -8,7 +8,9 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
const logger = RMABLogger.create('API.InteractiveSearch');
@@ -71,6 +73,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();
@@ -84,9 +98,8 @@ export async function POST(
}
const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) {
if (indexersConfig.length === 0) {
return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
{ status: 400 }
@@ -102,22 +115,53 @@ export async function POST(
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService();
// Use custom title if provided, otherwise use audiobook's title
const searchQuery = customTitle || requestRecord.audiobook.title;
// Group indexers by their category configuration
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery });
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
// Use custom title if provided, otherwise use audiobook's title
const searchTitle = customTitle || requestRecord.audiobook.title;
const searchAuthor = requestRecord.audiobook.author;
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
if (customTitle) {
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
}
const results = await prowlarr.search(searchQuery, {
indexerIds: enabledIndexerIds,
maxResults: 100, // Increased limit for broader search
// Log each group for transparency
groups.forEach((group, index) => {
logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
});
logger.debug(`Found ${results.length} raw results`, { requestId: id });
// Search Prowlarr for each group and combine results
const prowlarr = await getProwlarrService();
const allResults = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, {
categories: group.categories,
indexerIds: group.indexerIds,
maxResults: 100,
});
logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
allResults.push(...groupResults);
} catch (error) {
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other groups even if one fails
}
}
const results = allResults;
logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`, { requestId: id });
if (results.length === 0) {
return NextResponse.json({
@@ -127,12 +171,31 @@ export async function POST(
});
}
// Fetch runtime from Audnexus if ASIN available (for size-based scoring)
let durationMinutes: number | undefined;
if (requestRecord.audiobook.audibleAsin) {
try {
const { getAudibleService } = await import('@/lib/integrations/audible.service');
const audibleService = getAudibleService();
const runtime = await audibleService.getRuntime(requestRecord.audiobook.audibleAsin);
if (runtime) {
durationMinutes = runtime;
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${requestRecord.audiobook.audibleAsin}`);
} else {
logger.debug(`No runtime found for ASIN ${requestRecord.audiobook.audibleAsin}`);
}
} catch (error) {
logger.debug(`Failed to fetch runtime for ASIN ${requestRecord.audiobook.audibleAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
durationMinutes,
}, {
indexerPriorities,
flagConfigs,
@@ -147,17 +210,23 @@ export async function POST(
const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
logger.debug('==================== RANKING DEBUG ====================');
logger.debug('Search parameters', { searchQuery, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => {
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
logger.debug(`${index + 1}. "${result.title}"`, {
indexer: result.indexer,
indexerId: result.indexerId,
baseScore: `${result.score.toFixed(1)}/100`,
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`,
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`,
formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
sizeScore: durationMinutes
? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
: 'N/A (no runtime)',
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
finalScore: result.finalScore.toFixed(1),
+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 }
);
}
});
}
@@ -0,0 +1,63 @@
/**
* Component: Setup Wizard Download Client Categories API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
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.DownloadClientCategories');
/**
* POST - Fetch categories from a download client during setup wizard
*/
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { type, name, url, username, password, disableSSLVerify } = await req.json();
if (!type || !url) {
return NextResponse.json(
{ success: false, error: 'Type and URL are required' },
{ status: 400 }
);
}
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
const testConfig: DownloadClientConfig = {
id: 'setup-categories',
type,
name: name || type,
enabled: true,
url,
username: username || '',
password: password || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: false,
};
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const service = await manager.createClientFromConfig(testConfig);
const categories = await service.getCategories();
return NextResponse.json({ success: true, categories });
} catch (error) {
logger.error('Failed to fetch categories', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch categories' },
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -4,10 +4,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
export async function POST(request: NextRequest) {
return requireSetupIncompleteOrAdmin(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 { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestOIDC');
export async function POST(request: NextRequest) {
return requireSetupIncompleteOrAdmin(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 { requireSetupIncompleteOrAdmin } 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 requireSetupIncompleteOrAdmin(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 }
);
}
});
}
+8 -4
View File
@@ -38,6 +38,7 @@ function LoginContent() {
hasLocalUsers: boolean;
oidcProviderName: string | null;
localLoginDisabled: boolean;
allowWeakPassword: boolean;
automationEnabled: boolean;
} | null>(null);
const [showRegisterForm, setShowRegisterForm] = useState(false);
@@ -78,6 +79,7 @@ function LoginContent() {
hasLocalUsers: false,
oidcProviderName: null,
localLoginDisabled: false,
allowWeakPassword: false,
automationEnabled: false,
});
}
@@ -345,7 +347,7 @@ function LoginContent() {
return;
}
if (registerPassword.length < 8) {
if (!authProviders?.allowWeakPassword && registerPassword.length < 8) {
setError('Password must be at least 8 characters');
setIsLoggingIn(false);
return;
@@ -639,10 +641,12 @@ function LoginContent() {
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
required
minLength={8}
minLength={authProviders?.allowWeakPassword ? 1 : 8}
autoComplete="new-password"
/>
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
{!authProviders?.allowWeakPassword && (
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
)}
</div>
<div>
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
@@ -656,7 +660,7 @@ function LoginContent() {
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
required
minLength={8}
minLength={authProviders?.allowWeakPassword ? 1 : 8}
autoComplete="new-password"
/>
</div>
+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>
+29
View File
@@ -27,7 +27,13 @@ import { AudibleRegion } from '@/lib/types/audible';
interface SelectedIndexer {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
ebookCategories: number[];
}
interface SetupState {
@@ -86,6 +92,14 @@ interface SetupState {
bookdateApiKey: string;
bookdateModel: string;
bookdateConfigured: boolean;
// Cached UI state for back-navigation persistence
plexLibraries: { id: string; title: string; type: string }[];
absLibraries: { id: string; name: string; itemCount: number }[];
oidcTested: boolean;
pathsTested: boolean;
bookdateModels: { id: string; name: string }[];
validated: {
plex: boolean;
prowlarr: boolean;
@@ -152,6 +166,14 @@ export default function SetupWizard() {
bookdateApiKey: '',
bookdateModel: '',
bookdateConfigured: false,
// Cached UI state for back-navigation persistence
plexLibraries: [],
absLibraries: [],
oidcTested: false,
pathsTested: false,
bookdateModels: [],
validated: {
plex: false,
prowlarr: false,
@@ -379,6 +401,7 @@ export default function SetupWizard() {
plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId}
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
plexLibraries={state.plexLibraries}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -397,6 +420,7 @@ export default function SetupWizard() {
absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId}
absTriggerScanAfterImport={state.absTriggerScanAfterImport}
absLibraries={state.absLibraries}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -435,6 +459,7 @@ export default function SetupWizard() {
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
oidcAdminClaimName={state.oidcAdminClaimName}
oidcAdminClaimValue={state.oidcAdminClaimValue}
oidcTested={state.oidcTested}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -482,6 +507,7 @@ export default function SetupWizard() {
<ProwlarrStep
prowlarrUrl={state.prowlarrUrl}
prowlarrApiKey={state.prowlarrApiKey}
prowlarrIndexers={state.prowlarrIndexers}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -495,6 +521,7 @@ export default function SetupWizard() {
return (
<DownloadClientStep
downloadClients={state.downloadClients}
downloadDir={state.downloadDir}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -511,6 +538,7 @@ export default function SetupWizard() {
mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled}
chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -527,6 +555,7 @@ export default function SetupWizard() {
bookdateApiKey={state.bookdateApiKey}
bookdateModel={state.bookdateModel}
bookdateConfigured={state.bookdateConfigured}
bookdateModels={state.bookdateModels}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onSkip={() => goToStep(currentStepNumber + 1)}
+22 -3
View File
@@ -5,7 +5,7 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
interface AdminAccountStepProps {
@@ -25,6 +25,23 @@ export function AdminAccountStep({
}: AdminAccountStepProps) {
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({});
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
// Fetch password policy
useEffect(() => {
const fetchPolicy = async () => {
try {
const response = await fetch('/api/auth/providers');
if (response.ok) {
const data = await response.json();
setAllowWeakPassword(data.allowWeakPassword === true);
}
} catch {
// Default to strict validation on error
}
};
fetchPolicy();
}, []);
const validate = () => {
const newErrors: { username?: string; password?: string; confirm?: string } = {};
@@ -35,7 +52,9 @@ export function AdminAccountStep({
}
// Validate password
if (!adminPassword || adminPassword.length < 8) {
if (!adminPassword) {
newErrors.password = 'Password is required';
} else if (!allowWeakPassword && adminPassword.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
@@ -104,7 +123,7 @@ export function AdminAccountStep({
<p className="mt-1 text-sm text-red-400">{errors.password}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Choose a strong password (minimum 8 characters)
{allowWeakPassword ? 'Choose a password' : 'Choose a strong password (minimum 8 characters)'}
</p>
</div>
+13 -5
View File
@@ -14,7 +14,8 @@ interface AudiobookshelfStepProps {
absApiToken: string;
absLibraryId: string;
absTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void;
absLibraries: Library[];
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
}
@@ -30,6 +31,7 @@ export function AudiobookshelfStep({
absApiToken,
absLibraryId,
absTriggerScanAfterImport,
absLibraries,
onUpdate,
onNext,
onBack,
@@ -39,8 +41,12 @@ export function AudiobookshelfStep({
success: boolean;
message?: string;
libraries?: Library[];
} | null>(null);
const [libraries, setLibraries] = useState<Library[]>([]);
} | null>(
absLibraries.length > 0
? { success: true, message: 'Connection verified previously.' }
: null
);
const [libraries, setLibraries] = useState<Library[]>(absLibraries);
const testConnection = async () => {
setTesting(true);
@@ -56,12 +62,14 @@ export function AudiobookshelfStep({
const data = await response.json();
if (response.ok && data.success) {
const libs = data.libraries || [];
setTestResult({
success: true,
message: 'Connection successful!',
libraries: data.libraries || [],
libraries: libs,
});
setLibraries(data.libraries || []);
setLibraries(libs);
onUpdate('absLibraries', libs);
} else {
setTestResult({
success: false,
+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.
+11 -4
View File
@@ -12,6 +12,7 @@ interface BookDateStepProps {
bookdateApiKey: string;
bookdateModel: string;
bookdateConfigured: boolean;
bookdateModels: ModelOption[];
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onSkip: () => void;
@@ -28,6 +29,7 @@ export function BookDateStep({
bookdateApiKey,
bookdateModel,
bookdateConfigured,
bookdateModels,
onUpdate,
onNext,
onSkip,
@@ -35,7 +37,7 @@ export function BookDateStep({
}: BookDateStepProps) {
const [testing, setTesting] = useState(false);
const [tested, setTested] = useState(bookdateConfigured);
const [models, setModels] = useState<ModelOption[]>([]);
const [models, setModels] = useState<ModelOption[]>(bookdateModels);
const [error, setError] = useState<string | null>(null);
const handleTestConnection = async () => {
@@ -65,19 +67,22 @@ export function BookDateStep({
throw new Error(data.error || 'Connection test failed');
}
setModels(data.models || []);
const fetchedModels = data.models || [];
setModels(fetchedModels);
setTested(true);
onUpdate('bookdateConfigured', true);
onUpdate('bookdateModels', fetchedModels);
// Auto-select first model if none selected
if (!bookdateModel && data.models?.length > 0) {
onUpdate('bookdateModel', data.models[0].id);
if (!bookdateModel && fetchedModels.length > 0) {
onUpdate('bookdateModel', fetchedModels[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection test failed');
setTested(false);
onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
} finally {
setTesting(false);
}
@@ -123,6 +128,7 @@ export function BookDateStep({
setTested(false);
setModels([]);
onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
>
@@ -144,6 +150,7 @@ export function BookDateStep({
setTested(false);
setModels([]);
onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
+9 -3
View File
@@ -5,13 +5,14 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } 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,13 @@ interface DownloadClient {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
postImportCategory?: string;
}
interface DownloadClientStepProps {
downloadClients: DownloadClient[];
downloadDir?: string;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
@@ -33,6 +37,7 @@ interface DownloadClientStepProps {
export function DownloadClientStep({
downloadClients,
downloadDir,
onUpdate,
onNext,
onBack,
@@ -66,7 +71,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 +85,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">
+10 -1
View File
@@ -22,6 +22,7 @@ interface OIDCConfigStepProps {
oidcAdminClaimEnabled: boolean;
oidcAdminClaimName: string;
oidcAdminClaimValue: string;
oidcTested: boolean;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
@@ -40,6 +41,7 @@ export function OIDCConfigStep({
oidcAdminClaimEnabled,
oidcAdminClaimName,
oidcAdminClaimValue,
oidcTested,
onUpdate,
onNext,
onBack,
@@ -48,7 +50,11 @@ export function OIDCConfigStep({
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
} | null>(
oidcTested
? { success: true, message: 'OIDC configuration verified previously.' }
: null
);
const testConnection = async () => {
setTesting(true);
@@ -72,17 +78,20 @@ export function OIDCConfigStep({
success: true,
message: 'OIDC discovery successful! Provider configuration validated.',
});
onUpdate('oidcTested', true);
} else {
setTestResult({
success: false,
message: data.error || 'OIDC discovery failed',
});
onUpdate('oidcTested', false);
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
onUpdate('oidcTested', false);
} finally {
setTesting(false);
}
+11 -2
View File
@@ -14,7 +14,8 @@ interface PathsStepProps {
mediaDir: string;
metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean;
onUpdate: (field: string, value: string | boolean) => void;
pathsTested: boolean;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
}
@@ -24,6 +25,7 @@ export function PathsStep({
mediaDir,
metadataTaggingEnabled,
chapterMergingEnabled,
pathsTested,
onUpdate,
onNext,
onBack,
@@ -34,7 +36,11 @@ export function PathsStep({
message: string;
downloadDirValid?: boolean;
mediaDirValid?: boolean;
} | null>(null);
} | null>(
pathsTested
? { success: true, message: 'Paths validated previously.', downloadDirValid: true, mediaDirValid: true }
: null
);
const testPaths = async () => {
setTesting(true);
@@ -59,6 +65,7 @@ export function PathsStep({
downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid,
});
onUpdate('pathsTested', true);
} else {
setTestResult({
success: false,
@@ -66,12 +73,14 @@ export function PathsStep({
downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid,
});
onUpdate('pathsTested', false);
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Path validation failed',
});
onUpdate('pathsTested', false);
} finally {
setTesting(false);
}
+13 -5
View File
@@ -14,7 +14,8 @@ interface PlexStepProps {
plexToken: string;
plexLibraryId: string;
plexTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void;
plexLibraries: PlexLibrary[];
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
}
@@ -30,6 +31,7 @@ export function PlexStep({
plexToken,
plexLibraryId,
plexTriggerScanAfterImport,
plexLibraries,
onUpdate,
onNext,
onBack,
@@ -39,8 +41,12 @@ export function PlexStep({
success: boolean;
message: string;
libraries?: PlexLibrary[];
} | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
} | null>(
plexLibraries.length > 0
? { success: true, message: 'Connection verified previously.' }
: null
);
const [libraries, setLibraries] = useState<PlexLibrary[]>(plexLibraries);
const testConnection = async () => {
setTesting(true);
@@ -56,12 +62,14 @@ export function PlexStep({
const data = await response.json();
if (response.ok && data.success) {
const libs = data.libraries || [];
setTestResult({
success: true,
message: `Connected to ${data.serverName || 'Plex server'} successfully!`,
libraries: data.libraries || [],
libraries: libs,
});
setLibraries(data.libraries || []);
setLibraries(libs);
onUpdate('plexLibraries', libs);
} else {
setTestResult({
success: false,
+10 -7
View File
@@ -5,7 +5,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
@@ -13,6 +13,7 @@ import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement
interface ProwlarrStepProps {
prowlarrUrl: string;
prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
@@ -33,17 +34,19 @@ interface SelectedIndexer {
export function ProwlarrStep({
prowlarrUrl,
prowlarrApiKey,
prowlarrIndexers,
onUpdate,
onNext,
onBack,
}: ProwlarrStepProps) {
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]);
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>(prowlarrIndexers);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Sync configured indexers with parent
useEffect(() => {
onUpdate('prowlarrIndexers', configuredIndexers);
}, [configuredIndexers, onUpdate]);
// Update both local and parent state when indexers change
const handleIndexersChange = (indexers: SelectedIndexer[]) => {
setConfiguredIndexers(indexers);
onUpdate('prowlarrIndexers', indexers);
};
const handleNext = () => {
setErrorMessage(null);
@@ -136,7 +139,7 @@ export function ProwlarrStep({
prowlarrApiKey={prowlarrApiKey}
mode="wizard"
initialIndexers={configuredIndexers}
onIndexersChange={setConfiguredIndexers}
onIndexersChange={handleIndexersChange}
/>
</div>
</div>
+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,31 @@
'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;
postImportCategory?: 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 +58,16 @@ 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>
)}
{client.postImportCategory && (
<p className="text-xs text-purple-600 dark:text-purple-400 truncate" title={`Post-import category: ${client.postImportCategory}`}>
Post-import: {client.postImportCategory}
</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,28 @@ interface DownloadClient {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
postImportCategory?: 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,27 +56,22 @@ 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 with parent when clients change
// Sync downloadDir prop (wizard mode)
useEffect(() => {
if (onClientsChange) {
onClientsChange(clients);
if (downloadDirProp) {
setResolvedDownloadDir(downloadDirProp);
}
}, [clients, onClientsChange]);
// Sync with initialClients prop changes (wizard mode)
useEffect(() => {
if (mode === 'wizard') {
setClients(initialClients);
}
}, [initialClients, mode]);
}, [downloadDirProp]);
const fetchClients = async () => {
setLoading(true);
@@ -93,11 +93,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;
}
@@ -144,7 +159,9 @@ export function DownloadClientManagement({
await fetchClients(); // Refresh list
} else {
// Local removal for wizard mode
setClients(clients.filter(c => c.id !== deleteConfirm.clientId));
const updated = clients.filter(c => c.id !== deleteConfirm.clientId);
setClients(updated);
onClientsChange?.(updated);
}
setDeleteConfirm({ isOpen: false });
@@ -191,15 +208,18 @@ export function DownloadClientManagement({
}
} else {
// Local update for wizard mode
let updated: DownloadClient[];
if (modalState.mode === 'add') {
const newClient = {
...clientData,
id: `temp-${Date.now()}`, // Temporary ID for wizard mode
};
setClients([...clients, newClient]);
updated = [...clients, newClient];
} else {
setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c)));
updated = clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c));
}
setClients(updated);
onClientsChange?.(updated);
}
setModalState({ isOpen: false, mode: 'add' });
@@ -210,8 +230,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 +253,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 +269,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 +285,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 +331,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 +346,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 +420,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, CLIENT_PROTOCOL_MAP } 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,12 @@ interface DownloadClientModalProps {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
postImportCategory?: string;
};
onSave: (client: any) => Promise<void>;
apiMode: 'wizard' | 'settings';
downloadDir?: string;
}
export function DownloadClientModal({
@@ -42,9 +46,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 +62,10 @@ export function DownloadClientModal({
const [remotePath, setRemotePath] = useState('');
const [localPath, setLocalPath] = useState('');
const [category, setCategory] = useState('readmeabook');
const [customPath, setCustomPath] = useState('');
const [postImportCategory, setPostImportCategory] = useState('');
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [fetchingCategories, setFetchingCategories] = useState(false);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
@@ -79,6 +88,8 @@ export function DownloadClientModal({
setRemotePath(initialClient.remotePath || '');
setLocalPath(initialClient.localPath || '');
setCategory(initialClient.category || 'readmeabook');
setCustomPath(initialClient.customPath || '');
setPostImportCategory(initialClient.postImportCategory || '');
} else {
// Add mode defaults
setName(typeName);
@@ -91,9 +102,13 @@ export function DownloadClientModal({
setRemotePath('');
setLocalPath('');
setCategory('readmeabook');
setCustomPath('');
setPostImportCategory('');
}
setTestResult(null);
setErrors({});
setAvailableCategories([]);
setFetchingCategories(false);
}
}, [isOpen, mode, initialClient, type]);
@@ -113,6 +128,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';
@@ -126,6 +145,50 @@ export function DownloadClientModal({
return Object.keys(newErrors).length === 0;
};
const fetchCategories = async () => {
setFetchingCategories(true);
try {
const isPasswordMasked = password === '********';
const categoryData = {
type,
name,
url,
username: username || undefined,
password: isPasswordMasked ? undefined : password,
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
disableSSLVerify,
remotePathMappingEnabled,
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
};
const endpoint = apiMode === 'wizard'
? '/api/setup/download-client-categories'
: '/api/admin/settings/download-clients/categories';
const response = apiMode === 'wizard'
? await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(categoryData),
})
: await fetchWithAuth(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(categoryData),
});
const data = await response.json();
if (response.ok && data.success) {
setAvailableCategories(data.categories || []);
}
} catch {
// Non-critical — categories are optional
} finally {
setFetchingCategories(false);
}
};
const handleTestConnection = async () => {
if (!validate()) {
return;
@@ -140,8 +203,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 } : {}),
@@ -175,6 +239,11 @@ export function DownloadClientModal({
// Handle both endpoint response formats (settings returns message, wizard returns version)
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
setTestResult({ success: true, message });
// Fetch categories for torrent clients after successful connection
if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') {
fetchCategories();
}
} else {
setTestResult({ success: false, message: data.error || 'Connection test failed' });
}
@@ -202,11 +271,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 +286,8 @@ export function DownloadClientModal({
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
category,
customPath: sanitizedCustomPath || undefined,
postImportCategory,
};
if (mode === 'edit' && initialClient) {
@@ -264,7 +338,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 +346,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 +364,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 +378,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 +421,58 @@ 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>
{/* Post-Import Category (torrent clients only) */}
{type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Post-Import Category
</label>
{type === 'qbittorrent' && availableCategories.length > 0 ? (
<select
value={postImportCategory}
onChange={(e) => setPostImportCategory(e.target.value)}
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">None (keep original)</option>
{availableCategories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
) : (
<Input
value={postImportCategory}
onChange={(e) => setPostImportCategory(e.target.value)}
placeholder="e.g. completed"
disabled={fetchingCategories}
/>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
After import, change the download&apos;s category/label in the client. Leave empty to skip.
</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>
@@ -63,17 +63,14 @@ export function IndexerManagement({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Sync with parent when configuredIndexers changes
// In settings mode, the parent fetches indexers asynchronously and passes them
// as initialIndexers after mount. This effect picks up that late-arriving data.
// Wizard mode doesn't need this — it initializes correctly via useState above.
useEffect(() => {
if (onIndexersChange) {
onIndexersChange(configuredIndexers);
if (mode === 'settings') {
setConfiguredIndexers(initialIndexers);
}
}, [configuredIndexers, onIndexersChange]);
// Sync with initialIndexers prop changes
useEffect(() => {
setConfiguredIndexers(initialIndexers);
}, [initialIndexers]);
}, [initialIndexers, mode]);
const fetchIndexers = async () => {
setLoading(true);
@@ -149,17 +146,16 @@ export function IndexerManagement({
};
const handleSave = (config: SavedIndexerConfig) => {
let updated: SavedIndexerConfig[];
if (modalState.mode === 'add') {
// Add new indexer
setConfiguredIndexers([...configuredIndexers, config]);
updated = [...configuredIndexers, config];
} else {
// Update existing indexer
setConfiguredIndexers(
configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
)
updated = configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
);
}
setConfiguredIndexers(updated);
onIndexersChange?.(updated);
};
const handleDelete = (id: number) => {
@@ -175,9 +171,9 @@ export function IndexerManagement({
const confirmDelete = () => {
if (deleteModalState.indexerId) {
setConfiguredIndexers(
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId)
);
const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
setConfiguredIndexers(updated);
onIndexersChange?.(updated);
}
};
@@ -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>
);
}
+21 -3
View File
@@ -5,7 +5,7 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
@@ -22,6 +22,24 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
// Fetch password policy when modal opens
useEffect(() => {
if (!isOpen) return;
const fetchPolicy = async () => {
try {
const response = await fetch('/api/auth/providers');
if (response.ok) {
const data = await response.json();
setAllowWeakPassword(data.allowWeakPassword === true);
}
} catch {
// Default to strict validation on error
}
};
fetchPolicy();
}, [isOpen]);
// Validation errors for individual fields
const [errors, setErrors] = useState({
@@ -47,7 +65,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
if (!newPassword) {
newErrors.newPassword = 'New password is required';
isValid = false;
} else if (newPassword.length < 8) {
} else if (!allowWeakPassword && newPassword.length < 8) {
newErrors.newPassword = 'Password must be at least 8 characters';
isValid = false;
} else if (newPassword === currentPassword) {
@@ -211,7 +229,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
}}
placeholder="Enter your new password"
autoComplete="new-password"
helperText="Must be at least 8 characters"
helperText={allowWeakPassword ? undefined : 'Must be at least 8 characters'}
error={errors.newPassword}
disabled={loading || success}
/>
+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;
+935
View File
@@ -0,0 +1,935 @@
/**
* 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`);
}
}
/** Not applicable for usenet clients */
async getCategories(): Promise<string[]> {
return [];
}
/** Not applicable for usenet clients */
async setCategory(_id: string, _category: string): Promise<void> {
// No-op: post-import category is scoped to torrent clients
}
// =========================================================================
// 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');
}
+53 -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[];
@@ -213,6 +208,55 @@ export class ProwlarrService {
}
}
/**
* Search with multiple query variations to increase coverage
* Fires 2 queries per call: "title author" and "title", then deduplicates by guid
*/
async searchWithVariations(
title: string,
author: string,
filters?: SearchFilters
): Promise<TorrentResult[]> {
const queries = [
`${title} ${author}`,
title,
];
logger.info(`Searching with ${queries.length} query variations`, { queries });
const allResults: TorrentResult[] = [];
for (const query of queries) {
try {
const results = await this.search(query, filters);
logger.info(`Query "${query}" returned ${results.length} results`);
allResults.push(...results);
} catch (error) {
logger.error(`Query "${query}" failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other queries even if one fails
}
}
const deduplicated = this.deduplicateResults(allResults);
logger.info(`Multi-query search: ${allResults.length} total → ${deduplicated.length} after dedup (${allResults.length - deduplicated.length} duplicates removed)`);
return deduplicated;
}
/**
* Deduplicate results by guid, preserving order (first occurrence wins)
*/
private deduplicateResults(results: TorrentResult[]): TorrentResult[] {
const seen = new Set<string>();
return results.filter(result => {
if (seen.has(result.guid)) {
return false;
}
seen.add(result.guid);
return true;
});
}
/**
* Get list of configured indexers
*/
@@ -560,20 +604,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")
+239 -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(','));
@@ -696,6 +729,26 @@ export class QBittorrentService {
}
}
/**
* Get all configured categories from qBittorrent
*/
async getCategories(): Promise<string[]> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
});
return Object.keys(response.data || {});
} catch (error) {
logger.error('Failed to get categories', { error: error instanceof Error ? error.message : String(error) });
return [];
}
}
/**
* Set category for torrent
*/
@@ -729,13 +782,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 +903,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 +1000,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 +1170,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 +1251,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 +1277,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

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