mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-13 01:30:11 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b013538b63 | |||
| bceb13f4dd | |||
| 6b83e5dac1 | |||
| af0eaceb98 | |||
| 1d25f7f7b2 | |||
| 4e84887d33 | |||
| 4a38dd3da8 | |||
| f9947b745e | |||
| 7e53f037af | |||
| 4b90b35748 | |||
| d7acd67aa4 | |||
| a663452658 |
@@ -71,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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):**
|
||||
|
||||
@@ -32,6 +32,7 @@ Configurable Audible region for accurate metadata matching across different inte
|
||||
- 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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.3",
|
||||
"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;
|
||||
@@ -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")
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface PathsSettings {
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
audiobookPathTemplate?: string;
|
||||
ebookPathTemplate?: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient,
|
||||
DownloadClientType,
|
||||
ProtocolType,
|
||||
DownloadInfo,
|
||||
DownloadStatus,
|
||||
AddDownloadOptions,
|
||||
ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('SABnzbd');
|
||||
|
||||
@@ -81,7 +91,10 @@ export interface DownloadProgress {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export class SABnzbdService {
|
||||
export class SABnzbdService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'sabnzbd';
|
||||
readonly protocol: ProtocolType = 'usenet';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
@@ -123,13 +136,13 @@ export class SABnzbdService {
|
||||
/**
|
||||
* Test connection to SABnzbd
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
// Validate API key is not empty
|
||||
if (!this.apiKey || this.apiKey.trim() === '') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API key is required for SABnzbd',
|
||||
message: 'API key is required for SABnzbd',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,7 +164,7 @@ export class SABnzbdService {
|
||||
const errorMsg = response.data?.error || 'Authentication failed';
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg.includes('API Key')
|
||||
message: errorMsg.includes('API Key')
|
||||
? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).'
|
||||
: errorMsg,
|
||||
};
|
||||
@@ -160,7 +173,7 @@ export class SABnzbdService {
|
||||
// Queue endpoint requires auth - if we got here, API key is valid
|
||||
// Now get the version
|
||||
const version = await this.getVersion();
|
||||
return { success: true, version };
|
||||
return { success: true, version, message: `Connected to SABnzbd v${version}` };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -168,28 +181,28 @@ export class SABnzbdService {
|
||||
if (errorMessage.includes('ECONNREFUSED')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
|
||||
message: 'Connection refused. Is SABnzbd running and accessible at this URL?',
|
||||
};
|
||||
} else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection timed out. Check the URL and network connectivity.',
|
||||
message: 'Connection timed out. Check the URL and network connectivity.',
|
||||
};
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
|
||||
message: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
|
||||
};
|
||||
} else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
|
||||
message: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -447,8 +460,16 @@ export class SABnzbdService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add NZB by URL
|
||||
* Returns the NZB ID
|
||||
* Add NZB to SABnzbd
|
||||
*
|
||||
* Downloads the NZB file content from the source URL (typically a Prowlarr proxy URL)
|
||||
* and uploads it directly to SABnzbd via mode=addfile. This ensures SABnzbd does not
|
||||
* need network access to Prowlarr — RMAB acts as the intermediary, matching the pattern
|
||||
* used by qBittorrent for .torrent files.
|
||||
*
|
||||
* @param url - NZB download URL (usually a Prowlarr proxy URL)
|
||||
* @param options - Category, priority, and pause options
|
||||
* @returns SABnzbd NZB ID (nzo_id)
|
||||
*/
|
||||
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
||||
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
|
||||
@@ -459,20 +480,70 @@ export class SABnzbdService {
|
||||
// This syncs the category path with SABnzbd's complete_dir and handles path mapping
|
||||
await this.ensureCategory();
|
||||
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'addurl',
|
||||
name: url,
|
||||
cat: category,
|
||||
priority: this.mapPriority(options?.priority),
|
||||
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
// Download the NZB file content from the source URL
|
||||
// This decouples SABnzbd from needing direct network access to Prowlarr
|
||||
let nzbBuffer: Buffer;
|
||||
let filename: string;
|
||||
|
||||
try {
|
||||
logger.info('Downloading NZB file from source URL...');
|
||||
|
||||
const nzbResponse = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
// Use the same SSL settings as the SABnzbd client if the NZB URL
|
||||
// happens to be served over HTTPS with a self-signed cert
|
||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||
});
|
||||
|
||||
nzbBuffer = Buffer.from(nzbResponse.data);
|
||||
|
||||
if (nzbBuffer.length === 0) {
|
||||
throw new Error('NZB file is empty (0 bytes)');
|
||||
}
|
||||
|
||||
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
|
||||
|
||||
// Extract filename from Content-Disposition header, URL path, or use fallback
|
||||
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status) {
|
||||
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Upload NZB file content to SABnzbd via mode=addfile (multipart POST)
|
||||
const formData = new FormData();
|
||||
formData.append('nzbfile', nzbBuffer, {
|
||||
filename,
|
||||
contentType: 'application/x-nzb',
|
||||
});
|
||||
formData.append('mode', 'addfile');
|
||||
formData.append('cat', category);
|
||||
formData.append('priority', this.mapPriority(options?.priority));
|
||||
formData.append('pp', '3'); // Post-processing: +Repair, +Unpack, +Delete
|
||||
formData.append('output', 'json');
|
||||
formData.append('apikey', this.apiKey);
|
||||
|
||||
const response = await this.client.post('/api', formData, {
|
||||
headers: formData.getHeaders(),
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
|
||||
if (response.data?.status === false) {
|
||||
throw new Error(response.data.error || 'Failed to add NZB');
|
||||
throw new Error(response.data.error || 'Failed to add NZB to SABnzbd');
|
||||
}
|
||||
|
||||
const nzbIds = response.data?.nzo_ids;
|
||||
@@ -486,6 +557,39 @@ export class SABnzbdService {
|
||||
return nzbId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable filename for the NZB upload.
|
||||
* Tries Content-Disposition header first, then URL path, then falls back to a default.
|
||||
*/
|
||||
private extractNZBFilename(url: string, contentDisposition?: string): string {
|
||||
// Try Content-Disposition header (e.g., 'attachment; filename="My.Audiobook.nzb"')
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
|
||||
if (match?.[1]) {
|
||||
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
|
||||
if (decoded) {
|
||||
logger.debug(`Filename from Content-Disposition: ${decoded}`);
|
||||
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try extracting from URL path (before query params)
|
||||
try {
|
||||
const urlPath = new URL(url).pathname;
|
||||
const basename = urlPath.split('/').pop();
|
||||
if (basename && basename.length > 0 && basename !== 'download') {
|
||||
const decoded = decodeURIComponent(basename);
|
||||
logger.debug(`Filename from URL path: ${decoded}`);
|
||||
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
|
||||
}
|
||||
} catch {
|
||||
// URL parsing failed, fall through to default
|
||||
}
|
||||
|
||||
return 'download.nzb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NZB info by ID
|
||||
* Checks queue first, then history
|
||||
@@ -663,6 +767,118 @@ export class SABnzbdService {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Add a download via the unified interface.
|
||||
* Delegates to addNZB with mapped options.
|
||||
*/
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
const priorityMap: Record<string, 'low' | 'normal' | 'high' | 'force'> = {
|
||||
low: 'low',
|
||||
normal: 'normal',
|
||||
high: 'high',
|
||||
force: 'force',
|
||||
};
|
||||
|
||||
return this.addNZB(url, {
|
||||
category: options?.category,
|
||||
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
|
||||
paused: options?.paused,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download status via the unified interface.
|
||||
* Checks both queue and history to find the NZB.
|
||||
*/
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const nzbInfo = await this.getNZB(id);
|
||||
if (!nzbInfo) {
|
||||
return null;
|
||||
}
|
||||
return this.mapNZBInfoToDownloadInfo(nzbInfo);
|
||||
}
|
||||
|
||||
/** Pause a download via the unified interface */
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
return this.pauseNZB(id);
|
||||
}
|
||||
|
||||
/** Resume a download via the unified interface */
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
return this.resumeNZB(id);
|
||||
}
|
||||
|
||||
/** Delete a download via the unified interface */
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
return this.deleteNZB(id, deleteFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download cleanup via the unified interface.
|
||||
* Archives the completed NZB from SABnzbd history.
|
||||
*/
|
||||
async postProcess(id: string): Promise<void> {
|
||||
await this.archiveCompletedNZB(id);
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NZBInfo to the unified DownloadInfo format.
|
||||
*/
|
||||
private mapNZBInfoToDownloadInfo(nzb: NZBInfo): DownloadInfo {
|
||||
return {
|
||||
id: nzb.nzbId,
|
||||
name: nzb.name,
|
||||
size: nzb.size,
|
||||
bytesDownloaded: Math.round(nzb.size * nzb.progress),
|
||||
progress: nzb.progress,
|
||||
status: this.mapNZBStatusToDownloadStatus(nzb.status),
|
||||
downloadSpeed: nzb.downloadSpeed,
|
||||
eta: nzb.timeLeft,
|
||||
category: nzb.category,
|
||||
downloadPath: nzb.downloadPath,
|
||||
completedAt: nzb.completedAt,
|
||||
errorMessage: nzb.errorMessage,
|
||||
// Usenet has no seeding concept
|
||||
seedingTime: undefined,
|
||||
ratio: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map SABnzbd NZB status to unified DownloadStatus.
|
||||
*/
|
||||
private mapNZBStatusToDownloadStatus(status: NZBStatus): DownloadStatus {
|
||||
const statusMap: Record<NZBStatus, DownloadStatus> = {
|
||||
downloading: 'downloading',
|
||||
queued: 'queued',
|
||||
paused: 'paused',
|
||||
extracting: 'processing',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
repairing: 'processing',
|
||||
};
|
||||
|
||||
return statusMap[status] || 'downloading';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Legacy Methods (used internally and by direct callers)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get download progress from queue item
|
||||
*/
|
||||
@@ -796,8 +1012,11 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
|
||||
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
|
||||
}
|
||||
|
||||
// Get download_dir from main config
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
// Get download_dir from main config, applying customPath if configured
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath
|
||||
? require('path').join(baseDir, clientConfig.customPath)
|
||||
: baseDir;
|
||||
|
||||
logger.debug('RMAB download_dir from config', { downloadDir });
|
||||
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* Component: Transmission Integration Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient,
|
||||
DownloadClientType,
|
||||
ProtocolType,
|
||||
DownloadInfo,
|
||||
DownloadStatus,
|
||||
AddDownloadOptions,
|
||||
ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
// Handle both ESM and CommonJS imports
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
|
||||
const logger = RMABLogger.create('Transmission');
|
||||
|
||||
/** Transmission RPC numeric status codes */
|
||||
type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/** Transmission torrent fields we request */
|
||||
interface TransmissionTorrent {
|
||||
hashString: string;
|
||||
name: string;
|
||||
totalSize: number;
|
||||
downloadedEver: number;
|
||||
percentDone: number;
|
||||
status: TransmissionStatus;
|
||||
rateDownload: number;
|
||||
eta: number;
|
||||
labels: string[];
|
||||
downloadDir: string;
|
||||
doneDate: number;
|
||||
errorString: string;
|
||||
error: number;
|
||||
secondsSeeding: number;
|
||||
uploadRatio: number;
|
||||
uploadedEver: number;
|
||||
}
|
||||
|
||||
/** Fields we request from the Transmission RPC API */
|
||||
const TORRENT_FIELDS = [
|
||||
'hashString',
|
||||
'name',
|
||||
'totalSize',
|
||||
'downloadedEver',
|
||||
'percentDone',
|
||||
'status',
|
||||
'rateDownload',
|
||||
'eta',
|
||||
'labels',
|
||||
'downloadDir',
|
||||
'doneDate',
|
||||
'errorString',
|
||||
'error',
|
||||
'secondsSeeding',
|
||||
'uploadRatio',
|
||||
'uploadedEver',
|
||||
];
|
||||
|
||||
export class TransmissionService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'transmission';
|
||||
readonly protocol: ProtocolType = 'torrent';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private username: string;
|
||||
private password: string;
|
||||
private defaultSavePath: string;
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
private pathMappingConfig: PathMappingConfig;
|
||||
private sessionId: string = '';
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
defaultSavePath: string = '/downloads',
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false,
|
||||
pathMappingConfig?: PathMappingConfig
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
logger.info('[Transmission] SSL certificate verification disabled');
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an RPC request to Transmission.
|
||||
* Handles CSRF token (409 → capture X-Transmission-Session-Id → retry).
|
||||
*/
|
||||
private async rpc(method: string, args?: Record<string, any>): Promise<any> {
|
||||
const body = { method, arguments: args };
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.sessionId) {
|
||||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||||
}
|
||||
|
||||
// Add Basic Auth if credentials provided
|
||||
const auth = this.username
|
||||
? { username: this.username, password: this.password }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
// Capture CSRF token and retry
|
||||
const newSessionId = error.response.headers['x-transmission-session-id'];
|
||||
if (newSessionId) {
|
||||
this.sessionId = newSessionId;
|
||||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||||
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const data = await this.rpc('session-get', { fields: ['version'] });
|
||||
|
||||
if (data.result !== 'success') {
|
||||
return { success: false, message: `Transmission RPC error: ${data.result}` };
|
||||
}
|
||||
|
||||
const version = data.arguments?.version;
|
||||
return {
|
||||
success: true,
|
||||
version,
|
||||
message: `Connected to Transmission${version ? ` ${version}` : ''}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const code = error.code;
|
||||
const status = error.response?.status;
|
||||
|
||||
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
|
||||
code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) {
|
||||
return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` };
|
||||
}
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` };
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
|
||||
return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` };
|
||||
}
|
||||
if (code === 'ENOTFOUND') {
|
||||
return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` };
|
||||
}
|
||||
if (status === 401) {
|
||||
return { success: false, message: 'Authentication failed. Check your username and password.' };
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('Connection test failed', { error: message });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
const category = options?.category || this.defaultCategory;
|
||||
|
||||
if (url.startsWith('magnet:')) {
|
||||
return this.addMagnetLink(url, category, options);
|
||||
} else {
|
||||
return this.addTorrentFile(url, category, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async addMagnetLink(
|
||||
magnetUrl: string,
|
||||
category: string,
|
||||
options?: AddDownloadOptions
|
||||
): Promise<string> {
|
||||
const infoHash = this.extractHashFromMagnet(magnetUrl);
|
||||
if (!infoHash) {
|
||||
throw new Error('Invalid magnet link - could not extract info_hash');
|
||||
}
|
||||
|
||||
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
await this.getTorrentByHash(infoHash);
|
||||
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
return infoHash;
|
||||
} catch {
|
||||
// Torrent doesn't exist, continue
|
||||
}
|
||||
|
||||
const localSavePath = this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
const args: Record<string, any> = {
|
||||
filename: magnetUrl,
|
||||
'download-dir': remoteSavePath,
|
||||
paused: options?.paused || false,
|
||||
labels: [category],
|
||||
};
|
||||
|
||||
logger.info('[Transmission] Adding magnet link...');
|
||||
const data = await this.rpc('torrent-add', args);
|
||||
|
||||
if (data.result !== 'success') {
|
||||
throw new Error(`Transmission rejected magnet link: ${data.result}`);
|
||||
}
|
||||
|
||||
// torrent-add returns torrent-added or torrent-duplicate
|
||||
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
|
||||
if (!added) {
|
||||
throw new Error('Transmission did not return torrent info after adding');
|
||||
}
|
||||
|
||||
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
|
||||
await this.disableSeedLimits(added.hashString || infoHash);
|
||||
|
||||
logger.info(`Successfully added magnet link: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
private async addTorrentFile(
|
||||
torrentUrl: string,
|
||||
category: string,
|
||||
options?: AddDownloadOptions
|
||||
): Promise<string> {
|
||||
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
|
||||
|
||||
let torrentResponse;
|
||||
try {
|
||||
torrentResponse = await axios.get(torrentUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Check if response body is a magnet link
|
||||
if (torrentResponse.data.length > 0) {
|
||||
const responseText = torrentResponse.data.toString();
|
||||
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
|
||||
if (magnetMatch) {
|
||||
logger.info('Response body is a magnet link');
|
||||
return this.addMagnetLink(magnetMatch[0], category, options);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error) || !error.response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
if (status >= 300 && status < 400) {
|
||||
const location = error.response.headers['location'];
|
||||
if (location && location.startsWith('magnet:')) {
|
||||
return this.addMagnetLink(location, category, options);
|
||||
}
|
||||
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
|
||||
try {
|
||||
torrentResponse = await axios.get(location, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Failed to download torrent file after redirect');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid redirect location: ${location}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to download torrent: HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const torrentBuffer = Buffer.from(torrentResponse.data);
|
||||
|
||||
let parsedTorrentData: any;
|
||||
try {
|
||||
parsedTorrentData = await parseTorrent(torrentBuffer);
|
||||
} catch {
|
||||
throw new Error('Invalid .torrent file - failed to parse');
|
||||
}
|
||||
|
||||
const infoHash = parsedTorrentData.infoHash;
|
||||
if (!infoHash) {
|
||||
throw new Error('Failed to extract info_hash from .torrent file');
|
||||
}
|
||||
|
||||
logger.info(`Extracted info_hash: ${infoHash}`);
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
await this.getTorrentByHash(infoHash);
|
||||
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
return infoHash;
|
||||
} catch {
|
||||
// Torrent doesn't exist, continue
|
||||
}
|
||||
|
||||
const localSavePath = this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Transmission accepts base64-encoded .torrent content via 'metainfo' field
|
||||
const metainfo = torrentBuffer.toString('base64');
|
||||
|
||||
const args: Record<string, any> = {
|
||||
metainfo,
|
||||
'download-dir': remoteSavePath,
|
||||
paused: options?.paused || false,
|
||||
labels: [category],
|
||||
};
|
||||
|
||||
logger.info('[Transmission] Adding .torrent file...');
|
||||
const data = await this.rpc('torrent-add', args);
|
||||
|
||||
if (data.result !== 'success') {
|
||||
throw new Error(`Transmission rejected .torrent file: ${data.result}`);
|
||||
}
|
||||
|
||||
// torrent-add returns torrent-added or torrent-duplicate
|
||||
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
|
||||
|
||||
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
|
||||
await this.disableSeedLimits(added?.hashString || infoHash);
|
||||
|
||||
logger.info(`Successfully added torrent: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const maxRetries = 3;
|
||||
const initialDelayMs = 500;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
return this.mapToDownloadInfo(torrent);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (!message.includes('not found')) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt === maxRetries) {
|
||||
return null;
|
||||
}
|
||||
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
||||
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-stop', { ids: [torrent.hashString] });
|
||||
logger.info(`Paused torrent: ${id}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to pause torrent');
|
||||
}
|
||||
}
|
||||
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-start', { ids: [torrent.hashString] });
|
||||
logger.info(`Resumed torrent: ${id}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to resume torrent');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-remove', {
|
||||
ids: [torrent.hashString],
|
||||
'delete-local-data': deleteFiles,
|
||||
});
|
||||
logger.info(`Deleted torrent: ${id}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to delete torrent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download cleanup.
|
||||
* No-op for Transmission — torrents continue seeding until the
|
||||
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
|
||||
*/
|
||||
async postProcess(_id: string): Promise<void> {
|
||||
// No-op: torrents are managed by the seeding cleanup scheduler
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available categories/labels.
|
||||
* Transmission uses free-form labels — no predefined list to fetch.
|
||||
*/
|
||||
async getCategories(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the label for a torrent.
|
||||
* Uses the torrent-set RPC method to replace the labels array.
|
||||
*/
|
||||
async setCategory(id: string, category: string): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-set', { ids: [torrent.hashString], labels: [category] });
|
||||
logger.info(`Set label for torrent ${id}: ${category}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to set label', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to set torrent label');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Disable Transmission's global seed ratio and idle time limits for a torrent.
|
||||
* Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle
|
||||
* via the cleanup-seeded-torrents processor using per-indexer seeding times.
|
||||
*/
|
||||
private async disableSeedLimits(hashOrId: string): Promise<void> {
|
||||
try {
|
||||
await this.rpc('torrent-set', {
|
||||
ids: [hashOrId],
|
||||
seedRatioMode: 2,
|
||||
seedIdleMode: 2,
|
||||
});
|
||||
logger.info(`Disabled seed limits for torrent: ${hashOrId}`);
|
||||
} catch (error) {
|
||||
// Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules
|
||||
logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a torrent by its info hash.
|
||||
*/
|
||||
private async getTorrentByHash(hash: string): Promise<TransmissionTorrent> {
|
||||
const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS });
|
||||
|
||||
if (data.result !== 'success') {
|
||||
throw new Error(`Transmission RPC error: ${data.result}`);
|
||||
}
|
||||
|
||||
const torrents: TransmissionTorrent[] = data.arguments?.torrents || [];
|
||||
if (torrents.length === 0) {
|
||||
throw new Error(`Torrent ${hash} not found`);
|
||||
}
|
||||
|
||||
return torrents[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Transmission torrent to unified DownloadInfo.
|
||||
*/
|
||||
private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo {
|
||||
// Return raw download path (path mapping is applied downstream by the consumer)
|
||||
const downloadPath = path.join(torrent.downloadDir, torrent.name);
|
||||
|
||||
return {
|
||||
id: torrent.hashString,
|
||||
name: torrent.name,
|
||||
size: torrent.totalSize,
|
||||
bytesDownloaded: torrent.downloadedEver,
|
||||
progress: torrent.percentDone,
|
||||
status: this.mapStatus(torrent.status, torrent.error),
|
||||
downloadSpeed: torrent.rateDownload,
|
||||
eta: torrent.eta < 0 ? 0 : torrent.eta,
|
||||
category: torrent.labels?.[0] || '',
|
||||
downloadPath,
|
||||
completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined,
|
||||
errorMessage: torrent.error > 0 ? torrent.errorString : undefined,
|
||||
seedingTime: torrent.secondsSeeding,
|
||||
ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Transmission numeric status to unified DownloadStatus.
|
||||
* 0=stopped, 1=check-pending, 2=checking, 3=download-pending,
|
||||
* 4=downloading, 5=seed-pending, 6=seeding
|
||||
*/
|
||||
private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus {
|
||||
if (errorCode > 0) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const statusMap: Record<TransmissionStatus, DownloadStatus> = {
|
||||
0: 'paused',
|
||||
1: 'checking',
|
||||
2: 'checking',
|
||||
3: 'queued',
|
||||
4: 'downloading',
|
||||
5: 'seeding',
|
||||
6: 'seeding',
|
||||
};
|
||||
|
||||
return statusMap[status] || 'downloading';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract info_hash from magnet link.
|
||||
*/
|
||||
private extractHashFromMagnet(magnetUrl: string): string | null {
|
||||
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
|
||||
if (match) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern)
|
||||
let transmissionServiceInstance: TransmissionService | null = null;
|
||||
let configLoaded = false;
|
||||
|
||||
export async function getTransmissionService(): Promise<TransmissionService> {
|
||||
if (transmissionServiceInstance && configLoaded) {
|
||||
return transmissionServiceInstance;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||
const configService = await getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
|
||||
logger.info('[Transmission] Loading configuration from download client manager...');
|
||||
const clientConfig = await manager.getClientForProtocol('torrent');
|
||||
|
||||
if (!clientConfig) {
|
||||
throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.');
|
||||
}
|
||||
|
||||
if (clientConfig.type !== 'transmission') {
|
||||
throw new Error(`Expected Transmission client but found ${clientConfig.type}`);
|
||||
}
|
||||
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath
|
||||
? require('path').join(baseDir, clientConfig.customPath)
|
||||
: baseDir;
|
||||
|
||||
const pathMappingConfig: PathMappingConfig = {
|
||||
enabled: clientConfig.remotePathMappingEnabled || false,
|
||||
remotePath: clientConfig.remotePath || '',
|
||||
localPath: clientConfig.localPath || '',
|
||||
};
|
||||
|
||||
logger.info('[Transmission] Config loaded:', {
|
||||
name: clientConfig.name,
|
||||
hasUrl: !!clientConfig.url,
|
||||
hasUsername: !!clientConfig.username,
|
||||
hasPassword: !!clientConfig.password,
|
||||
disableSSLVerify: clientConfig.disableSSLVerify,
|
||||
downloadDir,
|
||||
pathMappingEnabled: pathMappingConfig.enabled,
|
||||
});
|
||||
|
||||
if (!clientConfig.url) {
|
||||
throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
transmissionServiceInstance = new TransmissionService(
|
||||
clientConfig.url,
|
||||
clientConfig.username || '',
|
||||
clientConfig.password || '',
|
||||
downloadDir,
|
||||
clientConfig.category || 'readmeabook',
|
||||
clientConfig.disableSSLVerify,
|
||||
pathMappingConfig
|
||||
);
|
||||
|
||||
const connectionResult = await transmissionServiceInstance.testConnection();
|
||||
if (!connectionResult.success) {
|
||||
throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
logger.info('[Transmission] Connection test successful');
|
||||
configLoaded = true;
|
||||
return transmissionServiceInstance;
|
||||
} catch (error) {
|
||||
logger.error('[Transmission] Failed to initialize service', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
transmissionServiceInstance = null;
|
||||
configLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateTransmissionService(): void {
|
||||
transmissionServiceInstance = null;
|
||||
configLoaded = false;
|
||||
logger.info('[Transmission] Service singleton invalidated');
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Component: Download Client Interface
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Defines the contract all download clients must implement.
|
||||
* Enables protocol-agnostic download management across torrent and usenet clients.
|
||||
*/
|
||||
|
||||
// =========================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// =========================================================================
|
||||
|
||||
/** Supported download client types — single source of truth */
|
||||
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const;
|
||||
|
||||
/** Identifies the specific download client software */
|
||||
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
|
||||
|
||||
/** Human-readable display names for each client type */
|
||||
export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
|
||||
qbittorrent: 'qBittorrent',
|
||||
sabnzbd: 'SABnzbd',
|
||||
nzbget: 'NZBGet',
|
||||
transmission: 'Transmission',
|
||||
};
|
||||
|
||||
/** Get display name for a client type, falling back to the raw type */
|
||||
export function getClientDisplayName(type: string): string {
|
||||
return CLIENT_DISPLAY_NAMES[type as DownloadClientType] || type;
|
||||
}
|
||||
|
||||
/** The download protocol a client operates on */
|
||||
export type ProtocolType = 'torrent' | 'usenet';
|
||||
|
||||
/** Maps each client type to its download protocol */
|
||||
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
|
||||
qbittorrent: 'torrent',
|
||||
sabnzbd: 'usenet',
|
||||
nzbget: 'usenet',
|
||||
transmission: 'torrent',
|
||||
};
|
||||
|
||||
/** Unified download status across all clients */
|
||||
export type DownloadStatus =
|
||||
| 'downloading'
|
||||
| 'completed'
|
||||
| 'seeding'
|
||||
| 'paused'
|
||||
| 'queued'
|
||||
| 'failed'
|
||||
| 'processing'
|
||||
| 'checking';
|
||||
|
||||
// =========================================================================
|
||||
// DATA INTERFACES
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Unified download information returned by all clients.
|
||||
* Normalizes torrent and NZB data into a single shape.
|
||||
*/
|
||||
export interface DownloadInfo {
|
||||
/** Client-assigned identifier (torrent hash or NZB ID) */
|
||||
id: string;
|
||||
/** Display name of the download */
|
||||
name: string;
|
||||
/** Total size in bytes */
|
||||
size: number;
|
||||
/** Bytes downloaded so far */
|
||||
bytesDownloaded: number;
|
||||
/** Download progress from 0.0 to 1.0 */
|
||||
progress: number;
|
||||
/** Normalized download status */
|
||||
status: DownloadStatus;
|
||||
/** Current download speed in bytes/sec */
|
||||
downloadSpeed: number;
|
||||
/** Estimated time remaining in seconds */
|
||||
eta: number;
|
||||
/** Category/label assigned to this download */
|
||||
category: string;
|
||||
/** Filesystem path where download is stored (available after completion) */
|
||||
downloadPath?: string;
|
||||
/** When the download completed */
|
||||
completedAt?: Date;
|
||||
/** Error message if download failed */
|
||||
errorMessage?: string;
|
||||
/** Time spent seeding in seconds (torrent clients only) */
|
||||
seedingTime?: number;
|
||||
/** Upload/download ratio (torrent clients only) */
|
||||
ratio?: number;
|
||||
}
|
||||
|
||||
/** Options for adding a new download */
|
||||
export interface AddDownloadOptions {
|
||||
/** Category/label to assign */
|
||||
category?: string;
|
||||
/** Priority level (interpretation varies by client) */
|
||||
priority?: string;
|
||||
/** Whether to add in paused state */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a connection test */
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOWNLOAD CLIENT INTERFACE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* IDownloadClient — the contract every download client must implement.
|
||||
*
|
||||
* Provides a unified API for managing downloads across different protocols
|
||||
* and client software. Processors interact with this interface exclusively,
|
||||
* enabling new download clients to be added without modifying consumer code.
|
||||
*
|
||||
* To add a new client (e.g., Transmission):
|
||||
* 1. Create a service class implementing IDownloadClient
|
||||
* 2. Add the type to DownloadClientType
|
||||
* 3. Add factory case in DownloadClientManager
|
||||
*/
|
||||
export interface IDownloadClient {
|
||||
/** Identifies the client software (e.g., 'qbittorrent', 'sabnzbd') */
|
||||
readonly clientType: DownloadClientType;
|
||||
/** The protocol this client operates on */
|
||||
readonly protocol: ProtocolType;
|
||||
|
||||
/**
|
||||
* Test the connection to the download client.
|
||||
* @returns Connection test result with success/failure and optional version
|
||||
*/
|
||||
testConnection(): Promise<ConnectionTestResult>;
|
||||
|
||||
/**
|
||||
* Add a new download.
|
||||
* @param url - Download URL (magnet link, .torrent URL, or .nzb URL)
|
||||
* @param options - Optional download settings
|
||||
* @returns Client-assigned download ID (torrent hash or NZB ID)
|
||||
*/
|
||||
addDownload(url: string, options?: AddDownloadOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Get current status of a download.
|
||||
* Includes retry logic for race conditions (e.g., torrent not immediately available after adding).
|
||||
* @param id - Download ID returned by addDownload
|
||||
* @returns Download info, or null if not found
|
||||
*/
|
||||
getDownload(id: string): Promise<DownloadInfo | null>;
|
||||
|
||||
/**
|
||||
* Pause a download.
|
||||
* @param id - Download ID
|
||||
*/
|
||||
pauseDownload(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resume a paused download.
|
||||
* @param id - Download ID
|
||||
*/
|
||||
resumeDownload(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a download from the client.
|
||||
* @param id - Download ID
|
||||
* @param deleteFiles - Whether to also delete downloaded files (default: false)
|
||||
*/
|
||||
deleteDownload(id: string, deleteFiles?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Perform post-download cleanup specific to the client.
|
||||
* - qBittorrent: No-op (torrents continue seeding, handled by cleanup job)
|
||||
* - SABnzbd: Archives the completed NZB from history
|
||||
* @param id - Download ID
|
||||
*/
|
||||
postProcess(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get available categories/labels from the download client.
|
||||
* - qBittorrent: Returns configured category names
|
||||
* - Transmission: Returns empty array (uses free-form labels)
|
||||
* - Usenet clients: Returns empty array (feature scoped to torrent clients)
|
||||
*/
|
||||
getCategories(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Set the category/label for a download.
|
||||
* - qBittorrent: Sets torrent category
|
||||
* - Transmission: Sets torrent label
|
||||
* - Usenet clients: No-op
|
||||
* @param id - Download ID
|
||||
* @param category - Category/label name to assign
|
||||
*/
|
||||
setCategory(id: string, category: string): Promise<void>;
|
||||
}
|
||||
@@ -220,3 +220,68 @@ export async function isLocalAdmin(userId: string): Promise<boolean> {
|
||||
|
||||
return user.isSetupAdmin && user.plexId.startsWith('local-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require setup to be incomplete
|
||||
* Blocks access to setup-only endpoints after initial setup is finished.
|
||||
* Returns 403 if setup is already complete, otherwise invokes the handler.
|
||||
*/
|
||||
export async function requireSetupIncomplete(
|
||||
request: NextRequest,
|
||||
handler: (request: NextRequest) => Promise<NextResponse>
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: 'setup_completed' },
|
||||
});
|
||||
|
||||
if (config?.value === 'true') {
|
||||
logger.warn('Setup endpoint called after setup is complete', {
|
||||
path: request.nextUrl.pathname,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'Setup has already been completed',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If database is not ready, setup is definitely not complete — allow through
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require setup incomplete OR authenticated admin
|
||||
* For endpoints shared between the setup wizard and admin settings.
|
||||
* Allows access during setup (no auth needed) or after setup (admin auth required).
|
||||
*/
|
||||
export async function requireSetupIncompleteOrAdmin(
|
||||
request: NextRequest,
|
||||
handler: (request: NextRequest) => Promise<NextResponse>
|
||||
): Promise<NextResponse> {
|
||||
let setupComplete = false;
|
||||
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: 'setup_completed' },
|
||||
});
|
||||
setupComplete = config?.value === 'true';
|
||||
} catch {
|
||||
// If database is not ready, setup is definitely not complete — allow through
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
if (!setupComplete) {
|
||||
// Setup in progress — allow unauthenticated access (setup wizard)
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
// Setup is complete — require admin authentication
|
||||
return requireAuth(request, (authenticatedReq) =>
|
||||
requireAdmin(authenticatedReq, () => handler(request))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* Component: Cleanup Seeded Torrents Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Cleans up torrents that have met their seeding requirements
|
||||
* Cleans up downloads that have met their seeding requirements.
|
||||
* Uses the IDownloadClient interface for client-agnostic operation.
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
|
||||
export interface CleanupSeededTorrentsPayload {
|
||||
jobId?: string;
|
||||
@@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
try {
|
||||
// Get indexer configuration with per-indexer seeding times
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
|
||||
if (!indexersConfigStr) {
|
||||
@@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
|
||||
// Find all completed requests + soft-deleted requests (orphaned downloads)
|
||||
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
|
||||
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
|
||||
// Before deleting torrent, we check if other active requests are using it
|
||||
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
|
||||
// NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too.
|
||||
// Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type).
|
||||
const completedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
|
||||
OR: [
|
||||
// Active requests that are fully available (scanned by Plex/ABS)
|
||||
// Audiobook requests that are fully available (matched in Plex/ABS)
|
||||
{
|
||||
type: 'audiobook',
|
||||
status: 'available',
|
||||
deletedAt: null,
|
||||
},
|
||||
// Soft-deleted requests (orphaned downloads)
|
||||
// We'll check if torrent is shared with active requests before deletion
|
||||
// Ebook requests that are fully downloaded (terminal state for ebooks)
|
||||
{
|
||||
type: 'ebook',
|
||||
status: 'downloaded',
|
||||
deletedAt: null,
|
||||
},
|
||||
// Soft-deleted requests of any type (orphaned downloads)
|
||||
{
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
@@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
take: 100, // Limit to 100 requests per run
|
||||
});
|
||||
|
||||
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
|
||||
logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`);
|
||||
|
||||
let cleaned = 0;
|
||||
let skipped = 0;
|
||||
let noConfig = 0;
|
||||
const deletedHashes = new Set<string>(); // Track torrents already deleted this run
|
||||
|
||||
for (const request of completedRequests) {
|
||||
try {
|
||||
@@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SABnzbd downloads - Usenet doesn't have seeding concept
|
||||
// Skip Usenet downloads - no seeding concept
|
||||
if (downloadHistory.nzbId && !downloadHistory.torrentHash) {
|
||||
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
|
||||
// For soft-deleted Usenet requests, hard delete immediately (no seeding needed)
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
|
||||
logger.info(`Hard-deleted orphaned Usenet request ${request.id}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process torrent downloads
|
||||
if (!downloadHistory.torrentHash) {
|
||||
// Only process downloads that have a client ID
|
||||
if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the download client ID and protocol
|
||||
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!;
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
if (!protocol) {
|
||||
logger.warn(`Unknown download client type: ${clientType}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
|
||||
// Get torrent info from qBittorrent to check seeding time
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
// Skip if this torrent was already deleted earlier in this run
|
||||
if (deletedHashes.has(clientId.toLowerCase())) {
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`);
|
||||
}
|
||||
cleaned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let torrent;
|
||||
// Get download info from the appropriate client via the interface
|
||||
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||
|
||||
if (!client) {
|
||||
logger.warn(`No ${clientType} client configured, skipping request ${request.id}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let downloadInfo;
|
||||
try {
|
||||
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
downloadInfo = await client.getDownload(clientId);
|
||||
} catch (error) {
|
||||
// Torrent might already be deleted, skip
|
||||
// Download not found in client (already removed), skip
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!downloadInfo) {
|
||||
// Download not found in client (already removed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if seeding time requirement is met
|
||||
const actualSeedingTime = torrent.seeding_time || 0;
|
||||
const actualSeedingTime = downloadInfo.seedingTime || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
|
||||
if (!hasMetRequirement) {
|
||||
@@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
|
||||
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
|
||||
// This prevents deleting shared torrents when user re-requests the same audiobook
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
type: 'audiobook', // Only check audiobook requests
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
torrentHash: downloadHistory.torrentHash,
|
||||
selected: true,
|
||||
// CRITICAL: Check if any other active (non-deleted) request is using this same download
|
||||
const hashToCheck = downloadHistory.torrentHash;
|
||||
if (hashToCheck) {
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
torrentHash: hashToCheck,
|
||||
selected: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (otherActiveRequests.length > 0) {
|
||||
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
if (otherActiveRequests.length > 0) {
|
||||
logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
|
||||
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
|
||||
// If this is a soft-deleted request, hard delete it but DON'T delete the download
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`);
|
||||
}
|
||||
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safe to delete - no other active requests using this torrent
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
|
||||
// Safe to delete - no other active requests using this download
|
||||
await client.deleteDownload(clientId, true); // true = delete files
|
||||
deletedHashes.add(clientId.toLowerCase());
|
||||
|
||||
// If this is a soft-deleted request (orphaned download), hard delete it now
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`);
|
||||
} else {
|
||||
logger.info(`Deleted torrent and files for active request ${request.id}`);
|
||||
logger.info(`Deleted download and files for active request ${request.id}`);
|
||||
}
|
||||
|
||||
cleaned++;
|
||||
@@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
|
||||
|
||||
// Get download configuration
|
||||
const configService = getConfigService();
|
||||
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
|
||||
const downloadsDir = await configService.get('download_dir') || '/downloads';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { getSABnzbdService } from '../integrations/sabnzbd.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { ProwlarrService } from '../integrations/prowlarr.service';
|
||||
@@ -14,7 +12,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Process download job
|
||||
* Routes to appropriate download client based on configuration
|
||||
* Routes to appropriate download client based on protocol detection
|
||||
* Adds selected result to download client and starts monitoring
|
||||
*/
|
||||
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
|
||||
@@ -41,151 +39,85 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
},
|
||||
});
|
||||
|
||||
// Detect protocol from result and route to appropriate client
|
||||
// Detect protocol from result and get appropriate client
|
||||
const isUsenet = ProwlarrService.isNZBResult(torrent);
|
||||
const protocol = isUsenet ? 'usenet' : 'torrent';
|
||||
const config = await getConfigService();
|
||||
const manager = getDownloadClientManager(config);
|
||||
|
||||
const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (!clientConfig) {
|
||||
const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
|
||||
throw new Error(`No ${protocol} client configured`);
|
||||
if (!client) {
|
||||
throw new Error(`No ${protocol} download client configured. Please add a ${protocol} client in Settings > Download Clients.`);
|
||||
}
|
||||
|
||||
let downloadClientId: string;
|
||||
let downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||
// Get client config for category
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
const category = clientConfig?.category || 'readmeabook';
|
||||
|
||||
if (isUsenet) {
|
||||
// Route to SABnzbd
|
||||
logger.info(`Routing to SABnzbd`);
|
||||
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
|
||||
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
|
||||
category: clientConfig.category || 'readmeabook',
|
||||
priority: 'normal',
|
||||
});
|
||||
downloadClient = 'sabnzbd';
|
||||
// Add download via unified interface
|
||||
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
|
||||
category,
|
||||
priority: 'normal',
|
||||
});
|
||||
|
||||
logger.info(`NZB added with ID: ${downloadClientId}`);
|
||||
logger.info(`Download added with ID: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
|
||||
downloadClient: 'sabnzbd',
|
||||
downloadClientId,
|
||||
torrentName: torrent.title,
|
||||
nzbId: downloadClientId, // Store NZB ID
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
|
||||
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
|
||||
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
|
||||
leechers: 0,
|
||||
downloadStatus: 'downloading',
|
||||
selected: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorJob(
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId,
|
||||
downloadClient: client.clientType,
|
||||
downloadClientId,
|
||||
'sabnzbd',
|
||||
3 // Wait 3 seconds before first check
|
||||
);
|
||||
torrentName: torrent.title,
|
||||
// Set protocol-specific ID fields for backward compatibility
|
||||
torrentHash: client.protocol === 'torrent' ? (torrent.infoHash || downloadClientId) : undefined,
|
||||
nzbId: client.protocol === 'usenet' ? downloadClientId : undefined,
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: indexerPageUrl,
|
||||
magnetLink: torrent.downloadUrl,
|
||||
seeders: torrent.seeders || 0,
|
||||
leechers: torrent.leechers || 0,
|
||||
downloadStatus: 'downloading',
|
||||
selected: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'NZB added to SABnzbd and monitoring started',
|
||||
requestId,
|
||||
downloadHistoryId: downloadHistory.id,
|
||||
nzbId: downloadClientId,
|
||||
torrent: {
|
||||
title: torrent.title,
|
||||
size: torrent.size,
|
||||
format: torrent.format,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Route to qBittorrent (default)
|
||||
logger.info(`Routing to qBittorrent`);
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
downloadClientId,
|
||||
client.clientType,
|
||||
3 // Wait 3 seconds before first check
|
||||
);
|
||||
|
||||
const qbt = await getQBittorrentService();
|
||||
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
|
||||
category: clientConfig.category || 'readmeabook',
|
||||
tags: ['audiobook'],
|
||||
sequentialDownload: true,
|
||||
paused: false,
|
||||
});
|
||||
downloadClient = 'qbittorrent';
|
||||
logger.info(`Started monitoring job for request ${requestId} (${client.clientType}, 3s initial delay)`);
|
||||
|
||||
logger.info(`Torrent added with hash: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
|
||||
downloadClient: 'qbittorrent',
|
||||
downloadClientId,
|
||||
torrentName: torrent.title,
|
||||
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
|
||||
magnetLink: torrent.downloadUrl,
|
||||
seeders: torrent.seeders || 0,
|
||||
leechers: torrent.leechers || 0,
|
||||
downloadStatus: 'downloading',
|
||||
selected: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
downloadClientId,
|
||||
'qbittorrent',
|
||||
3 // Wait 3 seconds before first check to avoid race condition
|
||||
);
|
||||
|
||||
logger.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Torrent added to qBittorrent and monitoring started',
|
||||
requestId,
|
||||
downloadHistoryId: downloadHistory.id,
|
||||
torrentHash: downloadClientId,
|
||||
torrent: {
|
||||
title: torrent.title,
|
||||
size: torrent.size,
|
||||
seeders: torrent.seeders || 0,
|
||||
format: torrent.format,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Download added to ${client.clientType} and monitoring started`,
|
||||
requestId,
|
||||
downloadHistoryId: downloadHistory.id,
|
||||
downloadClientId,
|
||||
torrent: {
|
||||
title: torrent.title,
|
||||
size: torrent.size,
|
||||
seeders: torrent.seeders || 0,
|
||||
format: torrent.format,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
|
||||
@@ -3,50 +3,13 @@
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
|
||||
/**
|
||||
* Helper function to retry getTorrent with exponential backoff
|
||||
* Handles race condition where torrent isn't immediately available after adding
|
||||
*/
|
||||
async function getTorrentWithRetry(
|
||||
qbt: any,
|
||||
hash: string,
|
||||
logger: RMABLogger,
|
||||
maxRetries: number = 3,
|
||||
initialDelayMs: number = 500
|
||||
): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await qbt.getTorrent(hash);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// If this is the last attempt, throw the error
|
||||
if (attempt === maxRetries - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
||||
logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
throw lastError || new Error('Failed to get torrent after retries');
|
||||
}
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
|
||||
/**
|
||||
* Process monitor download job
|
||||
@@ -59,57 +22,42 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
|
||||
|
||||
try {
|
||||
let progress: any;
|
||||
let downloadPath: string | undefined;
|
||||
// Get the download client service via the manager
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType];
|
||||
if (!protocol) {
|
||||
throw new Error(`Unknown download client type: ${downloadClient}`);
|
||||
}
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (downloadClient === 'qbittorrent') {
|
||||
// qBittorrent flow
|
||||
const qbt = await getQBittorrentService();
|
||||
if (!client) {
|
||||
throw new Error(`No ${downloadClient} client configured`);
|
||||
}
|
||||
|
||||
// Get torrent status with retry logic (handles race condition)
|
||||
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
|
||||
progress = qbt.getDownloadProgress(torrent);
|
||||
// Get download status via unified interface
|
||||
const info = await client.getDownload(downloadClientId);
|
||||
|
||||
// Store download path for later use
|
||||
downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
|
||||
} else if (downloadClient === 'sabnzbd') {
|
||||
// SABnzbd flow
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
if (!info) {
|
||||
throw new Error(`Download ${downloadClientId} not found in ${downloadClient}`);
|
||||
}
|
||||
|
||||
// Get NZB status
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
|
||||
// Build progress object for request updates
|
||||
const progressPercent = Math.round(info.progress * 100);
|
||||
const progressState = info.status;
|
||||
|
||||
if (!nzbInfo) {
|
||||
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
|
||||
}
|
||||
|
||||
// Convert NZBInfo to progress format
|
||||
progress = {
|
||||
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
|
||||
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
|
||||
bytesTotal: nzbInfo.size,
|
||||
speed: nzbInfo.downloadSpeed,
|
||||
eta: nzbInfo.timeLeft,
|
||||
state: nzbInfo.status,
|
||||
};
|
||||
|
||||
// Store download path if available (only set after completion)
|
||||
downloadPath = nzbInfo.downloadPath;
|
||||
|
||||
logger.info(`SABnzbd status: ${nzbInfo.status}`, {
|
||||
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
|
||||
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
|
||||
if (client.protocol === 'usenet') {
|
||||
logger.info(`${client.clientType} status: ${info.status}`, {
|
||||
progress: `${(info.progress * 100).toFixed(1)}%`,
|
||||
speed: `${(info.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Download client ${downloadClient} not supported`);
|
||||
}
|
||||
|
||||
// Update request progress
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
progress: progress.percent,
|
||||
progress: progressPercent,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -118,23 +66,21 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: progress.state,
|
||||
downloadStatus: progressState,
|
||||
},
|
||||
});
|
||||
|
||||
// Check download state
|
||||
if (progress.state === 'completed') {
|
||||
if (progressState === 'completed' || progressState === 'seeding') {
|
||||
logger.info(`Download completed for request ${requestId}`);
|
||||
|
||||
// Ensure we have a download path
|
||||
const downloadPath = info.downloadPath;
|
||||
if (!downloadPath) {
|
||||
throw new Error('Download path not available from download client');
|
||||
}
|
||||
|
||||
// Get path mapping configuration from the specific download client
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent';
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
|
||||
// Build path mapping config from client settings
|
||||
@@ -150,17 +96,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
const organizePath = PathMapper.transform(downloadPath, pathMappingConfig);
|
||||
|
||||
logger.info(`Download completed`, {
|
||||
downloadClient,
|
||||
downloadClient: client.clientType,
|
||||
downloadPath,
|
||||
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
|
||||
});
|
||||
|
||||
// Update download history to completed
|
||||
// Update download history to completed (store mapped path for retry reliability)
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'completed',
|
||||
completedAt: new Date(),
|
||||
downloadPath: organizePath,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,10 +144,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
progress: 100,
|
||||
downloadPath: organizePath,
|
||||
};
|
||||
} else if (progress.state === 'failed') {
|
||||
} else if (progressState === 'failed') {
|
||||
logger.error(`Download failed for request ${requestId}`);
|
||||
|
||||
const errorMessage = 'Download failed in qBittorrent';
|
||||
const errorMessage = `Download failed in ${client.clientType}`;
|
||||
|
||||
// Update request to failed
|
||||
await prisma.request.update({
|
||||
@@ -249,7 +196,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
completed: true,
|
||||
message: 'Download failed',
|
||||
requestId,
|
||||
progress: progress.percent,
|
||||
progress: progressPercent,
|
||||
};
|
||||
} else {
|
||||
// Still downloading - schedule another check in 10 seconds
|
||||
@@ -263,11 +210,11 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
);
|
||||
|
||||
// Only log every 5% progress to reduce log spam
|
||||
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
|
||||
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5;
|
||||
if (shouldLog) {
|
||||
logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
|
||||
speed: progress.speed,
|
||||
eta: progress.eta,
|
||||
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
|
||||
speed: info.downloadSpeed,
|
||||
eta: info.eta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -276,20 +223,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
completed: false,
|
||||
message: 'Download in progress, monitoring continues',
|
||||
requestId,
|
||||
progress: progress.percent,
|
||||
speed: progress.speed,
|
||||
eta: progress.eta,
|
||||
state: progress.state,
|
||||
progress: progressPercent,
|
||||
speed: info.downloadSpeed,
|
||||
eta: info.eta,
|
||||
state: progressState,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Check if this is a transient "torrent not found" error
|
||||
// Check if this is a transient "not found" error
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
|
||||
const isNotFound = errorMessage.includes('not found');
|
||||
|
||||
if (isTorrentNotFound) {
|
||||
if (isNotFound) {
|
||||
// Transient error - don't mark request as failed, let Bull retry
|
||||
// The request stays in 'downloading' status until Bull exhausts all retries
|
||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getFileOrganizer } from '../utils/file-organizer';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
@@ -178,6 +180,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
});
|
||||
|
||||
// Apply post-import category to torrent client if configured
|
||||
await applyPostImportCategory(requestId, logger);
|
||||
|
||||
logger.info(`Request ${requestId} completed successfully - status: downloaded`, {
|
||||
success: true,
|
||||
message: 'Files organized successfully',
|
||||
@@ -242,106 +247,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup Usenet downloads if configured
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for this download');
|
||||
|
||||
// Get download history to find NZB ID and indexer
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasNzbId: !!downloadHistory?.nzbId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
nzbId: downloadHistory?.nzbId || 'none',
|
||||
indexerId: downloadHistory?.indexerId || 'none',
|
||||
});
|
||||
|
||||
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
|
||||
|
||||
if (indexersConfig) {
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a Usenet indexer with cleanup enabled
|
||||
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
|
||||
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
// Check if it's a file or directory
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Remove directory and all contents
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
// Remove single file
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
|
||||
// Note: We only archive from history, not queue. If the NZB is still in the queue
|
||||
// when we're organizing files, something went wrong with the download monitoring.
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
|
||||
|
||||
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -592,12 +499,20 @@ async function processEbookOrganization(
|
||||
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
|
||||
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
|
||||
|
||||
// Get file organizer and template
|
||||
// Get file organizer and ebook-specific template (falls back to audiobook template)
|
||||
const organizer = await getFileOrganizer();
|
||||
const templateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
const ebookTemplateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'ebook_path_template' },
|
||||
});
|
||||
const template = templateConfig?.value || '{author}/{title} {asin}';
|
||||
let template: string;
|
||||
if (ebookTemplateConfig?.value) {
|
||||
template = ebookTemplateConfig.value;
|
||||
} else {
|
||||
const audiobookTemplateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
});
|
||||
template = audiobookTemplateConfig?.value || '{author}/{title} {asin}';
|
||||
}
|
||||
|
||||
// Check if Kindle EPUB fix is needed
|
||||
let effectiveDownloadPath = downloadPath;
|
||||
@@ -694,6 +609,9 @@ async function processEbookOrganization(
|
||||
},
|
||||
});
|
||||
|
||||
// Apply post-import category to torrent client if configured
|
||||
await applyPostImportCategory(requestId, logger);
|
||||
|
||||
logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
|
||||
|
||||
// Send "available" notification for ebooks at downloaded state
|
||||
@@ -739,99 +657,8 @@ async function processEbookOrganization(
|
||||
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
|
||||
}
|
||||
|
||||
// Cleanup Usenet downloads if configured (same logic as audiobooks)
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for ebook download');
|
||||
|
||||
// downloadHistory was already fetched earlier in this function
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasNzbId: !!downloadHistory?.nzbId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
nzbId: downloadHistory?.nzbId || 'none',
|
||||
indexerId: downloadHistory?.indexerId || 'none',
|
||||
});
|
||||
|
||||
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
|
||||
|
||||
if (indexersConfig) {
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a Usenet indexer with cleanup enabled
|
||||
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
|
||||
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
// Check if it's a file or directory
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Remove directory and all contents
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
// Remove single file
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
|
||||
|
||||
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -932,6 +759,182 @@ async function createEbookRequestIfEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// POST-IMPORT CATEGORY
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Apply post-import category to the download client after successful import.
|
||||
* Only applies to torrent clients (qBittorrent/Transmission) when configured.
|
||||
* Non-fatal: logs a warning on failure but does not fail the job.
|
||||
*/
|
||||
async function applyPostImportCategory(
|
||||
requestId: string,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get download history to find client type and download ID
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!downloadHistory?.downloadClientId || !downloadHistory?.downloadClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientType = downloadHistory.downloadClient as DownloadClientType;
|
||||
|
||||
// Only applies to torrent clients
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType];
|
||||
if (protocol !== 'torrent') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get client config and check if postImportCategory is set
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const clients = await manager.getAllClients();
|
||||
const clientConfig = clients.find(c => c.enabled && c.type === clientType);
|
||||
|
||||
if (!clientConfig?.postImportCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Applying post-import category "${clientConfig.postImportCategory}" to download ${downloadHistory.downloadClientId}`);
|
||||
|
||||
const service = await manager.createClientFromConfig(clientConfig);
|
||||
await service.setCategory(downloadHistory.downloadClientId, clientConfig.postImportCategory);
|
||||
|
||||
logger.info(`Post-import category applied successfully`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to apply post-import category: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOWNLOAD CLEANUP
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Cleanup download files and archive from download client after successful organization.
|
||||
* Uses the IDownloadClient.postProcess() method for client-specific cleanup (e.g., SABnzbd archive).
|
||||
* Shared between audiobook and ebook organization flows.
|
||||
*/
|
||||
async function cleanupDownloadAfterOrganize(
|
||||
requestId: string,
|
||||
downloadPath: string,
|
||||
configService: any,
|
||||
jobId: string | undefined,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for this download');
|
||||
|
||||
// Get download history to find client ID and indexer
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasDownloadClientId: !!downloadHistory?.downloadClientId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
downloadClient: downloadHistory?.downloadClient || 'none',
|
||||
});
|
||||
|
||||
if (!downloadHistory?.indexerId || !downloadHistory?.downloadClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
if (!indexersConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a non-torrent indexer with cleanup enabled
|
||||
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Cleaning up download ${downloadHistory.downloadClientId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then use the download client interface for client-specific post-processing
|
||||
// (e.g., usenet clients archive from history, torrent clients are a no-op)
|
||||
const clientType = downloadHistory.downloadClient;
|
||||
if (clientType && clientType !== 'direct') {
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
if (!protocol) {
|
||||
logger.warn(`Unknown download client type: ${clientType}, skipping post-processing`);
|
||||
return;
|
||||
}
|
||||
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||
|
||||
if (client) {
|
||||
await client.postProcess(downloadHistory.downloadClientId);
|
||||
logger.info(`Successfully post-processed download ${downloadHistory.downloadClientId} via ${client.clientType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =========================================================================
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
* Component: Retry Failed Imports Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Retries file organization for requests that are awaiting import
|
||||
* Retries file organization for requests that are awaiting import.
|
||||
* Uses the IDownloadClient interface for client-agnostic path resolution.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
|
||||
|
||||
export interface RetryFailedImportsPayload {
|
||||
jobId?: string;
|
||||
@@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
|
||||
// Helper function to get path mapping config for a specific download client type
|
||||
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
|
||||
const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent';
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
|
||||
if (clientConfig && clientConfig.remotePathMappingEnabled) {
|
||||
@@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
return { enabled: false, remotePath: '', localPath: '' };
|
||||
};
|
||||
|
||||
// Find all active audiobook requests in awaiting_import status
|
||||
// Note: Ebook requests use the same organize_files processor but with type branching
|
||||
// Find all requests in awaiting_import status (both audiobook and ebook)
|
||||
// The organize_files processor handles both types with type-based branching
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
|
||||
status: 'awaiting_import',
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
|
||||
let downloadPath: string;
|
||||
|
||||
// Try to get download path from the appropriate download client
|
||||
// Get path mapping for this specific download client
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
const mappingConfig = await getPathMappingForClient(clientType);
|
||||
|
||||
if (downloadHistory.torrentHash) {
|
||||
// qBittorrent download
|
||||
try {
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
const qbPath = `${torrent.save_path}/${torrent.name}`;
|
||||
downloadPath = PathMapper.transform(qbPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
|
||||
(downloadPath !== qbPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} catch (qbtError) {
|
||||
// Torrent not found in qBittorrent - try to construct path from config
|
||||
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} else if (downloadHistory.nzbId) {
|
||||
// SABnzbd download
|
||||
try {
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
|
||||
if (nzbInfo && nzbInfo.downloadPath) {
|
||||
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
|
||||
(downloadPath !== nzbInfo.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} else {
|
||||
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} catch (sabnzbdError) {
|
||||
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
|
||||
// Use stored path or construct from download_dir directly
|
||||
if (clientType === 'direct') {
|
||||
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
|
||||
} else {
|
||||
// No download client ID - use fallback path
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
|
||||
// Real download client — resolve path via client API with path mapping
|
||||
const mappingConfig = await getPathMappingForClient(clientType);
|
||||
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
|
||||
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
|
||||
if (!protocol) {
|
||||
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
if (clientId) {
|
||||
// Try to get path from download client via unified interface
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
if (client) {
|
||||
try {
|
||||
const info = await client.getDownload(clientId);
|
||||
if (info?.downloadPath) {
|
||||
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
|
||||
(downloadPath !== info.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} else {
|
||||
// Download found but no path — try stored path, then fallback
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
} catch (clientError) {
|
||||
// Client error — try stored path, then fallback
|
||||
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
} else {
|
||||
// No client configured — try stored path, then fallback
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
} else {
|
||||
// No client ID — try stored path, then fallback
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using configured download path for request ${request.id}: ${configuredPath}` +
|
||||
(downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
// Check if we got a valid path (getFallbackPath returns empty string on failure)
|
||||
if (!downloadPath) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
@@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
downloadPath
|
||||
);
|
||||
triggered++;
|
||||
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
|
||||
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
@@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stored download path from the database (saved at download completion time).
|
||||
* Returns empty string if not available (old records won't have this field).
|
||||
*/
|
||||
function getStoredPath(
|
||||
downloadHistory: { downloadPath?: string | null },
|
||||
requestId: string,
|
||||
logger: RMABLogger
|
||||
): string {
|
||||
if (downloadHistory.downloadPath) {
|
||||
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
|
||||
return downloadHistory.downloadPath;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a fallback download path from config when the download client can't provide one.
|
||||
* Returns empty string if path cannot be determined (caller should skip the request).
|
||||
*/
|
||||
async function getFallbackPath(
|
||||
downloadHistory: { torrentName: string | null },
|
||||
configService: any,
|
||||
mappingConfig: PathMappingConfig,
|
||||
requestId: string,
|
||||
logger: RMABLogger,
|
||||
manager?: DownloadClientManager,
|
||||
protocol?: ProtocolType
|
||||
): Promise<string> {
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const baseDir = await configService.get('download_dir');
|
||||
|
||||
if (!baseDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Resolve customPath from the client config if available
|
||||
let downloadDir = baseDir;
|
||||
if (manager && protocol) {
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
if (clientConfig?.customPath) {
|
||||
downloadDir = path.join(baseDir, clientConfig.customPath);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
|
||||
(mappedPath !== fallbackPath ? ` → ${mappedPath} (mapped)` : '')
|
||||
);
|
||||
return mappedPath;
|
||||
}
|
||||
|
||||
@@ -243,9 +243,14 @@ async function searchIndexers(
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Group indexers by their EBOOK category configuration
|
||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
if (skippedIndexers.length > 0) {
|
||||
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
|
||||
@@ -58,9 +58,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
// Group indexers by their category configuration
|
||||
// This minimizes API calls while ensuring each indexer only searches its configured categories
|
||||
const groups = groupIndexersByCategories(indexersConfig);
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
if (skippedIndexers.length > 0) {
|
||||
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
@@ -70,10 +75,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
|
||||
// Build search query (title only - cast wide net, let ranking filter)
|
||||
const searchQuery = audiobook.title;
|
||||
|
||||
logger.info(`Searching for: "${searchQuery}"`);
|
||||
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
|
||||
|
||||
// Search Prowlarr for each group and combine results
|
||||
const allResults = [];
|
||||
@@ -83,7 +85,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.search(searchQuery, {
|
||||
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
minSeeders: 1, // Only torrents with at least 1 seeder
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* to all enabled backends subscribed to the event.
|
||||
*/
|
||||
|
||||
import { getNotificationService } from '../services/notification.service';
|
||||
import { getNotificationService } from '../services/notification';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SendNotificationPayload {
|
||||
|
||||
@@ -150,7 +150,11 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
return { success: false, error: 'Username must be at least 3 characters' };
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
if (!password) {
|
||||
return { success: false, error: 'Password is required' };
|
||||
}
|
||||
if (!allowWeakPassword && password.length < 8) {
|
||||
return { success: false, error: 'Password must be at least 8 characters' };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,37 +2,42 @@
|
||||
* Component: Download Client Manager Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
|
||||
* Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing.
|
||||
* Supports migration from legacy single-client config to multi-client JSON array format.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
import { ConfigurationService } from './config.service';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { isEncryptedFormat } from './credential-migration.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
import { NZBGetService } from '@/lib/integrations/nzbget.service';
|
||||
import { TransmissionService } from '@/lib/integrations/transmission.service';
|
||||
import { PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('DownloadClientManager');
|
||||
|
||||
export interface DownloadClientConfig {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string; // qBittorrent only
|
||||
password: string; // Password (qBittorrent) or API key (SABnzbd)
|
||||
username?: string; // qBittorrent/Transmission/NZBGet only
|
||||
password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd)
|
||||
disableSSLVerify: boolean;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string; // Default: 'readmeabook'
|
||||
customPath?: string; // Relative sub-path appended to download_dir
|
||||
postImportCategory?: string; // Category to assign after import (torrent clients only)
|
||||
}
|
||||
|
||||
type ProtocolType = 'torrent' | 'usenet';
|
||||
|
||||
/**
|
||||
* Download Client Manager
|
||||
@@ -47,6 +52,7 @@ export class DownloadClientManager {
|
||||
private static instance: DownloadClientManager | null = null;
|
||||
private configService: ConfigurationService;
|
||||
private clientsCache: DownloadClientConfig[] | null = null;
|
||||
private serviceCache: Map<string, IDownloadClient> = new Map();
|
||||
private migrationPerformed = false;
|
||||
|
||||
private constructor(configService: ConfigurationService) {
|
||||
@@ -69,6 +75,7 @@ export class DownloadClientManager {
|
||||
static invalidate(): void {
|
||||
if (DownloadClientManager.instance) {
|
||||
DownloadClientManager.instance.clientsCache = null;
|
||||
DownloadClientManager.instance.serviceCache.clear();
|
||||
DownloadClientManager.instance.migrationPerformed = false;
|
||||
logger.debug('Download client cache invalidated');
|
||||
}
|
||||
@@ -127,16 +134,17 @@ export class DownloadClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client for specific protocol
|
||||
* Get client for specific protocol.
|
||||
* Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found
|
||||
* (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol).
|
||||
*/
|
||||
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
|
||||
const clients = await this.getAllClients();
|
||||
const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
|
||||
|
||||
const client = clients.find(c => c.enabled && c.type === targetType);
|
||||
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
||||
|
||||
if (!client) {
|
||||
logger.warn(`No enabled ${targetType} client configured`);
|
||||
logger.warn(`No enabled ${protocol} client configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,36 +160,83 @@ export class DownloadClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instantiated client service for protocol
|
||||
* Get instantiated client service for protocol.
|
||||
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
|
||||
*/
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
|
||||
const client = await this.getClientForProtocol(protocol);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.type === 'qbittorrent') {
|
||||
return this.createQBittorrentService(client);
|
||||
} else {
|
||||
return this.createSABnzbdService(client);
|
||||
return this.getOrCreateService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: create a new IDownloadClient from config.
|
||||
* This is the single place where client type maps to a concrete class.
|
||||
* Add new client types (e.g. Transmission, NZBGet) here.
|
||||
*/
|
||||
private async createService(config: DownloadClientConfig): Promise<IDownloadClient> {
|
||||
const baseDir = await this.configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = config.customPath
|
||||
? path.join(baseDir, config.customPath)
|
||||
: baseDir;
|
||||
|
||||
switch (config.type) {
|
||||
case 'qbittorrent':
|
||||
return this.createQBittorrentService(config, downloadDir);
|
||||
case 'sabnzbd':
|
||||
return this.createSABnzbdService(config, downloadDir);
|
||||
case 'nzbget':
|
||||
return this.createNZBGetService(config, downloadDir);
|
||||
case 'transmission':
|
||||
return this.createTransmissionService(config, downloadDir);
|
||||
default:
|
||||
throw new Error(`Unsupported download client type: ${config.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a specific client config
|
||||
* Get a cached service instance or create a new one.
|
||||
* Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie).
|
||||
*/
|
||||
private async getOrCreateService(config: DownloadClientConfig): Promise<IDownloadClient> {
|
||||
const cached = this.serviceCache.get(config.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const service = await this.createService(config);
|
||||
this.serviceCache.set(config.id, service);
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IDownloadClient instance from a config object.
|
||||
* Uses cached instances when available to preserve session state.
|
||||
*/
|
||||
async createClientFromConfig(config: DownloadClientConfig): Promise<IDownloadClient> {
|
||||
return this.getOrCreateService(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a specific client config.
|
||||
* Uses the unified IDownloadClient.testConnection() method.
|
||||
*/
|
||||
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (config.type === 'qbittorrent') {
|
||||
const service = this.createQBittorrentService(config);
|
||||
await service.testConnection();
|
||||
return { success: true, message: 'Successfully connected to qBittorrent' };
|
||||
} else {
|
||||
const service = this.createSABnzbdService(config);
|
||||
const version = await service.getVersion();
|
||||
return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
|
||||
// Always create a fresh instance for connection testing (don't use cache)
|
||||
const service = await this.createService(config);
|
||||
const result = await service.testConnection();
|
||||
|
||||
if (result.success) {
|
||||
const versionSuffix = result.version ? ` (v${result.version})` : '';
|
||||
return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` };
|
||||
}
|
||||
|
||||
return { success: false, message: result.message || 'Connection failed' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Connection test failed', { type: config.type, error: message });
|
||||
@@ -192,7 +247,7 @@ export class DownloadClientManager {
|
||||
/**
|
||||
* Create qBittorrent service instance
|
||||
*/
|
||||
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
|
||||
private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -205,8 +260,8 @@ export class DownloadClientManager {
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password || '', // Optional for IP whitelist auth
|
||||
'/downloads', // defaultSavePath
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
downloadDir,
|
||||
config.category || 'readmeabook',
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
@@ -215,7 +270,7 @@ export class DownloadClientManager {
|
||||
/**
|
||||
* Create SABnzbd service instance
|
||||
*/
|
||||
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
|
||||
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -227,8 +282,54 @@ export class DownloadClientManager {
|
||||
return new SABnzbdService(
|
||||
config.url,
|
||||
config.password, // API key stored in password field
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
|
||||
config.category || 'readmeabook',
|
||||
downloadDir,
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NZBGet service instance
|
||||
*/
|
||||
private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
remotePath: config.remotePath,
|
||||
localPath: config.localPath,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new NZBGetService(
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password,
|
||||
config.category || 'readmeabook',
|
||||
downloadDir,
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Transmission service instance
|
||||
*/
|
||||
private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
remotePath: config.remotePath,
|
||||
localPath: config.localPath,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new TransmissionService(
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password || '',
|
||||
downloadDir,
|
||||
config.category || 'readmeabook',
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
@@ -272,8 +373,8 @@ export class DownloadClientManager {
|
||||
|
||||
const newClient: DownloadClientConfig = {
|
||||
id: randomUUID(),
|
||||
type: clientType as 'qbittorrent' | 'sabnzbd',
|
||||
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
|
||||
type: clientType as DownloadClientType,
|
||||
name: getClientDisplayName(clientType),
|
||||
enabled: true,
|
||||
url: clientUrl,
|
||||
username: clientUsername || undefined,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user